fix(portals): queue a different Xtream episode behind a settling launch

Only a repeat of the same episode is ignored while its launch settles;
another episode of the series waits its turn and then replaces the
running player like any later start.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-10-03 14:04:35 +02:00
1 parent 65d7804b21
commit f7c71c222a
2 files changed
+62 -7

No files matched your search

@@ -34,6 +34,9 @@ function session(
} as ExternalPlayerSession;
}
/** Lets the launch chain's microtasks run up to the player call. */
const flush = () => new Promise((resolve) => setTimeout(resolve));
describe('openEpisodeExternally', () => {
function host(active: ExternalPlayerSession | null) {
const openExternalPlayback = jest.fn().mockResolvedValue(undefined);
@@ -100,12 +103,47 @@ describe('openEpisodeExternally', () => {
);
const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
await openEpisodeExternally(t.host, PLAYBACK, 'mpv');
await flush();
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
settleLaunch();
await first;
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
});
it('queues another episode behind the first launch instead of dropping it', async () => {
const t = host(null);
let settleLaunch: () => void = () => undefined;
t.openExternalPlayback.mockImplementationOnce(
() => new Promise<void>((resolve) => (settleLaunch = resolve))
);
const first = openEpisodeExternally(t.host, PLAYBACK, 'mpv');
const second = openEpisodeExternally(
t.host,
{
...PLAYBACK,
streamUrl: 'http://xtream.example/series/1003.mp4',
contentInfo: {
...PLAYBACK.contentInfo!,
contentXtreamId: 1003,
},
},
'mpv'
);
await flush();
expect(t.openExternalPlayback).toHaveBeenCalledTimes(1);
settleLaunch();
await first;
await second;
expect(t.openExternalPlayback).toHaveBeenCalledTimes(2);
expect(t.openExternalPlayback.mock.calls[1][0]).toEqual(
expect.objectContaining({
streamUrl: 'http://xtream.example/series/1003.mp4',
})
);
});
it('keeps the running player when closing it fails', async () => {
const t = host(session());
t.closeSession.mockRejectedValue(new Error('still busy'));
@@ -20,16 +20,20 @@ export interface SeriesExternalLaunchHost {
launchOwner(): string | null;
}
/** Owners (`playlist:series`) whose launch has not settled yet. */
/** `owner:episode` keys of launches that have not settled yet. */
const launchesInFlight = new Set<string>();
/** Per owner, the tail of its launch chain: a second episode waits its turn. */
const launchChains = new Map<string, Promise<unknown>>();
/**
* The "…" menu's MPV/VLC launch of an episode. An episode of this series
* still running externally is closed first: with instance reuse off a
* second detached player would start beside it. When that close fails, or
* the user moved on while it ran, the running player stays and nothing new
* launches. A repeat before the first launch settled (Electron publishes
* the session only afterwards) is ignored.
* launches. A repeat of the same episode before its launch settled
* (Electron publishes the session only afterwards) is ignored; another
* episode of the series waits for that launch to settle and then replaces
* it like any later start.
*/
export async function openEpisodeExternally(
host: SeriesExternalLaunchHost,
@@ -37,14 +41,27 @@ export async function openEpisodeExternally(
player: ExternalPlayerName
): Promise<ExternalPlayerSession | void> {
const owner = host.launchOwner();
if (!owner || launchesInFlight.has(owner)) {
if (!owner) {
return;
}
launchesInFlight.add(owner);
const key = `${owner}:${playback.contentInfo?.contentXtreamId ?? playback.streamUrl}`;
if (launchesInFlight.has(key)) {
return;
}
launchesInFlight.add(key);
const previous = launchChains.get(owner) ?? Promise.resolve();
const run = previous.then(() =>
launchEpisode(host, owner, playback, player)
);
const tail = run.catch(() => undefined);
launchChains.set(owner, tail);
try {
return await launchEpisode(host, owner, playback, player);
return await run;
} finally {
launchesInFlight.delete(owner);
launchesInFlight.delete(key);
if (launchChains.get(owner) === tail) {
launchChains.delete(owner);
}
}
}