Merge remote-tracking branch 'origin/master' into claude/dash-clearkey-shaka

# Conflicts:
#	CLAUDE.md
#	libs/ui/playback/src/lib/web-player-view/web-player-view.component.ts
This commit is contained in:
4gray committed 2026-07-24 18:44:22 +02:00
commit 5b2dd3b8b0
425 files changed
+59216 -3432

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ export * from './lib/epg-channel-metadata.model';
export * from './lib/epg-channel-with-programs.interface';
export * from './lib/epg-channel.model';
export * from './lib/epg-item.interface';
export * from './lib/epg-mapping-key.util';
export * from './lib/epg-program.model';
export * from './lib/external-player-arguments.utils';
export * from './lib/external-player-session.interface';
@@ -35,6 +36,7 @@ export * from './lib/store-keys.enum';
export * from './lib/stream-format.enum';
export * from './lib/catalog-title-match.interface';
export * from './lib/theme.enum';
export * from './lib/season-marker.util';
export * from './lib/title-normalization.util';
export * from './lib/tmdb.interface';
export * from './lib/xtream-account-info-dialog-data.interface';
@@ -119,6 +119,7 @@ export const ELECTRON_BRIDGE_DOWNLOAD_STATUSES = {
Completed: 'completed',
Downloading: 'downloading',
Failed: 'failed',
Paused: 'paused',
Queued: 'queued',
} as const;
@@ -266,6 +267,19 @@ export interface ElectronBridgeEpgFreshnessResult {
freshUrls: string[];
}
export interface ElectronBridgeEpgMapping {
id: number;
channelKey: string;
epgChannelId: string;
playlistId: string | null;
}
export interface ElectronBridgeEpgSearchResult {
id: string;
displayName: string;
iconUrl: string | null;
}
export interface ElectronBridgeEpgLookupOptions {
sourceUrls?: string[];
}
@@ -616,6 +630,25 @@ export interface ElectronBridgeApi {
searchTerm: string,
limit?: number
) => Promise<EpgProgram[]>;
// EPG channel mapping (manual user overrides)
getEpgMapping: (
channelKey: string
) => Promise<ElectronBridgeEpgMapping | null>;
getEpgMappingsBatch: (
channelKeys: string[]
) => Promise<Record<string, string>>;
setEpgMapping: (
channelKey: string,
epgChannelId: string,
playlistId?: string
) => Promise<ElectronBridgeResult>;
deleteEpgMapping: (channelKey: string) => Promise<ElectronBridgeResult>;
searchEpgChannels: (
searchTerm: string,
limit?: number
) => Promise<ElectronBridgeEpgSearchResult[]>;
updateSettings: (settings: Partial<Settings>) => Promise<void>;
getAiSettings: () => Promise<ElectronBridgeAiSettings>;
setMpvPlayerPath: (mpvPlayerPath: string) => Promise<void>;
@@ -921,6 +954,11 @@ export interface ElectronBridgeApi {
data: ElectronBridgeDownloadStartPayload
) => Promise<ElectronBridgeDownloadStartResult>;
downloadsCancel: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
downloadsPause: (downloadId: number) => Promise<ElectronBridgeErrorResult>;
downloadsResume: (
downloadId: number,
downloadFolder: string
) => Promise<ElectronBridgeErrorResult>;
downloadsRetry: (
downloadId: number,
downloadFolder: string
@@ -37,11 +37,16 @@ export interface EmbeddedMpvSupport {
engine?: EmbeddedMpvEngine;
/**
* True when this machine could run the frame-copy engine (macOS arm64
* or Linux, with the helper binary present), regardless of whether it
* is active.
* or Linux x64, after its helper/runtime capability gate), regardless of
* whether it is active.
* Drives the Settings toggle; switching engines requires an app restart.
*/
frameCopyAvailable?: boolean;
/**
* Stable fail-closed capability reason when frameCopyAvailable is false.
* Intended for startup tracing and support diagnostics, not user copy.
*/
frameCopyUnavailableReason?: string;
}
/**
@@ -0,0 +1,38 @@
import {
buildStalkerEpgMappingKey,
buildXtreamEpgMappingKey,
} from './epg-mapping-key.util';
describe('buildXtreamEpgMappingKey', () => {
it('scopes the key to the playlist', () => {
expect(buildXtreamEpgMappingKey('playlist-a', 12345)).toBe(
'xtream:playlist-a:12345'
);
});
it('produces distinct keys for the same stream id in different playlists', () => {
expect(buildXtreamEpgMappingKey('playlist-a', 12345)).not.toBe(
buildXtreamEpgMappingKey('playlist-b', 12345)
);
});
it('accepts string stream ids', () => {
expect(buildXtreamEpgMappingKey('playlist-a', '42')).toBe(
'xtream:playlist-a:42'
);
});
});
describe('buildStalkerEpgMappingKey', () => {
it('scopes the key to the playlist', () => {
expect(buildStalkerEpgMappingKey('portal-a', '205')).toBe(
'stalker:portal-a:205'
);
});
it('never collides with the Xtream key space', () => {
expect(buildStalkerEpgMappingKey('p', 1)).not.toBe(
buildXtreamEpgMappingKey('p', 1)
);
});
});
@@ -0,0 +1,28 @@
/**
* Key helpers for the manual EPG channel mapping table
* (`epg_channel_mappings`).
*
* M3U channels use their EPG lookup key (tvg-id, falling back to the
* channel name) directly — those identifiers are meaningful across
* playlists, so a mapping saved once applies everywhere.
*
* Xtream stream IDs and Stalker channel IDs, on the other hand, are
* provider-local: stream 12345 in portal A has nothing to do with
* stream 12345 in portal B. Mapping keys for portal channels therefore
* embed the playlist ID so mappings never leak across portals. Shared
* between the renderer and the Electron main process/worker, like
* `title-normalization.util.ts`.
*/
export function buildXtreamEpgMappingKey(
playlistId: string,
xtreamId: number | string
): string {
return `xtream:${playlistId}:${xtreamId}`;
}
export function buildStalkerEpgMappingKey(
playlistId: string,
stalkerId: number | string
): string {
return `stalker:${playlistId}:${stalkerId}`;
}
@@ -24,7 +24,8 @@ export interface ParsedPlaylistItem {
referrer: string;
'user-agent': string;
};
url: string;
/** absent when an #EXTINF entry has no stream URL (e.g. truncated file) */
url?: string;
raw: string;
catchup?: {
type?: string;
@@ -54,6 +54,8 @@ export interface Playlist {
recentlyViewed?: PlaylistRecentlyViewedItem[];
/** Indicates if this is a full stalker portal URL (e.g., /stalker_portal/c) requiring handshake authentication */
isFullStalkerPortal?: boolean;
/** Xtream server timezone string for catch-up URL construction */
serverTimezone?: string;
/** Session token for full stalker portal authentication - persisted for session */
stalkerToken?: string;
/** Serial number for stalker portal - generated once and stored for consistency */
@@ -0,0 +1,116 @@
import {
extractSeasonFromTitle,
pickSeasonMarkedTitle,
resolveEnrichmentSeasonNumber,
} from './season-marker.util';
describe('extractSeasonFromTitle', () => {
it('reads bracketed number-first markers', () => {
expect(extractSeasonFromTitle('The Mandalorian (2 season)')).toBe(2);
expect(extractSeasonFromTitle('Кухня (6 сезон)')).toBe(6);
});
it('reads word-first markers with any joiner', () => {
expect(extractSeasonFromTitle('Breaking Bad Season 2')).toBe(2);
expect(extractSeasonFromTitle('Breaking Bad season_02')).toBe(2);
expect(extractSeasonFromTitle('Breaking Bad season2')).toBe(2);
expect(extractSeasonFromTitle('Мандалорец сезон 2')).toBe(2);
expect(extractSeasonFromTitle('Dark Staffel 3')).toBe(3);
expect(extractSeasonFromTitle('La Casa Temporada 4')).toBe(4);
});
it('reads number-first markers including ordinals', () => {
expect(extractSeasonFromTitle('The Boys 2 Season')).toBe(2);
expect(extractSeasonFromTitle('The Boys 2nd Season')).toBe(2);
expect(extractSeasonFromTitle('Пацаны 2 сезон')).toBe(2);
expect(extractSeasonFromTitle('Пацаны 2-й сезон')).toBe(2);
});
it('reads S-form markers, including S..E.. episode tags', () => {
expect(extractSeasonFromTitle('The Boys S05')).toBe(5);
expect(extractSeasonFromTitle('Gintama s2')).toBe(2);
expect(extractSeasonFromTitle('[S03] Dark')).toBe(3);
expect(extractSeasonFromTitle('Dark S02E05')).toBe(2);
});
it('never fires on titles without an explicit marker', () => {
expect(extractSeasonFromTitle('The Mandalorian')).toBeNull();
expect(extractSeasonFromTitle("Ocean's 11")).toBeNull();
expect(extractSeasonFromTitle('Cars 2')).toBeNull();
expect(extractSeasonFromTitle('The 4400')).toBeNull();
expect(extractSeasonFromTitle('The Four Seasons')).toBeNull();
expect(extractSeasonFromTitle('2 Fast 2 Furious')).toBeNull();
expect(extractSeasonFromTitle('2001: A Space Odyssey')).toBeNull();
expect(extractSeasonFromTitle('')).toBeNull();
expect(extractSeasonFromTitle(null)).toBeNull();
});
it('rejects plurals, word-internal hits and season zero', () => {
expect(extractSeasonFromTitle('Best of 2 Seasons')).toBeNull();
expect(extractSeasonFromTitle('Postseason 3')).toBeNull();
expect(extractSeasonFromTitle('Dark S00')).toBeNull();
});
});
describe('pickSeasonMarkedTitle', () => {
it('prefers the title that carries a season marker', () => {
expect(
pickSeasonMarkedTitle(
'Regular Series',
'The Mandalorian (2 season)'
)
).toBe('The Mandalorian (2 season)');
expect(
pickSeasonMarkedTitle('The Boys S05', 'The Boys')
).toBe('The Boys S05');
});
it('falls back to the first non-empty title without markers', () => {
expect(pickSeasonMarkedTitle(undefined, 'The Mandalorian')).toBe(
'The Mandalorian'
);
expect(pickSeasonMarkedTitle('', null)).toBeNull();
});
});
describe('resolveEnrichmentSeasonNumber', () => {
it('overrides a renumbered single-season slice with the title season', () => {
expect(
resolveEnrichmentSeasonNumber({
rawTitle: 'The Mandalorian (2 season)',
providerSeasonNumber: 1,
providerSeasonCount: 1,
})
).toBe(2);
});
it('keeps provider numbering for multi-season items', () => {
expect(
resolveEnrichmentSeasonNumber({
rawTitle: 'The Mandalorian (2 season)',
providerSeasonNumber: 1,
providerSeasonCount: 3,
})
).toBe(1);
});
it('keeps provider numbering when the marker agrees with it', () => {
expect(
resolveEnrichmentSeasonNumber({
rawTitle: 'The Mandalorian (2 season)',
providerSeasonNumber: 2,
providerSeasonCount: 1,
})
).toBe(2);
});
it('keeps provider numbering without a title marker', () => {
expect(
resolveEnrichmentSeasonNumber({
rawTitle: 'The Mandalorian',
providerSeasonNumber: 1,
providerSeasonCount: 1,
})
).toBe(1);
});
});
@@ -0,0 +1,122 @@
/**
* Season markers in provider titles. Providers often slice a show into
* per-season catalog items ("The Mandalorian (2 season)", "Пацаны 2 сезон",
* "The Boys S05") and renumber the single contained season to 1 — the
* marker in the title is then the only source of the real season number.
* Pure functions shared by the renderer and tests — no Angular/Node deps.
*/
/**
* Season vocabulary shared with the trailing-suffix stripper in
* `title-normalization.util.ts`. Words only — digit placement (before or
* after the word) is handled by the patterns built from it.
*/
export const SEASON_WORD_ALTERNATIVES = 'season|сезон|staffel|temporada|saison';
const SEASON_WORD = `(?:${SEASON_WORD_ALTERNATIVES})`;
/**
* "Season 2", "season_02", "season2", "сезон 2". The leading guard blocks
* word-internal matches ("postseason 3"); the digit lookahead rejects
* 3+-digit numbers, which are never season markers.
*/
const WORD_FIRST_MARKER = new RegExp(
`(?:^|[^\\p{L}])${SEASON_WORD}[\\s_.-]*(\\d{1,2})(?!\\d)`,
'iu'
);
/**
* "2 season", "2nd Season", "2 сезон", "2-й сезон". The digit guard keeps
* long numbers out ("2001 season" never yields season 20/01), the trailing
* lookahead rejects plurals ("The Four Seasons", "2 Seasons").
*/
const NUMBER_FIRST_MARKER = new RegExp(
`(?:^|[^\\d])(\\d{1,2})\\s*(?:st|nd|rd|th|-?й|-?я|-?ой)?[\\s_.-]+${SEASON_WORD}(?!\\p{L})`,
'iu'
);
/**
* "S02", "s2" — and the season half of "S02E05". Tightly bounded on both
* sides so words ending in "s" followed by a number ("Ocean's 11",
* "Cars 2") can never match.
*/
const S_FORM_MARKER = /(?:^|[\s([{_.-])s(\d{1,2})(?=$|[\s)\]}_.-]|e\d)/i;
/**
* Extract an explicit season number from a RAW provider title (before any
* normalization — bracket groups like "(2 season)" are removed by
* `normalizeTitleKeys`, so this must see the original string). Returns
* `null` when there is no unambiguous marker; season 0 ("specials") is
* deliberately rejected.
*/
export function extractSeasonFromTitle(
raw: string | null | undefined
): number | null {
if (!raw) {
return null;
}
for (const pattern of [
WORD_FIRST_MARKER,
NUMBER_FIRST_MARKER,
S_FORM_MARKER,
]) {
const match = raw.match(pattern);
if (match) {
const season = Number(match[1]);
if (season >= 1) {
return season;
}
}
}
return null;
}
/**
* The first candidate title carrying an explicit season marker, else the
* first non-empty one. Providers spread the descriptive title across
* fields (a generic `name` with the marker only in `o_name`), and the
* marker must be read from whichever field carries it.
*/
export function pickSeasonMarkedTitle(
...titles: readonly (string | null | undefined)[]
): string | null {
for (const title of titles) {
if (extractSeasonFromTitle(title) !== null) {
return title ?? null;
}
}
return titles.find((title) => !!title) ?? null;
}
export interface SeasonNumberResolution {
/** RAW provider title of the series item (not normalized) */
rawTitle: string | null | undefined;
/** Season number reported by the provider's episode data */
providerSeasonNumber: number;
/** How many seasons the provider item contains in total */
providerSeasonCount: number;
}
/**
* The TMDB season number to fetch for a provider season. Provider data
* stays authoritative except for the one case it is known to lie about:
* a SINGLE-season item whose title carries a different explicit season
* marker is a per-season slice with renumbered seasons — the marker names
* the real TMDB season. Multi-season items always keep provider numbering
* (overriding all their seasons from one title marker would be wrong).
*/
export function resolveEnrichmentSeasonNumber(
resolution: SeasonNumberResolution
): number {
const fromTitle = extractSeasonFromTitle(resolution.rawTitle);
if (
fromTitle !== null &&
resolution.providerSeasonCount === 1 &&
fromTitle !== resolution.providerSeasonNumber
) {
return fromTitle;
}
return resolution.providerSeasonNumber;
}
@@ -83,6 +83,12 @@ export interface Settings {
* Missing values remain off for compatibility with older saved settings.
*/
webPlayerSharedControls?: boolean;
/**
* Fill the empty space around the inline VOD/series player with a blurred,
* dimmed copy of the poster (YouTube "Ambient mode" style) instead of plain
* black bars. Off by default; only affects the built-in web players.
*/
playerAmbientMode?: boolean;
epgUrl: string[];
streamFormat: StreamFormat;
openStreamOnDoubleClick: boolean;
@@ -3,6 +3,7 @@ export enum StalkerPortalActions {
GetGenres = 'get_genres',
CreateLink = 'create_link',
GetOrderedList = 'get_ordered_list',
GetAllChannels = 'get_all_channels',
Favorites = 'favorites',
Handshake = 'handshake',
DoAuth = 'do_auth',
@@ -39,6 +39,8 @@ export interface StalkerVodInfo {
rating_kinopoisk: string;
/** Populated by TMDB enrichment; absent in raw portal responses */
tmdb_cast?: TmdbEnrichedCastMember[];
/** Directors (movies) / creators (series) as clickable person chips */
tmdb_directors?: TmdbEnrichedCastMember[];
/** TMDB backdrop URL — Stalker portals never provide one themselves */
tmdb_backdrop?: string;
/** YouTube trailer key from TMDB — Stalker portals provide no trailers */
@@ -54,6 +54,158 @@ describe('normalizeTitleKeys', () => {
expect(normalizeTitleKeys('The Boys s05').exact).toBe('the boys');
expect(normalizeTitleKeys('Пацаны сезон 2').base).toBe('пацаны');
});
it('strips number-first season suffixes ("2 season", "2 сезон")', () => {
expect(normalizeTitle('The Mandalorian 2 Season')).toBe(
'the mandalorian'
);
expect(normalizeTitle('The Mandalorian 2nd Season')).toBe(
'the mandalorian'
);
expect(normalizeTitle('Мандалорец 2 сезон')).toBe('мандалорец');
expect(normalizeTitle('Пацаны 2-й сезон')).toBe('пацаны');
});
it('keeps plural "seasons" endings — only singular markers are tags', () => {
expect(normalizeTitle('Best of 2 Seasons')).toBe('best of 2 seasons');
});
});
describe('provider tag stripping', () => {
it('strips wrapped pipe tags', () => {
expect(normalizeTitle('|DE| ARD')).toBe('ard');
expect(normalizeTitle('|MULTI| Fallout - 4K')).toBe('fallout');
expect(normalizeTitle('|EXYU| The Pitt')).toBe('the pitt');
});
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-OSN+ - The Last of Us (2023)')).toBe(
'the last of us'
);
});
it('never treats numeric fragments as leading tags', () => {
expect(normalizeTitle('1917 - Behind the Lines')).toBe(
'1917 behind the lines'
);
});
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');
});
it('strips underscore and double-dash suffix tags', () => {
expect(normalizeTitle('Fallout_eng')).toBe('fallout');
expect(normalizeTitle('Breaking Bad (US)_msub')).toBe('breaking bad');
expect(normalizeTitle('The Pitt (2025)_sub')).toBe('the pitt');
expect(normalizeTitle('The Last of Us--esp')).toBe('the last of us');
});
it('keeps underscore-as-space titles intact', () => {
expect(normalizeTitle('The_Last_of_Us')).toBe('the last of us');
});
it('keeps sole-underscore titles whose tail is not a known tag', () => {
expect(normalizeTitle('Mr_Robot')).toBe('mr robot');
expect(normalizeTitle('Cowboy_Bebop')).toBe('cowboy bebop');
expect(normalizeTitle('Mrs_Davis')).toBe('mrs davis');
});
it('strips joined dash tags only for case-uniform vocabulary tokens', () => {
expect(normalizeTitle('Breaking Bad-eng')).toBe('breaking bad');
expect(normalizeTitle('The Last of Us-DE')).toBe('the last of us');
expect(normalizeTitle('The Pitt (2025)-it')).toBe('the pitt');
expect(normalizeTitle('Spider-Man')).toBe('spider man');
expect(normalizeTitle('Kick-It')).toBe('kick it');
});
it('keeps English hyphenated word endings that collide with codes', () => {
expect(normalizeTitle('drive-in')).toBe('drive in');
expect(normalizeTitle('Plug-in')).toBe('plug in');
});
it('strips bare trailing UPPERCASE vocabulary tags', () => {
expect(normalizeTitle('The Pitt (2025) DE')).toBe('the pitt');
expect(normalizeTitle('Breaking Bad ES')).toBe('breaking bad');
expect(normalizeTitle('EN| Breaking Bad SUB')).toBe('breaking bad');
expect(normalizeTitle('The Last of Us (2023) AF')).toBe(
'the last of us'
);
});
it('never strips trailing tags that could be real endings', () => {
expect(normalizeTitle('Rocky II')).toBe('rocky ii');
expect(normalizeTitle('Made in USA')).toBe('made in usa');
expect(normalizeTitle('NCIS: LA')).toBe('ncis la');
expect(normalizeTitle('Making It')).toBe('making it');
expect(normalizeTitle('THE LAST OF US')).toBe('the last of us');
});
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',
];
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',
];
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',
];
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',
];
it.each([
['the pitt', pittCorpus],
['fallout', falloutCorpus],
['the last of us', lastOfUsCorpus],
['breaking bad', breakingBadCorpus],
])(
'normalizes every observed provider variant of "%s" to one key',
(expected, corpus) => {
for (const name of corpus) {
expect(normalizeTitleKeys(name).base).toBe(expected);
}
}
);
it('keeps localized subtitles (indistinguishable from real ones)', () => {
expect(normalizeTitle('Breaking Bad: A Química do Mal')).toBe(
'breaking bad a quimica do mal'
);
});
});
describe('titleYearsCompatible', () => {
@@ -4,6 +4,8 @@
* matching). Pure functions — no Angular/Node dependencies.
*/
import { SEASON_WORD_ALTERNATIVES } from './season-marker.util';
const QUALITY_TAGS = new Set([
'4k',
'uhd',
@@ -28,11 +30,142 @@ const QUALITY_TAGS = new Set([
]);
/**
* Leading channel/language prefix like "EN - ", "DE| ", "FR: ".
* UPPERCASE-only on purpose: a case-insensitive match would amputate real
* title words ("It: Chapter Two" → "Chapter Two").
* 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.
*/
const LANGUAGE_PREFIX = /^[A-Z]{2,3}\s*[-|:]\s+/;
const WRAPPED_TAG_PREFIX = /^\s*\|(?=[0-9+]*[A-Z])[A-Z0-9+]{2,5}\|\s*/;
/**
* Leading channel/language prefix like "EN - ", "DE| ", "FR: ", including
* compound provider/quality forms ("4K-DE - ", "AR-SUBS - ", "4K-OSN+ - ")
* and longer pipe-tagged forms ("EXYU| ", "MULTI| ").
* UPPERCASE-only on purpose: a case-insensitive match would amputate real
* title words ("It: Chapter Two" → "Chapter Two"). Every segment must
* contain a letter so numeric titles ("1917 - ...") are never tags.
*
* Separator strength gates how wide a single segment may be:
* - dash ("EN - "): compound OR 2–3 chars — a bare 4–5 char word before
* a spaced dash is a real title ("DUNE - Part Two", "ALIEN - Covenant")
* - 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")
*/
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+` +
`|${SEG}{2,3}\\s*:\\s+` +
')'
);
/**
* Curated whitelist for TRAILING language/subtitle tags ("Fallout_eng",
* "Breaking Bad-DE", "The Pitt (2025) ES"). Trailing stripping must be
* vocabulary-gated: a pattern-only rule would amputate real endings —
* roman numerals ("Rocky II"), acronyms ("Made in USA"), franchise
* 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',
]);
/**
* Vocabulary tags that are also common English hyphenated-word endings
* ("drive-in", "plug-in"). Accepted only after a STRONG separator (bare
* spaced/uppercase form), never in the weak joined-dash/underscore forms
* where "X-in"/"X_in" reads as a title, not a tag.
*/
const WEAK_JOIN_EXCLUSIONS = new Set(['IN']);
const DOUBLE_DASH_SUFFIX = /[-–]{2}[A-Za-z]{2,5}\s*$/;
const UNDERSCORE_SUFFIX = /_([A-Za-z]{2,5})\s*$/;
const JOINED_DASH_SUFFIX = /-([A-Za-z]{2,5})\s*$/;
const TRAILING_TAG_SUFFIX = /\s([A-Z]{2,5})\s*$/;
/** Tag tokens are case-uniform; real title words are Capitalized. */
function isCaseUniform(token: string): boolean {
return token === token.toLowerCase() || token === token.toUpperCase();
}
/**
* A captured joined-dash/underscore token is provider metadata only when
* it is a known vocabulary tag, case-uniform, and not one of the English
* word-forming exclusions. This keeps "Mr_Robot", "Cowboy_Bebop",
* "drive-in", and "Plug-in" intact while stripping "_eng", "-DE", "-it".
*/
function isJoinedTag(token: string): boolean {
const upper = token.toUpperCase();
return (
TRAILING_TAG_VOCABULARY.has(upper) &&
!WEAK_JOIN_EXCLUSIONS.has(upper) &&
isCaseUniform(token)
);
}
/** ES2015-safe trailing-whitespace trim (the lib target predates trimEnd). */
function trimRight(value: string): string {
return value.replace(/\s+$/, '');
}
/**
* Strip appended language/subtitle tags. Runs BEFORE lowercasing —
* casing is the main false-positive guard: ALL-CAPS titles carry no
* casing signal ("THE LAST OF US" must keep its "US"), so caps-gated
* rules are skipped for them, and Capitalized endings ("Making It",
* "Kick-It") never look like tags. Compound tags ("FR-EN") shed one
* token per pass, so stripping repeats to a fixpoint.
*/
function stripTrailingTags(value: string): string {
let result = value;
for (let pass = 0; pass < 3; pass++) {
const next = stripTrailingTagOnce(result);
if (next === result) break;
result = next;
}
return result;
}
function stripTrailingTagOnce(value: string): string {
const result = trimRight(value);
const hasLowercase = /\p{Ll}/u.test(result);
// "The Last of Us--esp": no real title contains a double dash.
if (DOUBLE_DASH_SUFFIX.test(result)) {
return trimRight(result.replace(DOUBLE_DASH_SUFFIX, ''));
}
// "Fallout_eng" — but not "The_Last_of_Us" (underscores as spaces, only
// strip a sole underscore) and not "Mr_Robot"/"Cowboy_Bebop" (the tail
// must be a known tag, so the segment is real-title evidence otherwise).
const underscore = result.match(UNDERSCORE_SUFFIX);
if (
underscore &&
result.indexOf('_') === result.lastIndexOf('_') &&
isJoinedTag(underscore[1])
) {
return trimRight(result.replace(UNDERSCORE_SUFFIX, ''));
}
// "Breaking Bad-eng", "The Last of Us-DE" — but not "drive-in"/"Plug-in".
// hasLowercase gates ALL-CAPS titles out (no casing signal to trust).
const joined = result.match(JOINED_DASH_SUFFIX);
if (joined && hasLowercase && isJoinedTag(joined[1])) {
return trimRight(result.replace(JOINED_DASH_SUFFIX, ''));
}
const trailing = result.match(TRAILING_TAG_SUFFIX);
if (trailing && hasLowercase && TRAILING_TAG_VOCABULARY.has(trailing[1])) {
return trimRight(result.replace(TRAILING_TAG_SUFFIX, ''));
}
return result;
}
const YEAR_PATTERN = /\b(19\d{2}|20\d{2})\b/;
@@ -44,12 +177,22 @@ const YEAR_PATTERN = /\b(19\d{2}|20\d{2})\b/;
const TRAILING_YEAR_PATTERN = /(?:^|\s)(19\d{2}|20\d{2})$/;
/**
* Trailing season markers on series titles: "The Boys s05", "сезон 2".
* Uses (?:^|\s) instead of \b — JS word boundaries are ASCII-only and
* never fire next to Cyrillic letters.
* Trailing season markers on series titles: "The Boys s05", "сезон 2",
* plus number-first forms ("2 season", "Пацаны 2 сезон", "2nd Season" —
* "2-й" normalizes to "2 й", hence the optional ordinal token). The
* ordinal list carries NFD-decomposed forms too: this pattern runs after
* diacritics stripping, which turns "й" into "и" ("2-й сезон" → "2 и
* сезон"). Uses (?:^|\s) instead of \b — JS word boundaries are
* ASCII-only and never fire next to Cyrillic letters.
*/
const SEASON_SUFFIX_PATTERN =
/(?:^|\s)(?:s\d{1,2}|season\s*\d{1,2}|сезон\s*\d{1,2}|staffel\s*\d{1,2}|temporada\s*\d{1,2})$/i;
const SEASON_SUFFIX_PATTERN = new RegExp(
'(?:^|\\s)(?:' +
's\\d{1,2}' +
`|(?:${SEASON_WORD_ALTERNATIVES})\\s*\\d{1,2}` +
`|\\d{1,2}\\s*(?:st|nd|rd|th|й|и|я|ой|ои)?\\s+(?:${SEASON_WORD_ALTERNATIVES})` +
')$',
'iu'
);
/**
* A provider title normalized on two tiers. Trailing years on provider
@@ -74,11 +217,14 @@ export function normalizeTitleKeys(
return { exact: '', base: '', trailingYear: null };
}
const cleaned = raw
// Inner classes exclude the opening delimiter too, so runaway
// inputs like "[[[[[..." backtrack linearly (CodeQL js/polynomial-redos)
.replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ')
.replace(LANGUAGE_PREFIX, '')
const cleaned = stripTrailingTags(
raw
.replace(WRAPPED_TAG_PREFIX, '')
// Inner classes exclude the opening delimiter too, so runaway
// inputs like "[[[[[..." backtrack linearly (CodeQL js/polynomial-redos)
.replace(/\[[^\][]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ')
.replace(LANGUAGE_PREFIX, '')
)
.normalize('NFD')
.replace(/[\u0300-\u036F]/g, '')
.toLowerCase()
@@ -30,6 +30,7 @@ export function normalizeXtreamVod(item: XtreamVodDetails): NormalizedVodMeta {
ratingKinopoisk: info?.rating_kinopoisk,
youtubeTrailer: info?.youtube_trailer,
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
tmdbRecommendations: info?.tmdb_recommendations,
};
}
@@ -59,6 +60,7 @@ export function normalizeStalkerVod(item: StalkerVodDetails): NormalizedVodMeta
// Stalker portals provide no trailers; TMDB enrichment can
youtubeTrailer: info?.tmdb_trailer,
tmdbCast: info?.tmdb_cast,
tmdbDirectors: info?.tmdb_directors,
tmdbRecommendations: info?.tmdb_recommendations,
};
}
@@ -84,6 +84,8 @@ export interface NormalizedVodMeta {
youtubeTrailer?: string;
/** Cast with profile photos, populated by TMDB enrichment */
tmdbCast?: TmdbEnrichedCastMember[];
/** Directors (movies) / creators (series) as clickable person chips */
tmdbDirectors?: TmdbEnrichedCastMember[];
/** TMDB recommendations (drives the cross-portal "Similar" rail) */
tmdbRecommendations?: TmdbRecommendation[];
}
@@ -26,6 +26,8 @@ export interface XtreamSerieInfo {
category_id: string;
/** Populated by TMDB enrichment; absent in raw provider responses */
tmdb_cast?: TmdbEnrichedCastMember[];
/** Directors (movies) / creators (series) as clickable person chips */
tmdb_directors?: TmdbEnrichedCastMember[];
/** Populated by TMDB enrichment; matched against the catalog in views */
tmdb_recommendations?: TmdbRecommendation[];
/** Matched TMDB show id — enables lazy season/episode enrichment */
@@ -36,6 +36,8 @@ export interface XtreamVodInfo {
rating_imdb?: string;
/** Populated by TMDB enrichment; absent in raw provider responses */
tmdb_cast?: TmdbEnrichedCastMember[];
/** Directors (movies) / creators (series) as clickable person chips */
tmdb_directors?: TmdbEnrichedCastMember[];
/** Populated by TMDB enrichment; matched against the catalog in views */
tmdb_recommendations?: TmdbRecommendation[];
}