feat(xtream): recognize more language tags in VOD multi-source

The sources popover's language filter and copy chips now read prefixes
with Unicode pipe lookalikes, brackets and spaced dashes, Cyrillic tags
and MULTI. When a stream title carries no tag, the language falls back
to what the stream's visible categories unambiguously state ("EN |
Netflix") — discovery aggregates category names per (playlist, stream)
in SQL, and category prefixes must pass a known-language gate because
everyday category words like new/top/hot are real ISO 639-3 codes.

Both signals stay parsed guesses: browse filter and chips only, never
ranking, failover or dub-warning inputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5 committed 2026-08-11 23:48:58 +02:00
1 parent 6041233f41
commit da0889aece
21 files changed
+759 -51

No files matched your search

+1
View File
@@ -52,6 +52,7 @@ export * from './lib/stalker-account-info-dialog-data.interface';
export * from './lib/title-normalization.util';
export * from './lib/tmdb.interface';
export * from './lib/vod-source.interface';
export * from './lib/vod-source-language.util';
export * from './lib/vod-source-match-key.util';
export * from './lib/xtream-account-info-dialog-data.interface';
export * from './lib/xtream-category.interface';
@@ -0,0 +1,146 @@
import {
isKnownLanguageTag,
titleLanguagePrefix,
unambiguousCategoryLanguage,
vodSourceLanguage,
} from './vod-source-language.util';
describe('titleLanguagePrefix', () => {
it('reads the tag before a pipe, any case', () => {
expect(titleLanguagePrefix('EN| Night of the Living Dead')).toBe('EN');
expect(titleLanguagePrefix('ALB |Some Movie')).toBe('ALB');
expect(titleLanguagePrefix(' ru| Ночь')).toBe('RU');
});
it('reads Cyrillic tags', () => {
expect(titleLanguagePrefix('РУС | Фильм')).toBe('РУС');
expect(titleLanguagePrefix('укр| Фільм')).toBe('УКР');
});
it('reads Unicode pipe lookalikes as the separator', () => {
expect(titleLanguagePrefix('EN │ Movie')).toBe('EN');
expect(titleLanguagePrefix('DE ¦ Der Film')).toBe('DE');
expect(titleLanguagePrefix('FR|Le Film')).toBe('FR');
});
it('reads a bracketed tag at the start', () => {
expect(titleLanguagePrefix('[EN] Movie')).toBe('EN');
expect(titleLanguagePrefix('(ru) Ночь')).toBe('RU');
// A bracket deeper in the title is not a prefix.
expect(titleLanguagePrefix('Movie [EN]')).toBeNull();
});
it('reads an uppercase tag before a spaced dash, and only that form', () => {
expect(titleLanguagePrefix('EN - Movie')).toBe('EN');
expect(titleLanguagePrefix('РУС - Фильм')).toBe('РУС');
// Lowercase before a dash is a title word ("Up - the movie").
expect(titleLanguagePrefix('Up - the movie')).toBeNull();
// Unspaced dashes are ordinary punctuation.
expect(titleLanguagePrefix('X-Men')).toBeNull();
expect(titleLanguagePrefix('EN-Movie')).toBeNull();
});
it('accepts the MULTI marker despite its five letters', () => {
expect(titleLanguagePrefix('MULTI | Movie')).toBe('MULTI');
expect(titleLanguagePrefix('Multi| Movie')).toBe('MULTI');
});
it('rejects titles whose separator is not a language marker', () => {
// Five letters is a word, not a language code.
expect(titleLanguagePrefix('NIGHT| of something')).toBeNull();
expect(titleLanguagePrefix('Night of the Living Dead')).toBeNull();
expect(titleLanguagePrefix('4K| Movie')).toBeNull();
expect(titleLanguagePrefix(undefined)).toBeNull();
expect(titleLanguagePrefix('')).toBeNull();
});
});
describe('isKnownLanguageTag', () => {
it('accepts assigned two-letter ISO codes in any case', () => {
expect(isKnownLanguageTag('EN')).toBe(true);
expect(isKnownLanguageTag('de')).toBe(true);
expect(isKnownLanguageTag('uk')).toBe(true);
});
it('rejects unassigned two-letter tokens', () => {
expect(isKnownLanguageTag('HD')).toBe(false);
expect(isKnownLanguageTag('XX')).toBe(false);
});
it('accepts curated long tags, Latin and Cyrillic', () => {
expect(isKnownLanguageTag('ENG')).toBe(true);
expect(isKnownLanguageTag('DEU')).toBe(true);
expect(isKnownLanguageTag('LAT')).toBe(true);
expect(isKnownLanguageTag('РУС')).toBe(true);
expect(isKnownLanguageTag('MULTI')).toBe(true);
});
it('rejects everyday category words that are real ISO 639-3 codes', () => {
// `new`, `top` and `hot` are assigned in ISO 639-3, which is exactly
// why validation is curated instead of registry-driven.
expect(isKnownLanguageTag('NEW')).toBe(false);
expect(isKnownLanguageTag('TOP')).toBe(false);
expect(isKnownLanguageTag('HOT')).toBe(false);
expect(isKnownLanguageTag('VIP')).toBe(false);
expect(isKnownLanguageTag('KIDS')).toBe(false);
});
});
describe('unambiguousCategoryLanguage', () => {
it('reads the language every prefixed category agrees on', () => {
expect(
unambiguousCategoryLanguage(['EN | Netflix', 'EN | Action'])
).toBe('EN');
});
it('lets unprefixed categories abstain rather than veto', () => {
expect(
unambiguousCategoryLanguage(['EN | Netflix', 'Netflix 4K'])
).toBe('EN');
});
it('yields nothing on a conflict', () => {
expect(
unambiguousCategoryLanguage(['EN | Netflix', 'DE | Cinema'])
).toBeNull();
});
it('rejects category prefixes that are not languages', () => {
expect(unambiguousCategoryLanguage(['NEW | 2024'])).toBeNull();
expect(unambiguousCategoryLanguage(['TOP | 250'])).toBeNull();
expect(unambiguousCategoryLanguage(['VIP | Cinema'])).toBeNull();
// ...while a real language beside them still reads through.
expect(
unambiguousCategoryLanguage(['NEW | 2024', 'DE | Apple TV'])
).toBe('DE');
});
it('handles empty and missing input', () => {
expect(unambiguousCategoryLanguage([])).toBeNull();
expect(unambiguousCategoryLanguage(null)).toBeNull();
expect(unambiguousCategoryLanguage(undefined)).toBeNull();
expect(unambiguousCategoryLanguage([null, ''])).toBeNull();
});
});
describe('vodSourceLanguage', () => {
it('prefers the title prefix over the category language', () => {
expect(
vodSourceLanguage({
rawTitle: 'RU| Movie',
categoryLanguage: 'EN',
})
).toBe('RU');
});
it('falls back to the category language when the title says nothing', () => {
expect(
vodSourceLanguage({ rawTitle: 'Movie', categoryLanguage: 'EN' })
).toBe('EN');
});
it('yields nothing when neither side knows', () => {
expect(vodSourceLanguage({ rawTitle: 'Movie' })).toBeNull();
expect(vodSourceLanguage({})).toBeNull();
});
});
@@ -0,0 +1,267 @@
/**
* Language prefixes for VOD multi-source rows.
*
* Panels rarely state a spoken language as a fact; what they do instead is
* prefix stream titles ("EN| Movie") and category names ("EN | Netflix") with
* a short tag. Everything here is therefore a GUESS by construction: it feeds
* 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:
*
* - `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"
* 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
* `isKnownLanguageTag`. Empty beats wrong: an unrecognized tag yields no
* language rather than a wrong filter option.
*/
/**
* 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.
*/
const PIPE = '[|¦│┃❘∣⏐⎪︱︳丨|]';
/**
* A candidate language token: 2–4 letters of ONE script, or the `MULTI`
* marker panels use for multi-audio releases. Single-script on purpose —
* a mixed-script "word" before a pipe is decoration, not a tag. Digits are
* excluded, so "4K |" never reads as a language.
*/
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` — dash-separated, and deliberately stricter than the pipe
* form: the tag must be ALL uppercase and the dash spaced on both sides.
* Dashes are ordinary title punctuation ("X-Men", "Up - the movie"), so a
* lowercase or unspaced form is a title, not a tag.
*/
const DASH_FORM = new RegExp(
`^\\s*((?:[A-Z]{2,4}|[А-ЯЁ]{2,4}|MULTI))\\s+[-–—]\\s+`
);
/**
* `EN| Night of the Living Dead` → `EN`.
*
* The provider's language convention for stream titles, in the three shapes
* 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.
*/
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;
}
/**
* `Intl.DisplayNames` is ES2021 and this workspace compiles against the
* es2018 lib, so it is reached through a narrow shim (the same pattern
* `Intl.Locale` uses in the metadata util). Absent — which no supported
* runtime actually is — two-letter validation declines rather than guesses.
*/
const DisplayNamesCtor = (
Intl as unknown as {
DisplayNames?: new (
locales: string[],
options: { type: string; fallback: string }
) => { of(code: string): string | undefined };
}
).DisplayNames;
let languageNames: { of(code: string): string | undefined } | null | undefined;
/**
* Whether a two-letter token is an assigned ISO 639-1 code.
*
* With `fallback: 'code'`, `DisplayNames.of()` answers a real language with
* its name ("en" → "English") and echoes an unassigned code back unchanged
* ("hd" → "hd") — so "name differs from code" is precisely "this language
* exists". The two-letter space is safe for this trick; the three-letter
* space is NOT (ISO 639-3 assigns `new`, `top` and `hot`), which is why
* longer tokens go through the curated list instead.
*/
function isAssignedTwoLetterCode(lower: string): boolean {
if (!DisplayNamesCtor) {
return false;
}
try {
languageNames ??= new DisplayNamesCtor(['en'], {
type: 'language',
fallback: 'code',
});
const name = languageNames.of(lower);
return typeof name === 'string' && name.toLowerCase() !== lower;
} catch {
// Structurally invalid tag, or a runtime without language data —
// declining beats guessing.
return false;
}
}
/**
* Three-and-more-letter tags panels actually use, plus the Cyrillic
* shorthands `Intl` cannot validate. Curated rather than derived from ISO
* 639-2/3: those registries assign codes to `new`, `top` and `hot`, so
* validating against them would turn everyday category prefixes into
* languages.
*/
const KNOWN_LONG_TAGS = new Set([
// ISO 639-2 pairs (B/T) and common panel spellings, Latin script
'eng',
'rus',
'ukr',
'bel',
'kaz',
'ger',
'deu',
'fra',
'fre',
'spa',
'esp',
'lat',
'ita',
'por',
'tur',
'ara',
'pol',
'nld',
'dut',
'swe',
'nor',
'dan',
'fin',
'gre',
'ell',
'hun',
'cze',
'ces',
'svk',
'slo',
'srb',
'srp',
'hrv',
'cro',
'bul',
'ron',
'rum',
'alb',
'sqi',
'mkd',
'bos',
'heb',
'hin',
'vie',
'tha',
'kor',
'jpn',
'chi',
'zho',
'per',
'fas',
'aze',
'kat',
'geo',
'hye',
'arm',
'uzb',
'lit',
'lav',
'est',
'multi',
// Cyrillic shorthands
'ру',
'уа',
'рус',
'укр',
'бел',
'каз',
'анг',
'англ',
'нем',
'фра',
'исп',
'ита',
'пол',
'тур',
'узб',
'арм',
'груз',
'азе',
]);
/**
* Whether a parsed prefix names a language, as opposed to any short word a
* category happens to start with.
*/
export function isKnownLanguageTag(tag: string): boolean {
const lower = tag.toLowerCase();
if (KNOWN_LONG_TAGS.has(lower)) {
return true;
}
return /^[a-z]{2}$/.test(lower) && isAssignedTwoLetterCode(lower);
}
/**
* The language a stream's categories agree on, or null.
*
* A stream usually sits in several categories of one playlist ("EN | Netflix"
* and "EN | Action"), and the aggregation is what makes the answer honest:
* every prefixed category must name the SAME language, and that language must
* pass `isKnownLanguageTag`. Categories without a recognized language prefix
* abstain rather than veto — "EN | Netflix" plus "Netflix 4K" still reads EN,
* while "EN | Netflix" plus "DE | Cinema" is a conflict and yields nothing.
*/
export function unambiguousCategoryLanguage(
categoryNames: readonly (string | null | undefined)[] | null | undefined
): string | null {
if (!categoryNames?.length) {
return null;
}
let language: string | null = null;
for (const name of categoryNames) {
const prefix = titleLanguagePrefix(name);
if (!prefix || !isKnownLanguageTag(prefix)) {
continue;
}
if (language !== null && language !== prefix) {
return null;
}
language = prefix;
}
return language;
}
/**
* The language shown and filtered on for one source row.
*
* The stream's own title prefix is the more specific signal and wins; the
* category-derived language stands in only when the title says nothing. Both
* are guesses — this feeds the filter select and the copy-row chip, never a
* ranking decision.
*/
export function vodSourceLanguage(source: {
rawTitle?: string | null;
categoryLanguage?: string | null;
}): string | null {
return (
titleLanguagePrefix(source.rawTitle) ?? source.categoryLanguage ?? null
);
}
@@ -90,6 +90,12 @@ export interface VodSourceCandidateRow {
posterUrl: string | null;
matchConfidence: VodSourceMatchConfidence;
year: number | null;
/**
* Names of every visible category this stream sits in within its
* playlist. Carried so the renderer can read a language prefix off them
* ("EN | Netflix") when the stream title itself states none.
*/
categoryNames?: string[];
}
/**
@@ -132,6 +138,15 @@ export interface VodSourceCandidate {
* the dub change".
*/
audioLanguage?: VodSourceField;
/**
* Language read off the stream's category names ("EN | Netflix"), when
* every prefixed category agrees (`unambiguousCategoryLanguage`). A
* guess by nature — it stands in for a missing title prefix in the
* browse filter and chips, and is never read by ranking, failover or
* the dub warning (`factualOnly` and `audioDiffersFactually` cannot
* reach it).
*/
categoryLanguage?: string | null;
/** ISO timestamp of the last failed playback attempt, if any. */
lastFailedAt?: string;