mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
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:
commit
5b2dd3b8b0
425 files changed
+59216
-3432
No files matched your search
@@ -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[];
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user