fix(portals): do not spend a source's failover turn on mere selection

`setActiveSource` marked the source tried, but discovery calls it the moment
the page opens and a pin or the picker can call it before anything plays. So
opening a movie burned the route copy's turn: if a pinned alternative then
failed, failover skipped a healthy untouched source — and with only one
alternative, reported the options exhausted.

Selection and attempt are now separate. `setActiveSource` selects;
`markPlaying` also spends the turn, and only the three places that really
start playback call it. `runFailover` additionally retires whatever is on
screen before picking, so the failing source is spent however it got there —
relying on the start paths alone would leave one hole per path, and the cost
of missing it is a ping-pong between two sources.

One existing spec asserted the old behaviour (a route copy burned by a switch
it never played); it now plays first, so it still covers what it meant to —
that the tried set survives a rediscovery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 18:40:08 +02:00
1 parent 10a42612d5
commit 7cce227268
7 files changed
+91 -14

No files matched your search

+1 -1
View File
@@ -838,7 +838,7 @@ engine` (restart required) or
- Switching = one `inlinePlayback.set({...next, startTime})`, never null-then-set, so the player and engine survive and re-seek. The carried position is read *before* the 15s persistence throttle, and `VodDetailsPlaybackService` uses a one-shot `resumeSettled` latch so a resuming engine's `timeupdate` at ~0 cannot overwrite the resume point. `handleInlineTimeUpdate` returns that verdict and the route feeds multi-source the requested `startTime` until the engine reaches it — one latch for both, or a switch during the initial seek would restart the film. Before anything plays there is no live position at all, so the controller is seeded from the persisted one (`seedResumeSeconds`, one-way: a live value always wins). Portal failures in the multi-source path log through the redacting `createLogger`/`redactSensitiveData` — an Xtream error message carries the stream URL, and that URL is built out of the username and password.
- Pins are keyed portal-agnostically (`tmdb:{id}` else `title:{base}:{year}` else the yearless `title:{base}:`, `vod_source_pins` table); enrichment supplies the id and the year late, so a pin may sit under any poorer form — three key sets (`pinKeysFor`): `lookup` passes every alias most-trusted-first, `write` holds only keys naming exactly one film, and `loaded` records where the pin on screen was found — the yearless alias is readable but never written or deleted on spec, since it is shared by every remake, with the single exception of the row this session actually read. A pin is not decoration: the primary Play action starts from the pinned source (except when that button reads Stop — an active external session wins, or the control would launch a second player), and it outranks everything else in failover ranking. The row changes only after the write lands, so a refused pin is never shown as saved. Starting a pinned source loads THAT source's own playback position — progress is keyed by (playlist, stream), so the row the page loaded belongs to the route's copy. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` feeds one `ownsContent()` predicate used by BOTH the session matcher and the playback-position bridge — if they disagree, the page shows Stop for a session whose progress it throws away and a later switch rewinds hours. Two identity keys: `vodMultiSourceMovieKey` (title, year, tmdbId) makes TMDB enrichment re-trigger discovery and rebuild the pin keys, while `vodMultiSourceSessionKey` (`playlistId:contentId`) decides whether that rerun is a refresh or a new session — a refresh keeps the active source, its resolved facts, the tried set, the live position and any switch in flight; only a different film resets them.
- Claims in the present tense (the "Playing from" caption and the source row's `Playing` badge) are gated on `VodDetailsRouteComponent.playbackLive`, never on `isActive` — discovery marks a source active before anything plays and it stays active after the player closes. Inline that means a `timeupdate` has arrived (`inlinePlayback()` is only the request to play); external it means the session is past `launching`. A merely selected row reads `Current`.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (`reportsPlaybackFailures()`); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally, and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state an audio track as fact.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only — the toggle is hidden in settings and in the sources menu on MPV, VLC and Embedded MPV, since only the built-in web players raise the playback diagnostic that triggers it (`reportsPlaybackFailures()`); it awaits a discovery still in flight before concluding there is nowhere to go (a stream can fail faster than SQLite answers) and re-checks the session afterwards, since the user can navigate during that wait; pinned Play takes the same guarded wait. Each source is tried at most once per session (`triedSourceIds` only grows), so it terminates structurally — but SELECTION is not an attempt: `setActiveSource` only selects, `markPlaying` spends the turn, and `runFailover` retires whatever is on screen before picking, so discovery selecting the route row (or a pin selecting an alternative) before anything plays cannot burn a healthy fallback; and it continues past candidates that fail to resolve rather than stopping at the first one — `switchTo` reports whether it was unresolvable (keep going) or superseded (stop), since only the former marks the candidate tried. The switch is never silent: the toast names the new playlist (through `playlistDisplayLabel`, since a stored playlist name is routinely the pasted URL with credentials), offers Undo, and warns "dub may differ" only when both sides state an audio track as fact.
- HEAD probe reuses the main-process handler extracted to `apps/electron-backend/src/app/events/stream-probe.ts` (`STREAM_PROBE_URL`; `XTREAM_PROBE_URL` still delegates there for catchup), and carries the playlist's own `userAgent`/`referer`/`origin` (`StreamProbeHeaders`) — a panel that requires them answers 401/403 otherwise and a working source would be shown as dead. No ffprobe — the binary is not bundled.
- See `docs/architecture/vod-multi-source.md`
+11
View File
@@ -338,6 +338,17 @@ Termination is structural: `triedSourceIds` only ever grows within a session, so
an N-source movie fails over at most N−1 times and then shows the honest error
screen. Returning to an earlier source by hand does not clear the set.
Selection is not an attempt. `setActiveSource` only selects; `markPlaying` also
spends the source's turn, and only the three places that really start playback
call it — a switch, the route's own Play/Resume, and restoring the playing row
after a rediscovery. Discovery selects the route's row the moment the page
opens, and a pin or the picker can select an alternative before anything plays;
counting those would let a later failure skip a healthy fallback, or call the
options exhausted with one untouched. `runFailover` then retires whatever is on
screen before picking, so the source that just failed is spent however it got
there — one hole per start path would be an infinite ping-pong between two
sources.
A source that cannot even be resolved is marked tried without becoming active,
and failover **continues to the next candidate** rather than giving up —
production calls `failover()` only once, on the original failure, so stopping at
@@ -47,11 +47,11 @@ describe('VodMultiSourceController', () => {
describe('failover cannot loop', () => {
it('never returns a source that was already tried', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
controller.markPlaying('a');
const first = controller.pickFailoverTarget();
expect(first).not.toBeNull();
controller.setActiveSource(first!.id);
controller.markPlaying(first!.id);
const second = controller.pickFailoverTarget();
expect(second!.id).not.toBe(first!.id);
@@ -60,7 +60,7 @@ describe('VodMultiSourceController', () => {
it('exhausts after each source has been tried exactly once', () => {
const controller = controllerWith('a', 'b', 'c');
controller.setActiveSource('a');
controller.markPlaying('a');
const visited = ['a'];
for (let hop = 0; hop < 5; hop++) {
@@ -70,7 +70,7 @@ describe('VodMultiSourceController', () => {
}
expect(visited).not.toContain(next.id);
visited.push(next.id);
controller.setActiveSource(next.id);
controller.markPlaying(next.id);
}
// Three sources, three visits, then an honest dead end.
@@ -81,13 +81,38 @@ describe('VodMultiSourceController', () => {
it('stays exhausted even if the user returns to an earlier source', () => {
const controller = controllerWith('a', 'b');
controller.setActiveSource('a');
controller.setActiveSource('b');
controller.setActiveSource('a');
controller.markPlaying('a');
controller.markPlaying('b');
controller.markPlaying('a');
// Returning by hand is allowed; automatic retrying is not.
expect(controller.pickFailoverTarget()).toBeNull();
});
describe('selection is not an attempt', () => {
it('keeps the route source available after discovery selects it', () => {
// Discovery marks the route's row active the moment the page
// opens. Spending its turn there means a failure on some other
// source skips a healthy fallback.
const controller = controllerWith('a', 'b');
controller.setActiveSource('a');
controller.markPlaying('b');
expect(controller.pickFailoverTarget()?.id).toBe('a');
expect(controller.isExhausted()).toBe(false);
});
it('still exhausts once every source has actually played', () => {
const controller = controllerWith('a', 'b');
controller.setActiveSource('a');
controller.markPlaying('b');
controller.markPlaying('a');
expect(controller.isExhausted()).toBe(true);
});
});
});
describe('failover ranking', () => {
@@ -84,11 +84,23 @@ export class VodMultiSourceController {
);
}
/**
* Select a source WITHOUT spending its failover turn.
*
* Discovery selects the route's row the moment the page opens, and a pin
* or the picker can select an alternative before anything plays. None of
* those is an attempt: counting them means a later failure finds the
* route source "tried" and skips a perfectly healthy fallback — or calls
* the options exhausted while one is untouched.
*/
setActiveSource(sourceId: string | null) {
this._activeSourceId.set(sourceId);
if (sourceId) {
this.tried.add(sourceId);
}
}
/** Select a source AND spend its turn — playback is actually starting. */
markPlaying(sourceId: string) {
this.markTried(sourceId);
this.setActiveSource(sourceId);
}
/**
@@ -136,8 +136,28 @@ describe('VodMultiSourceHostService — session lifecycle', () => {
expect(discovery.discover).toHaveBeenCalledTimes(2);
});
it('does not burn a source the user only selected', async () => {
// One alternative, so the route copy is the ONLY fallback left and
// the outcome cannot depend on how candidates are ranked.
await loadMovie([ALT_TWO]);
// Straight to the alternative from the picker: the route copy was
// selected by discovery but never played, so it is still a fallback.
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
expect(rowFor(CURRENT_A_ID)?.isTried).toBe(false);
vodAutoFailover.set(true);
// Burning it at discovery made this failure report the options
// exhausted while a healthy copy sat untouched.
await expect(service.failover()).resolves.not.toBeNull();
expect(rowFor(CURRENT_A_ID)?.isActive).toBe(true);
});
it('keeps the playing source when enrichment reloads the same movie', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
// The route copy actually plays first, so its turn is genuinely spent.
service.markRouteSourceActive();
await expect(service.play(ALT_TWO.id)).resolves.toBe(true);
service.reportPosition(2538);
@@ -358,7 +358,7 @@ export class VodMultiSourceHostService {
markRouteSourceActive(): void {
this.switchToken++;
if (this.routeSourceId) {
this.controller.setActiveSource(this.routeSourceId);
this.controller.markPlaying(this.routeSourceId);
this.publish();
}
}
@@ -82,7 +82,7 @@ export function applyDiscoveredSources(
: [...discovered, switchedTo];
controller.setSources([current, ...sources], matchKind);
controller.setActiveSource(switchedTo.id);
controller.markPlaying(switchedTo.id);
}
/** What `switchToSource` needs from the host, without reaching into it. */
@@ -140,7 +140,7 @@ export async function switchToSource(
controller.updateSource(resolved.candidate);
deps.setPreviousSource(previous?.id ?? null);
controller.setActiveSource(candidate.id);
controller.markPlaying(candidate.id);
controller.setResumeSeconds(resumeSeconds);
deps.startPlayback(resolved.playback);
@@ -169,6 +169,15 @@ export async function runFailover(
controller: VodMultiSourceController,
switchTo: (candidate: VodSourceCandidate) => Promise<SwitchOutcome>
): Promise<boolean> {
// Whatever is on screen just failed, so it is spent — whichever way it got
// there. Relying on the start paths to have marked it would leave one hole
// per path, and the cost of missing it is an infinite ping-pong between
// two sources.
const failing = controller.activeSourceId();
if (failing) {
controller.markTried(failing);
}
for (;;) {
const target = controller.pickFailoverTarget();
if (!target) {