fix(portals): re-check the movie after waiting, and follow the alternative

Three findings, two of them regressions from the previous round.

Awaiting a pending discovery before failover let the user navigate during
that wait: the continuation then ran against whatever controller was current
and could answer one film's playback failure by starting another film's
alternative. Both waits — failover and pinned Play — now re-check that the
same movie still owns the screen.

Pinned Play also needed the wait it did not have. Pressing Play while the
pin lookup was still out concluded "nothing is pinned" and started the
route's own source, making a persisted preference depend on worker latency.

And the position bridge still accepted only the route's ids, so an external
player running an alternative had every progress update discarded: the
resume point stayed where playback began and a switch an hour in rewound the
lot. The session matcher and the bridge now share one ownership predicate,
since a page that shows Stop for a session whose progress it throws away is
the bug in two halves.

The test for the external case previously set the position signal directly,
which bypassed the very filter that was broken; it now drives the bridge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 01:52:43 +02:00
1 parent b1ad9657c3
commit ed442da3ca
7 files changed
+179 -40

No files matched your search

+2 -2
View File
@@ -825,8 +825,8 @@ engine` (restart required) or
- **Metadata provenance is the core contract.** Every field is `{value, provenance}` where `api`/`probe` are facts (plain tag), `parsed` is a title-regex guess (tag prefixed `~`, warn colour), and absent renders **no tag at all** plus a `check` chip. `factualOnly()` in `vod-source-metadata.util.ts` is the only accessor allowed for ranking/failover, so guesses are structurally unable to influence a decision. `VodSourceProbeStatus` separates `fail` (contacted and refused) from `unknown` (timed out / blocked / no capability) — an unchecked source is never shown as offline. Quality is derived from pixel **width** because letterboxing crops height.
- Discovery (`DB_FIND_TITLE_SOURCES`, trigram FTS over `content_title_fts`) is lazy and returns only what the `content` table can prove; titles whose tokens are all shorter than three characters ("Up", "It") fall back to a scan, since the trigram tokenizer cannot index them at all. A source that is never read looks exactly like one that does not exist, so: the current playlist is excluded **in SQL** and duplicates collapse there too (`GROUP BY cat.playlist_id, c.xtream_id` before the limit — one playlist's dozens of identically ranked category rows would otherwise crowd out every alternative), and the scan matches the token as a whole word (`' ' || LOWER(title) || ' ' GLOB '*[^a-z0-9]it[^a-z0-9]*'`) ordered by title length **with no row limit** — FTS keeps its 60-row window because it ranks by relevance, while a scan cannot rank, and the GLOB reads every row regardless so a limit would only truncate the answer. Resolution is deferred to click/pin/check because `content` stores no `container_extension` and `constructVodUrl` returns `''` without one — each alternative costs a live `get_vod_info` against the foreign playlist's credentials.
- 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 — lookups pass every alias most-trusted-first, and writes go to **all** of them, or a lower-trust alias keeps pointing at the source the user just replaced. 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. An external player launched for an alternative carries the OTHER playlist's ids, so `VodDetailsPlaybackBindings.activeSource` lets `matchedExternalPlayback` still recognise it as this page's session. 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.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only; it awaits a discovery still in flight before concluding there is nowhere to go, since a stream can fail faster than SQLite answers. 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.
- 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 — lookups pass every alias most-trusted-first, and writes go to **all** of them, or a lower-trust alias keeps pointing at the source the user just replaced. 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. 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.
- Auto-failover is `Settings.vodAutoFailover`, **opt-in and off by default**, web engines only; 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.
- 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`
+13 -3
View File
@@ -263,9 +263,13 @@ serves both, because two would eventually disagree.
## Failover
Only fires when `Settings.vodAutoFailover` is on, and it first awaits a
discovery still in flight — a stream can fail faster than SQLite answers,
and concluding "nowhere to go" against an empty controller would strand the
user on the error screen with alternatives landing a moment later.
discovery still in flight — a stream can fail faster than SQLite answers, and
concluding "nowhere to go" against an empty controller would strand the user on
the error screen with alternatives landing a moment later. `stillOwnsScreen()`
does that wait and then re-checks the session, because the user can navigate
during it and the controller afterwards may belong to a different film; the
pinned-Play path takes the same wait, or a persisted pin would lose to worker
latency.
Ranking (`pickFailoverTarget`):
1. never tried this session — a **hard filter**, not a preference
@@ -311,6 +315,12 @@ another click opened a second player. The page therefore claims a session that
matches either the route's own stream or the alternative multi-source says is
active (`VodDetailsPlaybackBindings.activeSource`).
`ownsContent()` answers that question once, for both consumers: the session
matcher AND the playback-position bridge. They cannot be allowed to disagree —
a page that shows a Stop button for a session whose progress it discards keeps
the resume point at wherever playback began, so a switch an hour later rewinds
the whole session.
Stop then has to win over the pin. The primary action consults the pin first —
that is what makes "make this the main source" decide where playback starts —
but when a session is already running the same button reads Stop, and doing
@@ -7,7 +7,10 @@ import {
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { PlaybackPositionRuntimeBridgeService } from '@iptvnator/services';
import type { PlayerContentInfo } from '@iptvnator/shared/interfaces';
import type {
PlaybackPositionData,
PlayerContentInfo,
} from '@iptvnator/shared/interfaces';
import { VodDetailsPlaybackService } from './vod-details-playback.service';
/**
@@ -22,6 +25,8 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
const ROUTE_VOD_ID = 650020;
let service: VodDetailsPlaybackService;
/** The bridge callback the service registers at construction. */
let positionListener: ((data: PlaybackPositionData) => void) | undefined;
const activeSession = signal<unknown>(null);
const activeSource = signal<PlayerContentInfo | null>(null);
@@ -40,6 +45,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
beforeEach(() => {
activeSession.set(null);
activeSource.set(null);
positionListener = undefined;
TestBed.configureTestingModule({
providers: [
@@ -72,9 +78,12 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
{
provide: PlaybackPositionRuntimeBridgeService,
useValue: {
onPlaybackPositionUpdate: jest
.fn()
.mockReturnValue(() => undefined),
onPlaybackPositionUpdate: (
listener: (data: PlaybackPositionData) => void
) => {
positionListener = listener;
return () => undefined;
},
},
},
],
@@ -129,4 +138,43 @@ describe('VodDetailsPlaybackService — external session ownership', () => {
// no longer this page's to stop.
expect(service.matchedExternalPlayback()).toBeNull();
});
describe('position updates from the bridge', () => {
function emit(playlistId: string, contentXtreamId: number, at: number) {
positionListener?.({
playlistId,
contentXtreamId,
contentType: 'vod',
positionSeconds: at,
durationSeconds: 7744,
});
}
it('takes the route stream’s progress', () => {
emit(ROUTE_PLAYLIST, ROUTE_VOD_ID, 120);
expect(service.vodPlaybackPosition()?.positionSeconds).toBe(120);
});
it('takes the progress of the alternative it switched to', () => {
activeSource.set({
playlistId: 'playlist-2',
contentXtreamId: 991,
contentType: 'vod',
});
// An external player running an alternative reports under THAT
// playlist's ids. Dropping these leaves the resume point where
// playback started, and a later switch rewinds the whole session.
emit('playlist-2', 991, 3600);
expect(service.vodPlaybackPosition()?.positionSeconds).toBe(3600);
});
it('ignores progress for a movie this page is not showing', () => {
emit('playlist-3', 12345, 900);
expect(service.vodPlaybackPosition()).toBeNull();
});
});
});
@@ -68,37 +68,51 @@ export class VodDetailsPlaybackService {
readonly matchedExternalPlayback = computed(() => {
const session = this.externalPlayback.activeSession();
const vodId = this.bindings()?.vodId();
const playlistId = this.xtreamStore.currentPlaylist()?.id;
if (
!session?.contentInfo ||
!playlistId ||
!this.xtreamStore.currentPlaylist()?.id ||
session.status === 'error' ||
session.status === 'closed'
) {
return null;
}
const contentInfo = session.contentInfo;
if (contentInfo.contentType !== 'vod') {
return null;
return this.ownsContent(session.contentInfo) ? session : null;
});
/**
* Whether this page owns the content an external session or a position
* update refers to.
*
* Multi-source can put playback on a movie in ANOTHER playlist, and its
* session ids and position rows then carry that playlist's identity. One
* predicate for both consumers: when they disagree, the page shows a Stop
* button for a session whose progress it is throwing away.
*/
private ownsContent(
info:
| {
playlistId?: string;
contentXtreamId?: number;
contentType?: string;
}
| undefined
): boolean {
// An absent playlist id must never match an absent current playlist.
if (!info?.playlistId || info.contentType !== 'vod') {
return false;
}
// This page owns the session when it launched the route's own stream —
// or the alternative it switched to, whose ids belong to the other
// playlist entirely.
const active = this.bindings()?.activeSource?.();
const isRouteStream =
contentInfo.playlistId === playlistId &&
contentInfo.contentXtreamId === vodId;
const isActiveSource =
!!active &&
contentInfo.playlistId === active.playlistId &&
contentInfo.contentXtreamId === active.contentXtreamId;
return isRouteStream || isActiveSource ? session : null;
});
return (
(info.playlistId === this.xtreamStore.currentPlaylist()?.id &&
info.contentXtreamId === this.bindings()?.vodId()) ||
(!!active &&
info.playlistId === active.playlistId &&
info.contentXtreamId === active.contentXtreamId)
);
}
readonly externalPrimaryLabel = computed(() => {
const session = this.matchedExternalPlayback();
if (!session) {
@@ -160,18 +174,13 @@ export class VodDetailsPlaybackService {
const unsubscribePositionUpdates =
this.playbackPositionBridge.onPlaybackPositionUpdate(
(data: PlaybackPositionData) => {
const playlistId = this.xtreamStore.currentPlaylist()?.id;
const vodId = this.bindings()?.vodId();
if (
data.contentType !== 'vod' ||
data.playlistId !== playlistId ||
data.contentXtreamId !== vodId
) {
return;
// An external player running an ALTERNATIVE reports under
// that playlist's ids. Dropping those updates would leave
// the resume point at wherever playback started, and a
// later switch would rewind the whole session.
if (this.ownsContent(data)) {
this.vodPlaybackPosition.set(data);
}
this.vodPlaybackPosition.set(data);
}
) ?? null;
@@ -19,6 +19,7 @@ import {
CURRENT_A_ID,
MOVIE_A,
PROBE_OK,
createDeferred,
resolveWith,
} from './vod-multi-source-host.fixtures';
@@ -174,6 +175,32 @@ describe('VodMultiSourceHostService — pinning', () => {
expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true);
});
it('waits for the stored pin before letting Play fall through', async () => {
const slow = createDeferred<{
sources: VodSourceCandidate[];
matchKind: string;
}>();
discovery.discover.mockReturnValueOnce(slow.promise);
pins.get.mockResolvedValue({
matchKey: 'title:the matrix:1999',
playlistId: ALT_TWO.playlistId,
contentId: ALT_TWO.contentId,
portalType: 'xtream',
});
const loading = service.load(MOVIE_A);
// Play pressed while the lookup is still out. Answering "nothing is
// pinned" here would start the route's own source and make a persisted
// preference depend on worker latency.
const playing = service.playPinnedSource();
slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loading;
await expect(playing).resolves.toBe(true);
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('leaves Play alone when nothing is pinned', async () => {
await loadMovie([ALT_TWO]);
@@ -198,6 +198,30 @@ describe('VodMultiSourceHostService — stale resolutions', () => {
expect(rowFor(ALT_TWO.id)?.isActive).toBe(true);
});
it('abandons a failover whose movie was left during the wait', async () => {
const slow = createDeferred<{
sources: VodSourceCandidate[];
matchKind: string;
}>();
discovery.discover.mockReturnValueOnce(slow.promise);
vodAutoFailover.set(true);
const loadingA = service.load(MOVIE_A);
const failingOver = service.failover();
// The user navigates while A's discovery is still out. Running against
// whatever controller is current afterwards would answer A's playback
// failure by starting one of B's alternatives.
await loadMovie([ALT_TWO, ALT_THREE], MOVIE_B);
startPlayback.mockClear();
slow.resolve({ sources: [ALT_TWO], matchKind: 'title-year' });
await loadingA;
await expect(failingOver).resolves.toBeNull();
expect(startPlayback).not.toHaveBeenCalled();
});
it('leaves the spinner on the row that is still resolving', async () => {
await loadMovie([ALT_TWO, ALT_THREE]);
@@ -258,10 +258,29 @@ export class VodMultiSourceHostService {
* nothing pinned to honour, leaving the caller's own Play path in charge.
*/
async playPinnedSource(): Promise<boolean> {
// The pin arrives with discovery. Concluding "nothing is pinned"
// before the lookup returns would start the route's own source and
// make the persisted preference a coin toss on worker latency.
if (!(await this.stillOwnsScreen())) {
return false;
}
const pinnedId = this.pendingPinnedSourceId();
return pinnedId ? this.play(pinnedId) : false;
}
/**
* Wait for a discovery still in flight, then say whether this film is
* still the one on screen. Both callers touch the controller afterwards,
* and the user can navigate during the wait — acting then would answer
* one film's question with another film's sources.
*/
private async stillOwnsScreen(): Promise<boolean> {
const session = this.sessionToken;
await this.loadInFlight;
return session === this.sessionToken;
}
/** Play from a specific source once; does not change the pin. */
async play(sourceId: string): Promise<boolean> {
const candidate = this.controller.findSource(sourceId);
@@ -335,7 +354,9 @@ export class VodMultiSourceHostService {
// "nowhere to go" against a controller whose discovery has not landed
// yet would strand the user on the error screen with alternatives
// arriving a moment later and nothing left to retry them.
await this.loadInFlight;
if (!(await this.stillOwnsScreen())) {
return null;
}
const switched = await runFailover(this.controller, (candidate) =>
this.switchTo(candidate)