diff --git a/CLAUDE.md b/CLAUDE.md index e404cdaf2..9ca04bce9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -823,9 +823,9 @@ engine` (restart required) or - Finds the same movie in the user's other imported playlists and adds a "Sources N" chip to the Xtream VOD action row (only when ≥1 alternative exists), plus a `.source-caption` line reporting where playback is coming from. The chip opens a 460px anchored CDK-overlay popover (`libs/ui/components/src/lib/vod-sources/`; not `MatMenu`, which caps its width at 280px), reused unchanged in the inline player's now-playing bar and on the playback-error screen. Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The chip counts alternative **streams**; the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal. - Scope v1 is **Xtream ↔ Xtream, movies only, Electron only**. Stalker never reaches the `content` table and M3U is a JSON blob whose search forces `content_type:'live'`; both are additive later since `VodSourceCandidate.portalType` already carries all three. In the PWA every entry point is gated off by a bridge `typeof` check and the chip renders nothing. - **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. +- 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. The year gate covers BOTH match tiers: `normalizeTitleKeys` strips bracketed segments, so "Dune (1984)" normalizes identically to "Dune" and would otherwise be an *exact* match for the 2021 film; a bracketed year is read out of the raw title and a stated disagreement rejects the row. One row inside the excluded playlist is kept when the caller names it (`keepContentId`), because a pin can point at another copy in the playlist being viewed — the host reads the pin before discovery for exactly this. 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` 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. +- 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. 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. - 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/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts index 1e47e624b..6e21dc50d 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.spec.ts @@ -157,6 +157,31 @@ describe('title-sources.operations', () => { ).resolves.toEqual([]); }); + it('rejects a remake whose year is written in brackets', async () => { + // "Dune (1984)" normalizes to exactly "dune" — the brackets are + // stripped as tag noise — so without reading the year out first it + // is an EXACT match for the 2021 film, ranked above every fuzzy + // one, and auto-failover would switch to the wrong movie. + const { db } = createDbMock([{ ...duneRow, title: 'Dune (1984)' }]); + + await expect( + findTitleSources(db, { title: 'Dune', year: 2021 }) + ).resolves.toEqual([]); + }); + + it('matches a bracketed year against the same film', async () => { + const { db } = createDbMock([{ ...duneRow, title: 'Dune (2021)' }]); + + const matches = await findTitleSources(db, { + title: 'Dune', + year: 2021, + }); + + expect(matches).toHaveLength(1); + // Reported too, so the row can say which film it is. + expect(matches[0].year).toBe(2021); + }); + it('rejects a base-tier match whose year contradicts the request', async () => { const { db } = createDbMock([{ ...duneRow, title: 'Dune 1984' }]); @@ -267,6 +292,27 @@ describe('title-sources.operations', () => { ); }); + it('keeps one named copy of the excluded playlist', async () => { + // A pin can point at another copy of the film inside the playlist + // being viewed. Excluding the playlist wholesale would drop that + // row, and the explicit preference would be silently ignored. + const { db, all } = createDbMock([ + { ...duneRow, xtream_id: 777, content_id: 9 }, + ]); + + const matches = await findTitleSources(db, { + title: 'Dune', + excludePlaylistId: 'playlist-1', + keepContentId: 777, + }); + + const query = compiledQuery(all); + expect(query.sql).toContain('OR c.xtream_id = ?'); + expect(query.params).toContain(777); + // And the TypeScript pass must not throw it away either. + expect(matches.map((match) => match.xtreamId)).toEqual([777]); + }); + it('leaves the queries unfiltered when no playlist is excluded', async () => { const { db, all } = createDbMock([duneRow]); diff --git a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts index 36252387d..804136ab4 100644 --- a/apps/electron-backend/src/app/database/operations/title-sources.operations.ts +++ b/apps/electron-backend/src/app/database/operations/title-sources.operations.ts @@ -39,6 +39,28 @@ export interface FindTitleSourcesRequest { year?: number | null; /** The playlist the user is already on — never returned as an alternative. */ excludePlaylistId?: string | null; + /** + * One stream id inside that playlist to keep anyway. + * + * A pin can point at another copy of the film in the playlist the user is + * currently on. Excluding the playlist wholesale would drop the pinned row + * from the list, and the explicit preference would be silently ignored. + */ + keepContentId?: number | null; +} + +/** + * A year written in brackets — "Dune (1984)", the commonest catalog form. + * + * `normalizeTitleKeys` strips bracketed segments wholesale, because they + * usually hold quality and language tags. A year written that way therefore + * survives in neither key, and both Dunes normalize to exactly "dune" — which + * the exact tier accepts without ever consulting the year, so failover could + * switch the user to the other film entirely. + */ +function bracketedYear(title: string): number | null { + const match = title.match(/[([{]\s*(19\d{2}|20\d{2})\s*[)\]}]/); + return match ? Number(match[1]) : null; } function buildFtsMatchQuery(normalizedTitle: string): string { @@ -59,11 +81,16 @@ function buildFtsMatchQuery(normalizedTitle: string): string { * never be read at all. */ function excludePlaylistClause( - excludePlaylistId: string | null | undefined + excludePlaylistId: string | null | undefined, + keepContentId: number | null | undefined ): SQL { - return excludePlaylistId - ? sql`AND cat.playlist_id <> ${excludePlaylistId}` - : sql``; + if (!excludePlaylistId) { + return sql``; + } + + return typeof keepContentId === 'number' + ? sql`AND (cat.playlist_id <> ${excludePlaylistId} OR c.xtream_id = ${keepContentId})` + : sql`AND cat.playlist_id <> ${excludePlaylistId}`; } /** @@ -166,14 +193,19 @@ export async function findTitleSources( return []; } - // The year the caller knows, falling back to a release tag on the title. - const wantedYear = request.year ?? wanted.trailingYear ?? null; + // The year the caller knows, falling back to a release tag on the title + // in either of the two forms providers write it. + const wantedYear = + request.year ?? wanted.trailingYear ?? bracketedYear(rawTitle); // Search on the year-stripped form so a portal that tags the year and one // that does not still find each other; the year gate below re-tightens it. const matchQuery = buildFtsMatchQuery(wanted.base); - const excludePlaylist = excludePlaylistClause(request.excludePlaylistId); + const excludePlaylist = excludePlaylistClause( + request.excludePlaylistId, + request.keepContentId + ); let rows: TitleSourceRow[]; try { @@ -196,23 +228,30 @@ export async function findTitleSources( for (const row of rows) { // SQL already excluded these; this keeps the guarantee a property of - // the function rather than of one WHERE clause. + // the function rather than of one WHERE clause — minus the one copy + // the caller asked to keep. if ( request.excludePlaylistId && - row.playlist_id === request.excludePlaylistId + row.playlist_id === request.excludePlaylistId && + row.xtream_id !== request.keepContentId ) { continue; } const rowKeys = normalizeTitleKeys(row.title); + const rowYear = rowKeys.trailingYear ?? bracketedYear(row.title); + // Two films share a title far too often for this to be optional, and + // it applies to BOTH tiers: "Dune (1984)" normalizes to exactly the + // same string as "Dune", so an exact match says nothing about which + // film it is. When each side states a year and they disagree, it is + // not the same movie. An unknown year still never blocks. + const yearsAgree = titleYearsCompatible(rowYear, wantedYear); const exactMatch = - rowKeys.exact !== '' && rowKeys.exact === wanted.exact; + rowKeys.exact !== '' && + rowKeys.exact === wanted.exact && + yearsAgree; const baseMatch = - !exactMatch && - rowKeys.base === wanted.base && - // A year-stripped match is only trustworthy when the two sides do - // not actively disagree about the year ("Dune" 1984 vs 2021). - titleYearsCompatible(rowKeys.trailingYear, wantedYear); + !exactMatch && rowKeys.base === wanted.base && yearsAgree; if (!exactMatch && !baseMatch) { continue; @@ -232,7 +271,7 @@ export async function findTitleSources( title: row.title, posterUrl: row.poster_url, matchConfidence: exactMatch ? 'exact' : 'fuzzy', - year: rowKeys.trailingYear, + year: rowYear, }); } diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index f64103f84..01f67a033 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -210,6 +210,21 @@ Both remain necessary-not-sufficient filters: the two-tier normalized confirmation still runs afterwards, so the looser query never admits "Upgrade" for "Up". +One row inside the excluded playlist is kept when the caller names it +(`keepContentId`): a pin can point at another copy of the film in the playlist +being viewed, and dropping that row would leave the preference pointing at +nothing. The host therefore reads the pin BEFORE discovery. + +### Same title, different film + +The year gate applies to **both** match tiers, not just the year-stripped one. +`normalizeTitleKeys` strips bracketed segments wholesale — they usually hold +quality and language tags — so "Dune (1984)" and "Dune" normalize to the same +string and the exact tier would accept the remake without ever consulting a +year, ranked *above* every fuzzy match. Discovery reads a bracketed year out of +the raw title first, and when both sides state a year and they disagree, it is +not the same movie. An unknown year still never blocks. + ## Why resolution is lazy The `content` table stores no `container_extension`, and diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts index cc5523a0c..78fd6384f 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-discovery.service.ts @@ -22,6 +22,12 @@ export interface VodSourceDiscoveryRequest { year?: number | null; /** The playlist being viewed; never offered back as an alternative. */ currentPlaylistId: string; + /** + * One stream id inside it to keep anyway — a pin can point at another copy + * of the film in the playlist the user is currently on, and excluding it + * would silently ignore an explicit preference. + */ + keepContentId?: number | null; } export interface VodSourceDiscoveryResult { @@ -61,6 +67,7 @@ export class VodSourceDiscoveryService { title: request.title, year: request.year ?? null, excludePlaylistId: request.currentPlaylistId, + keepContentId: request.keepContentId ?? null, }); return { diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts index 6d9bfd748..a9a8bb79d 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.ts @@ -23,7 +23,10 @@ import { VodSourcesChipComponent, } from '@iptvnator/ui/components'; import { SafePipe } from '@iptvnator/pipes'; -import { createLogger } from '@iptvnator/portal/shared/util'; +import { + createLogger, + PORTAL_PLAYBACK_POSITIONS, +} from '@iptvnator/portal/shared/util'; import { XtreamStore } from '@iptvnator/portal/xtream/data-access'; import { type PlaybackFallbackRequest, @@ -45,6 +48,7 @@ import { XtreamVodInfo, XtreamVodStream, youtubeEmbedUrl, + type VodSourceCandidate, } from '@iptvnator/shared/interfaces'; import { SimilarCatalogItem, @@ -90,6 +94,7 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { private readonly snackBar = inject(MatSnackBar); private readonly translateService = inject(TranslateService); private readonly playback = inject(VodDetailsPlaybackService); + private readonly playbackPositions = inject(PORTAL_PLAYBACK_POSITIONS); /** Alternative sources for this movie in the user's other playlists */ readonly multiSource = inject(VodMultiSourceHostService); private readonly logger = createLogger('VodDetailsRoute'); @@ -461,13 +466,30 @@ export class VodDetailsRouteComponent implements OnInit, OnDestroy { // A pinned source is an explicit "play this movie from here", so it // outranks the playlist the route happens to be on. Falls through to // the normal path when nothing is pinned or the pin cannot resolve. - if (await this.multiSource.playPinnedSource()) { + if (await this.multiSource.playPinnedSource(this.resumeSecondsFor)) { return; } this.playback.onPrimaryAction(vodItem); } + /** + * Where a specific source was last watched. + * + * Positions are keyed by (playlist, stream), so a pinned alternative has + * its own row — the one this page loaded belongs to the route's copy. + */ + private readonly resumeSecondsFor = async ( + source: VodSourceCandidate + ): Promise => { + const position = await this.playbackPositions.getPlaybackPosition( + source.playlistId, + source.contentId, + 'vod' + ); + return position?.positionSeconds ?? null; + }; + stopExternalPlayback(): Promise { return this.playback.stopExternalPlayback(); } 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 02bc4053f..5be3aa400 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 @@ -125,6 +125,87 @@ describe('VodMultiSourceHostService — pinning', () => { ); }); + it('asks discovery to keep a copy pinned inside the current playlist', async () => { + // The pin was set on another copy of the film in the playlist the user + // is now opening. Excluding that playlist wholesale would leave the pin + // pointing at a row that is not in the list, so Play would ignore it. + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: MOVIE_A.playlistId, + contentId: 4242, + portalType: 'xtream', + }); + + await loadMovie([]); + + expect(discovery.discover).toHaveBeenCalledWith( + expect.objectContaining({ + currentPlaylistId: MOVIE_A.playlistId, + keepContentId: 4242, + }) + ); + }); + + it('asks for no exception when the pin is in another playlist', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + + await loadMovie([ALT_TWO]); + + expect(discovery.discover).toHaveBeenCalledWith( + expect.objectContaining({ keepContentId: null }) + ); + }); + + it('resumes a pinned alternative from its OWN stored position', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + + // What the page loaded belongs to the ROUTE's copy — a different row + // entirely from the one the user has been watching through the pin. + service.seedResumePosition(12); + + const resumeFor = jest.fn().mockResolvedValue(3600); + await expect(service.playPinnedSource(resumeFor)).resolves.toBe(true); + + expect(resumeFor).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }) + ); + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 3600 } + ); + }); + + it('keeps the page position when the pinned source has none', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + service.seedResumePosition(2538); + + await expect( + service.playPinnedSource(jest.fn().mockResolvedValue(null)) + ).resolves.toBe(true); + + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 2538 } + ); + }); + it('retires every stale alias and writes only the canonical key', async () => { await loadMovie([ALT_TWO]); const matchKeys: string[] = pins.get.mock.calls[0][0]; 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 52139e937..f7a8ee218 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 @@ -186,6 +186,9 @@ describe('VodMultiSourceHostService — stale resolutions', () => { vodAutoFailover.set(true); const loading = service.load(MOVIE_A); + while (discovery.discover.mock.calls.length === 0) { + await Promise.resolve(); + } // The stream died faster than the database answered. Concluding // "nowhere to go" here would strand the user on the error screen with @@ -207,6 +210,11 @@ describe('VodMultiSourceHostService — stale resolutions', () => { vodAutoFailover.set(true); const loadingA = service.load(MOVIE_A); + // The pin lookup runs first now, so wait until A is genuinely inside + // discovery before the failure arrives. + while (discovery.discover.mock.calls.length === 0) { + await Promise.resolve(); + } const failingOver = service.failover(); // The user navigates while A's discovery is still out. Running against diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts index 7022cc133..79c6551e5 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts @@ -107,6 +107,7 @@ describe('VodMultiSourceHostService — session lifecycle', () => { title: 'The Matrix', year: 1999, currentPlaylistId: 'playlist-1', + keepContentId: null, }); movie.set({ ...MOVIE_A }); @@ -187,25 +188,21 @@ describe('VodMultiSourceHostService — session lifecycle', () => { it('discards a discovery that resolves after the movie changed', async () => { const first = createDeferred(); - const second = createDeferred(); - discovery.discover - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise); + discovery.discover.mockReturnValueOnce(first.promise); const loadA = service.load(MOVIE_A); - const loadB = service.load(MOVIE_B); + // A's pin lookup runs before its discovery, so wait for the call that + // actually matters rather than guessing at microtask counts. + while (discovery.discover.mock.calls.length === 0) { + await Promise.resolve(); + } + const loadB = loadMovie([ALT_THREE], MOVIE_B); first.resolve({ sources: [ALT_TWO], matchKind: 'title-year' }); await loadA; - - expect(service.sources()).toEqual([]); - // Abandoned on arrival: it never even looks the stale pin up. - expect(pins.get).not.toHaveBeenCalled(); - - second.resolve({ sources: [ALT_THREE], matchKind: 'title-year' }); await loadB; - expect(pins.get).toHaveBeenCalledTimes(1); + // A's alternatives must not appear anywhere in B's session. expect(service.sources().map((source) => source.id)).toEqual([ 'playlist-1:xtream:202', ALT_THREE.id, 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 e55c0afdc..323c6d500 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 @@ -31,7 +31,10 @@ import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice'; import { currentSourceRow } from './vod-multi-source-current-row'; import { probeSource } from './vod-multi-source-probe'; import { + pinnedCopyInPlaylist, + pinnedSourceAwaitingPlay, pinnedSourceIdOf, + playPinned, readPin, togglePinnedSource, } from './vod-multi-source-pin'; @@ -75,10 +78,7 @@ export class VodMultiSourceHostService { private discoveryToken = 0; /** * Bumped when the FILM changes, and only then — a rediscovery of the same - * film (enrichment described it better) must not cancel a switch or probe - * in flight for it. Without this guard the continuation would activate the - * old film's source in the new session and restart it from that session's - * zero resume position. + * film must not cancel a switch or probe in flight for it. */ private sessionToken = 0; /** @@ -132,8 +132,7 @@ export class VodMultiSourceHostService { () => this.settingsStore.vodAutoFailover?.() === true ); - /** Live getter, not a snapshot: `isAvailable` is itself a getter over - * the Electron bridge, so copying it once could disagree with it. */ + /** Live getter: the bridge's own `isAvailable` can change under us. */ get isAvailable(): boolean { return this.discovery.isAvailable; } @@ -176,11 +175,9 @@ export class VodMultiSourceHostService { * the tried-source set that makes failover terminate. * * The SAME film arriving again is a refresh, not a new session: enrichment - * supplies its year and TMDB id after the page opened, which changes the - * movie key on purpose (discovery gets the year, a `tmdb:` pin becomes - * findable) but changes nothing about what is playing. Resetting there - * would take the film back off the source the user switched to and hand - * failover a clean tried-set for sources it has already burned. + * changes the movie key on purpose, but nothing about what is playing. + * Resetting there would take the film off the source the user switched to + * and hand failover a clean tried-set for sources it has already burned. */ async load(movie: VodMultiSourceMovie): Promise { const finished = this.discover(movie); @@ -214,10 +211,17 @@ export class VodMultiSourceHostService { return; } + // Read FIRST, because discovery has to be told which copy to keep. + const pin = await readPin(this.pins, this.matchKeys); + if (token !== this.discoveryToken) { + return; + } + const result = await this.discovery.discover({ title: movie.title, year: movie.year, currentPlaylistId: movie.playlistId, + keepContentId: pinnedCopyInPlaylist(pin, movie.playlistId), }); if (token !== this.discoveryToken) { return; @@ -229,11 +233,6 @@ export class VodMultiSourceHostService { result.sources, result.matchKind ); - - const pin = await readPin(this.pins, this.matchKeys); - if (token !== this.discoveryToken) { - return; - } if (pin) { this.controller.setPinnedSource(pinnedSourceIdOf(pin)); } @@ -241,32 +240,30 @@ export class VodMultiSourceHostService { this.publish(); } - /** - * The pinned source, when it is not the one the route already plays. - * - * "Make this the main source" has to survive reopening the movie, or the - * persisted preference is just an icon. The host consults this before its - * normal Play so the pin decides where playback starts. - */ - readonly pendingPinnedSourceId = computed(() => { - const pinned = this._sources().find((source) => source.isPinned); - return pinned && !pinned.isActive ? pinned.id : null; - }); + /** The pinned source, when it is not the one the route already plays. */ + readonly pendingPinnedSourceId = computed(() => + pinnedSourceAwaitingPlay(this._sources()) + ); /** * Start from the pinned source if there is one. Returns false when there is * 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. + async playPinnedSource( + resumeFor?: (source: VodSourceCandidate) => Promise + ): Promise { + // The pin arrives with discovery; answering before the lookup lands + // would make a persisted preference lose to worker latency. if (!(await this.stillOwnsScreen())) { return false; } - const pinnedId = this.pendingPinnedSourceId(); - return pinnedId ? this.play(pinnedId) : false; + return playPinned({ + controller: this.controller, + pinnedSourceId: this.pendingPinnedSourceId(), + resumeFor, + play: (sourceId) => this.play(sourceId), + }); } /** diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts index f779002cf..c4b9ff4ac 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-pin.ts @@ -1,7 +1,9 @@ +import type { VodMultiSourceController } from '@iptvnator/portal/shared/data-access'; import type { VodSourcePinService } from '@iptvnator/services'; import { buildVodSourceMatchKey, type VodSourceCandidate, + type VodSourceDescriptor, type VodSourcePin, } from '@iptvnator/shared/interfaces'; @@ -13,6 +15,34 @@ import { * host needs to know how a match key is chosen. */ +/** + * The one copy inside the playlist being viewed that discovery must keep. + * + * A pin can point at another copy of the film in that same playlist, and + * excluding the playlist wholesale would drop the pinned row from the list — + * leaving the preference pointing at nothing and silently ignored. + */ +export function pinnedCopyInPlaylist( + pin: VodSourcePin | null, + playlistId: string +): number | null { + return pin && pin.playlistId === playlistId ? pin.contentId : null; +} + +/** + * The pinned row, when it is not the one already playing. + * + * "Make this the main source" has to survive reopening the movie, or the + * persisted preference is just an icon. The host consults this before its + * normal Play, so the pin decides where playback starts. + */ +export function pinnedSourceAwaitingPlay( + sources: readonly VodSourceDescriptor[] +): string | null { + const pinned = sources.find((source) => source.isPinned); + return pinned && !pinned.isActive ? pinned.id : null; +} + /** The row id the controller uses, derived from a stored pin. */ export function pinnedSourceIdOf(pin: VodSourcePin): string { return `${pin.playlistId}:${pin.portalType}:${pin.contentId}`; @@ -103,3 +133,35 @@ export async function togglePinnedSource( ? candidate.id : undefined; } + +/** What starting the pinned source needs from the host. */ +export interface PinnedPlayDeps { + controller: VodMultiSourceController; + pinnedSourceId: string | null; + /** Where THAT source was last watched, when the host can look it up. */ + resumeFor?: (source: VodSourceCandidate) => Promise; + play: (sourceId: string) => Promise; +} + +/** + * Start the movie from its pinned source, at the position that source was + * last left at. + * + * Playback positions are keyed by (playlist, stream), so watching through a + * pinned alternative stores progress under ITS ids. The page loaded the ROUTE + * row, which for this source is stale or missing entirely — resuming from it + * would restart the film or jump to where a different copy was left. + */ +export async function playPinned(deps: PinnedPlayDeps): Promise { + const pinned = deps.controller.findSource(deps.pinnedSourceId ?? ''); + if (!pinned) { + return false; + } + + const stored = await deps.resumeFor?.(pinned); + if (stored !== null && stored !== undefined) { + deps.controller.setResumeSeconds(stored); + } + + return deps.play(pinned.id); +} diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 3b08a53dd..7c979ded4 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -875,6 +875,8 @@ export interface ElectronBridgeApi { title: string; year?: number | null; excludePlaylistId?: string | null; + /** A stream id inside the excluded playlist to keep anyway (a pin). */ + keepContentId?: number | null; }) => Promise; /** Per-movie pinned source; keys are passed most-trusted first */ dbGetVodSourcePin: (matchKeys: string[]) => Promise;