Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions config/playlists.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
|
*/

Expand Down
53 changes: 50 additions & 3 deletions resources/js/__tests__/composables/playlist.test.ts
Original file line number Diff line number Diff line change
@@ -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')

Expand All @@ -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()
})
})
14 changes: 14 additions & 0 deletions resources/js/composables/playlist.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
import type { Playlist } from '@/types'
import { router } from '@inertiajs/vue3'

export function usePlaylist() {
let refreshTimer: ReturnType<typeof setTimeout> | 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,
}
}
8 changes: 7 additions & 1 deletion resources/js/composables/shaka.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export function useShaka(
progress?: MaybeRefOrGetter<number | null>,
) {
const { get, update } = useSettings('player')
const { isPlaylistReplacement } = usePlaylist()
const { isPlaylistReplacement, scheduleAssetRefresh, cancelAssetRefresh } = usePlaylist()
const { markViewed } = useVideo()

const player = shallowRef<shaka.Player>()
Expand Down Expand Up @@ -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 }))
}
Expand All @@ -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 }))
}
Expand Down Expand Up @@ -205,6 +209,8 @@ export function useShaka(
}

const reset = () => {
cancelAssetRefresh()

initializing.value = false
loaded.value = false
current.value = null
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ export type VideoCollection = Omit<Paginator, 'data'> & {
export type Playlist = Model & {
resource?: ModelResource
asset: string | null
asset_refresh_in: number
encryption_key_id: string | null
encryption_key: string | null
expired: boolean
Expand Down
1 change: 1 addition & 0 deletions src/App/Api/Playlists/Resources/PlaylistResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
24 changes: 0 additions & 24 deletions src/Domain/Playlists/Actions/RefreshPlaylistManifest.php

This file was deleted.

20 changes: 0 additions & 20 deletions src/Domain/Playlists/Listeners/RefreshExpiringPlaylistManifest.php

This file was deleted.

7 changes: 5 additions & 2 deletions src/Domain/Playlists/Models/Playlist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,7 +38,6 @@ class Playlist extends Model
use HasFactory;
use HasStates;
use HasUlids;
use InteractsWithModelCache;
use InteractsWithUser;
use Prunable;

Expand Down Expand Up @@ -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([
Expand Down
6 changes: 4 additions & 2 deletions src/Domain/Videos/Actions/CreateNewVideoPlaylist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SegmentBase> 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',
]));
Expand Down
6 changes: 4 additions & 2 deletions src/Domain/Videos/Actions/CreateNewVideoStream.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SegmentBase> with no @indexRange, which Shaka Player silently drops.
$captions->each(fn (CaptionStream $caption) => $streamer->addTextStream($caption->path, "{$caption->id}_caption.mp4", [
'language' => $caption->language,
]));

Expand Down
18 changes: 18 additions & 0 deletions tests/Feature/Playlist/PlaylistTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
31 changes: 0 additions & 31 deletions tests/Feature/Playlist/RefreshExpiringPlaylistManifestTest.php

This file was deleted.

59 changes: 0 additions & 59 deletions tests/Feature/Playlist/RefreshPlaylistManifestTest.php

This file was deleted.