mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
fix(playback): reset resume for new sources
This commit is contained in:
1 parent
eb9ae0de18
commit
2db8ad2455
8 files changed
+133
-25
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+64
-10
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user