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. */