fix(playback): reset resume for new sources

This commit is contained in:
4gray committed 2026-08-04 21:27:34 +02:00
1 parent eb9ae0de18
commit 2db8ad2455
8 files changed
+133 -25

No files matched your search

+6 -4
View File
@@ -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
+7 -5
View File
@@ -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").
@@ -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
@@ -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();
@@ -21,6 +21,7 @@ export class PlaybackRecoverySession {
private readonly sessionKey = signal<string | null>(null);
private readonly generation = signal(0);
private readonly resumePosition = signal<number | null>(null);
private sourceRevision: symbol | null = null;
public readonly attemptedTargets: Signal<
ReadonlySet<PlaybackRecommendationTarget>
@@ -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({
@@ -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<ResolvedPortalPlayback | null>;
@@ -20,6 +21,7 @@ export function createWebPlayerApplicationState(sources: {
}): {
readonly playback: Signal<ResolvedPortalPlayback>;
readonly isLive: Signal<boolean>;
readonly sourceRevision: Signal<WebPlayerSourceRevisionToken>;
readonly token: Signal<WebPlayerApplicationToken>;
} {
const playback = computed(() => {
@@ -38,15 +40,19 @@ export function createWebPlayerApplicationState(sources: {
const explicit = sources.playback();
return explicit ? resolveWebPlayerIsLive(explicit) : true;
});
const token = computed<WebPlayerApplicationToken>(() => {
const sourceRevision = computed<WebPlayerSourceRevisionToken>(() => {
if (sources.playback() === null) {
void sources.streamUrl();
void sources.startTime();
}
void isLive();
return Symbol();
});
const token = computed<WebPlayerApplicationToken>(() => {
void sourceRevision();
void sources.selectedPlayer();
void sources.reloadToken();
return Symbol();
});
return { playback, isLive, token };
return { playback, isLive, sourceRevision, token };
}
@@ -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);
}
@@ -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);