diff --git a/config/playlists.php b/config/playlists.php index 5c1b7e54c..1c528e284 100644 --- a/config/playlists.php +++ b/config/playlists.php @@ -80,10 +80,9 @@ | Manifest Refresh Before |-------------------------------------------------------------------------- | - | While a video is actively being watched, the video session heartbeat - | re-signs and broadcasts a fresh manifest URL this many seconds before - | the current one expires, so long-running playback never hits an - | expired signed URL. + | The player schedules a refetch of the manifest resource this many + | seconds before its signed URL expires, so long-running playback + | never hits an expired signed URL. | */ diff --git a/resources/js/__tests__/composables/playlist.test.ts b/resources/js/__tests__/composables/playlist.test.ts index fda0fd980..a89a838eb 100644 --- a/resources/js/__tests__/composables/playlist.test.ts +++ b/resources/js/__tests__/composables/playlist.test.ts @@ -1,11 +1,18 @@ import { usePlaylist } from '@/composables/playlist' import type { Playlist } from '@/types' -import { describe, expect, it } from 'vitest' +import { router } from '@inertiajs/vue3' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const playlist = (id: string, asset: string): Playlist => ({ id, asset }) as Playlist -const { isPlaylistReplacement } = usePlaylist() +vi.mock('@inertiajs/vue3', () => ({ + router: { reload: vi.fn() }, +})) + +const playlist = (id: string, asset: string, assetRefreshIn = 0): Playlist => + ({ id, asset, asset_refresh_in: assetRefreshIn }) as Playlist describe('isPlaylistReplacement', () => { + const { isPlaylistReplacement } = usePlaylist() + it('is not a replacement when id and asset are unchanged', () => { const current = playlist('1', 'https://example.test/a?expires=1') @@ -28,3 +35,43 @@ describe('isPlaylistReplacement', () => { expect(isPlaylistReplacement(null, null)).toBe(false) }) }) + +describe('scheduleAssetRefresh', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => { + vi.useRealTimers() + vi.mocked(router.reload).mockClear() + }) + + it('reloads the playlist prop once the refresh window elapses', () => { + const { scheduleAssetRefresh } = usePlaylist() + + scheduleAssetRefresh(playlist('1', 'https://example.test/a', 300)) + vi.advanceTimersByTime(299_999) + expect(router.reload).not.toHaveBeenCalled() + + vi.advanceTimersByTime(1) + expect(router.reload).toHaveBeenCalledWith({ only: ['playlist', 'progress'] }) + }) + + it('reschedules from the latest call instead of stacking timers', () => { + const { scheduleAssetRefresh } = usePlaylist() + + scheduleAssetRefresh(playlist('1', 'https://example.test/a', 300)) + vi.advanceTimersByTime(200_000) + scheduleAssetRefresh(playlist('1', 'https://example.test/a', 300)) + vi.advanceTimersByTime(299_999) + + expect(router.reload).not.toHaveBeenCalled() + }) + + it('does not reload once cancelled', () => { + const { scheduleAssetRefresh, cancelAssetRefresh } = usePlaylist() + + scheduleAssetRefresh(playlist('1', 'https://example.test/a', 300)) + cancelAssetRefresh() + vi.advanceTimersByTime(300_000) + + expect(router.reload).not.toHaveBeenCalled() + }) +}) diff --git a/resources/js/composables/playlist.ts b/resources/js/composables/playlist.ts index 84eeb5c67..2946a29d2 100644 --- a/resources/js/composables/playlist.ts +++ b/resources/js/composables/playlist.ts @@ -1,10 +1,24 @@ import type { Playlist } from '@/types' +import { router } from '@inertiajs/vue3' export function usePlaylist() { + let refreshTimer: ReturnType | undefined + const isPlaylistReplacement = (next: Playlist | null, current: Playlist | null): boolean => next?.id !== current?.id || next?.asset !== current?.asset + const cancelAssetRefresh = () => clearTimeout(refreshTimer) + + // Refetch the playlist prop before its signed manifest url expires, so playback never hits a stale signature + const scheduleAssetRefresh = (playlist: Playlist) => { + cancelAssetRefresh() + + refreshTimer = setTimeout(() => router.reload({ only: ['playlist', 'progress'] }), playlist.asset_refresh_in * 1000) + } + return { isPlaylistReplacement, + scheduleAssetRefresh, + cancelAssetRefresh, } } diff --git a/resources/js/composables/shaka.ts b/resources/js/composables/shaka.ts index 5c8ba1301..cd4b517fa 100644 --- a/resources/js/composables/shaka.ts +++ b/resources/js/composables/shaka.ts @@ -15,7 +15,7 @@ export function useShaka( progress?: MaybeRefOrGetter, ) { const { get, update } = useSettings('player') - const { isPlaylistReplacement } = usePlaylist() + const { isPlaylistReplacement, scheduleAssetRefresh, cancelAssetRefresh } = usePlaylist() const { markViewed } = useVideo() const player = shallowRef() @@ -156,6 +156,8 @@ export function useShaka( if (get('captions', true) && textTracks.length > 0) { player.value.selectTextTrack(textTracks[0]) } + + scheduleAssetRefresh(playlist) } catch (err) { onErrorEvent(new CustomEvent('error', { detail: err })) } @@ -177,6 +179,8 @@ export function useShaka( try { await player.value.load(playlist.asset, startTime) + + scheduleAssetRefresh(playlist) } catch (err) { onErrorEvent(new CustomEvent('error', { detail: err })) } @@ -205,6 +209,8 @@ export function useShaka( } const reset = () => { + cancelAssetRefresh() + initializing.value = false loaded.value = false current.value = null diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index a2310cbcd..1eb0a6089 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -202,6 +202,7 @@ export type VideoCollection = Omit & { export type Playlist = Model & { resource?: ModelResource asset: string | null + asset_refresh_in: number encryption_key_id: string | null encryption_key: string | null expired: boolean diff --git a/src/App/Api/Playlists/Resources/PlaylistResource.php b/src/App/Api/Playlists/Resources/PlaylistResource.php index ef15f68a1..49a38045b 100644 --- a/src/App/Api/Playlists/Resources/PlaylistResource.php +++ b/src/App/Api/Playlists/Resources/PlaylistResource.php @@ -26,6 +26,7 @@ public function toArray($request): array 'encryption_key_id' => $this->encryption_key_id, 'encryption_key' => $this->encryption_key, 'asset' => $this->getUrl(), + 'asset_refresh_in' => $this->getUrlRefreshIn(), 'failed' => $this->isFailed(), 'expired' => $this->isExpired(), 'valid' => $this->isValid(), diff --git a/src/Domain/Playlists/Actions/RefreshPlaylistManifest.php b/src/Domain/Playlists/Actions/RefreshPlaylistManifest.php deleted file mode 100644 index 829d24efd..000000000 --- a/src/Domain/Playlists/Actions/RefreshPlaylistManifest.php +++ /dev/null @@ -1,24 +0,0 @@ -isValid() || $playlist->modelCacheHas('manifest-fresh')) { - return; - } - - $ttl = max(Playlist::getManifestUrlLifetime() - Playlist::getManifestRefreshBefore(), 0); - - $playlist->modelCache('manifest-fresh', true, now()->addSeconds($ttl)); - - // Touching the playlist re-broadcasts it, so viewers pick up a freshly signed manifest URL - $playlist->touch(); - } -} diff --git a/src/Domain/Playlists/Listeners/RefreshExpiringPlaylistManifest.php b/src/Domain/Playlists/Listeners/RefreshExpiringPlaylistManifest.php deleted file mode 100644 index ad75c104e..000000000 --- a/src/Domain/Playlists/Listeners/RefreshExpiringPlaylistManifest.php +++ /dev/null @@ -1,20 +0,0 @@ -video->getPlaylist()) { - $this->refreshPlaylistManifest->handle($playlist); - } - } -} diff --git a/src/Domain/Playlists/Models/Playlist.php b/src/Domain/Playlists/Models/Playlist.php index e32e824b8..2bc6ad39b 100644 --- a/src/Domain/Playlists/Models/Playlist.php +++ b/src/Domain/Playlists/Models/Playlist.php @@ -14,7 +14,6 @@ use Domain\Playlists\States\Verified; use Domain\Shared\Casts\AsDateTime; use Domain\Users\Concerns\InteractsWithUser; -use Foxws\ModelCache\Concerns\InteractsWithModelCache; use Illuminate\Broadcasting\Channel; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\BroadcastsEvents; @@ -39,7 +38,6 @@ class Playlist extends Model use HasFactory; use HasStates; use HasUlids; - use InteractsWithModelCache; use InteractsWithUser; use Prunable; @@ -177,6 +175,11 @@ public function getUrl(): string return $this->getUrlResolver($this->file_name ?? 'index.mpd'); } + public function getUrlRefreshIn(): int + { + return max(static::getManifestUrlLifetime() - static::getManifestRefreshBefore(), 0); + } + public function markAsReady(): void { $this->updateOrFail([ diff --git a/src/Domain/Videos/Actions/CreateNewVideoPlaylist.php b/src/Domain/Videos/Actions/CreateNewVideoPlaylist.php index 248ba9dbb..e829bad94 100644 --- a/src/Domain/Videos/Actions/CreateNewVideoPlaylist.php +++ b/src/Domain/Videos/Actions/CreateNewVideoPlaylist.php @@ -63,8 +63,10 @@ public function handle(Video $video): Collection } }); - // Add text streams for captions if they exist - $captions->each(fn (CaptionStream $caption) => $packager->addTextStream($caption->path, "{$caption->id}_caption.vtt", [ + // Add text streams for captions if they exist. Fragmented into MP4 (rather than a raw + // .vtt sidecar) so shaka-packager can emit a real segment index — a bare .vtt output + // gets a with no @indexRange, which Shaka Player silently drops. + $captions->each(fn (CaptionStream $caption) => $packager->addTextStream($caption->path, "{$caption->id}_caption.mp4", [ 'language' => $caption->language, 'dash_roles' => 'subtitle', ])); diff --git a/src/Domain/Videos/Actions/CreateNewVideoStream.php b/src/Domain/Videos/Actions/CreateNewVideoStream.php index 0920a284b..6c5d1b0fd 100644 --- a/src/Domain/Videos/Actions/CreateNewVideoStream.php +++ b/src/Domain/Videos/Actions/CreateNewVideoStream.php @@ -88,8 +88,10 @@ public function handle(Video $video): Collection $streamer->withResolutions($resolutions); } - // Add text streams for captions if they exist - $captions->each(fn (CaptionStream $caption) => $streamer->addTextStream($caption->path, "{$caption->id}_caption.vtt", [ + // Add text streams for captions if they exist. Fragmented into MP4 (rather than a raw + // .vtt sidecar) so shaka-packager can emit a real segment index — a bare .vtt output + // gets a with no @indexRange, which Shaka Player silently drops. + $captions->each(fn (CaptionStream $caption) => $streamer->addTextStream($caption->path, "{$caption->id}_caption.mp4", [ 'language' => $caption->language, ])); diff --git a/tests/Feature/Playlist/PlaylistTest.php b/tests/Feature/Playlist/PlaylistTest.php index 6a8093519..0084f08f7 100644 --- a/tests/Feature/Playlist/PlaylistTest.php +++ b/tests/Feature/Playlist/PlaylistTest.php @@ -133,6 +133,24 @@ ->and($path)->toContain('segment-001.ts'); }); +it('can get seconds until the manifest url should be refreshed', function () { + config()->set('playlists.manifest_url_lifetime', 600); + config()->set('playlists.manifest_refresh_before', 300); + + $playlist = Playlist::factory()->create(); + + expect($playlist->getUrlRefreshIn())->toBe(300); +}); + +it('clamps the manifest url refresh window at zero', function () { + config()->set('playlists.manifest_url_lifetime', 60); + config()->set('playlists.manifest_refresh_before', 300); + + $playlist = Playlist::factory()->create(); + + expect($playlist->getUrlRefreshIn())->toBe(0); +}); + it('can get model from playlistable', function () { $video = Video::factory()->create(); diff --git a/tests/Feature/Playlist/RefreshExpiringPlaylistManifestTest.php b/tests/Feature/Playlist/RefreshExpiringPlaylistManifestTest.php deleted file mode 100644 index 54c53587d..000000000 --- a/tests/Feature/Playlist/RefreshExpiringPlaylistManifestTest.php +++ /dev/null @@ -1,31 +0,0 @@ -create(); - $playlist = Playlist::factory()->verified()->create([ - 'playlistable_id' => $video->getKey(), - 'updated_at' => now()->subHour(), - ]); - $originalUpdatedAt = $playlist->updated_at; - - VideoHasBeenViewedEvent::dispatch($video, null, ['time' => 12.5]); - - expect($playlist->fresh()->updated_at)->not->toEqual($originalUpdatedAt); -}); - -it('does nothing when the video has no playlist', function () { - $video = Video::factory()->create(); - - VideoHasBeenViewedEvent::dispatch($video, null, ['time' => 12.5]); - - expect($video->getPlaylist())->toBeNull(); -}); diff --git a/tests/Feature/Playlist/RefreshPlaylistManifestTest.php b/tests/Feature/Playlist/RefreshPlaylistManifestTest.php deleted file mode 100644 index ffade3435..000000000 --- a/tests/Feature/Playlist/RefreshPlaylistManifestTest.php +++ /dev/null @@ -1,59 +0,0 @@ -verified()->create(['updated_at' => now()->subHour()]); - $originalUpdatedAt = $playlist->updated_at; - - app(RefreshPlaylistManifest::class)->handle($playlist); - - expect($playlist->fresh()->updated_at)->not->toEqual($originalUpdatedAt) - ->and($playlist->modelCacheHas('manifest-fresh'))->toBeTrue(); -}); - -it('does not refresh again while the freshness window is still active', function () { - $playlist = Playlist::factory()->verified()->create(); - - app(RefreshPlaylistManifest::class)->handle($playlist); - $refreshedAt = $playlist->fresh()->updated_at; - - // Travel forward so a second (unwanted) touch would land on a detectably different timestamp - $this->travel(1)->second(); - - app(RefreshPlaylistManifest::class)->handle($playlist); - - expect($playlist->fresh()->updated_at)->toEqual($refreshedAt); -}); - -it('refreshes again once the freshness window has elapsed', function () { - config()->set('playlists.manifest_url_lifetime', 600); - config()->set('playlists.manifest_refresh_before', 300); - - $playlist = Playlist::factory()->verified()->create(); - - app(RefreshPlaylistManifest::class)->handle($playlist); - $firstRefresh = $playlist->fresh()->updated_at; - - $this->travel(301)->seconds(); - - app(RefreshPlaylistManifest::class)->handle($playlist); - - expect($playlist->fresh()->updated_at)->not->toEqual($firstRefresh); -}); - -it('does not refresh a playlist that is not verified', function () { - $playlist = Playlist::factory()->create(); - $originalUpdatedAt = $playlist->updated_at; - - app(RefreshPlaylistManifest::class)->handle($playlist); - - expect($playlist->fresh()->updated_at)->toEqual($originalUpdatedAt) - ->and($playlist->modelCacheHas('manifest-fresh'))->toBeFalse(); -});