mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
fix(playback): bind external launch ownership
This commit is contained in:
1 parent
b4408e84e2
commit
24482c429e
31 files changed
+566
-129
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
@@ -611,6 +611,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
webPlayer.externalFallbackRequested.emit({
|
||||
player: 'mpv',
|
||||
playback,
|
||||
trackLaunch: jest.fn(),
|
||||
diagnostic: {
|
||||
code: 'network-error',
|
||||
player: 'videojs',
|
||||
|
||||
+3
-1
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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> {
|
||||
|
||||
+4
@@ -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', () => {
|
||||
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
@@ -625,6 +625,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
component.handleExternalFallbackRequest({
|
||||
player: 'mpv',
|
||||
playback,
|
||||
trackLaunch: jest.fn(),
|
||||
} as PlaybackFallbackRequest);
|
||||
|
||||
const [forwardedPlayback, forwardedPlayer] =
|
||||
|
||||
+3
-1
@@ -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[] {
|
||||
|
||||
+6
-4
@@ -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> {
|
||||
|
||||
+120
-1
@@ -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;
|
||||
|
||||
+55
-9
@@ -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),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
+28
-14
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user