fix(portals): read array-shaped codecs, bound short-title scans, honour alias clears

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5 committed 2026-07-28 21:32:38 +02:00
1 parent a25412ea61
commit d89511466c
7 files changed
+128 -13

No files matched your search

@@ -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".
@@ -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
`;
+8
View File
@@ -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
@@ -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();
});
});
@@ -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;
}
@@ -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',
@@ -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. */