From d89511466cc3853bcc82b734af081de1114da012 Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 28 Jul 2026 21:32:38 +0200 Subject: [PATCH] fix(portals): read array-shaped codecs, bound short-title scans, honour alias clears MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three from review. `info.video`/`info.audio` are declared — and sent by the mock server and many panels — as string arrays, but the resolver only read the ffprobe object shape. Every array response therefore lost the provider's codec, so those source rows showed no codec fact and the "dub may differ" warning could never fire. `readStreamInfo` now accepts both, and states nothing when the provider stated nothing. The FTS-empty fallback scan matched only the FIRST token, which is fine for a one-word short title but not for "I Am": every catalog row containing the word "i" came back for TypeScript to throw away — a full scan of a large catalog on the single database worker, just to open a detail page. Every token must now appear. `writePin` reported success when the canonical write landed but retiring the old alias failed. Lookups read aliases before the canonical key, so reopening the movie before enrichment would start the source the user just replaced, with the icon promising otherwise. Co-Authored-By: Claude Opus 5 --- .../title-sources.operations.spec.ts | 15 +++++++ .../operations/title-sources.operations.ts | 18 ++++++-- docs/architecture/vod-multi-source.md | 8 ++++ .../vod-source-resolver-stream-info.spec.ts | 41 +++++++++++++++++++ .../vod-source-resolver.service.ts | 31 ++++++++++++-- .../vod-multi-source-host-pin-writes.spec.ts | 17 ++++++++ .../lib/vod-details/vod-multi-source-pin.ts | 11 ++--- 7 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver-stream-info.spec.ts 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 6e21dc50d..68a103de4 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 @@ -95,6 +95,21 @@ describe('title-sources.operations', () => { expect(compiledQuery(fts.all).sql).toContain('LIMIT'); }); + it('requires every token of an all-short multiword title', async () => { + // "I Am" reaches the scan too — no token survives the trigram + // minimum. Matching on the first token alone returns every catalog + // row containing the word "i", which the TypeScript pass then + // throws away: a full scan of a large catalog on the single DB + // worker, just to open a detail page. + const scan = createDbMock([]); + await findTitleSources(scan.db, { title: 'I Am' }); + const scanQuery = compiledQuery(scan.all); + + expect(scanQuery.params).toContain('*[^a-z0-9]i[^a-z0-9]*'); + expect(scanQuery.params).toContain('*[^a-z0-9]am[^a-z0-9]*'); + expect(scanQuery.sql.match(/GLOB \?/g)).toHaveLength(2); + }); + it('does not offer a scan hit whose title merely contains the query', async () => { // The scan is deliberately loose; the two-tier normalized check // afterwards is what keeps "Up" from matching "Upgrade". 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 804136ab4..11c86e4c9 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 @@ -120,10 +120,20 @@ function excludePlaylistClause( * text, so a non-ASCII short title is no better and no worse served than before. */ function scanCandidateQuery(base: string, excludePlaylist: SQL) { - // Normalized, so it holds letters, digits and spaces only — nothing GLOB + // EVERY token has to appear, not just the first. This path is reached + // whenever no token survives FTS's minimum length, which includes + // multiword titles like "I Am" — matching on "i" alone would return most + // of the catalog and hand it all to the TypeScript pass to throw away. + const tokens = base.split(' ').filter(Boolean); + // Normalized, so they hold letters, digits and spaces only — nothing GLOB // would read as a metacharacter. - const token = base.split(' ')[0]; - const wordMatch = `*[^a-z0-9]${token}[^a-z0-9]*`; + const wordMatches = sql.join( + tokens.map( + (token) => + sql`' ' || LOWER(c.title) || ' ' GLOB ${`*[^a-z0-9]${token}[^a-z0-9]*`}` + ), + sql` AND ` + ); return sql` SELECT c.id AS content_id, @@ -139,7 +149,7 @@ function scanCandidateQuery(base: string, excludePlaylist: SQL) { WHERE c.type = 'movie' AND cat.hidden = 0 AND p.type = 'xtream' - AND ' ' || LOWER(c.title) || ' ' GLOB ${wordMatch} + AND ${wordMatches} ${excludePlaylist} ORDER BY LENGTH(c.title), c.title `; diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index f68b845aa..c10d835c9 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -457,6 +457,14 @@ lookup comes back empty, because "never watched" is an answer: the button must read Play, not `Resume 42:18` on a stream that starts at zero. A pin on the route's own row changes nothing; the loaded position already IS that copy's. +## Provider codec metadata + +`info.video` / `info.audio` come back in two shapes: the declared string array +(`['H.264']`, which the mock server and many panels send) and the ffprobe +object carrying `codec_name`/`width`/`height`. `readStreamInfo` accepts both — +reading only the object silently lost the codec on every array response, and +with it the "dub may differ" warning, which compares stated audio tracks. + ## Claims about the present `isActive` means "the source a switch or Play would use" — selection, not diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver-stream-info.spec.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver-stream-info.spec.ts new file mode 100644 index 000000000..5df8a0de9 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver-stream-info.spec.ts @@ -0,0 +1,41 @@ +import { readStreamInfo } from './vod-source-resolver.service'; + +/** + * Xtream panels disagree about the shape of `info.video` / `info.audio`. + * + * The declared shape is a string array and that is what the mock server and + * many panels send; others send the ffprobe object. Reading only one of them + * loses the provider's codec on every response of the other kind — and with + * it the "dub may differ" warning, which compares stated audio tracks. + */ +describe('readStreamInfo', () => { + it('reads the declared array shape', () => { + expect(readStreamInfo(['H.264'])).toEqual({ codec_name: 'H.264' }); + expect(readStreamInfo(['AAC'])).toEqual({ codec_name: 'AAC' }); + }); + + it('reads the ffprobe object shape, dimensions included', () => { + expect( + readStreamInfo({ codec_name: 'hevc', width: 3840, height: 2160 }) + ).toEqual({ codec_name: 'hevc', width: 3840, height: 2160 }); + }); + + it('skips blank entries rather than stating an empty codec', () => { + expect(readStreamInfo(['', ' ', 'H.264'])).toEqual({ + codec_name: 'H.264', + }); + }); + + it('trims, since the value is published as a fact', () => { + expect(readStreamInfo([' H.264 '])).toEqual({ codec_name: 'H.264' }); + }); + + it('says nothing when the provider said nothing', () => { + // Empty beats wrong: an absent codec must not become a stated one. + expect(readStreamInfo([])).toBeUndefined(); + expect(readStreamInfo(['', ' '])).toBeUndefined(); + expect(readStreamInfo(undefined)).toBeUndefined(); + expect(readStreamInfo(null)).toBeUndefined(); + expect(readStreamInfo('H.264')).toBeUndefined(); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts index 4385e09f7..5bdc3ef81 100644 --- a/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/multi-source/vod-source-resolver.service.ts @@ -180,10 +180,8 @@ export class VodSourceResolverService { const info = Array.isArray(vodDetails?.info) ? undefined : vodDetails?.info; - const video = info?.video as - | { codec_name?: string; width?: number; height?: number } - | undefined; - const audio = info?.audio as { codec_name?: string } | undefined; + const video = readStreamInfo(info?.video); + const audio = readStreamInfo(info?.audio); const container = vodDetails?.movie_data?.container_extension; if (container) { @@ -222,3 +220,28 @@ export class VodSourceResolverService { } } } + +/** + * Xtream panels disagree about `info.video` / `info.audio`. + * + * The declared shape is a string array (`['H.264']`), and that is what the + * mock server and many panels return; others return the ffprobe object with + * `codec_name`/`width`/`height`. Reading only the object shape silently lost + * the codec on every array-shaped response — so the source rows showed no + * provider-stated codec and the "dub may differ" warning could never fire. + */ +export function readStreamInfo( + value: unknown +): { codec_name?: string; width?: number; height?: number } | undefined { + if (Array.isArray(value)) { + const codec = value.find( + (entry): entry is string => + typeof entry === 'string' && entry.trim() !== '' + ); + return codec ? { codec_name: codec.trim() } : undefined; + } + + return typeof value === 'object' && value !== null + ? (value as { codec_name?: string; width?: number; height?: number }) + : undefined; +} diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts index 9ed7f3b49..582a8ff9b 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-pin-writes.spec.ts @@ -196,6 +196,23 @@ describe('VodMultiSourceHostService — pin persistence', () => { expect(rowFor(ALT_TWO.id)?.isPinned).toBe(false); }); + it('does not claim a pin whose stale alias survived', async () => { + // A tmdb id AND a title/year, so the canonical key is written and the + // title alias is left to retire — the case where a failed clear can + // actually strand a stale row. + await loadMovie([ALT_TWO], { ...MOVIE_A, tmdbId: 603 }); + pins.set.mockResolvedValue(true); + // The canonical key is written, but retiring the alias fails. + pins.clear.mockResolvedValue(false); + + await service.togglePin(ALT_TWO.id); + + // Lookups read the alias BEFORE the key just written, so reopening + // this movie before enrichment lands would start the OLD source. The + // icon must not promise otherwise. + 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', 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 c5e1a69d0..119f8a11f 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 @@ -109,11 +109,12 @@ export async function writePin( return false; } - await erasePin( - pins, - retirablePinKeys(keys).filter((key) => key !== matchKey) - ); - return true; + const retire = retirablePinKeys(keys).filter((key) => key !== matchKey); + // A surviving alias is read BEFORE the canonical key on the next open, so + // a failed retire means reopening the movie picks the OLD source until + // enrichment supplies the tmdb id again. Report that rather than claiming + // the change stuck. + return retire.length === 0 || (await erasePin(pins, retire)); } /** Clears every alias, so unpinning is not undone by a stale row. */