diff --git a/CLAUDE.md b/CLAUDE.md index fff7c037b..a90e5de11 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** (its own duplicate 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. 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}`, `vod_source_pins` table); lookups pass every alias most-trusted-first so a late TMDB id does not orphan a title-keyed pin. 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. +- 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. 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, 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). 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 153dc73c4..1e47e624b 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 @@ -250,6 +250,23 @@ describe('title-sources.operations', () => { ); }); + it('collapses one playlist’s copies before the FTS limit applies', async () => { + // A playlist listing the film in dozens of categories produces + // identically ranked rows. Collapsing them only in TypeScript + // cannot recover the other playlists the window never reached. + const { db, all } = createDbMock([duneRow]); + + await findTitleSources(db, { title: 'Dune' }); + + const query = compiledQuery(all); + expect(query.sql).toContain( + 'GROUP BY cat.playlist_id, c.xtream_id' + ); + expect(query.sql.indexOf('GROUP BY')).toBeLessThan( + query.sql.indexOf('LIMIT') + ); + }); + 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 8bf7ca09c..36252387d 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 @@ -118,6 +118,15 @@ function scanCandidateQuery(base: string, excludePlaylist: SQL) { `; } +/** + * The relevance-ranked path, and the only one still taking a window. + * + * One playlist can list a film in dozens of categories, and those rows are + * identically ranked — enough of them would fill the window before another + * playlist was ever read. The TypeScript pass collapses them afterwards, + * which cannot recover what the query never returned, so the collapse has to + * happen first: one row per `(playlist_id, xtream_id)`, then the limit. + */ function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) { return sql` SELECT @@ -137,6 +146,7 @@ function ftsCandidateQuery(matchQuery: string, excludePlaylist: SQL) { AND cat.hidden = 0 AND p.type = 'xtream' ${excludePlaylist} + GROUP BY cat.playlist_id, c.xtream_id ORDER BY rank, c.title LIMIT ${CANDIDATE_LIMIT} `; diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index fc82b59f2..9da347c49 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1036,7 +1036,7 @@ "FILTER_PLACEHOLDER": "التصفية حسب قائمة التشغيل", "NO_MATCHES": "لا توجد قوائم تشغيل تطابق هذا الفلتر", "COPIES": "{{count}} نسخ في قائمة التشغيل هذه", - "MORE_SOURCES": "{{count}} أخرى في قائمة المصادر" + "MORE_SOURCES": "+{{count}} أخرى في قائمة المصادر" } }, "INFORMATION": "معلومات", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index ed30d8a5c..e2ebe5ed6 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1035,7 +1035,7 @@ "CHECK_ALL": "全部检测", "FILTER_PLACEHOLDER": "按播放列表筛选", "NO_MATCHES": "未找到匹配的播放列表", - "COPIES": "此播放列表中有 {{count}} 个副本", + "COPIES": "此播放列表中有 {{count}} 个版本", "MORE_SOURCES": "其余 {{count}} 个在播放源列表中" } }, diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 8c002b9ae..490ed72dc 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -119,11 +119,21 @@ every portal, which is the problem being solved. `buildVodSourceMatchKey()` produces `tmdb:{id}` when a usable TMDB id exists, otherwise `title:{normalizedBase}:{year}` via the shared `normalizeTitleKeys`. -Lookups pass **every** alias, most-trusted first -(`buildVodSourceMatchKeyCandidates`). A movie pinned before TMDB enrichment -landed is stored under its title key and prefers a `tmdb:` key afterwards; -reading both means the id arriving later does not orphan the pin, and unpinning -clears every alias so a stale row cannot resurrect it. +Enrichment adds BOTH identifying fields late, so the same movie can already be +pinned under any poorer form of itself: + +| pinned when | stored under | +|---|---| +| after enrichment | `tmdb:{id}` | +| before the TMDB id | `title:{base}:{year}` | +| before the year too | `title:{base}:` | + +`buildVodSourceMatchKeyCandidates` returns all three, most-trusted first, and +**writes go to every one of them**. Reading every alias is what keeps a late +TMDB id from orphaning an earlier pin; writing every alias is what stops the +lower-trust ones from still pointing at the source the user just replaced — +which a reopen before enrichment lands would then play. Unpinning clears them +all, so a stale row cannot resurrect it. The row only changes once the write lands. A pin the database refused is worse than no pin at all — the icon promises the preference will be there next time, @@ -164,6 +174,11 @@ one that does not exist — the chip simply does not appear. So: - **The current playlist is excluded in SQL**, not afterwards. It routinely lists a film in several categories, and those rows would otherwise spend the FTS window before a single other playlist was read. +- **Duplicates collapse in SQL too**, for the same reason. One playlist can + list a film in dozens of categories, and those rows rank identically, so + `GROUP BY cat.playlist_id, c.xtream_id` runs before the limit. Collapsing + them only in TypeScript afterwards cannot recover the playlists the window + never reached. - **Short titles scan on a word boundary, and take no window at all.** The trigram tokenizer cannot index tokens under three characters, so "Up", "It" or "Us" produce an empty `MATCH` and fall back to a scan. A substring scan 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 new file mode 100644 index 000000000..f79c05c3b --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin.spec.ts @@ -0,0 +1,227 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { + VodSourceDiscoveryService, + VodSourceResolverService, +} from '@iptvnator/portal/shared/data-access'; +import { + SettingsStore, + StreamProbeService, + VodSourcePinService, +} from '@iptvnator/services'; +import type { VodSourceCandidate } from '@iptvnator/shared/interfaces'; +import { VodMultiSourceHostService } from './vod-multi-source-host.service'; +import type { VodMultiSourceMovie } from './vod-multi-source-identity'; + +import { + ALT_THREE, + ALT_TWO, + CURRENT_A_ID, + MOVIE_A, + PROBE_OK, + resolveWith, +} from './vod-multi-source-host.fixtures'; + +/** + * "Make this the main source" as a persisted decision. + * + * A pin is not decoration: it decides where Play starts and outranks every + * other signal in failover. So what the row shows has to be what the database + * actually holds — under every alias the movie can be looked up by. + */ +describe('VodMultiSourceHostService — pinning', () => { + let service: VodMultiSourceHostService; + + const movie = signal(null); + const vodAutoFailover = signal(false); + const startPlayback = jest.fn(); + const discovery = { isAvailable: true, discover: jest.fn() }; + const resolver = { resolve: jest.fn() }; + const pins = { get: jest.fn(), set: jest.fn(), clear: jest.fn() }; + const probes = { probe: jest.fn() }; + + async function loadMovie( + sources: VodSourceCandidate[], + target: VodMultiSourceMovie = MOVIE_A + ): Promise { + discovery.discover.mockResolvedValue({ + sources, + matchKind: 'title-year', + }); + await service.load(target); + } + + function rowFor(sourceId: string) { + return service.sources().find((source) => source.id === sourceId); + } + + beforeEach(() => { + jest.resetAllMocks(); + movie.set(null); + vodAutoFailover.set(false); + discovery.isAvailable = true; + discovery.discover.mockResolvedValue({ + sources: [], + matchKind: 'title-year', + }); + resolver.resolve.mockImplementation(resolveWith()); + pins.get.mockResolvedValue(null); + pins.set.mockResolvedValue(true); + pins.clear.mockResolvedValue(true); + probes.probe.mockResolvedValue(PROBE_OK); + + TestBed.configureTestingModule({ + providers: [ + VodMultiSourceHostService, + { provide: VodSourceDiscoveryService, useValue: discovery }, + { provide: VodSourceResolverService, useValue: resolver }, + { provide: VodSourcePinService, useValue: pins }, + { provide: StreamProbeService, useValue: probes }, + { provide: SettingsStore, useValue: { vodAutoFailover } }, + ], + }); + + service = TestBed.inject(VodMultiSourceHostService); + TestBed.runInInjectionContext(() => + service.bind({ startPlayback, movie }) + ); + }); + + it('plays the pinned source instead of the route playlist', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO, ALT_THREE]); + + // Without this the persisted preference is only an icon: reopening the + // movie would still start the playlist the route is on. + await expect(service.playPinnedSource()).resolves.toBe(true); + expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); + }); + + it('resumes the pinned source from the 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 route knows after loading playback positions. Nothing has + // played yet, so no timeupdate has reported anything — and the button + // the user is about to press says "Resume". + service.seedResumePosition(2538); + + await expect(service.playPinnedSource()).resolves.toBe(true); + + expect(resolver.resolve).toHaveBeenCalledWith( + expect.objectContaining({ id: ALT_TWO.id }), + { startTime: 2538 } + ); + }); + + it('points every alias of the movie at the newly pinned source', async () => { + await loadMovie([ALT_TWO]); + const matchKeys: string[] = pins.get.mock.calls[0][0]; + expect(matchKeys.length).toBeGreaterThan(1); + + await service.togglePin(ALT_TWO.id); + + // Writing only the most-trusted key leaves the others pointing at + // whatever was pinned before, and a reopen that reads a lower-trust + // alias — because enrichment has not landed yet — starts the source + // the user just replaced. + expect(pins.set).toHaveBeenCalledTimes(matchKeys.length); + for (const matchKey of matchKeys) { + expect(pins.set).toHaveBeenCalledWith({ + matchKey, + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + } + }); + + it('does not show a pin the database refused to store', async () => { + await loadMovie([ALT_TWO]); + pins.set.mockResolvedValue(false); + + await service.togglePin(ALT_TWO.id); + + // The icon promises the preference survives reopening the movie. A + // write that failed makes that a lie, so the row must not change. + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + }); + + it('keeps the pin when clearing it fails', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO]); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + + pins.clear.mockResolvedValue(false); + await service.togglePin(ALT_TWO.id); + + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + }); + + it('leaves Play alone when nothing is pinned', async () => { + await loadMovie([ALT_TWO]); + + await expect(service.playPinnedSource()).resolves.toBe(false); + expect(startPlayback).not.toHaveBeenCalled(); + }); + + it('prefers the pinned source when failing over', async () => { + pins.get.mockResolvedValue({ + matchKey: 'title:the matrix:1999', + playlistId: ALT_THREE.playlistId, + contentId: ALT_THREE.contentId, + portalType: 'xtream', + }); + await loadMovie([ALT_TWO, ALT_THREE]); + vodAutoFailover.set(true); + + await expect(service.failover()).resolves.not.toBeNull(); + expect(rowFor(ALT_THREE.id)?.isActive).toBe(true); + }); + + it('round-trips a pin under the movie match key', async () => { + await loadMovie([ALT_TWO]); + const matchKeys: string[] = pins.get.mock.calls[0][0]; + expect(matchKeys.length).toBeGreaterThan(0); + const pin = { + matchKey: matchKeys[0], + playlistId: ALT_TWO.playlistId, + contentId: ALT_TWO.contentId, + portalType: 'xtream', + }; + + await service.togglePin(ALT_TWO.id); + + expect(pins.set).toHaveBeenCalledWith(pin); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + + await service.togglePin(ALT_TWO.id); + + expect(pins.clear).toHaveBeenCalledWith(matchKeys); + // Unpinning writes nothing further — the pin round-trip is one write + // per alias, then a single clear of all of them. + expect(pins.set).toHaveBeenCalledTimes(matchKeys.length); + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); + + pins.get.mockResolvedValue(pin); + await loadMovie([ALT_TWO]); + + expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); + expect(rowFor(CURRENT_A_ID)?.isPinned).toBe(false); + }); +}); 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 8f249a8e3..3e6aaf8a3 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 @@ -177,6 +177,32 @@ describe('VodMultiSourceHostService — stale resolutions', () => { expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); }); + it('leaves the spinner on the row that is still resolving', async () => { + await loadMovie([ALT_TWO, ALT_THREE]); + + const slowTwo = createDeferred>(); + const slowThree = createDeferred>(); + resolver.resolve + .mockReturnValueOnce(slowTwo.promise) + .mockReturnValueOnce(slowThree.promise); + + const first = service.play(ALT_TWO.id); + const second = service.play(ALT_THREE.id); + expect(service.busySourceId()).toBe(ALT_THREE.id); + + slowTwo.resolve(resolvedFor(ALT_TWO, 0)); + await first; + + // The abandoned pick finishing must not take the spinner off the row + // the user is actually waiting on. + expect(service.busySourceId()).toBe(ALT_THREE.id); + + slowThree.resolve(resolvedFor(ALT_THREE, 0)); + await second; + + expect(service.busySourceId()).toBeNull(); + }); + it('drops a pin change whose movie was navigated away from', async () => { const pinFor = (candidate: VodSourceCandidate) => ({ matchKey: 'title:the matrix:1999', diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts index a47ae29a5..cac4feb3a 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host.service.spec.ts @@ -217,7 +217,6 @@ describe('VodMultiSourceHostService', () => { expect(visited).toHaveLength(2); expect(new Set(visited).size).toBe(visited.length); expect(visited).not.toContain(CURRENT_A_ID); - expect(service.isExhausted()).toBe(true); }); it('keeps going when the best candidate cannot be resolved', async () => { @@ -247,121 +246,6 @@ describe('VodMultiSourceHostService', () => { // spinning through the same candidate forever. await expect(service.failover()).resolves.toBeNull(); expect(startPlayback).not.toHaveBeenCalled(); - expect(service.isExhausted()).toBe(true); - }); - - it('plays the pinned source instead of the route playlist', async () => { - pins.get.mockResolvedValue({ - matchKey: 'title:the matrix:1999', - playlistId: ALT_TWO.playlistId, - contentId: ALT_TWO.contentId, - portalType: 'xtream', - }); - await loadMovie([ALT_TWO, ALT_THREE]); - - // Without this the persisted preference is only an icon: reopening the - // movie would still start the playlist the route is on. - await expect(service.playPinnedSource()).resolves.toBe(true); - expect(rowFor(ALT_TWO.id)?.isActive).toBe(true); - }); - - it('resumes the pinned source from the 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 route knows after loading playback positions. Nothing has - // played yet, so no timeupdate has reported anything — and the button - // the user is about to press says "Resume". - service.seedResumePosition(2538); - - await expect(service.playPinnedSource()).resolves.toBe(true); - - expect(resolver.resolve).toHaveBeenCalledWith( - expect.objectContaining({ id: ALT_TWO.id }), - { startTime: 2538 } - ); - }); - - it('does not show a pin the database refused to store', async () => { - await loadMovie([ALT_TWO]); - pins.set.mockResolvedValue(false); - - await service.togglePin(ALT_TWO.id); - - // The icon promises the preference survives reopening the movie. A - // write that failed makes that a lie, so the row must not change. - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); - }); - - it('keeps the pin when clearing it fails', async () => { - pins.get.mockResolvedValue({ - matchKey: 'title:the matrix:1999', - playlistId: ALT_TWO.playlistId, - contentId: ALT_TWO.contentId, - portalType: 'xtream', - }); - await loadMovie([ALT_TWO]); - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); - - pins.clear.mockResolvedValue(false); - await service.togglePin(ALT_TWO.id); - - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); - }); - - it('leaves Play alone when nothing is pinned', async () => { - await loadMovie([ALT_TWO]); - - await expect(service.playPinnedSource()).resolves.toBe(false); - expect(startPlayback).not.toHaveBeenCalled(); - }); - - it('prefers the pinned source when failing over', async () => { - pins.get.mockResolvedValue({ - matchKey: 'title:the matrix:1999', - playlistId: ALT_THREE.playlistId, - contentId: ALT_THREE.contentId, - portalType: 'xtream', - }); - await loadMovie([ALT_TWO, ALT_THREE]); - vodAutoFailover.set(true); - - await expect(service.failover()).resolves.not.toBeNull(); - expect(rowFor(ALT_THREE.id)?.isActive).toBe(true); - }); - - it('round-trips a pin under the movie match key', async () => { - await loadMovie([ALT_TWO]); - const matchKeys: string[] = pins.get.mock.calls[0][0]; - expect(matchKeys.length).toBeGreaterThan(0); - const pin = { - matchKey: matchKeys[0], - playlistId: ALT_TWO.playlistId, - contentId: ALT_TWO.contentId, - portalType: 'xtream', - }; - - await service.togglePin(ALT_TWO.id); - - expect(pins.set).toHaveBeenCalledWith(pin); - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); - - await service.togglePin(ALT_TWO.id); - - expect(pins.clear).toHaveBeenCalledWith(matchKeys); - expect(pins.set).toHaveBeenCalledTimes(1); - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); - - pins.get.mockResolvedValue(pin); - await loadMovie([ALT_TWO]); - - expect(rowFor(ALT_TWO.id)?.isPinned).toBe(true); - expect(rowFor(CURRENT_A_ID)?.isPinned).toBe(false); }); it('reports unknown, never fail, for a source it cannot check', async () => { 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 e704975e9..e8664c2ef 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 @@ -48,12 +48,9 @@ import { * Wires VOD multi-source into one open movie on the Xtream detail page. * * Component-provided, not root-provided: the controller's "each source is - * tried at most once" set must die with the movie, or a later film would - * inherit a poisoned failover history. - * - * The host component supplies the movie identity and the two playback seams - * (start a playback, read the live position) through `bind()`, so this service - * never reaches into the route component. + * tried at most once" set must die with the movie. The host supplies the + * movie identity and the playback seam through `bind()`, so this never + * reaches into the route component. */ export interface VodMultiSourceBindings { @@ -142,10 +139,9 @@ export class VodMultiSourceHostService { } /** - * Wire the host's playback seam and start watching the movie on screen. - * - * The effect lives here rather than in the route component because what it - * guards is this service's own lifecycle — see `load()`. + * Wire the host's playback seam and watch the movie on screen. The effect + * lives here because what it guards is this service's own lifecycle — see + * `load()`. */ bind(bindings: VodMultiSourceBindings): void { this.bindings = bindings; @@ -265,7 +261,12 @@ export class VodMultiSourceHostService { try { return (await this.switchTo(candidate)) === 'switched'; } finally { - this._busySourceId.set(null); + // Only if this attempt still owns the spinner: a slower first pick + // finishing after a second one would otherwise clear the row that + // is still resolving. + if (this._busySourceId() === sourceId) { + this._busySourceId.set(null); + } } } @@ -324,11 +325,6 @@ export class VodMultiSourceHostService { return switched ? this._lastSwitch() : null; } - /** True once every alternative has been attempted this session. */ - isExhausted(): boolean { - return this.controller.isExhausted(); - } - /** The live position, fed ahead of the persist throttle. */ reportPosition(seconds: number): void { this.controller.setResumeSeconds(seconds); 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 15e885929..ef46f96fb 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 @@ -26,29 +26,46 @@ export async function readPin( } /** - * Persist the pin for `candidate`. + * Persist the pin for `candidate` under EVERY alias of the movie. * - * Written under the most-trusted key while lookups pass every alias, so a TMDB - * id arriving after the fact does not orphan the row. + * Writing only the most-trusted key would leave the others pointing at + * whatever was pinned before: reopening the movie before enrichment lands — + * or when that request fails — reads a lower-trust alias and starts the source + * the user just replaced. Since lookups accept any alias, every alias has to + * agree. + * + * Success is the most-trusted key's: it is the one a later lookup reaches + * first, and a half-written alias set is no worse than the stale one it + * replaced (unpinning clears them all regardless). */ export async function writePin( pins: Pick, matchKeys: readonly string[], candidate: VodSourceCandidate ): Promise { - const matchKey = matchKeys[0] ?? buildVodSourceMatchKey(candidate); - if (!matchKey) { + const keys = matchKeys.length + ? matchKeys + : [buildVodSourceMatchKey(candidate)].filter( + (key): key is string => !!key + ); + if (keys.length === 0) { return false; } // The write can fail — no bridge, or the DB refused it. Reporting success // then would show a pin the next visit does not have. - return pins.set({ - matchKey, - playlistId: candidate.playlistId, - contentId: candidate.contentId, - portalType: candidate.portalType, - }); + const written = await Promise.all( + keys.map((matchKey) => + pins.set({ + matchKey, + playlistId: candidate.playlistId, + contentId: candidate.contentId, + portalType: candidate.portalType, + }) + ) + ); + + return written[0] === true; } /** Clears every alias, so unpinning is not undone by a stale row. */ diff --git a/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts b/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts new file mode 100644 index 000000000..800787371 --- /dev/null +++ b/libs/shared/interfaces/src/lib/vod-source-match-key.util.spec.ts @@ -0,0 +1,59 @@ +import { + buildVodSourceMatchKey, + buildVodSourceMatchKeyCandidates, + isTmdbMatchKey, +} from './vod-source-match-key.util'; + +const MOVIE = { title: 'The Matrix', year: 1999, tmdbId: 603 }; + +describe('buildVodSourceMatchKey', () => { + it('prefers the TMDB id when there is a usable one', () => { + expect(buildVodSourceMatchKey(MOVIE)).toBe('tmdb:603'); + expect(isTmdbMatchKey('tmdb:603')).toBe(true); + }); + + it.each([[0], [''], [null], [undefined], ['abc']])( + 'falls back to the title for the unusable TMDB id %p', + (tmdbId) => { + expect(buildVodSourceMatchKey({ ...MOVIE, tmdbId })).toBe( + 'title:the matrix:1999' + ); + } + ); + + it('refuses to key on nothing rather than writing a junk row', () => { + expect(buildVodSourceMatchKey({ title: ' ', year: null })).toBeNull(); + }); +}); + +describe('buildVodSourceMatchKeyCandidates', () => { + it('covers every poorer form the pin may already be stored under', () => { + // Enrichment adds the id AND the year, so a pin set before it landed + // is under a title key — and one set before the year was known is + // under the yearless form. Missing either loses the preference. + expect(buildVodSourceMatchKeyCandidates(MOVIE)).toEqual([ + 'tmdb:603', + 'title:the matrix:1999', + 'title:the matrix:', + ]); + }); + + it('still offers the yearless alias without a TMDB id', () => { + expect( + buildVodSourceMatchKeyCandidates({ ...MOVIE, tmdbId: null }) + ).toEqual(['title:the matrix:1999', 'title:the matrix:']); + }); + + it('does not repeat the yearless key when the year is unknown', () => { + expect( + buildVodSourceMatchKeyCandidates({ + title: 'The Matrix', + year: null, + }) + ).toEqual(['title:the matrix:']); + }); + + it('is empty when the movie cannot be identified at all', () => { + expect(buildVodSourceMatchKeyCandidates({ title: '' })).toEqual([]); + }); +}); diff --git a/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts b/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts index 74dfabb59..a5036c83e 100644 --- a/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts +++ b/libs/shared/interfaces/src/lib/vod-source-match-key.util.ts @@ -45,9 +45,12 @@ export function buildVodSourceMatchKey(input: { /** * Every key a movie may legitimately be stored under, most-trusted first. * - * A pin written before TMDB enrichment lands is keyed by title; after - * enrichment the same movie prefers a `tmdb:` key. Looking up both means the - * earlier pin is not orphaned by the id arriving. + * Enrichment arrives late and adds BOTH identifying fields, so a pin can have + * been written under any earlier, poorer form of the same movie: + * - before the TMDB id → `title:{base}:{year}` + * - before the release year too → `title:{base}:` + * Each of those is a key the row may still be sitting under, and a lookup that + * skipped it would silently lose a preference the user set minutes ago. */ export function buildVodSourceMatchKeyCandidates(input: { tmdbId?: number | string | null; @@ -55,16 +58,20 @@ export function buildVodSourceMatchKeyCandidates(input: { year?: number | null; }): string[] { const keys: string[] = []; + const push = (key: string | null) => { + if (key && !keys.includes(key)) { + keys.push(key); + } + }; const tmdbId = normalizeTmdbId(input.tmdbId); if (tmdbId !== null) { - keys.push(`${TMDB_PREFIX}${tmdbId}`); + push(`${TMDB_PREFIX}${tmdbId}`); } - const titleKey = buildVodSourceMatchKey({ ...input, tmdbId: null }); - if (titleKey && !keys.includes(titleKey)) { - keys.push(titleKey); - } + push(buildVodSourceMatchKey({ ...input, tmdbId: null })); + // The yearless form, for a pin set before the release date was known. + push(buildVodSourceMatchKey({ ...input, tmdbId: null, year: null })); return keys; }