diff --git a/CLAUDE.md b/CLAUDE.md index 11a21c7dc..e404cdaf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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` diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 9b71fe1a2..f64103f84 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -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 diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index 87d1d5759..18c966985 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -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(null); const activeSource = signal(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(); + }); + }); }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts index 27c832759..0b3e442c3 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.ts @@ -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; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts index 11d354950..02bc4053f 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -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]); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts index 06773e3db..52139e937 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-races.spec.ts @@ -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]); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts index 00a8a9a77..e55c0afdc 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.ts @@ -258,10 +258,29 @@ export class VodMultiSourceHostService { * nothing pinned to honour, leaving the caller's own Play path in charge. */ async playPinnedSource(): Promise { + // 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 { + 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 { 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)