fix(xtream): pair brackets and strip the new tag forms when matching

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-12 00:58:46 +02:00
1 parent c5349e728a
commit 2ddd38facc
7 files changed
+232 -56

No files matched your search

@@ -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([
@@ -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',
]);
/**
@@ -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('РУС');
@@ -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;
}
/**