fix(xtream): stop grouping the scan tier, it can drop a matching source

content is unique per (category, type, stream), so one stream sitting in
several categories is several rows and nothing forces their titles to
agree. The GROUP BY added for group_concat let SQLite keep an arbitrary
row's title, and the normalized confirmation then rejected the whole
stream on a title a sibling row would have matched — the source vanished.

The FTS tier can afford that grouping because its window makes it
necessary; the scan tier takes no window at all, so it now returns a row
per category and their names are merged per stream in TypeScript, which
also keeps the rejected sibling's category in the language derivation.

Found by Codex. Latent rather than active on the catalog I measured (0
streams currently carry differing titles across categories), but the
schema permits it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-12 01:41:11 +02:00
1 parent fc89ef7c58
commit 5063e53f71
5 files changed
+80 -21

No files matched your search

+1 -1
View File
@@ -1209,7 +1209,7 @@ engine` (restart required) or
**VOD Multi-Source** (alternative sources for a movie):
- 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 660px 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. It opens ABOVE the chip (right edges aligned, pressed state on the chip while open), height-capped by the overlay's flexible bounding box so only the source list scrolls, and flips below when less than the overlay `minHeight` remains above; filter chips (All / Available / HD+ / language select) compose with the host search, "Available" auto-runs check-all when no verdicts exist, and expanded copy rows show a parsed language chip + raw stream title with diff-only tags ("same as above" for the parent's copy). A row's language is `vodSourceLanguage` (`libs/shared/interfaces/src/lib/vod-source-language.util.ts`): the title's own prefix (pipe incl. Unicode lookalikes, bracketed, or ALL-CAPS spaced-dash form; Latin/Cyrillic 2–4 letters + `MULTI`; only the legacy pipe form is permissive — bracket/dash matches must also pass `isKnownLanguageTag`, since those positions carry quality/rip tags like `[HD]`) wins, else the language the stream's visible categories unambiguously carry ("EN | Netflix" — discovery aggregates `group_concat(cat.name, char(31))` per (playlist, stream), prefixed categories must agree, and category prefixes must pass `isKnownLanguageTag`, since `new`/`top`/`hot` are real ISO 639-3 codes but everyday category words; the route's own row reads the one category the route arrived through, overlaid late by the host's same-key `refreshRouteFacts` since cold/direct routes load categories after discovery). Both forms are parsed guesses: browse filter and chips only, never ranking/failover/dub-warning inputs. Recognition alone is not enough — `normalizeTitleKeys` must STRIP the same tag or the copy is never discovered, so its leading-tag rule shares `PROVIDER_PIPE_CLASS` and drops the required space after a pipe. It goes no further on purpose: a wrong guess costs a filter option, a wrong strip corrupts identity, and on 1.27M real titles a case-insensitive/Cyrillic pipe rule corrupts 349 keys ("Akira | 1988", "Момо | Momo" — the name sits in the tag position) while `–`/`—` on the dash branch amputates 14 subtitled titles. Verify such widenings against the real catalog before shipping them. Checks run through a 4-slot queue and settled verdicts are cached 10 min per movie+source (`VodSourceProbeCacheService`). Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The details-page chip badge counts TOTAL **copies** across all playlists (the in-player chip still counts alternatives); the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal. The action row's Favorites and Download buttons are icon-only 64px squares: filled red heart when favorited, and a download idle icon → progress ring (real percent, indeterminate spin, paused-resume) → green done-checkmark whose click reveals the file (state read from the download manager; the labeled "Play from source" secondary is gone — provider playback for a downloaded movie goes through the Sources popover).
- 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 660px 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. It opens ABOVE the chip (right edges aligned, pressed state on the chip while open), height-capped by the overlay's flexible bounding box so only the source list scrolls, and flips below when less than the overlay `minHeight` remains above; filter chips (All / Available / HD+ / language select) compose with the host search, "Available" auto-runs check-all when no verdicts exist, and expanded copy rows show a parsed language chip + raw stream title with diff-only tags ("same as above" for the parent's copy). A row's language is `vodSourceLanguage` (`libs/shared/interfaces/src/lib/vod-source-language.util.ts`): the title's own prefix (pipe incl. Unicode lookalikes, bracketed, or ALL-CAPS spaced-dash form; Latin/Cyrillic 2–4 letters + `MULTI`; only the legacy pipe form is permissive — bracket/dash matches must also pass `isKnownLanguageTag`, since those positions carry quality/rip tags like `[HD]`) wins, else the language the stream's visible categories unambiguously carry ("EN | Netflix" — discovery returns all category names — the FTS tier joins them with `group_concat(cat.name, char(31))` under the GROUP BY it already needs, the scan tier must NOT group (per-category uniqueness means sibling rows can carry different titles and grouping would drop a matching one) and its names merge in TypeScript, prefixed categories must agree, and category prefixes must pass `isKnownLanguageTag`, since `new`/`top`/`hot` are real ISO 639-3 codes but everyday category words; the route's own row reads the one category the route arrived through, overlaid late by the host's same-key `refreshRouteFacts` since cold/direct routes load categories after discovery). Both forms are parsed guesses: browse filter and chips only, never ranking/failover/dub-warning inputs. Recognition alone is not enough — `normalizeTitleKeys` must STRIP the same tag or the copy is never discovered, so its leading-tag rule shares `PROVIDER_PIPE_CLASS` and drops the required space after a pipe. It goes no further on purpose: a wrong guess costs a filter option, a wrong strip corrupts identity, and on 1.27M real titles a case-insensitive/Cyrillic pipe rule corrupts 349 keys ("Akira | 1988", "Момо | Momo" — the name sits in the tag position) while `–`/`—` on the dash branch amputates 14 subtitled titles. Verify such widenings against the real catalog before shipping them. Checks run through a 4-slot queue and settled verdicts are cached 10 min per movie+source (`VodSourceProbeCacheService`). Both chips are handed the same `matchKind` and `vodAutoFailover` and both write the setting back. The details-page chip badge counts TOTAL **copies** across all playlists (the in-player chip still counts alternatives); the caption ("also found in N other playlists") counts distinct **playlists** via `alternativePlaylistCount`, because the popover groups one portal's copies under that portal. The action row's Favorites and Download buttons are icon-only 64px squares: filled red heart when favorited, and a download idle icon → progress ring (real percent, indeterminate spin, paused-resume) → green done-checkmark whose click reveals the file (state read from the download manager; the labeled "Play from source" secondary is gone — provider playback for a downloaded movie goes through the Sources popover).
- 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 — but a known height vetoes the answer on every tier, since cropping only removes lines: a taller frame is a different shape (1440×1080 anamorphic or 1600×900 are not 720p, 960×540 is not 576p) and gets no tag rather than a wrong one carrying `api` provenance. The route's OWN row is never resolved, so it takes its facts from the `get_vod_info` the page already loaded (`providerVodMetadataOf`, shared with the resolver) and picks them up via `refreshRouteFacts()` even when they arrive without changing the movie identity — otherwise `audioDiffersFactually` has nothing on one side and the dub warning cannot fire on a route-to-alternative switch.
- 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 an ASCII 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 — but the two tiers read different forms: the base tier accepts bracketed or trailing (it just stripped a trailing year, the only thing separating "Dune 1984" from "Dune 2021"), while the exact tier reads bracketed ONLY, since reaching it means both titles are the same string and a trailing number is then part of the NAME ("Blade Runner 2049" against a metadata year of 2017 would otherwise vanish once enrichment lands). A non-ASCII token cannot be folded by `LOWER()` (ASCII-only) but CAN be by a GLOB character class (UTF-8 code points), so `caseInsensitiveGlobPattern` folds the case in JS and emits one `[lowerUpper]` class per character — returning `null`, leaving the two substring tests alone, for a GLOB metacharacter or a length-changing case map (`ß`→`SS`). The movie's own year comes from `releaseTagYear` (bracketed or trailing only), never `extractYear`: a year inside the NAME ("2001: A Space Odyssey") would fail every genuine 1968 copy at the year gate and move the pin key once enrichment lands. 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.
@@ -31,6 +31,30 @@ describe('title-sources.operations — confirmation and scoping', () => {
]);
});
it('keeps a stream whose sibling row carries a different title', async () => {
// `content` is unique per (category, type, stream), so one stream
// in two categories is two rows and nothing forces their titles to
// agree. The scan tier must therefore not GROUP: given a free
// choice SQLite can keep "Dune Part Two", the normalized
// confirmation rejects it, and the source disappears even though
// its sibling row says "Dune". "Up" routes to the scan tier.
const { db } = createDbMock([
{ ...duneRow, title: 'Up Above', category_names: 'DE | Kino' },
{ ...duneRow, title: 'Up', category_names: 'EN | Movies' },
]);
const matches = await findTitleSources(db, { title: 'Up' });
expect(matches).toHaveLength(1);
expect(matches[0].title).toBe('Up');
// ...and the rejected sibling's category still describes the same
// stream, so its name is merged in rather than dropped.
expect(matches[0].categoryNames).toEqual([
'DE | Kino',
'EN | Movies',
]);
});
it('splits aggregated category names on the unit separator', async () => {
// `group_concat`'s default `,` appears in real category names, so
// the queries aggregate with char(31) and the split must read
@@ -75,22 +75,23 @@ describe('title-sources.operations', () => {
expect(compiledQuery(fts.all).sql).toContain('LIMIT');
});
it('aggregates visible category names on both query paths', async () => {
it('returns category names without grouping the scan tier', async () => {
// The renderer reads a language prefix off category names
// ("EN | Netflix"), so both paths must return them — grouped per
// (playlist, stream) and joined with char(31), because the
// default `,` appears inside real category names.
// ("EN | Netflix"), so both tiers must return them — but only the
// FTS tier may GROUP: `content` is unique per (category, type,
// stream), so grouping the scan would let SQLite keep an
// arbitrary row's title and reject a stream a sibling row would
// have confirmed.
const scan = createDbMock([]);
await findTitleSources(scan.db, { title: 'It' });
const scanQuery = compiledQuery(scan.all);
expect(scanQuery.sql).toContain('group_concat(cat.name, char(31))');
expect(scanQuery.sql).toContain(
'GROUP BY cat.playlist_id, c.xtream_id'
);
expect(scanQuery.sql).toContain('cat.name AS category_names');
expect(scanQuery.sql).not.toContain('GROUP BY');
const fts = createDbMock([]);
await findTitleSources(fts.db, { title: 'Dune' });
const ftsQuery = compiledQuery(fts.all);
// char(31): `group_concat`'s default `,` occurs inside real names.
expect(ftsQuery.sql).toContain('group_concat(cat.name, char(31))');
expect(ftsQuery.sql).toContain(
'GROUP BY cat.playlist_id, c.xtream_id'
@@ -35,7 +35,12 @@ interface TitleSourceRow {
category_xtream_id: number;
playlist_id: string;
playlist_name: string;
/** `group_concat` of every visible category name, `\u001f`-separated. */
/**
* Visible category names for this row: `group_concat`-joined on the FTS
* tier, which returns one row per stream, and a single name on the scan
* tier, which returns one row per category. Both are read through
* `splitCategoryNames`, and the caller merges them per stream.
*/
category_names: string | null;
}
@@ -228,10 +233,15 @@ function scanCandidateQuery(
),
sql` AND `
);
// Grouped like the FTS path: one row per (playlist, stream), with every
// visible category name aggregated onto it so the renderer can read a
// language prefix off categories ("EN | Netflix"). The hidden filter
// above keeps hidden categories out of that aggregate too.
// Deliberately NOT grouped, unlike the FTS tier. `content` is unique per
// (category, type, stream), so one stream sitting in several categories
// is several rows and nothing forces their titles to agree. Grouping
// would hand SQLite a free choice of which title to keep, and the
// normalized confirmation below would then reject the whole stream on a
// title that a sibling row would have matched — the source vanishes.
// The FTS tier can afford the grouping because its window makes it
// necessary; this tier takes no window at all, so it keeps every row and
// merges their category names afterwards.
return sql`
SELECT
c.id AS content_id,
@@ -241,7 +251,7 @@ function scanCandidateQuery(
cat.xtream_id AS category_xtream_id,
cat.playlist_id AS playlist_id,
p.name AS playlist_name,
group_concat(cat.name, char(31)) AS category_names
cat.name AS category_names
FROM content AS c
INNER JOIN categories AS cat ON c.category_id = cat.id
INNER JOIN playlists AS p ON cat.playlist_id = p.id
@@ -250,7 +260,6 @@ function scanCandidateQuery(
AND p.type = 'xtream'
AND ${wordMatches}
${excludePlaylist}
GROUP BY cat.playlist_id, c.xtream_id
ORDER BY LENGTH(c.title), c.title
`;
}
@@ -336,6 +345,24 @@ export async function findTitleSources(
// One playlist can list the same film in several categories; the user
// thinks of that as one source.
const seen = new Set<string>();
// Every category the stream sits in, merged across the rows that carry
// it. The FTS tier already joined them into one row, so this is a no-op
// there; the scan tier returns a row per category and must not group (see
// `scanCandidateQuery`), so this is where its names come together. Rows
// the confirmation later rejects still contribute — a differently titled
// sibling row is the same stream, and its category says the same thing
// about the language.
const categoryNames = new Map<string, string[]>();
for (const row of rows) {
const key = `${row.playlist_id}:${row.xtream_id}`;
const names = categoryNames.get(key) ?? [];
for (const name of splitCategoryNames(row.category_names)) {
if (!names.includes(name)) {
names.push(name);
}
}
categoryNames.set(key, names);
}
for (const row of rows) {
// SQL already excluded these; this keeps the guarantee a property of
@@ -395,7 +422,7 @@ export async function findTitleSources(
posterUrl: row.poster_url,
matchConfidence: exactMatch ? 'exact' : 'fuzzy',
year: rowYear,
categoryNames: splitCategoryNames(row.category_names),
categoryNames: categoryNames.get(dedupeKey) ?? [],
});
}
+11 -4
View File
@@ -217,10 +217,17 @@ forms — and the fact that a row only appears once it HAS matched — keeps a
bad guess cosmetic.
The category path exists because many panels tag the CATEGORY ("EN | Netflix",
"DE | Apple TV") and leave stream titles bare. Discovery aggregates every
visible category name a stream sits in (`group_concat(cat.name, char(31))`
per `(playlist_id, xtream_id)` in both query tiers — `char(31)` because the
default `,` appears inside real category names), and
"DE | Apple TV") and leave stream titles bare. Discovery returns every visible
category name a stream sits in, and the two query tiers get there
differently: the FTS tier already groups per `(playlist_id, xtream_id)` for
its window, so it joins them with `group_concat(cat.name, char(31))` —
`char(31)` because the default `,` appears inside real category names — while
the scan tier must NOT group. `content` is unique per
`(category, type, stream)`, so one stream in several categories is several
rows whose titles need not agree; grouping would let SQLite keep an arbitrary
one and the normalized confirmation would then reject a stream that a sibling
row would have matched. The scan therefore returns a row per category and the
names are merged per stream in TypeScript. Either way
`unambiguousCategoryLanguage` reduces them: categories without a recognized
language prefix abstain, all prefixed ones must agree, and a conflict yields
nothing. Category prefixes must additionally pass `isKnownLanguageTag`.