From 2ddd38facc8caa8918bc052c205bc04c3763fc16 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 12 Aug 2026 00:58:46 +0200 Subject: [PATCH] fix(xtream): pair brackets and strip the new tag forms when matching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile: the bracket prefix chose its opening and closing delimiter independently, so a malformed "[EN)" was read as a language tag. Codex: recognizing a prefix is only half the job — normalizeTitleKeys has to strip the same tag, or the tagged copy never matches the bare one and multi-source cannot offer the film at all. Its leading-tag rule now shares the pipe-lookalike set and, on the pipe branch only, takes the same Latin+Cyrillic any-case alphabet with no required trailing space. Dash and colon keep their uppercase-Latin spaced form: those are ordinary punctuation, and loosening them would amputate "ОНО: Часть 2" the way a case-insensitive rule amputates "It: Chapter Two". Co-Authored-By: Claude Fable 5 --- .../xtream-vod-source-language-detection.md | 6 +- CLAUDE.md | 2 +- docs/architecture/vod-multi-source.md | 10 ++ .../src/lib/title-normalization.util.spec.ts | 127 +++++++++++++----- .../src/lib/title-normalization.util.ts | 94 +++++++++++-- .../src/lib/vod-source-language.util.spec.ts | 6 + .../src/lib/vod-source-language.util.ts | 43 ++++-- 7 files changed, 232 insertions(+), 56 deletions(-) diff --git a/.changes/xtream-vod-source-language-detection.md b/.changes/xtream-vod-source-language-detection.md index 907c27542..7630633a7 100644 --- a/.changes/xtream-vod-source-language-detection.md +++ b/.changes/xtream-vod-source-language-detection.md @@ -5,6 +5,6 @@ area: xtream The movie sources popover now recognizes more language tags: prefixes with Unicode pipes, brackets or dashes ("EN │ …", "[EN] …", "EN - …"), Cyrillic -tags ("РУС | …") and MULTI. When a stream title carries no tag at all, the -language is read from its category names ("EN | Netflix") — so the language -filter and chips appear on panels that only tag categories. +tags ("РУС | …") and MULTI. Those tags are also stripped when matching, so +copies carrying them are found as the same movie, and the language filter +now also reads the language off category names ("EN | Netflix"). diff --git a/CLAUDE.md b/CLAUDE.md index 04e16ad95..6ecd660e7 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`; 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). +- 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, on the pipe branch only, takes the same Latin+Cyrillic any-case alphabet with no required trailing space; dash/colon stay uppercase-Latin + spaced, since loosening them amputates "ОНО: Часть 2"/"It: Chapter Two". 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 d17e1ac38..93c137290 100644 --- a/docs/architecture/vod-multi-source.md +++ b/docs/architecture/vod-multi-source.md @@ -196,6 +196,16 @@ at its word, while a bracket or dash match must ALSO pass language, a fabricated one would mask a real category-derived language and get the row excluded by the very filter meant to find it. +Recognizing a prefix is only half the job: the same tag also has to be +STRIPPED by `normalizeTitleKeys`, or the tagged copy and the bare one never +match and the row is never discovered at all. Its leading-tag rule therefore +shares this file's pipe set (`PROVIDER_PIPE_CLASS`) and, on the pipe branch +only, takes the same Latin+Cyrillic any-case alphabet and needs no space +after the separator — so "РУС | Дюна", "ru| Dune" and "EN|Dune" reach the key +"dune". Dash and colon keep their uppercase-Latin, space-required form: those +are ordinary title punctuation, and loosening them would amputate +"ОНО: Часть 2" the way a case-insensitive rule amputates "It: Chapter Two". + 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))` diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts index f365e454c..040511804 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.spec.ts @@ -100,12 +100,8 @@ describe('provider tag stripping', () => { it('strips long and compound leading tags', () => { expect(normalizeTitle('EXYU| Fallout')).toBe('fallout'); expect(normalizeTitle('MULTI| Breaking Bad')).toBe('breaking bad'); - expect(normalizeTitle('4K-DE - The Pitt (2025) (US)')).toBe( - 'the pitt' - ); - expect(normalizeTitle('AR-SUBS - Fallout (2024) (US)')).toBe( - 'fallout' - ); + expect(normalizeTitle('4K-DE - The Pitt (2025) (US)')).toBe('the pitt'); + expect(normalizeTitle('AR-SUBS - Fallout (2024) (US)')).toBe('fallout'); expect(normalizeTitle('4K-OSN+ - The Last of Us (2023)')).toBe( 'the last of us' ); @@ -117,6 +113,26 @@ describe('provider tag stripping', () => { ); }); + it('strips Cyrillic and lowercase tags before a pipe', () => { + // A pipe is a strong tag signal, so its branch takes the wider + // alphabet and needs no trailing space: without this the tagged copy + // and the bare one never match, and multi-source cannot offer the + // film at all. + expect(normalizeTitle('РУС | Дюна')).toBe('дюна'); + expect(normalizeTitle('укр│ Дюна')).toBe('дюна'); + expect(normalizeTitle('ru| Dune')).toBe('dune'); + expect(normalizeTitle('EN|Dune')).toBe('dune'); + expect(normalizeTitle('|РУС| Дюна')).toBe('дюна'); + }); + + it('keeps that widening away from dash and colon', () => { + // Where the separator is ordinary punctuation, an uppercase-Latin + // restriction is the only thing standing between a tag and a real + // title — "ОНО: Часть 2" is the Cyrillic "It: Chapter Two". + expect(normalizeTitle('ОНО: Часть 2')).toBe('оно часть 2'); + expect(normalizeTitle('ОНО - Часть 2')).toBe('оно часть 2'); + }); + it('keeps bare 4-5 char words before a spaced dash (real titles)', () => { expect(normalizeTitle('DUNE - Part Two')).toBe('dune part two'); expect(normalizeTitle('ALIEN - Covenant')).toBe('alien covenant'); @@ -170,40 +186,89 @@ describe('provider tag stripping', () => { }); const pittCorpus = [ - 'The Pitt (2025)_sub', 'The Pitt (2025)-it', 'The Pitt (2025)', - 'The Pitt (Hindi)', 'The Pitt (2025) 4K', 'The Pitt (2025) DE', - 'The Pitt (2025) ES', 'The Pitt (2025) FR', 'The Pitt (2025)_eng', - 'The Pitt [MULTI-SUB]', 'The Pitt (2025) (4K DV)', 'GR - The Pitt', - '4K-DE - The Pitt (2025) (US)', '4K-TR - The Pitt (2025) (US)', - 'AR-SUBS - The Pitt (2025) (US)', 'DE - The Pitt (2025) (US)', - 'ALB| The Pitt', 'EXYU| The Pitt', '|ALB| The Pitt', '|DE| The Pitt', + 'The Pitt (2025)_sub', + 'The Pitt (2025)-it', + 'The Pitt (2025)', + 'The Pitt (Hindi)', + 'The Pitt (2025) 4K', + 'The Pitt (2025) DE', + 'The Pitt (2025) ES', + 'The Pitt (2025) FR', + 'The Pitt (2025)_eng', + 'The Pitt [MULTI-SUB]', + 'The Pitt (2025) (4K DV)', + 'GR - The Pitt', + '4K-DE - The Pitt (2025) (US)', + '4K-TR - The Pitt (2025) (US)', + 'AR-SUBS - The Pitt (2025) (US)', + 'DE - The Pitt (2025) (US)', + 'ALB| The Pitt', + 'EXYU| The Pitt', + '|ALB| The Pitt', + '|DE| The Pitt', ]; const falloutCorpus = [ - 'Fallout', 'DE - Fallout (2024)', 'Fallout (2024) - 4K', - 'Fallout (2024) FR-EN', 'Fallout (2024) Multi', 'Fallout (2024)_fr', - 'Fallout_esp', 'Fallout (4K)', '4K-AMZ - Fallout (2024) (US)', - 'AL - Fallout (2024)', 'AMZ - Fallout (2024) (US)', - 'AR-DE - Fallout (US)', 'LA - Fallout', 'EN| Fallout - 4K', - 'MULTI| Fallout - 4K', 'Fallout ( مدبلج )', 'Fallout (Telugu)', - '|EN| Fallout - 4K', '|MULTI| Fallout', '|TR| Fallout', + 'Fallout', + 'DE - Fallout (2024)', + 'Fallout (2024) - 4K', + 'Fallout (2024) FR-EN', + 'Fallout (2024) Multi', + 'Fallout (2024)_fr', + 'Fallout_esp', + 'Fallout (4K)', + '4K-AMZ - Fallout (2024) (US)', + 'AL - Fallout (2024)', + 'AMZ - Fallout (2024) (US)', + 'AR-DE - Fallout (US)', + 'LA - Fallout', + 'EN| Fallout - 4K', + 'MULTI| Fallout - 4K', + 'Fallout ( مدبلج )', + 'Fallout (Telugu)', + '|EN| Fallout - 4K', + '|MULTI| Fallout', + '|TR| Fallout', ]; const lastOfUsCorpus = [ - 'The Last of Us', 'The Last Of Us', 'The Last of Us (2023) 4K', - 'The Last of Us (2023) AF', 'The Last of Us_tr', - 'The Last of Us--esp', 'The Last of Us-DE', 'The Last of Us-esp', - 'The Last of Us [L]', 'The Last of Us ( HD )', - '4K-OSN+ - The Last of Us (2023)', 'IS - The Last of Us (2023) (US)', - 'RU - The Last of Us', 'ALB| The Last of Us', + 'The Last of Us', + 'The Last Of Us', + 'The Last of Us (2023) 4K', + 'The Last of Us (2023) AF', + 'The Last of Us_tr', + 'The Last of Us--esp', + 'The Last of Us-DE', + 'The Last of Us-esp', + 'The Last of Us [L]', + 'The Last of Us ( HD )', + '4K-OSN+ - The Last of Us (2023)', + 'IS - The Last of Us (2023) (US)', + 'RU - The Last of Us', + 'ALB| The Last of Us', ]; const breakingBadCorpus = [ - 'Breaking Bad', 'Breaking Bad (2008)_fr', 'Breaking Bad (US)_msub', - 'Breaking Bad_it', 'Breaking Bad-DE', 'Breaking Bad-eng', - 'Breaking Bad ( عائلي )', 'Breaking Bad (Pure)', - 'Breaking Bad - Multi', 'Breaking Bad ES', 'AR-DE - Breaking Bad', - 'EN| Breaking Bad SUB', 'MULTI| Breaking Bad', 'AR| Breaking Bad', + 'Breaking Bad', + 'Breaking Bad (2008)_fr', + 'Breaking Bad (US)_msub', + 'Breaking Bad_it', + 'Breaking Bad-DE', + 'Breaking Bad-eng', + 'Breaking Bad ( عائلي )', + 'Breaking Bad (Pure)', + 'Breaking Bad - Multi', + 'Breaking Bad ES', + 'AR-DE - Breaking Bad', + 'EN| Breaking Bad SUB', + 'MULTI| Breaking Bad', + 'AR| Breaking Bad', + // Pipe lookalikes and a lowercase tag: identical on screen to the + // forms above, so they have to reach the same key. + 'EN │ Breaking Bad', + 'DE ¦ Breaking Bad', + 'FR|Breaking Bad', + 'en| Breaking Bad', ]; it.each([ diff --git a/libs/shared/interfaces/src/lib/title-normalization.util.ts b/libs/shared/interfaces/src/lib/title-normalization.util.ts index b77104612..ffa651d8b 100644 --- a/libs/shared/interfaces/src/lib/title-normalization.util.ts +++ b/libs/shared/interfaces/src/lib/title-normalization.util.ts @@ -30,11 +30,38 @@ const QUALITY_TAGS = new Set([ ]); /** - * Wrapped tag at the very start of a provider title: "|DE| ARD", - * "|MULTI| Fallout". The lookahead requires a letter in the tag so a - * numeric fragment can never be treated as one. + * The pipe and the display lookalikes providers use interchangeably with it + * (`¦`, `│`, fullwidth `|`, …). They are visually identical to `|` in a + * catalog, so a rule that reads only U+007C leaves the same tag stripped in + * one playlist and welded to the title in another — and the two copies then + * never match as the same film. + * + * Exported because `vod-source-language.util.ts` reads the same separator to + * decide a row's language: one set, so the "is this a tag" answer cannot + * differ between matching and display. */ -const WRAPPED_TAG_PREFIX = /^\s*\|(?=[0-9+]*[A-Z])[A-Z0-9+]{2,5}\|\s*/; +export const PROVIDER_PIPE_CLASS = '[|¦│┃❘∣⏐⎪︱︳丨|]'; + +/** + * The alphabet a pipe-delimited tag may use: Latin or Cyrillic, either case + * — the same set the language-prefix reader accepts, so the two files agree + * on what a tag looks like. Every segment must still contain a letter, so a + * numeric fragment is never read as one. + * + * Wider than the uppercase-Latin `SEG` below because it is only ever used + * where a pipe is the separator. Nothing but a tag precedes a pipe: it is + * not valid in a Windows filename and does not occur in real titles. + */ +const PIPE_LETTER = 'A-Za-zА-Яа-яЁё'; +const PIPE_SEG = `(?=[0-9+]*[${PIPE_LETTER}])[${PIPE_LETTER}0-9+]`; + +/** + * Wrapped tag at the very start of a provider title: "|DE| ARD", + * "|MULTI| Fallout", "|РУС| Дюна". + */ +const WRAPPED_TAG_PREFIX = new RegExp( + `^\\s*${PROVIDER_PIPE_CLASS}${PIPE_SEG}{2,5}${PROVIDER_PIPE_CLASS}\\s*` +); /** * Leading channel/language prefix like "EN - ", "DE| ", "FR: ", including @@ -50,13 +77,24 @@ const WRAPPED_TAG_PREFIX = /^\s*\|(?=[0-9+]*[A-Z])[A-Z0-9+]{2,5}\|\s*/; * - pipe ("EXYU| "): compound OR 2–5 chars — a pipe is a strong tag signal * - colon ("EN: "): 2–3 chars — longer acronyms are franchise titles * ("NCIS: LA") + * + * The pipe branch takes the wider `PIPE_SEG` alphabet (Cyrillic as well as + * Latin, either case) and does not require a space after the separator. + * "РУС | Фильм", "ru| Movie" and "EN|Movie" otherwise keep their tag welded + * to the title, so the tagged copy never matches the bare one and + * multi-source cannot offer the film at all. + * + * Neither widening is extended to dash and colon: those are ordinary title + * punctuation, and the uppercase-Latin restriction plus the required space + * are what keep "ОНО: Часть 2" and "Spider-Man" intact — the Cyrillic and + * hyphenated cases of the "It: Chapter Two" hazard. */ const SEG = '(?=[0-9+]*[A-Z])[A-Z0-9+]'; const COMPOUND_TAG = `${SEG}{2,5}(?:-${SEG}{2,6}){1,2}`; const LANGUAGE_PREFIX = new RegExp( '^(?:' + `(?:${COMPOUND_TAG}|${SEG}{2,3})\\s*-\\s+` + - `|(?:${COMPOUND_TAG}|${SEG}{2,5})\\s*\\|\\s+` + + `|(?:${COMPOUND_TAG}|${PIPE_SEG}{2,5})\\s*${PROVIDER_PIPE_CLASS}\\s*` + `|${SEG}{2,3}\\s*:\\s+` + ')' ); @@ -69,10 +107,48 @@ const LANGUAGE_PREFIX = new RegExp( * suffixes ("NCIS: LA"). US/USA/UK/LA are deliberately absent. */ const TRAILING_TAG_VOCABULARY = new Set([ - 'AF', 'AL', 'ALB', 'AR', 'BY', 'DE', 'DUB', 'EN', 'ENG', 'ES', 'ESP', - 'EXYU', 'FR', 'FRA', 'GE', 'GR', 'HU', 'IN', 'IR', 'IS', 'IT', 'ITA', - 'KA', 'KU', 'LAT', 'ML', 'MSUB', 'MULTI', 'NL', 'PL', 'PT', 'RO', 'RU', - 'SC', 'SE', 'SUB', 'SUBS', 'SW', 'TA', 'TL', 'TR', 'TUR', + 'AF', + 'AL', + 'ALB', + 'AR', + 'BY', + 'DE', + 'DUB', + 'EN', + 'ENG', + 'ES', + 'ESP', + 'EXYU', + 'FR', + 'FRA', + 'GE', + 'GR', + 'HU', + 'IN', + 'IR', + 'IS', + 'IT', + 'ITA', + 'KA', + 'KU', + 'LAT', + 'ML', + 'MSUB', + 'MULTI', + 'NL', + 'PL', + 'PT', + 'RO', + 'RU', + 'SC', + 'SE', + 'SUB', + 'SUBS', + 'SW', + 'TA', + 'TL', + 'TR', + 'TUR', ]); /** 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 aef29094c..71fefb4c8 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 @@ -30,6 +30,12 @@ describe('titleLanguagePrefix', () => { expect(titleLanguagePrefix('Movie [EN]')).toBeNull(); }); + it('requires the brackets to pair', () => { + // Openers and closers matched independently would accept these. + expect(titleLanguagePrefix('[EN) Movie')).toBeNull(); + expect(titleLanguagePrefix('(DE] Film')).toBeNull(); + }); + it('reads an uppercase tag before a spaced dash, and only that form', () => { expect(titleLanguagePrefix('EN - Movie')).toBe('EN'); expect(titleLanguagePrefix('РУС - Фильм')).toBe('РУС'); 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 84a057a8d..7ea24ea74 100644 --- a/libs/shared/interfaces/src/lib/vod-source-language.util.ts +++ b/libs/shared/interfaces/src/lib/vod-source-language.util.ts @@ -1,3 +1,5 @@ +import { PROVIDER_PIPE_CLASS } from './title-normalization.util'; + /** * Language prefixes for VOD multi-source rows. * @@ -26,11 +28,11 @@ */ /** - * The pipe and its display lookalikes. Panels routinely decorate with - * `¦`, `│` or fullwidth bars that are visually identical to `|` but fail a - * literal match — the user sees "EN | Movie" either way. + * The pipe and its display lookalikes, shared with title normalization so + * that what counts as a tag separator here is exactly what counts as one + * when two copies are matched as the same film. */ -const PIPE = '[|¦│┃❘∣⏐⎪︱︳丨|]'; +const PIPE = PROVIDER_PIPE_CLASS; /** * A candidate language token: 2–4 letters of ONE script, or the `MULTI` @@ -43,8 +45,16 @@ const TOKEN = '(?:[A-Za-z]{2,4}|[А-Яа-яЁё]{2,4}|[Mm][Uu][Ll][Tt][Ii])'; /** `EN| Movie`, `ru │ Фильм`, `MULTI ¦ Movie` — any case before a pipe. */ const PIPE_FORM = new RegExp(`^\\s*(${TOKEN})\\s*${PIPE}`); -/** `[EN] Movie`, `(RU) Фильм` — a bracketed tag at the very start. */ -const BRACKET_FORM = new RegExp(`^\\s*[[(]\\s*(${TOKEN})\\s*[\\])]`); +/** + * `[EN] Movie`, `(RU) Фильм` — a bracketed tag at the very start. + * + * The two bracket styles are separate alternatives rather than one class of + * openers and one of closers: the latter pairs them independently, so a + * malformed `[EN)` would be read as a tag. + */ +const BRACKET_FORM = new RegExp( + `^\\s*(?:\\[\\s*(${TOKEN})\\s*\\]|\\(\\s*(${TOKEN})\\s*\\))` +); /** * `EN - Movie` — dash-separated, and deliberately stricter than the pipe @@ -73,15 +83,24 @@ export function titleLanguagePrefix( ): string | null { const title = rawTitle ?? ''; - const pipe = PIPE_FORM.exec(title); + const pipe = capturedTag(PIPE_FORM.exec(title)); if (pipe) { - return pipe[1].toUpperCase(); + return pipe.toUpperCase(); } - const gated = BRACKET_FORM.exec(title) ?? DASH_FORM.exec(title); - return gated && isKnownLanguageTag(gated[1]) - ? gated[1].toUpperCase() - : null; + const gated = + capturedTag(BRACKET_FORM.exec(title)) ?? + capturedTag(DASH_FORM.exec(title)); + return gated && isKnownLanguageTag(gated) ? gated.toUpperCase() : null; +} + +/** + * The tag out of whichever alternative matched. Read positionally rather + * than as group 1, because a form with several alternatives (brackets) has + * one group per alternative and only one of them is filled. + */ +function capturedTag(match: RegExpExecArray | null): string | null { + return match?.slice(1).find((group) => group !== undefined) ?? null; } /**