mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
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:
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user