diff --git a/CLAUDE.md b/CLAUDE.md index 981c35cc8..04e16ad95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`) 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). Both forms are parsed guesses: browse filter and chips only, never ranking/failover/dub-warning inputs. 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 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. 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. diff --git a/docs/architecture/vod-multi-source.md b/docs/architecture/vod-multi-source.md index 7c16af237..d17e1ac38 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -189,7 +189,12 @@ identical to `|`, invisible to a literal match), a bracketed tag at the very start (`[EN] Movie`), and an ALL-uppercase tag before a **spaced** dash (`EN - Movie`). The dash form is stricter on purpose: dashes are ordinary title punctuation, and "Up - the movie" or "X-Men" must not read as a -language. +language. Strictness is tiered per form: only the legacy pipe form is taken +at its word, while a bracket or dash match must ALSO pass +`isKnownLanguageTag` — those positions are where quality and rip tags live +("[HD]", "[CAM]", "NEW - "), and since a title prefix outranks the category +language, a fabricated one would mask a real category-derived language and +get the row excluded by the very filter meant to find it. The category path exists because many panels tag the CATEGORY ("EN | Netflix", "DE | Apple TV") and leave stream titles bare. Discovery aggregates every @@ -202,11 +207,14 @@ nothing. Category prefixes must additionally pass `isKnownLanguageTag`, because categories routinely start with "NEW |", "TOP |" or "VIP |" — and `new`, `top` and `hot` are even assigned ISO 639-3 codes, which is why the gate is an `Intl.DisplayNames` check for two-letter codes plus a curated list -for longer tags rather than a registry lookup. Title prefixes stay permissive: -a tag in front of a movie title is overwhelmingly a language, and tightening -there would drop working filter options. The route's own row reads the one -category the route arrived through (`VodMultiSourceMovie.categoryName`), -which is the visible one. +for longer tags rather than a registry lookup. Only the pipe title form stays +permissive: a tag before a pipe in a movie title is overwhelmingly a +language, and tightening the legacy form would drop filter options that work +today. The route's own row reads the one category the route arrived through +(`VodMultiSourceMovie.categoryName`), which is the visible one; it loads late +on cold/direct routes, so the host's same-key refresh +(`refreshRouteFacts`) overlays it — and provider facts — onto the existing +route row without a rediscovery. ### The popover: copy rows diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts index d8319975c..fe3184d8e 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-current-row.ts @@ -21,6 +21,21 @@ import type { VodMultiSourceMovie } from './vod-multi-source-identity'; * not fire on a route-to-alternative switch, which is the commonest one there * is. */ +/** + * The route row's category-derived language: what the one category the route + * arrived through states, if that is a language. Alternatives read this off + * every category the DB knows; the route only knows the visible one. Shared + * with the host's refresh path so the two cannot drift — the category loads + * late on cold/direct routes and arrives without changing the movie key. + */ +export function routeCategoryLanguage( + movie: VodMultiSourceMovie +): string | null { + return unambiguousCategoryLanguage( + movie.categoryName ? [movie.categoryName] : null + ); +} + export function currentSourceRow( movie: VodMultiSourceMovie ): VodSourceCandidate { @@ -33,11 +48,7 @@ export function currentSourceRow( rawTitle: movie.title, matchConfidence: 'exact', year: movie.year ?? null, - // Alternatives read this off every category the DB knows; the route - // only knows the one it arrived through, which is the visible one. - categoryLanguage: unambiguousCategoryLanguage( - movie.categoryName ? [movie.categoryName] : null - ), + categoryLanguage: routeCategoryLanguage(movie), }; return movie.metadata ? applyApiMetadata(row, movie.metadata) : row; diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts index c3d8a7fc7..ea3e3793e 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-multi-source-host-session.spec.ts @@ -144,6 +144,46 @@ describe('VodMultiSourceHostService — session lifecycle', () => { expect(discovery.discover).toHaveBeenCalledTimes(2); }); + it('overlays a late-arriving category language on the route row', async () => { + movie.set(MOVIE_A); + await flushEffects(); + expect(rowFor(CURRENT_A_ID)?.categoryLanguage ?? null).toBeNull(); + + // Cold/direct routes load categories after discovery ran, and the + // category name is outside the movie key on purpose — the same-key + // refresh is the only path that can deliver it to the route row. + movie.set({ ...MOVIE_A, categoryName: 'EN | Netflix' }); + await flushEffects(); + expect(rowFor(CURRENT_A_ID)?.categoryLanguage).toBe('EN'); + expect(discovery.discover).toHaveBeenCalledTimes(1); + + // A category that names no language never fabricates one. + movie.set({ ...MOVIE_A, categoryName: 'TOP | 250' }); + await flushEffects(); + expect(rowFor(CURRENT_A_ID)?.categoryLanguage ?? null).toBeNull(); + }); + + it('keeps a category that lands while discovery is in flight', async () => { + const first = createDeferred(); + discovery.discover.mockReturnValueOnce(first.promise); + + movie.set(MOVIE_A); + while (discovery.discover.mock.calls.length === 0) { + TestBed.tick(); + await Promise.resolve(); + } + + // The route row does not exist until discovery answers, so this + // same-key emission has nothing to refresh — it must not be lost. + movie.set({ ...MOVIE_A, categoryName: 'EN | Netflix' }); + await flushEffects(); + + first.resolve({ sources: [], matchKind: 'title-year' }); + await flushEffects(); + + expect(rowFor(CURRENT_A_ID)?.categoryLanguage).toBe('EN'); + }); + it('does not burn a source the user only selected', async () => { // One alternative, so the route copy is the ONLY fallback left and // the outcome cannot depend on how candidates are ranked. 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 1dc149c4f..9001e6046 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 @@ -33,7 +33,10 @@ import { import type { VodMultiSourceSwitchNotice } from './vod-multi-source-notice'; import { createVodSourceCounts } from './vod-multi-source-counts'; import { createCheckQueue } from './vod-multi-source-check-queue'; -import { currentSourceRow } from './vod-multi-source-current-row'; +import { + currentSourceRow, + routeCategoryLanguage, +} from './vod-multi-source-current-row'; import { probeSource } from './vod-multi-source-probe'; import { pinnedSourceAwaitingPlay, @@ -194,37 +197,56 @@ export class VodMultiSourceHostService { } /** - * Overlay the provider's facts onto the route's own row, in place. + * Overlay what arrived late onto the route's own row, in place. * - * The row is built when discovery runs, which on a sparse panel happens - * before `get_vod_info` answers — and if that answer adds no year and no - * TMDB id, the movie key does not change, so nothing rebuilds the row and - * it keeps stating nothing. Every comparison against it is then one-sided: - * the dub warning in particular cannot fire at all. + * The row is built when discovery runs, and two things routinely land + * AFTER that without changing the movie key: the provider's facts (a + * sparse panel's `get_vod_info` adds no year and no TMDB id) and the + * route category (cold/direct routes load categories late). Nothing + * rebuilds the row for either, so both are refreshed here — otherwise + * the dub warning stays one-sided and the row's category-derived + * language never appears, letting the language filter hide the very + * source that is playing. * * Merged onto the existing row rather than rebuilt from the movie, so a * probe result already sitting on it survives. + * + * A same-key emission can also arrive while discovery is still in + * flight, before the route row exists to refresh. That is not lost, + * and the reason is the `findSource` call below: it reads the + * controller's sources SIGNAL inside the `bind()` effect, so the + * publish that finally creates the row re-runs the effect, which lands + * here again with the movie's latest reading. Wrapping this read in + * `untracked()` would silently break that redelivery — a session spec + * pins it. */ private refreshRouteFacts(movie: VodMultiSourceMovie): void { - const facts = movie.metadata; const routeSourceId = this.routeSourceId; - if (!facts || !routeSourceId) { - return; - } - - const factsKey = JSON.stringify(facts); - if (this.routeFactsKey === factsKey) { - return; - } - - const existing = this.controller.findSource(routeSourceId); + const existing = routeSourceId + ? this.controller.findSource(routeSourceId) + : undefined; if (!existing) { return; } - this.routeFactsKey = factsKey; - this.controller.updateSource(applyApiMetadata(existing, facts)); - this.publish(); + let next = existing; + + const categoryLanguage = routeCategoryLanguage(movie); + if ((existing.categoryLanguage ?? null) !== categoryLanguage) { + next = { ...next, categoryLanguage }; + } + + const facts = movie.metadata; + const factsKey = facts ? JSON.stringify(facts) : null; + if (facts && this.routeFactsKey !== factsKey) { + this.routeFactsKey = factsKey; + next = applyApiMetadata(next, facts); + } + + if (next !== existing) { + this.controller.updateSource(next); + this.publish(); + } } /** diff --git a/libs/shared/interfaces/src/lib/vod-source-language.util.spec.ts b/libs/shared/interfaces/src/lib/vod-source-language.util.spec.ts index 06852b578..aef29094c 100644 --- a/libs/shared/interfaces/src/lib/vod-source-language.util.spec.ts +++ b/libs/shared/interfaces/src/lib/vod-source-language.util.spec.ts @@ -40,6 +40,20 @@ describe('titleLanguagePrefix', () => { expect(titleLanguagePrefix('EN-Movie')).toBeNull(); }); + it('gates bracket and dash tags to known languages', () => { + // Brackets and dashes are where quality and rip tags live; taking + // them at their word would fabricate an "HD" language that outranks + // and masks a real category-derived one. + expect(titleLanguagePrefix('[HD] Dune')).toBeNull(); + expect(titleLanguagePrefix('[UHD] Dune')).toBeNull(); + expect(titleLanguagePrefix('[CAM] Dune')).toBeNull(); + expect(titleLanguagePrefix('NEW - Dune')).toBeNull(); + expect(titleLanguagePrefix('VIP - Dune')).toBeNull(); + // The legacy pipe form stays permissive — tightening it would drop + // filter options that work today. + expect(titleLanguagePrefix('SNF| Dune')).toBe('SNF'); + }); + it('accepts the MULTI marker despite its five letters', () => { expect(titleLanguagePrefix('MULTI | Movie')).toBe('MULTI'); expect(titleLanguagePrefix('Multi| Movie')).toBe('MULTI'); diff --git a/libs/shared/interfaces/src/lib/vod-source-language.util.ts b/libs/shared/interfaces/src/lib/vod-source-language.util.ts index 09a383169..84a057a8d 100644 --- a/libs/shared/interfaces/src/lib/vod-source-language.util.ts +++ b/libs/shared/interfaces/src/lib/vod-source-language.util.ts @@ -7,14 +7,20 @@ * the browse filter and the copy-row chips, and is structurally excluded from * ranking and failover (`factualOnly` never reads it). * - * Two parsers with different strictness, on purpose: + * Three tiers of strictness, matched to each form's noise profile: * - * - `titleLanguagePrefix` is permissive. A short tag in front of a MOVIE title - * is overwhelmingly a language — titles do not start with "VIP |" — so any - * 2–4 letter prefix counts, as it always has. - * - Category names are noisier: "NEW | 2024", "TOP | 250" and "VIP | Cinema" + * - The PIPE title form is permissive, as it always has been. A tag before a + * pipe in a MOVIE title is overwhelmingly a language — titles do not start + * with "VIP |" — and tightening the legacy form would drop filter options + * that work today. + * - The BRACKET and DASH title forms are gated by `isKnownLanguageTag`. They + * are new (nothing to regress) and their prefixes skew toward quality and + * rip tags — "[HD] Dune", "NEW - Dune" — which would not only pollute the + * select but, since a title prefix outranks the category language, mask a + * real one and get the row excluded by the very filter meant to find it. + * - Category names are noisiest: "NEW | 2024", "TOP | 250" and "VIP | Cinema" * are everyday category shapes, and `new`, `top` and `hot` are even real - * ISO 639-3 codes, so a prefix read off a category must additionally pass + * ISO 639-3 codes, so a prefix read off a category always passes * `isKnownLanguageTag`. Empty beats wrong: an unrecognized tag yields no * language rather than a wrong filter option. */ @@ -57,16 +63,25 @@ const DASH_FORM = new RegExp( * seen in the wild: tag-before-pipe (including Unicode pipe lookalikes), * bracketed tag, and uppercase tag before a spaced dash. Anything longer than * four letters is a word that happens to precede a separator, not a language. + * + * Only the pipe form is taken at its word; a bracket or dash match must also + * name a KNOWN language, because those positions are where quality and rip + * tags live ("[HD]", "[CAM]") — see the tier rationale in the file header. */ export function titleLanguagePrefix( rawTitle: string | null | undefined ): string | null { const title = rawTitle ?? ''; - const match = - PIPE_FORM.exec(title) ?? - BRACKET_FORM.exec(title) ?? - DASH_FORM.exec(title); - return match ? match[1].toUpperCase() : null; + + const pipe = PIPE_FORM.exec(title); + if (pipe) { + return pipe[1].toUpperCase(); + } + + const gated = BRACKET_FORM.exec(title) ?? DASH_FORM.exec(title); + return gated && isKnownLanguageTag(gated[1]) + ? gated[1].toUpperCase() + : null; } /**