diff --git a/AGENTS.md b/AGENTS.md index e6dd85e55..f656cb57d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,10 +249,12 @@ Key files: `WebPlayerViewComponent` owns a host-derived content-session key that is stable for the mounted logical selection, attempted target IDs, the temporary player override, and VOD handoff position. Its `PlaybackBinding` is exactly - `{ generation, target }`, while - source/header/DRM/application changes use a separate fieldless opaque - `Symbol` token; neither ownership primitive contains URLs, headers, DRM - material, or credentials. A recommended built-in player temporarily + `{ generation, target }`, while every source/target/reload application uses a + fieldless opaque `Symbol` token. Source applications also advance a second + fieldless revision `Symbol` that clears only the VOD handoff position; + target-only switches and Retry leave it stable. None of these ownership + primitives contains URLs, headers, DRM material, or credentials. A + recommended built-in player temporarily outranks the host override and saved player for that mounted content session, never mutates `Settings.player`, and resumes finite VOD position on a best-effort basis; live playback returns to the live edge. Retry and diff --git a/CLAUDE.md b/CLAUDE.md index 4ec43157b..89902727e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -776,10 +776,12 @@ app as a real argument, so it is not an option. `WebPlayerViewComponent` owns a host-derived content-session key that is stable for the mounted logical selection, attempted target IDs, the temporary player override, and VOD handoff position. Its `PlaybackBinding` is exactly - `{ generation, target }`, while - source/header/DRM/application changes use a separate fieldless opaque - `Symbol` token; neither ownership primitive contains URLs, headers, DRM - material, or credentials. A recommended built-in player temporarily + `{ generation, target }`, while every source/target/reload application uses a + fieldless opaque `Symbol` token. Source applications also advance a second + fieldless revision `Symbol` that clears only the VOD handoff position; + target-only switches and Retry leave it stable. None of these ownership + primitives contains URLs, headers, DRM material, or credentials. A + recommended built-in player temporarily outranks the host override and saved player for that mounted content session, never mutates `Settings.player`, and resumes finite VOD position on a best-effort basis; live playback returns to the live edge. Retry and @@ -1144,7 +1146,7 @@ engine` (restart required) or - `get_profile`'s `js.status` decodes as: full profile/`0` = OK, `1` = blocked, `2` = login/password required → `do_auth` then `get_profile` with `auth_second_step=1` (only that retry sets it). Credentials come from the import dialog's username/password fields and are persisted so runtime re-auth can repeat `do_auth`. Status is read through a numeric coercion — portals stringify it. - Refusals throw `StalkerPortalError` (`login-required` / `login-rejected` / `device-conflict` / `blocked` / `auth-failed`) carrying the portal's markup-stripped `msg`/`block_msg` in `portalText`; the import dialog and the workspace context panel render it. Read it with `asStalkerPortalError()`, never `instanceof` in lazy-loaded code. `device-conflict` splits off `blocked` via `isStalkerDeviceConflictMessage` (narrow phrase set, structured `msg` only): it is the one refusal with a remedy, and the portal's own "Your STB is damaged" wording points away from it, so both surfaces lead with their own headline and append the portal text. - Auth failures are HTTP 200 + plain text (`Authorization failed.` / `Access denied.` / `Unauthorized request.`), classified at the transport boundary by `libs/shared/interfaces/src/lib/stalker-auth-failure.util.ts`; the Electron handler **returns** a `{stalkerAuthFailure}` marker rather than throwing, because `ipcRenderer.invoke` strips custom properties off rejections. -- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The *effective* cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. +- The handshake is idempotent, so `Playlist.stalkerToken` is re-presented and `get_profile` is skipped when it comes back unchanged (unless `not_valid` is set, or the persisted `stalkerSessionIdentity` no longer matches `stalkerSessionFingerprint(playlist)` — portal endpoint (origin **and** path) + identity + credentials; an edited endpoint, MAC or login must never inherit the previous session, and a token with no recorded fingerprint counts as unverified. The path is deliberate: discovery preserves tenant base paths, so `/tenant-a/server/load.php` and `/tenant-b/server/load.php` are different portals on one host and must not share a session). The advertised watchdog cadence is persisted alongside it (`stalkerWatchdogTimeout`/`stalkerTimeslot`) precisely because that reuse skips the response carrying it — and the skip only applies once the cadence is known, so a legacy token-only playlist profiles once instead of being stranded on the default. The _effective_ cadence is stored, so stored absence means "never profiled" and nothing re-profiles on every start. - Watchdog: `get_events` immediately (`init=1`), then every `watchdog_timeout` s (default **120**, clamped 30–3600) offset by `timeslot`. Ping failures are logged only — a missed ping never invalidates auth, it only affects the portal's "online" reporting. - Full contract: `docs/architecture/stalker-portal.md` ("Session Authentication Lifecycle"). diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index abcacbe7a..ab6159071 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -599,16 +599,21 @@ logical content keep the key and attempted-target set. A different channel, movie, or exact episode changes the key and synchronously clears the diagnostic, attempts, temporary player override, and handoff position. Destroying the component also ends the session; the same key in a later component is a new -session. +session. Applying a different source under the same key, including another +catch-up programme, clears only the VOD handoff position; attempts and the +temporary player override remain available for the recovery session. On a terminal failure, the current binding is accepted only when both its generation and inline target still match. The current target becomes attempted, the sanitized diagnostic is stored, and recommendations are reranked. The `PlaybackBinding` contains exactly `{ generation, target }`. Changes to the playback URL, headers, DRM, live/VOD mode, target, or reload generation are -instead correlated with a fieldless opaque `Symbol` application token. Neither -object embeds source material, and recovery ownership state never stores URLs, -headers, DRM keys, error payloads, or credentials. +instead correlated with a fieldless opaque `Symbol` application token. Source +applications also advance a second fieldless source-revision `Symbol` that +resets the VOD handoff position; target-only switches and Retry leave that +revision stable. None of these ownership objects embed source material, and +recovery ownership state never stores URLs, headers, DRM keys, error payloads, +or credentials. Selecting a built-in recommendation records the target, clears the diagnostic, and installs a temporary local override ahead of the host override and saved diff --git a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts index 1afca3e57..6814720ce 100644 --- a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.spec.ts @@ -70,6 +70,28 @@ describe('PlaybackRecoverySession', () => { expect(session.accepts(binding)).toBe(false); }); + it('resets only the resume position when the source revision changes', () => { + const session = new PlaybackRecoverySession(); + const firstRevision = Symbol(); + session.syncSession('channel-a'); + expect(session.syncSourceRevision(firstRevision)).toBe(true); + session.recordInlineAttempt(InlinePlaybackPlayer.VideoJs); + session.recordTimeUpdate({ currentTime: 24, duration: 100 }, false); + session.beginPlayerSwitch(InlinePlaybackPlayer.Html5, false); + const attempts = session.attemptedTargets(); + + expect(session.syncSourceRevision(firstRevision)).toBe(false); + expect(session.resumeStartTime(5, false)).toBe(24); + + expect(session.syncSourceRevision(Symbol())).toBe(true); + expect(session.resumeStartTime(5, false)).toBe(5); + expect(session.attemptedTargets()).toBe(attempts); + expect(session.temporaryPlayerOverride()).toBe( + InlinePlaybackPlayer.Html5 + ); + expect(session.switchPending()).toBe(true); + }); + it('accepts an empty session key and compares keys exactly', () => { const session = new PlaybackRecoverySession(); diff --git a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts index 1fb0099ac..bc25fef82 100644 --- a/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts +++ b/libs/ui/playback/src/lib/web-player-view/playback-recovery-session.ts @@ -21,6 +21,7 @@ export class PlaybackRecoverySession { private readonly sessionKey = signal(null); private readonly generation = signal(0); private readonly resumePosition = signal(null); + private sourceRevision: symbol | null = null; public readonly attemptedTargets: Signal< ReadonlySet @@ -42,11 +43,22 @@ export class PlaybackRecoverySession { this.temporaryPlayerOverrideState.set(null); this.switchPendingState.set(false); this.resumePosition.set(null); + this.sourceRevision = null; this.activeBindingState.set(null); this.generation.update((generation) => generation + 1); return true; } + syncSourceRevision(revision: symbol): boolean { + if (this.sourceRevision === revision) { + return false; + } + + this.sourceRevision = revision; + this.resumePosition.set(null); + return true; + } + beginPlayback(target: InlinePlaybackPlayer): PlaybackBinding { const generation = this.generation() + 1; const binding: PlaybackBinding = Object.freeze({ diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts b/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts index e470aac14..cd12b5922 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-application-state.ts @@ -9,6 +9,7 @@ import { } from './web-player-playback-state'; export type WebPlayerApplicationToken = symbol; +export type WebPlayerSourceRevisionToken = symbol; export function createWebPlayerApplicationState(sources: { readonly playback: Signal; @@ -20,6 +21,7 @@ export function createWebPlayerApplicationState(sources: { }): { readonly playback: Signal; readonly isLive: Signal; + readonly sourceRevision: Signal; readonly token: Signal; } { const playback = computed(() => { @@ -38,15 +40,19 @@ export function createWebPlayerApplicationState(sources: { const explicit = sources.playback(); return explicit ? resolveWebPlayerIsLive(explicit) : true; }); - const token = computed(() => { + const sourceRevision = computed(() => { if (sources.playback() === null) { void sources.streamUrl(); void sources.startTime(); } void isLive(); + return Symbol(); + }); + const token = computed(() => { + void sourceRevision(); void sources.selectedPlayer(); void sources.reloadToken(); return Symbol(); }); - return { playback, isLive, token }; + return { playback, isLive, sourceRevision, token }; } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts index f27bc4f7a..b39ff89a5 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.recovery.spec.ts @@ -283,6 +283,56 @@ describe('WebPlayerViewComponent recovery integration', () => { expect(html5().startTime()).toBe(0); }); + it('resets the VOD handoff for a new same-key source without clearing recovery history', async () => { + setPlayback({ + streamUrl: 'https://example.com/program-a.m3u8', + isLive: false, + }); + await render(); + vjs().timeUpdate.emit({ currentTime: 48, duration: 120 }); + vjs().playbackIssue.emit(mediaIssue('videojs', 'program-a.m3u8')); + fixture.detectChanges(); + click('playback-recommendation-html5'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + expect(html5().startTime()).toBe(48); + + setPlayback({ + streamUrl: 'https://example.com/program-b.m3u8', + isLive: false, + }); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(html5().startTime()).toBe(0); + html5().playbackIssue.emit(mediaIssue('html5', 'program-b.m3u8')); + fixture.detectChanges(); + expect(playerActionIds()).toEqual([ + 'playback-fallback-mpv', + 'playback-fallback-vlc', + ]); + }); + + it('preserves the latest VOD position across a same-source retry', async () => { + setPlayback({ + streamUrl: 'https://example.com/retry-movie.m3u8', + isLive: false, + }); + await render(); + vjs().timeUpdate.emit({ currentTime: 63, duration: 120 }); + vjs().playbackIssue.emit(networkIssue('videojs')); + fixture.detectChanges(); + + click('playback-retry'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(vjs().startTime()).toBe(63); + }); + it('ignores actual outputs from the destroyed target generation after switching players', async () => { const failures: PlaybackDiagnosticCode[] = []; component.playbackFailed.subscribe((code) => failures.push(code)); @@ -445,7 +495,7 @@ describe('WebPlayerViewComponent recovery integration', () => { } }); - it('exposes a fieldless opaque application token without source material', async () => { + it('exposes fieldless opaque application tokens without source material', async () => { const sentinels = [ 'opaque-stream.example/private.m3u8', 'OpaqueAgent/4.0', @@ -464,15 +514,19 @@ describe('WebPlayerViewComponent recovery integration', () => { }); await render(); - const token = ( - component as unknown as { - readonly playbackApplicationToken?: () => unknown; - } - ).playbackApplicationToken?.(); - expect(token).toBeDefined(); - expect(typeof token).toBe('symbol'); - expect(Reflect.ownKeys(Object(token))).toEqual([]); - const inspected = `${String(token)} ${JSON.stringify({ token })}`; + const tokens = component as unknown as { + readonly playbackApplicationToken?: () => unknown; + readonly playbackSourceRevisionToken?: () => unknown; + }; + const applicationToken = tokens.playbackApplicationToken?.(); + const sourceRevisionToken = tokens.playbackSourceRevisionToken?.(); + expect(applicationToken).toBeDefined(); + expect(sourceRevisionToken).toBeDefined(); + expect(typeof applicationToken).toBe('symbol'); + expect(typeof sourceRevisionToken).toBe('symbol'); + expect(Reflect.ownKeys(Object(applicationToken))).toEqual([]); + expect(Reflect.ownKeys(Object(sourceRevisionToken))).toEqual([]); + const inspected = `${String(applicationToken)} ${String(sourceRevisionToken)} ${JSON.stringify({ applicationToken, sourceRevisionToken })}`; for (const sentinel of sentinels) { expect(inspected).not.toContain(sentinel); } diff --git a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts index 59a2cca2f..be48b0b61 100644 --- a/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts +++ b/libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts @@ -147,6 +147,7 @@ export class WebPlayerViewComponent implements OnDestroy { }); readonly resolvedPlayback = this.applicationState.playback; readonly resolvedIsLive = this.applicationState.isLive; + readonly playbackSourceRevisionToken = this.applicationState.sourceRevision; readonly playbackApplicationToken = this.applicationState.token; readonly effectiveStartTime = computed(() => this.recoverySession.resumeStartTime( @@ -193,6 +194,10 @@ export class WebPlayerViewComponent implements OnDestroy { constructor() { effect(() => { this.syncRecoverySession(); + const sourceRevision = this.playbackSourceRevisionToken(); + untracked(() => + this.recoverySession.syncSourceRevision(sourceRevision) + ); const token = this.playbackApplicationToken(); const playback = untracked(this.resolvedPlayback); const isLive = untracked(this.resolvedIsLive);