mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
1 parent
6041233f41
commit
da0889aece
21 files changed
+759
-51
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user