fix(playback): bind external launch ownership

This commit is contained in:
4gray committed 2026-08-09 12:52:05 +02:00
1 parent b4408e84e2
commit 24482c429e
31 files changed
+566 -129

No files matched your search

+6 -2
View File
@@ -291,8 +291,12 @@ Key files:
evidence or ownership state. MPV/VLC actions remain mounted after an attempt
and expose credential-free per-target launching/started/playing/error state;
only an exact Electron `playing` update is labelled Playing. One handshake is
allowed at a time, a replacement waits for confirmed teardown of the tracked
external process, and stale updates cannot take ownership. If the local
allowed at a time. Its fieldless intent is bound to the exact session returned
by the source owner's launch promise, so a late timed-out attempt cannot take
over a retry; later global updates must match that ID. A replacement waits for
confirmed teardown of the tracked external process, applies the old exact
close before launch, and cancels an unlaunched handoff if diagnostic ownership
changes. If the local
handshake times out after an exact Electron session is known, that ID remains
correlated so a later exact update can recover the UI. The global dock mirrors
those statuses, keeps errors visible until dismissal, and intentionally has no
+7 -3
View File
@@ -827,8 +827,12 @@ app as a real argument, so it is not an option.
evidence or ownership state. MPV/VLC actions remain mounted after an attempt
and expose credential-free per-target launching/started/playing/error state;
only an exact Electron `playing` update is labelled Playing. One handshake is
allowed at a time, a replacement waits for confirmed teardown of the tracked
external process, and stale updates cannot take ownership. If the local
allowed at a time. Its fieldless intent is bound to the exact session returned
by the source owner's launch promise, so a late timed-out attempt cannot take
over a retry; later global updates must match that ID. A replacement waits for
confirmed teardown of the tracked external process, applies the old exact
close before launch, and cancels an unlaunched handoff if diagnostic ownership
changes. If the local
handshake times out after an exact Electron session is known, that ID remains
correlated so a later exact update can recover the UI. The global dock mirrors
those statuses, keeps errors visible until dismissal, and intentionally has no
@@ -1187,7 +1191,7 @@ engine` (restart required) or
- Portal mode (full vs. simple) follows OBSERVED behavior, never a URL substring. The single predicate is `isFullStalkerPortalPlaylist()` / `isFullStalkerPortalUrl()` in `@iptvnator/shared/interfaces` (`stalker-portal-mode.util.ts`): the persisted `Playlist.isFullStalkerPortal` flag is authoritative and the URL shape is a fallback for legacy rows only. Three diverging copies of this rule used to exist and shipped broken configurations (#850/#686/#755) — never re-implement it. A token-enforcing `portal.php` panel is a full portal; a `server/load.php` endpoint that answers without a token is a simple one.
- Import probes candidates in order (a pasted `.php` endpoint first, then `<base>/portal.php` → `<base>/server/load.php` → `<base>/stalker_portal/server/load.php`) and classifies each by behavior — a token-less `itv/get_genres` returning data proves a token-free panel; the plain-text auth failure proves a full portal, confirmed by a real handshake + `get_profile`. `StalkerPortalDiscoveryService` (`libs/portal/stalker/data-access`) persists the proven endpoint and mode.
- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair *drives*, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
- `executeStalkerRequest()` (`stores/utils/stalker-request.utils.ts`) is the choke point for catalog, content and playback requests: mode routing, the in-session repair override, and retry-once all live there. Four callers are deliberately outside it because they run below or before the thing it routes on — `StalkerAuthApi` (handshake/`get_profile`/`do_auth`, which the full-portal branch is built from; routing them back would recurse), `StalkerPortalDiscoveryService` (probes precede the mode they determine), `StalkerAccountInfoService.fetchViaProfile()`, and `StreamResolverService` for a collection item with no playlist row. They are exempt from the routing, not from the repair it hooks, but only `fetchViaProfile()` wires `StalkerPortalRepairService` itself: discovery is what repair _drives_, the row-less resolver branch has no playlist to repair, and the auth layer needs nothing — a terminal handshake failure propagates out of the full-portal branch into whichever `executeStalkerRequest()` call triggered the authentication, which is why terminal handshake failures are a repair trigger. Anything new that is not auth or discovery belongs on `executeStalkerRequest()`. Existing playlists are repaired LAZILY (`StalkerPortalRepairService`) — only after a request fails with a shape a wrong endpoint/mode produces, at most once per source configuration per playlist per session, persisted through the atomic `PlaylistsService.transformPlaylistMeta`. There is deliberately **no eager one-shot migration**: a portal that works is never re-probed.
- Both transports build the wire format from the same shared builders in `@iptvnator/shared/interfaces` — `buildStalkerRequestUrl()`, `buildStalkerIdentityRequestContext()`, `encodeStalkerCmdValue()` — so the Electron and PWA legs cannot drift. The mock's `/stalker` mirror shares the identity builder only — it dispatches in-process, so there is no portal URL to build and it mirrors the `JsHttpRequest` default by hand. Never fork any of them.
- Simple portals skip the auth lifecycle (no handshake, token or watchdog) but their requests are not stripped to a bare cookie: they still carry everything the shared builder derives from a MAC alone (`mac`/`stb_lang`/`timezone` cookie, MAG `User-Agent`/`X-User-Agent`, `Accept` set). They do NOT carry the serial — `dispatchStalkerRequest()`'s direct branch forwards only `url`/`macAddress`/`params`, so no `SN` header and no serial-derived `__cfduid`, whatever the playlist stores. That gate is on API requests only: `buildStalkerExternalPlaybackHeaders()` reads the serial off the playlist row with no mode check, so the same simple-mode playlist does send `SN`/`__cfduid` with a portal-owned stream.
- Contract: `docs/architecture/stalker-portal.md` ("Portal Mode and Endpoint Discovery", "Request Transport and `cmd` Encoding").
@@ -205,6 +205,48 @@ describe('external player shutdown on app quit', () => {
shutdownMpvSession();
});
it('waits for the stale reusable MPV process to exit before spawning fresh', async () => {
shutdownMpvSession();
const staleProc = createMockChildProcess();
(spawn as unknown as jest.Mock).mockReturnValueOnce(staleProc);
mockStoreValues({
[MPV_PLAYER_PATH]: '/usr/bin/mpv',
[MPV_REUSE_INSTANCE]: true,
});
await openMpvPlayer({
title: 'First stream',
url: 'https://example.com/one.m3u8',
});
(createConnection as unknown as jest.Mock).mockImplementation(() => {
const socket = Object.assign(new EventEmitter(), {
write: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
});
setImmediate(() => socket.emit('error', new Error('stale socket')));
return socket;
});
const freshProc = createMockChildProcess();
(spawn as unknown as jest.Mock).mockReturnValueOnce(freshProc);
const secondLaunch = openMpvPlayer({
title: 'Second stream',
url: 'https://example.com/two.m3u8',
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(staleProc.kill).toHaveBeenCalledTimes(1);
expect(spawn).toHaveBeenCalledTimes(1);
Object.defineProperty(staleProc, 'exitCode', { value: 0 });
staleProc.emit('exit', 0);
await waitForSpawnCallCount(2);
await secondLaunch;
expect(spawn).toHaveBeenCalledTimes(2);
shutdownMpvSession();
});
it('does not track non-reusable MPV processes for shutdown', async () => {
const proc = createMockChildProcess();
(spawn as unknown as jest.Mock).mockReturnValue(proc);
@@ -348,8 +348,11 @@ export async function openMpvPlayer({
return externalPlayerSessions.markOpened(session.id) ?? session;
} catch (err) {
console.error('Failed to send command to existing MPV:', err);
mpvProcess = null;
mpvSocketPath = null;
await terminateExternalPlayerProcess(reusedProcess);
if (mpvProcess === reusedProcess) {
mpvProcess = null;
mpvSocketPath = null;
}
stopPositionPolling();
}
}
@@ -207,11 +207,18 @@ describe('vlc-session.service process lifecycle', () => {
title: 'Second',
url: 'https://example.com/two.m3u8',
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(proc.kill).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledTimes(1);
Object.defineProperty(proc, 'exitCode', { value: 0 });
proc.emit('exit', 0);
await waitForSpawnCallCount(2);
freshProc.emit('spawn');
const session = await openPromise;
expect(proc.kill).toHaveBeenCalled();
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(session.status).toBe('opened');
});
});
@@ -425,15 +425,11 @@ export async function openVlcPlayer({
'Failed to reuse existing VLC, spawning fresh:',
err
);
if (vlcProcess && !vlcProcess.killed) {
try {
vlcProcess.kill();
} catch {
// Ignore cleanup failures.
}
await terminateExternalPlayerProcess(reusedProcess);
if (vlcProcess === reusedProcess) {
vlcProcess = null;
vlcRcPort = null;
}
vlcProcess = null;
vlcRcPort = null;
stopVlcPositionPolling();
}
}
+11 -5
View File
@@ -661,11 +661,17 @@ target becomes an explicit reopen action and a failed target becomes Try again.
Only an exact correlated Electron `playing` session update earns the Playing
label. A single external launch handshake owns the session: duplicate actions
are ignored, other external actions wait, and an existing live external session
must close before a different player can start. A replacement does not launch
until teardown of the exact spawned process is confirmed. If the local
handshake timeout fires after Electron has supplied an exact session ID, the ID
stays correlated so a later `opened`, `playing`, or `error` update can reconcile
the action with the global dock.
must close before a different player can start. The source-owning host binds
its returned launch promise to the fieldless current intent, so only that exact
result supplies the initial session ID; a late result from a timed-out attempt
cannot take over a retry. Later global updates must match the exact ID. A
replacement does not launch until teardown of the exact spawned process is
confirmed, and the old target is settled synchronously before the new launch so
coalesced signal effects cannot preserve stale feedback. If diagnostic
ownership changes during close, the unlaunched intent is cancelled without a
false launch error. If the local handshake timeout fires after Electron has
supplied an exact session ID, the ID stays correlated so a later `opened`,
`playing`, or `error` update can reconcile the action with the global dock.
The global external-playback dock uses the same Electron session status. It
shows Opening player with progress during launch, Player started for `opened`,
@@ -4,7 +4,7 @@
**Goal:** Keep MPV/VLC recovery actions visible while reporting exact launch/session state in the diagnostic panel and global dock.
**Architecture:** Add a component-local, credential-free external recovery state machine beside `PlaybackRecoverySession`. It correlates a launch intent to the next matching global `ExternalPlayerSession`, reranks but never removes attempted external targets, and rejects stale session/timer updates. The existing host output still owns source-specific launch payloads; `PORTAL_EXTERNAL_PLAYBACK` supplies session observation and close-before-switch.
**Architecture:** Add a component-local, credential-free external recovery state machine beside `PlaybackRecoverySession`. The existing host output still owns source-specific launch payloads and binds its returned launch promise to the fieldless current intent; only that exact result supplies the initial session ID. `PORTAL_EXTERNAL_PLAYBACK` then supplies exact-ID status observation and close-before-switch. Attempts rerank but never remove external targets, while stale promises, sessions, timers, and diagnostic owners are rejected.
**Tech Stack:** Angular signals and control flow, Angular Material icons/spinner, Jest, Nx, Playwright Electron E2E, ngx-translate.
@@ -24,7 +24,7 @@ Cover the exact public contract:
```typescript
const state = new ExternalPlaybackRecovery();
state.syncSession('content-a');
const intent = state.begin('mpv', 'old-session');
const intent = state.begin('mpv');
expect(intent).not.toBeNull();
expect(state.pending()).toBe(true);
@@ -33,7 +33,7 @@ expect(state.target('mpv')).toMatchObject({
attempts: 1,
sessionId: null,
});
expect(state.begin('vlc', 'old-session')).toBeNull();
expect(state.begin('vlc')).toBeNull();
expect(state.observe(session({ id: 'old-session', player: 'mpv' }))).toBe(
false
@@ -41,7 +41,12 @@ expect(state.observe(session({ id: 'old-session', player: 'mpv' }))).toBe(
expect(state.observe(session({ id: 'new-session', player: 'vlc' }))).toBe(
false
);
expect(state.observe(session({ id: 'new-session', player: 'mpv' }))).toBe(true);
expect(state.observe(session({ id: 'new-session', player: 'mpv' }))).toBe(
false
);
expect(
state.confirm(intent!, session({ id: 'new-session', player: 'mpv' }))
).toBe(true);
expect(state.target('mpv').status).toBe('started');
```
@@ -84,11 +89,14 @@ export class ExternalPlaybackRecovery {
>;
readonly pending: Signal<boolean>;
syncSession(key: string): boolean;
begin(
target: ExternalPlayerName,
previousSessionId: string | null
): ExternalRecoveryIntent | null;
begin(target: ExternalPlayerName): ExternalRecoveryIntent | null;
owns(intent: ExternalRecoveryIntent): boolean;
confirm(
intent: ExternalRecoveryIntent,
session: ExternalPlayerSession
): boolean;
cancel(intent: ExternalRecoveryIntent): boolean;
close(target: ExternalPlayerName, sessionId: string): boolean;
observe(session: ExternalPlayerSession | null): boolean;
fail(intent: ExternalRecoveryIntent): boolean;
target(target: ExternalPlayerName): ExternalRecoveryTargetState;
@@ -97,8 +105,8 @@ export class ExternalPlaybackRecovery {
```
The internal launch timer is 10 seconds, and all ownership tokens are fieldless
`Symbol()` values. Store only target, attempt count, status, ignored prior ID,
and the correlated session ID.
`Symbol()` values. Store only target, attempt count, status, and the exact
session ID returned through the owned host launch promise.
- [ ] **Step 4: Run GREEN and refactor**
@@ -28,21 +28,21 @@ This is the smallest change, but the UI would guess when launching finished
and could disagree with the Electron session. It is rejected because the app
already has structured launch state.
### 2. Add callbacks to every `PlaybackFallbackRequest` host
### 2. Use only callbacks on every `PlaybackFallbackRequest` host
Every M3U, Xtream, Stalker, and shared host could return its launch promise to
the web-player component. This is explicit but spreads recovery bookkeeping
through many source-owning components and still misses later `playing` and
`error` session updates.
Every M3U, Xtream, Stalker, and shared host could make the launch promise the
only feedback source. This supplies an exact initial session ID, but by itself
still misses later `playing` and `error` session updates.
### 3. Correlate the recovery attempt with the shared external session
### 3. Correlate the owned launch result with the shared external session
This is the selected approach. The web-player component keeps source launch
ownership in its existing host output, but observes the app-wide
`PORTAL_EXTERNAL_PLAYBACK.activeSession` signal. A local coordinator accepts
only the next session for the requested target and then only updates for that
exact session ID. No URL, headers, DRM data, credentials, or raw diagnostic
payload enters this ownership state.
ownership in its existing host output. Its credential-free tracker binds the
host's returned launch promise to the current fieldless intent token, and only
that exact result may supply the initial session ID. Later status changes come
from `PORTAL_EXTERNAL_PLAYBACK.activeSession` and must match that ID. This
prevents a delayed timed-out attempt from taking over a retry. No URL, headers,
DRM data, credentials, or raw diagnostic payload enters the ownership state.
## State Model
@@ -99,9 +99,11 @@ concise `aria-live="polite"` status text.
Before a recovery action opens another external player, it closes the currently
tracked live external session through `PORTAL_EXTERNAL_PLAYBACK`. If the
session is still live and cannot be closed, the new attempt fails locally
instead of starting a second process. Only after that close settles does the
existing host output emit the new fallback request. MPV and VLC are available
again after the handshake.
instead of starting a second process. The coordinator applies the exact
confirmed close synchronously before emitting the replacement, so coalesced
Angular effects cannot leave stale Playing feedback. If diagnostic ownership
changes while closing, the unlaunched intent is cancelled without reporting a
launch error. MPV and VLC are available again after the handshake.
If no matching session arrives within a bounded timeout, the target transitions
to `error`. The timeout is feedback for a missing handoff, not evidence about
@@ -1,5 +1,6 @@
import type {
ExternalPlayerName,
ExternalPlayerSession,
ResolvedPortalPlayback,
} from '@iptvnator/shared/interfaces';
import type { ErrorDetails, ErrorTypes } from 'hls.js';
@@ -278,4 +279,7 @@ export interface PlaybackFallbackRequest {
readonly player: ExternalPlayerName;
readonly playback: ResolvedPortalPlayback;
readonly diagnostic: PlaybackDiagnostic;
readonly trackLaunch: (
launch: Promise<ExternalPlayerSession | void>
) => void;
}
@@ -582,9 +582,13 @@ describe('VideoPlayerComponent', () => {
Origin: 'https://origin.example.com',
},
};
const launch = Promise.resolve();
const trackLaunch = jest.fn();
dataServiceMock.sendIpcEvent.mockReturnValueOnce(launch);
component.handleExternalFallbackRequest({
player: 'mpv',
playback,
trackLaunch,
diagnostic: {
code: 'unsupported-codec',
source: 'hls',
@@ -605,6 +609,7 @@ describe('VideoPlayerComponent', () => {
origin: 'https://origin.example.com',
}
);
expect(trackLaunch).toHaveBeenCalledWith(launch);
});
it('renders the embedded mpv inline player with the EPG panel', () => {
@@ -1083,10 +1083,14 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
return;
}
this.dataService.sendIpcEvent(
request.player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER,
payload
const launch = Promise.resolve(
this.dataService.sendIpcEvent<ExternalPlayerSession>(
request.player === 'mpv' ? OPEN_MPV_PLAYER : OPEN_VLC_PLAYER,
payload
)
);
request.trackLaunch(launch);
void launch;
}
private toLiveEpgPanelSummary(
@@ -611,6 +611,7 @@ describe('UnifiedLiveTabComponent', () => {
webPlayer.externalFallbackRequested.emit({
player: 'mpv',
playback,
trackLaunch: jest.fn(),
diagnostic: {
code: 'network-error',
player: 'videojs',
@@ -537,10 +537,12 @@ export class UnifiedLiveTabComponent {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
onClose(): void {
@@ -267,10 +267,12 @@ export class StalkerCatalogDetailComponent implements OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
async onVodDownload(item: VodDetailsItem): Promise<void> {
@@ -543,6 +543,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
const request: PlaybackFallbackRequest = {
player: 'vlc',
playback,
trackLaunch: jest.fn(),
diagnostic: {
code: 'network-error',
player: 'html5',
@@ -563,6 +564,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(portalPlayer.openExternalPlayback.mock.calls[0][0]).toBe(
playback
);
expect(request.trackLaunch).toHaveBeenCalledWith(
portalPlayer.openExternalPlayback.mock.results[0].value
);
});
it('renders radio channel rows with compact no-EPG density', () => {
@@ -1394,9 +1394,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
}
@@ -900,10 +900,12 @@ export class StalkerSeriesViewComponent implements OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
playPreviousEpisode(): void {
@@ -153,9 +153,11 @@ export class StalkerVodPlaybackController {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.config.portalPlayer.openExternalPlayback(
const launch = this.config.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
}
@@ -625,6 +625,7 @@ describe('LiveStreamLayoutComponent', () => {
component.handleExternalFallbackRequest({
player: 'mpv',
playback,
trackLaunch: jest.fn(),
} as PlaybackFallbackRequest);
const [forwardedPlayback, forwardedPlayer] =
@@ -565,10 +565,12 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
private getAllLiveStreams(): XtreamLiveChannelItem[] {
@@ -295,12 +295,14 @@ export class SerialDetailsPlaybackService {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void this.playbackPositionState.recordExternalLaunch(
request.playback,
this.portalPlayer.openExternalPlayback(
request.playback,
request.player
),
launch,
(playlistId, position) =>
this.playbackPositions.savePlaybackPosition(
playlistId,
@@ -776,8 +776,10 @@ describe('SerialDetailsComponent', () => {
const playbackService = fixture.debugElement.injector.get(
SerialDetailsPlaybackService
);
const trackLaunch = jest.fn();
playbackService.handleExternalFallbackRequest({
player: 'mpv',
trackLaunch,
playback: {
streamUrl: 'http://xtream.example/series/2001.mp4',
title: 'Season 2 Episode 1',
@@ -795,6 +797,9 @@ describe('SerialDetailsComponent', () => {
await fixture.whenStable();
expect(openExternalPlayback).toHaveBeenCalledTimes(1);
expect(trackLaunch).toHaveBeenCalledWith(
openExternalPlayback.mock.results[0].value
);
expect(savePlaybackPosition).toHaveBeenCalledWith(
'xtream-1',
expect.objectContaining({
@@ -324,10 +324,12 @@ export class VodDetailsPlaybackService {
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
void this.portalPlayer.openExternalPlayback(
const launch = this.portalPlayer.openExternalPlayback(
request.playback,
request.player
);
request.trackLaunch(launch);
void launch;
}
async loadPosition(playlistId: string, vodId: number): Promise<void> {
@@ -4,6 +4,11 @@ import type { ExternalPlayerSession } from '@iptvnator/shared/interfaces';
import { ExternalPlaybackRecoveryCoordinator } from './external-playback-recovery-coordinator';
describe('ExternalPlaybackRecoveryCoordinator', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('does not launch beside a replacement live session that appears during close', async () => {
const previous = session({ id: 'previous', player: 'mpv' });
const activeSession = signal<ExternalPlayerSession | null>(previous);
@@ -24,7 +29,7 @@ describe('ExternalPlaybackRecoveryCoordinator', () => {
externalPlayback
);
coordinator.syncSession('content-a');
const ready = jest.fn();
const ready = jest.fn(() => true);
coordinator.request('vlc', jest.fn(), ready);
activeSession.set(session({ id: 'replacement', player: 'mpv' }));
@@ -35,6 +40,120 @@ describe('ExternalPlaybackRecoveryCoordinator', () => {
expect(coordinator.states().vlc.status).toBe('error');
coordinator.destroy();
});
it('applies the confirmed old-session close before launching a replacement', async () => {
const previous = session({ id: 'previous', player: 'mpv' });
const activeSession = signal<ExternalPlayerSession | null>(null);
const closeSession = jest.fn(async () => {
activeSession.set(
session({
id: 'previous',
player: 'mpv',
status: 'closed',
canClose: false,
})
);
});
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession,
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
let trackFirst:
| ((launch: Promise<ExternalPlayerSession | void>) => void)
| undefined;
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
trackFirst = trackLaunch;
return true;
});
trackFirst?.(Promise.resolve(previous));
await Promise.resolve();
activeSession.set(previous);
let stateAtReady: string | undefined;
coordinator.request('vlc', jest.fn(), () => {
stateAtReady = coordinator.states().mpv.status;
return true;
});
await Promise.resolve();
expect(stateAtReady).toBe('idle');
coordinator.destroy();
});
it('cancels an intent when launch ownership disappears during close', async () => {
const previous = session({ id: 'previous', player: 'mpv' });
const activeSession = signal<ExternalPlayerSession | null>(previous);
let releaseClose: (() => void) | undefined;
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession: jest.fn(
() =>
new Promise<void>((resolve) => {
releaseClose = () => {
activeSession.set(
session({
...previous,
status: 'closed',
canClose: false,
})
);
resolve();
};
})
),
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
coordinator.request('vlc', jest.fn(), () => false);
releaseClose?.();
await Promise.resolve();
expect(coordinator.pending()).toBe(false);
expect(coordinator.states().vlc.status).toBe('idle');
coordinator.destroy();
});
it('correlates only the exact promise bound to the current attempt', async () => {
const activeSession = signal<ExternalPlayerSession | null>(null);
const coordinator = new ExternalPlaybackRecoveryCoordinator({
activeSession,
visibleSession: activeSession,
closeSession: jest.fn(),
dismissActiveSession: jest.fn(),
});
coordinator.syncSession('content-a');
let firstTracker:
| ((launch: Promise<ExternalPlayerSession | void>) => void)
| undefined;
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
firstTracker = trackLaunch;
return true;
});
jest.advanceTimersByTime(10_000);
let retryTracker:
| ((launch: Promise<ExternalPlayerSession | void>) => void)
| undefined;
coordinator.request('mpv', jest.fn(), (trackLaunch) => {
retryTracker = trackLaunch;
return true;
});
firstTracker?.(
Promise.resolve(session({ id: 'late-first', player: 'mpv' }))
);
retryTracker?.(
Promise.resolve(session({ id: 'exact-retry', player: 'mpv' }))
);
await Promise.resolve();
expect(coordinator.states().mpv.sessionId).toBe('exact-retry');
coordinator.destroy();
});
});
function session(
@@ -6,6 +6,9 @@ import {
} from './external-playback-recovery';
export type ExternalRecoveryTarget = 'mpv' | 'vlc';
export type ExternalPlaybackLaunchTracker = (
launch: Promise<ExternalPlayerSession | void>
) => void;
/**
* Serializes managed external-player recovery without retaining playback data.
@@ -33,17 +36,17 @@ export class ExternalPlaybackRecoveryCoordinator {
request(
target: ExternalRecoveryTarget,
onBegin: () => void,
onReady: () => void
onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean
): void {
const activeSession = this.externalPlayback?.activeSession() ?? null;
const intent = this.recovery.begin(target, activeSession?.id ?? null);
const intent = this.recovery.begin(target);
if (!intent) {
return;
}
onBegin();
if (!isLiveExternalSession(activeSession)) {
onReady();
this.runReady(intent, onReady);
return;
}
if (!activeSession.canClose || !this.externalPlayback) {
@@ -61,7 +64,7 @@ export class ExternalPlaybackRecoveryCoordinator {
private async closeThenRun(
session: ExternalPlayerSession,
intent: ExternalRecoveryIntent,
onReady: () => void
onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean
): Promise<void> {
try {
await this.externalPlayback?.closeSession(session);
@@ -69,13 +72,37 @@ export class ExternalPlaybackRecoveryCoordinator {
this.recovery.fail(intent);
return;
}
this.recovery.close(session.player, session.id);
const current = this.externalPlayback?.activeSession() ?? null;
if (isLiveExternalSession(current)) {
this.recovery.fail(intent);
return;
}
if (this.recovery.owns(intent)) {
onReady();
this.runReady(intent, onReady);
}
private runReady(
intent: ExternalRecoveryIntent,
onReady: (trackLaunch: ExternalPlaybackLaunchTracker) => boolean
): void {
if (!this.recovery.owns(intent)) {
return;
}
const accepted = onReady((launch) => {
void launch.then(
(session) => {
if (session) {
this.recovery.confirm(intent, session);
} else {
this.recovery.fail(intent);
}
},
() => this.recovery.fail(intent)
);
});
if (!accepted) {
this.recovery.cancel(intent);
}
}
}
@@ -31,7 +31,7 @@ describe('ExternalPlaybackRecovery', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = recovery.begin('mpv', 'old-session');
const intent = recovery.begin('mpv');
expect(intent).not.toBeNull();
expect(recovery.pending()).toBe(true);
@@ -40,14 +40,14 @@ describe('ExternalPlaybackRecovery', () => {
sessionId: null,
status: 'launching',
});
expect(recovery.begin('vlc', 'old-session')).toBeNull();
expect(recovery.begin('vlc')).toBeNull();
recovery.destroy();
});
it('correlates only the next matching target and then requires its exact id', () => {
it('requires the exact owned launch result before accepting session updates', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', 'old-session');
const intent = requireIntent(recovery.begin('mpv'));
expect(
recovery.observe(session({ id: 'old-session', status: 'playing' }))
@@ -61,6 +61,12 @@ describe('ExternalPlaybackRecovery', () => {
recovery.observe(
session({ id: 'mpv-session', player: 'mpv', status: 'opened' })
)
).toBe(false);
expect(
recovery.confirm(
intent,
session({ id: 'mpv-session', player: 'mpv', status: 'opened' })
)
).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
@@ -84,8 +90,11 @@ describe('ExternalPlaybackRecovery', () => {
] as const)('maps exact session %s to %s', (sessionStatus, stateStatus) => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', null);
recovery.observe(session({ id: 'mpv-session', status: 'opened' }));
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({ id: 'mpv-session', status: 'opened' })
);
expect(
recovery.observe(
@@ -99,8 +108,11 @@ describe('ExternalPlaybackRecovery', () => {
it('returns a closed exact session to idle while retaining attempt history', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', null);
recovery.observe(session({ id: 'mpv-session', status: 'opened' }));
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({ id: 'mpv-session', status: 'opened' })
);
expect(
recovery.observe(
@@ -122,7 +134,7 @@ describe('ExternalPlaybackRecovery', () => {
it('times out a missing launch handoff without accepting its later session', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', null);
recovery.begin('mpv');
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
@@ -137,8 +149,9 @@ describe('ExternalPlaybackRecovery', () => {
it('keeps an exact launching session correlated after the local timeout', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', null);
recovery.observe(
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({ id: 'slow-mpv', player: 'mpv', status: 'launching' })
);
@@ -161,7 +174,7 @@ describe('ExternalPlaybackRecovery', () => {
it('invalidates an old intent and timer when the content session changes', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = recovery.begin('mpv', null);
const intent = recovery.begin('mpv');
expect(intent).not.toBeNull();
recovery.syncSession('content-b');
@@ -183,7 +196,7 @@ describe('ExternalPlaybackRecovery', () => {
it('clears the launch timer and ownership when destroyed', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', null);
recovery.begin('mpv');
recovery.destroy();
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
@@ -196,11 +209,11 @@ describe('ExternalPlaybackRecovery', () => {
it('fails only the current intent and counts a later retry', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const first = requireIntent(recovery.begin('mpv', null));
const first = requireIntent(recovery.begin('mpv'));
expect(recovery.fail(first)).toBe(true);
expect(recovery.fail(first)).toBe(false);
const second = requireIntent(recovery.begin('mpv', null));
const second = requireIntent(recovery.begin('mpv'));
expect(recovery.target('mpv')).toEqual({
attempts: 2,
@@ -211,12 +224,60 @@ describe('ExternalPlaybackRecovery', () => {
recovery.destroy();
});
it('rejects a late result from a timed-out attempt while accepting the retry result', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const first = requireIntent(recovery.begin('mpv'));
jest.advanceTimersByTime(EXTERNAL_RECOVERY_LAUNCH_TIMEOUT_MS);
const retry = requireIntent(recovery.begin('mpv'));
expect(
recovery.confirm(
first,
session({ id: 'late-first', status: 'opened' })
)
).toBe(false);
expect(
recovery.observe(session({ id: 'late-first', status: 'playing' }))
).toBe(false);
expect(
recovery.confirm(
retry,
session({ id: 'exact-retry', status: 'opened' })
)
).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 2,
sessionId: 'exact-retry',
status: 'started',
});
recovery.destroy();
});
it('cancels an unlaunched current intent without reporting an error', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const intent = requireIntent(recovery.begin('vlc'));
expect(recovery.cancel(intent)).toBe(true);
expect(recovery.pending()).toBe(false);
expect(recovery.target('vlc')).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
recovery.destroy();
});
it('updates the previous exact session without cancelling a different target intent', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', null);
recovery.observe(session({ id: 'mpv-session', status: 'opened' }));
const vlcIntent = requireIntent(recovery.begin('vlc', 'mpv-session'));
const mpvIntent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
mpvIntent,
session({ id: 'mpv-session', status: 'opened' })
);
const vlcIntent = requireIntent(recovery.begin('vlc'));
expect(
recovery.observe(
@@ -233,11 +294,31 @@ describe('ExternalPlaybackRecovery', () => {
recovery.destroy();
});
it('applies a confirmed exact close even when a reactive observer coalesces it', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
const mpvIntent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
mpvIntent,
session({ id: 'mpv-session', status: 'opened' })
);
expect(recovery.close('mpv', 'mpv-session')).toBe(true);
expect(recovery.target('mpv')).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
expect(recovery.close('mpv', 'other-session')).toBe(false);
recovery.destroy();
});
it('stores no stream URL, headers, credentials, title, or error text', () => {
const recovery = new ExternalPlaybackRecovery();
recovery.syncSession('content-a');
recovery.begin('mpv', null);
recovery.observe(
const intent = requireIntent(recovery.begin('mpv'));
recovery.confirm(
intent,
session({
error: 'token=private at /Users/example/player',
headers: undefined,
@@ -25,7 +25,6 @@ export type ExternalRecoveryStates = Readonly<
>;
interface ActiveIntent extends ExternalRecoveryIntent {
readonly ignoredSessionId: string | null;
readonly sessionId: string | null;
}
@@ -59,17 +58,13 @@ export class ExternalPlaybackRecovery {
return true;
}
begin(
target: ExternalPlayerName,
previousSessionId: string | null
): ExternalRecoveryIntent | null {
begin(target: ExternalPlayerName): ExternalRecoveryIntent | null {
if (this.activeIntentState() !== null) {
return null;
}
const token = Symbol();
const intent: ActiveIntent = Object.freeze({
ignoredSessionId: previousSessionId,
sessionId: null,
target,
token,
@@ -108,15 +103,7 @@ export class ExternalPlaybackRecovery {
const active = this.activeIntentState();
if (active) {
if (active.sessionId === null) {
if (
session.player !== active.target ||
session.id === active.ignoredSessionId
) {
return false;
}
this.activeIntentState.set(
Object.freeze({ ...active, sessionId: session.id })
);
return false;
} else if (session.id !== active.sessionId) {
return false;
}
@@ -130,6 +117,54 @@ export class ExternalPlaybackRecovery {
: false;
}
confirm(
intent: ExternalRecoveryIntent,
session: ExternalPlayerSession
): boolean {
if (!this.owns(intent) || session.player !== intent.target) {
return false;
}
const active = this.activeIntentState();
if (!active) {
return false;
}
this.activeIntentState.set(
Object.freeze({ ...active, sessionId: session.id })
);
return this.applySession(intent.target, session);
}
cancel(intent: ExternalRecoveryIntent): boolean {
if (!this.owns(intent)) {
return false;
}
const current = this.target(intent.target);
if (current.status === 'launching') {
this.setTarget(intent.target, {
attempts: current.attempts,
sessionId: null,
status: 'idle',
});
}
this.clearActiveIntent();
return true;
}
close(target: ExternalPlayerName, sessionId: string): boolean {
const current = this.target(target);
if (current.sessionId !== sessionId) {
return false;
}
this.setTarget(target, {
attempts: current.attempts,
sessionId: null,
status: 'idle',
});
return true;
}
fail(intent: ExternalRecoveryIntent): boolean {
if (!this.owns(intent)) {
return false;
@@ -16,6 +16,7 @@ import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
type PlaybackFallbackRequest,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
@@ -133,7 +134,7 @@ describe('WebPlayerViewComponent external recovery integration', () => {
afterEach(() => fixture.destroy());
it('keeps both external actions mounted while MPV launches and after it starts', async () => {
const requests: unknown[] = [];
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
@@ -155,9 +156,10 @@ describe('WebPlayerViewComponent external recovery integration', () => {
expect(requiredButton('playback-fallback-mpv')).toBe(mpvButton);
expect(requiredButton('playback-fallback-vlc')).toBe(vlcButton);
activeSession.set(
externalSession({ id: 'mpv-session', status: 'opened' })
);
const opened = externalSession({ id: 'mpv-session', status: 'opened' });
activeSession.set(opened);
requests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
expect(component.externalRecoveryPending()).toBe(false);
@@ -174,15 +176,16 @@ describe('WebPlayerViewComponent external recovery integration', () => {
});
it('closes an active external player before emitting a different target', async () => {
const requests: Array<{ player: string }> = [];
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
click('playback-fallback-mpv');
activeSession.set(
externalSession({ id: 'mpv-session', status: 'opened' })
);
const opened = externalSession({ id: 'mpv-session', status: 'opened' });
activeSession.set(opened);
requests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
click('playback-fallback-vlc');
@@ -194,6 +197,48 @@ describe('WebPlayerViewComponent external recovery integration', () => {
expect(requests.map(({ player }) => player)).toEqual(['mpv', 'vlc']);
});
it('cancels the pending replacement when the diagnostic clears during close', async () => {
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
await showDiagnostic();
click('playback-fallback-mpv');
const opened = externalSession({ id: 'mpv-session', status: 'opened' });
activeSession.set(opened);
requests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
let releaseClose: (() => void) | undefined;
closeSession.mockImplementationOnce(
(session) =>
new Promise<void>((resolve) => {
releaseClose = () => {
activeSession.set({
...session,
status: 'closed',
canClose: false,
});
resolve();
};
})
);
click('playback-fallback-vlc');
vjs().playbackIssue.emit(null);
fixture.detectChanges();
releaseClose?.();
await fixture.whenStable();
expect(requests.map(({ player }) => player)).toEqual(['mpv']);
expect(component.externalRecoveryPending()).toBe(false);
expect(component.externalRecoveryState().vlc).toEqual({
attempts: 1,
sessionId: null,
status: 'idle',
});
});
it('reports a local failure instead of launching beside an unclosable session', async () => {
activeSession.set(
externalSession({
@@ -242,7 +287,7 @@ describe('WebPlayerViewComponent external recovery integration', () => {
])(
'emits one exact $target request for a same-tick double activation',
async ({ target, testId }) => {
const requests: unknown[] = [];
const requests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
requests.push(request)
);
@@ -257,6 +302,7 @@ describe('WebPlayerViewComponent external recovery integration', () => {
player: target,
playback: component.resolvedPlayback(),
diagnostic: issue,
trackLaunch: expect.any(Function),
},
]);
}
@@ -16,6 +16,7 @@ import {
PlaybackDiagnosticCode,
PlaybackDiagnosticSource,
type PlaybackDiagnostic,
type PlaybackFallbackRequest,
} from '@iptvnator/playback/util';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
@@ -200,7 +201,7 @@ describe('WebPlayerViewComponent recovery integration', () => {
});
it('retries the active target with a reload while preserving attempts', async () => {
const fallbackRequests: unknown[] = [];
const fallbackRequests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
fallbackRequests.push(request)
);
@@ -209,9 +210,13 @@ describe('WebPlayerViewComponent recovery integration', () => {
fixture.detectChanges();
click('playback-fallback-mpv');
expect(fallbackRequests).toHaveLength(1);
activeExternalSession.set(
externalSession({ id: 'mpv-retry-session', status: 'opened' })
);
const opened = externalSession({
id: 'mpv-retry-session',
status: 'opened',
});
activeExternalSession.set(opened);
fallbackRequests[0].trackLaunch(Promise.resolve(opened));
await Promise.resolve();
fixture.detectChanges();
vjs().playbackIssue.emit(networkIssue('videojs'));
fixture.detectChanges();
@@ -234,24 +239,33 @@ describe('WebPlayerViewComponent recovery integration', () => {
});
it('keeps desktop browser-access guidance and both actions after both attempts', async () => {
const fallbackRequests: PlaybackFallbackRequest[] = [];
component.externalFallbackRequested.subscribe((request) =>
fallbackRequests.push(request)
);
await render();
vjs().playbackIssue.emit(browserAccessIssue('videojs'));
fixture.detectChanges();
click('playback-fallback-mpv');
activeExternalSession.set(
externalSession({ id: 'mpv-browser-session', status: 'opened' })
);
const openedMpv = externalSession({
id: 'mpv-browser-session',
status: 'opened',
});
activeExternalSession.set(openedMpv);
fallbackRequests[0].trackLaunch(Promise.resolve(openedMpv));
await Promise.resolve();
fixture.detectChanges();
click('playback-fallback-vlc');
await fixture.whenStable();
activeExternalSession.set(
externalSession({
id: 'vlc-browser-session',
player: 'vlc',
status: 'opened',
})
);
const openedVlc = externalSession({
id: 'vlc-browser-session',
player: 'vlc',
status: 'opened',
});
activeExternalSession.set(openedVlc);
fallbackRequests[1].trackLaunch(Promise.resolve(openedVlc));
await Promise.resolve();
fixture.detectChanges();
const banner = query('playback-diagnostic-banner');
@@ -66,9 +66,8 @@ import {
function resolveWebPlayerSharedControls(): boolean {
const storedValue = inject(SettingsStore).webPlayerSharedControls?.();
return typeof storedValue === 'boolean'
? storedValue
: WEB_PLAYER_SHARED_CONTROLS_ENABLED;
const fallback = WEB_PLAYER_SHARED_CONTROLS_ENABLED;
return typeof storedValue === 'boolean' ? storedValue : fallback;
}
@Component({
@@ -383,13 +382,17 @@ export class WebPlayerViewComponent implements OnDestroy {
this.externalRecovery.request(
target,
() => this.recoverySession.recordExternalAttempt(target),
() => {
if (this.visiblePlaybackDiagnostic() !== diagnostic) return;
(trackLaunch) => {
if (this.visiblePlaybackDiagnostic() !== diagnostic) {
return false;
}
this.externalFallbackRequested.emit({
player: target,
playback: this.resolvedPlayback(),
diagnostic,
trackLaunch,
});
return true;
}
);
return;