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

@@ -6,6 +6,7 @@ const {
createTableStatements,
indexMigrationStatements,
runMigrations,
cleanupLegacyTmdbSearchCache,
} = __databaseConnectionTestHooks;
type SqliteHandle = Parameters<typeof runMigrations>[0];
@@ -22,10 +23,7 @@ type StatementHandler = {
type HandlerRule = [pattern: string, handler: StatementHandler];
function createSqliteMock(
rules: HandlerRule[],
exec: jest.Mock = jest.fn()
) {
function createSqliteMock(rules: HandlerRule[], exec: jest.Mock = jest.fn()) {
const prepare = jest.fn((statement: string) => {
const compact = compactSql(statement);
const rule = rules.find(([pattern]) => compact.includes(pattern));
@@ -129,6 +127,45 @@ describe('runMigrations error tolerance', () => {
});
});
describe('TMDB search lookup v2 cache cleanup', () => {
it('deletes legacy search rows and records the migration atomically', () => {
const deleteRun = jest.fn();
const markerRun = jest.fn();
const { sqlite, transaction } = createSqliteMock([
['SELECT value FROM app_state', { get: () => undefined }],
['DELETE FROM tmdb_metadata', { run: deleteRun }],
['INSERT INTO app_state', { run: markerRun }],
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).toHaveBeenCalledTimes(1);
expect(deleteRun).toHaveBeenCalledTimes(1);
expect(markerRun).toHaveBeenCalledWith(
'migration:tmdb-search-lookup-v2-cache-cleanup:v1'
);
const deleteSql = compactSql(
(sqlite.prepare as jest.Mock).mock.calls.find(([statement]) =>
statement.includes('DELETE FROM tmdb_metadata')
)?.[0]
);
expect(deleteSql).toContain(
"lookup_key LIKE 'title:%|year:%' AND lookup_key NOT LIKE 'title:%|year:%|v%'"
);
});
it('does nothing after the migration has completed', () => {
const { sqlite, prepare, transaction } = createSqliteMock([
completedMigrationStateRule,
]);
cleanupLegacyTmdbSearchCache(sqlite);
expect(transaction).not.toHaveBeenCalled();
expect(prepare).toHaveBeenCalledTimes(1);
});
});
describe('runMigrations Xtream cache deduplication', () => {
it('re-points content to the canonical duplicate category before deleting the rest', () => {
const candidatesAll = jest.fn(() => [
@@ -141,7 +178,11 @@ describe('runMigrations Xtream cache deduplication', () => {
completedMigrationStateRule,
[
'FROM categories GROUP BY playlist_id, type, xtream_id',
{ all: () => [{ playlistId: 'p1', type: 'live', xtreamId: 5 }] },
{
all: () => [
{ playlistId: 'p1', type: 'live', xtreamId: 5 },
],
},
],
['LEFT JOIN content', { all: candidatesAll }],
[
@@ -149,7 +190,10 @@ describe('runMigrations Xtream cache deduplication', () => {
{ run: updateContentRun },
],
['DELETE FROM categories WHERE id = ?', { run: deleteCategoryRun }],
['FROM content GROUP BY category_id, type, xtream_id', { all: () => [] }],
[
'FROM content GROUP BY category_id, type, xtream_id',
{ all: () => [] },
],
]);
runMigrations(sqlite);
@@ -25,6 +25,39 @@ describe('database schema statements', () => {
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
} = __databaseConnectionTestHooks;
const ensureDownloadsPauseResumeSchema = (
__databaseConnectionTestHooks as unknown as {
ensureDownloadsPauseResumeSchema: (sqlite: {
prepare: (statement: string) => {
get?: () => unknown;
run?: (...args: unknown[]) => unknown;
};
transaction: (callback: () => void) => () => void;
}) => void;
}
).ensureDownloadsPauseResumeSchema;
function createRebuildSqlite(legacyTableSql: string | undefined) {
const statements: string[] = [];
const transaction = jest.fn((callback: () => void) => callback);
const prepare = jest.fn((statement: string) => {
if (statement.includes('FROM sqlite_master')) {
return {
get: () =>
legacyTableSql === undefined
? undefined
: { sql: legacyTableSql },
};
}
return {
run: () => {
statements.push(compactSql(statement));
},
};
});
return { prepare, statements, transaction };
}
it('defines the core fresh-install tables, indexes, and FTS triggers', () => {
const schemaSql = createTableStatements.map(compactSql).join('\n');
@@ -45,6 +78,9 @@ describe('database schema statements', () => {
'CREATE TABLE IF NOT EXISTS playback_positions'
);
expect(schemaSql).toContain('CREATE TABLE IF NOT EXISTS downloads');
expect(schemaSql).toContain('request_headers TEXT');
expect(schemaSql).toContain('resume_validator TEXT');
expect(schemaSql).toContain("'paused'");
expect(schemaSql).toContain(
'CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique'
);
@@ -383,6 +419,80 @@ describe('database schema statements', () => {
);
});
it('rebuilds a legacy downloads table without paused status or stored headers', () => {
const sqlite = createRebuildSqlite(
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
);
ensureDownloadsPauseResumeSchema(sqlite);
expect(sqlite.transaction).toHaveBeenCalledTimes(1);
const { statements } = sqlite;
const renameIndex = statements.findIndex((statement) =>
statement.startsWith('ALTER TABLE downloads RENAME TO')
);
const createIndex = statements.findIndex((statement) =>
statement.startsWith('CREATE TABLE IF NOT EXISTS downloads')
);
const copyIndex = statements.findIndex((statement) =>
statement.startsWith('INSERT INTO downloads')
);
const dropLegacyIndex = statements.findIndex((statement) =>
statement.startsWith('DROP TABLE downloads_pause_resume_legacy')
);
expect(renameIndex).toBeGreaterThanOrEqual(0);
expect(createIndex).toBeGreaterThan(renameIndex);
expect(copyIndex).toBeGreaterThan(createIndex);
expect(dropLegacyIndex).toBeGreaterThan(copyIndex);
// Indexes on the old table are dropped before the rename and
// recreated after the copy.
expect(
statements.filter((statement) =>
statement.startsWith('DROP INDEX IF EXISTS')
).length
).toBe(3);
expect(
statements
.slice(dropLegacyIndex + 1)
.filter((statement) => statement.includes('CREATE')).length
).toBe(3);
// The rebuilt table carries the new contract; legacy rows get NULL
// stored headers, and the copy never references resume_validator so
// it defaults to NULL.
expect(statements[createIndex]).toContain(`'paused'`);
expect(statements[createIndex]).toContain('request_headers TEXT');
expect(statements[createIndex]).toContain('resume_validator TEXT');
expect(statements[copyIndex]).toContain('NULL AS request_headers');
expect(statements[copyIndex]).not.toContain('resume_validator');
});
it('copies stored headers when only the paused status is missing', () => {
const sqlite = createRebuildSqlite(
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')))`
);
ensureDownloadsPauseResumeSchema(sqlite);
const copy = sqlite.statements.find((statement) =>
statement.startsWith('INSERT INTO downloads')
);
expect(copy).toBeDefined();
expect(copy).not.toContain('NULL AS request_headers');
expect(copy).toContain('request_headers');
});
it('skips the downloads rebuild when the table already has the paused contract', () => {
const sqlite = createRebuildSqlite(
`CREATE TABLE downloads (id INTEGER PRIMARY KEY AUTOINCREMENT, request_headers TEXT, resume_validator TEXT, status TEXT CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')))`
);
ensureDownloadsPauseResumeSchema(sqlite);
expect(sqlite.transaction).not.toHaveBeenCalled();
expect(sqlite.statements).toHaveLength(0);
});
it('backfills migrated EPG program source URLs in bounded batches', () => {
const { backfillEpgProgramSourceUrls } =
__databaseConnectionTestHooks;
+199 -24
View File
@@ -30,6 +30,8 @@ const CONTENT_TITLE_FTS_MIGRATION_KEY =
'migration:content-title-fts-trigram:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY =
'migration:epg-program-source-url-backfill:v1';
const TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY =
'migration:tmdb-search-lookup-v2-cache-cleanup:v1';
const EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
function readTraceFlag(name: string): boolean {
@@ -83,6 +85,34 @@ const TMDB_METADATA_TABLE_SQL = `CREATE TABLE IF NOT EXISTS tmdb_metadata (
fetched_at TEXT DEFAULT (datetime('now'))
)`;
const TMDB_METADATA_INDEX_SQL = `CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language)`;
const DOWNLOADS_TABLE_SQL = `CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
file_path TEXT,
poster_url TEXT,
request_headers TEXT,
resume_validator TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')),
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
error_message TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`;
const DOWNLOADS_INDEX_STATEMENTS = [
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
];
const CREATE_TABLE_STATEMENTS = [
`CREATE TABLE IF NOT EXISTS playlists (
@@ -169,6 +199,7 @@ const CREATE_TABLE_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_title ON content(title)`,
`CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_epg_channel ON content(epg_channel_id)`,
`CREATE INDEX IF NOT EXISTS idx_content_type_added ON content(type, added)`,
`CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type)`,
// Partial covering index for visible categories — supports the dashboard's
@@ -261,6 +292,15 @@ const CREATE_TABLE_STATEMENTS = [
INSERT INTO epg_programs_fts(rowid, title, description, category)
VALUES (new.id, new.title, new.description, new.category);
END`,
// EPG channel mappings (manual user overrides)
`CREATE TABLE IF NOT EXISTS epg_channel_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_key TEXT NOT NULL UNIQUE,
epg_channel_id TEXT NOT NULL,
playlist_id TEXT,
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id)`,
// Playback Positions table
`CREATE TABLE IF NOT EXISTS playback_positions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -281,30 +321,8 @@ const CREATE_TABLE_STATEMENTS = [
`CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at)`,
`CREATE INDEX IF NOT EXISTS playback_positions_playlist_updated_idx ON playback_positions(playlist_id, updated_at DESC)`,
// Downloads table
`CREATE TABLE IF NOT EXISTS downloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')),
series_xtream_id INTEGER,
season_number INTEGER,
episode_number INTEGER,
title TEXT NOT NULL,
url TEXT NOT NULL,
file_name TEXT,
file_path TEXT,
poster_url TEXT,
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'completed', 'failed', 'canceled')),
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
error_message TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type)`,
`CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id)`,
`CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status)`,
DOWNLOADS_TABLE_SQL,
...DOWNLOADS_INDEX_STATEMENTS,
// TMDB metadata cache (details payloads + search match resolutions)
TMDB_METADATA_TABLE_SQL,
TMDB_METADATA_INDEX_SQL,
@@ -342,6 +360,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
`ALTER TABLE content ADD COLUMN backdrop_url TEXT`,
// v1.7.1: Scope XMLTV programs to their source URL for playlist-local EPG lookup
`ALTER TABLE epg_programs ADD COLUMN source_url TEXT`,
// Pause/resume: entity validator (ETag/Last-Modified) sent as If-Range on resume
`ALTER TABLE downloads ADD COLUMN resume_validator TEXT`,
];
const INDEX_MIGRATION_STATEMENTS = [
@@ -360,9 +380,11 @@ export const __databaseConnectionTestHooks = {
createTableStatements: CREATE_TABLE_STATEMENTS,
columnMigrationStatements: COLUMN_MIGRATION_STATEMENTS,
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
ensureDownloadsPauseResumeSchema,
normalizeXtreamContentAddedEpochs,
ensureContentTitleFts,
backfillEpgProgramSourceUrls,
cleanupLegacyTmdbSearchCache,
runMigrations,
} as const;
@@ -758,11 +780,164 @@ function widenTmdbMetadataMediaTypeCheck(sqliteDb: Database.Database): void {
}
}
/**
* Search-match cache keys gained a v2 suffix when title normalization changed.
* Remove the now-unreachable unversioned rows once rather than leaving negative
* resolutions and other legacy search matches in long-lived installations.
*/
function cleanupLegacyTmdbSearchCache(sqliteDb: Database.Database): void {
try {
const migrationState = sqliteDb
.prepare(`SELECT value FROM app_state WHERE key = ?`)
.get(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY) as
| { value?: unknown }
| undefined;
if (migrationState?.value === 'done') {
return;
}
const executeCleanup = sqliteDb.transaction(() => {
sqliteDb
.prepare(
`DELETE FROM tmdb_metadata
WHERE lookup_key LIKE 'title:%|year:%'
AND lookup_key NOT LIKE 'title:%|year:%|v%'`
)
.run();
sqliteDb
.prepare(
`INSERT INTO app_state (key, value, updated_at)
VALUES (?, 'done', datetime('now'))
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at`
)
.run(TMDB_SEARCH_LOOKUP_V2_CACHE_CLEANUP_MIGRATION_KEY);
});
executeCleanup();
} catch (error) {
const message =
typeof error === 'object' && error !== null && 'message' in error
? String((error as { message?: unknown }).message ?? error)
: String(error);
console.warn(
`Legacy TMDB search cache cleanup failed (continuing): ${message}`
);
}
}
/**
* Existing downloads tables had a status CHECK without 'paused'. SQLite cannot
* alter CHECK constraints in place, so rebuild only when the table SQL still
* reflects the old contract or lacks request_headers.
*/
function ensureDownloadsPauseResumeSchema(sqliteDb: Database.Database): void {
try {
const row = sqliteDb
.prepare(
`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'downloads'`
)
.get() as { sql?: string } | undefined;
if (!row?.sql) {
return;
}
const hasPausedStatus = row.sql.includes(`'paused'`);
const hasRequestHeaders = row.sql.includes('request_headers');
if (hasPausedStatus && hasRequestHeaders) {
return;
}
const legacyHeadersSelect = hasRequestHeaders
? 'request_headers'
: 'NULL AS request_headers';
const rebuild = sqliteDb.transaction(() => {
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
const match = statement.match(
/^CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+([^\s]+)\s+/i
);
if (match?.[1]) {
sqliteDb.prepare(`DROP INDEX IF EXISTS ${match[1]}`).run();
}
}
sqliteDb
.prepare(
`ALTER TABLE downloads RENAME TO downloads_pause_resume_legacy`
)
.run();
sqliteDb.prepare(DOWNLOADS_TABLE_SQL).run();
sqliteDb
.prepare(
`INSERT INTO downloads (
id,
playlist_id,
xtream_id,
content_type,
series_xtream_id,
season_number,
episode_number,
title,
url,
file_name,
file_path,
poster_url,
request_headers,
status,
bytes_downloaded,
total_bytes,
error_message,
created_at,
updated_at
)
SELECT
id,
playlist_id,
xtream_id,
content_type,
series_xtream_id,
season_number,
episode_number,
title,
url,
file_name,
file_path,
poster_url,
${legacyHeadersSelect},
status,
bytes_downloaded,
total_bytes,
error_message,
created_at,
updated_at
FROM downloads_pause_resume_legacy`
)
.run();
sqliteDb
.prepare(`DROP TABLE downloads_pause_resume_legacy`)
.run();
for (const statement of DOWNLOADS_INDEX_STATEMENTS) {
sqliteDb.prepare(statement).run();
}
});
rebuild();
console.log('[DB] Rebuilt downloads table with pause/resume schema');
} catch (error) {
console.warn('[DB] downloads pause/resume migration failed:', error);
}
}
/**
* Run migrations that may fail if already applied
*/
function runMigrations(sqliteDb: Database.Database): void {
widenTmdbMetadataMediaTypeCheck(sqliteDb);
cleanupLegacyTmdbSearchCache(sqliteDb);
ensureDownloadsPauseResumeSchema(sqliteDb);
runMigrationStatements(sqliteDb, COLUMN_MIGRATION_STATEMENTS);
ensureContentTitleFts(sqliteDb);
deduplicateXtreamCache(sqliteDb);
@@ -0,0 +1,30 @@
/**
* Drizzle ORM schema for manual EPG-to-channel mappings.
*
* Split out of schema.ts to keep that file within the repository's
* max-lines budget; re-exported from there so `import * as schema`
* consumers keep seeing a single schema namespace.
*/
import { sql } from 'drizzle-orm';
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
// EPG channel mappings (manual user overrides)
export const epgChannelMappings = sqliteTable(
'epg_channel_mappings',
{
id: integer('id').primaryKey({ autoIncrement: true }),
channelKey: text('channel_key').notNull().unique(),
epgChannelId: text('epg_channel_id').notNull(),
playlistId: text('playlist_id'),
updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`),
},
(table) => ({
playlistIdx: index('idx_epg_channel_mappings_playlist').on(
table.playlistId
),
})
);
export type EpgChannelMapping = typeof epgChannelMappings.$inferSelect;
export type NewEpgChannelMapping = typeof epgChannelMappings.$inferInsert;
+17 -1
View File
@@ -16,6 +16,10 @@ import {
uniqueIndex,
} from 'drizzle-orm/sqlite-core';
// Manual EPG-to-channel mappings live in their own schema module; re-export
// them so `import * as schema from './schema'` keeps a complete namespace.
export * from './epg-mapping.schema';
// Playlists table
export const playlists = sqliteTable('playlists', {
id: text('id').primaryKey(),
@@ -110,6 +114,9 @@ export const content = sqliteTable(
categoryIdx: index('idx_content_category').on(table.categoryId),
titleIdx: index('idx_content_title').on(table.title),
xtreamIdx: index('idx_content_xtream').on(table.xtreamId),
epgChannelIdx: index('idx_content_epg_channel').on(
table.epgChannelId
),
categoryTypeXtreamUnique: uniqueIndex(
'content_category_type_xtream_unique'
).on(table.categoryId, table.type, table.xtreamId),
@@ -325,9 +332,18 @@ export const downloads = sqliteTable(
fileName: text('file_name'),
filePath: text('file_path'),
posterUrl: text('poster_url'),
requestHeaders: text('request_headers'),
resumeValidator: text('resume_validator'),
// Download progress
status: text('status', {
enum: ['queued', 'downloading', 'completed', 'failed', 'canceled'],
enum: [
'queued',
'downloading',
'paused',
'completed',
'failed',
'canceled',
],
})
.notNull()
.default('queued'),
+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[];
}
+13
View File
@@ -0,0 +1,13 @@
export default {
displayName: 'shared-logging',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': [
'ts-jest',
{ tsconfig: '<rootDir>/tsconfig.spec.json' },
],
},
moduleFileExtensions: ['ts', 'js'],
coverageDirectory: '../../../coverage/libs/shared/logging',
};
+20
View File
@@ -0,0 +1,20 @@
{
"name": "shared-logging",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/logging/src",
"projectType": "library",
"tags": ["scope:shared", "domain:shared-runtime", "type:util"],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/logging/jest.config.ts",
"tsConfig": "libs/shared/logging/tsconfig.spec.json"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
export {
REDACTED_VALUE,
redactSensitiveData,
} from './lib/redact-sensitive-data';
export type { RedactionOptions } from './lib/redact-sensitive-data';
@@ -0,0 +1,357 @@
import { REDACTED_VALUE, redactSensitiveData } from './redact-sensitive-data';
const TEST_SECRETS = [
'settings-api-key-secret',
'nested-user-secret',
'nested-password-secret',
'nested-token-secret',
'nested-auth-secret',
'nested-mac-secret',
'query-password-secret',
'query-token-secret',
];
function serialized(value: unknown): string {
return JSON.stringify(value);
}
describe('redactSensitiveData', () => {
it('recursively redacts credentials while retaining diagnostic fields', () => {
const input = {
operation: 'get_profile',
settings: { tmdb: { apiKey: TEST_SECRETS[0] } },
params: {
username: TEST_SECRETS[1],
PASSWORD: TEST_SECRETS[2],
access_token: TEST_SECRETS[3],
headers: { Authorization: `Bearer ${TEST_SECRETS[4]}` },
macAddress: TEST_SECRETS[5],
},
};
const result = redactSensitiveData(input);
const output = serialized(result);
for (const secret of TEST_SECRETS) {
expect(output).not.toContain(secret);
}
expect(result).toEqual({
operation: 'get_profile',
settings: { tmdb: { apiKey: REDACTED_VALUE } },
params: {
username: REDACTED_VALUE,
PASSWORD: REDACTED_VALUE,
access_token: REDACTED_VALUE,
headers: { Authorization: REDACTED_VALUE },
macAddress: REDACTED_VALUE,
},
});
});
it('redacts Stalker identity credentials while retaining request diagnostics', () => {
const identitySecrets = {
sn: 'stalker-sn-secret',
serialNumber: 'stalker-serial-number-secret',
device_id: 'stalker-device-id-secret',
deviceId1: 'stalker-device-id1-secret',
device_id2: 'stalker-device-id2-secret',
signature: 'stalker-signature-secret',
signature1: 'stalker-signature1-secret',
signature2: 'stalker-signature2-secret',
stalkerSerialNumber: 'playlist-stalker-serial-number-secret',
stalkerDeviceId1: 'playlist-stalker-device-id1-secret',
stalkerDeviceId2: 'playlist-stalker-device-id2-secret',
stalkerSignature1: 'playlist-stalker-signature1-secret',
stalkerSignature2: 'playlist-stalker-signature2-secret',
prehash: 'stalker-prehash-secret',
};
const result = redactSensitiveData({
action: 'get_profile',
requestId: 'stalker-request-id',
params: identitySecrets,
});
const output = serialized(result);
for (const secret of Object.values(identitySecrets)) {
expect(output).not.toContain(secret);
}
expect(output).toContain('get_profile');
expect(output).toContain('stalker-request-id');
});
it('redacts credentials embedded in URL, URLSearchParams, errors, and serialized strings', () => {
const url = new URL(
`https://user:pass@example.com/live?password=${TEST_SECRETS[6]}&token=${TEST_SECRETS[7]}&action=get_live_streams`
);
const params = new URLSearchParams({
authorization: TEST_SECRETS[4],
category: 'news',
});
const error = new Error(
`Request failed: https://example.com/api?token=${TEST_SECRETS[3]}&action=profile`
);
const json = JSON.stringify({
refreshToken: TEST_SECRETS[3],
status: 401,
});
const output = serialized(
redactSensitiveData({ url, params, error, json })
);
for (const secret of TEST_SECRETS) {
expect(output).not.toContain(secret);
}
expect(output).toContain('action=get_live_streams');
expect(output).toContain('category=news');
expect(output).toContain('status');
expect(output).toContain('401');
});
it('redacts credentials in non-HTTP stream URL strings', () => {
const username = 'rtsp-user-secret';
const password = 'rtsp-password-secret';
const token = 'rtmp-token-secret';
const output = serialized(
redactSensitiveData([
`rtsp://${username}:${password}@stream.example/live?token=${token}&channel=news`,
`Playback failed: rtmp://stream.example/live?password=${password}&channel=sports`,
])
);
expect(output).not.toContain(username);
expect(output).not.toContain(password);
expect(output).not.toContain(token);
expect(output).toContain('channel=news');
expect(output).toContain('channel=sports');
});
it('redacts a credential from a single-parameter string', () => {
const password = 'single-param-password-secret';
const token = 'single-param-token-secret';
const output = serialized(
redactSensitiveData([
`password=${password}`,
`token=${token}`,
'operation=get_profile',
])
);
expect(output).not.toContain(password);
expect(output).not.toContain(token);
expect(output).toContain('get_profile');
});
it('redacts credentials embedded in diagnostic text', () => {
const token = 'diagnostic-token-secret';
const authorization = 'diagnostic-authorization-secret';
const authorizationAssignment =
'diagnostic-authorization-assignment-secret';
const stalkerDeviceId = 'diagnostic-stalker-device-id-secret';
const diagnostic = [
`Request failed: token=${token}&action=get_profile`,
`Upstream response Authorization: Bearer ${authorization}; status=401`,
`Retrying with authorization=Bearer ${authorizationAssignment}, attempt=2`,
`Identity rejected: stalkerDeviceId1: ${stalkerDeviceId}; action=handshake`,
];
const output = serialized(redactSensitiveData(diagnostic));
expect(output).not.toContain(token);
expect(output).not.toContain(authorization);
expect(output).not.toContain(authorizationAssignment);
expect(output).not.toContain(stalkerDeviceId);
expect(output).toContain('get_profile');
expect(output).toContain('status=401');
expect(output).toContain('attempt=2');
expect(output).toContain('action=handshake');
});
it('does not repeat a redacted Error message secret in its stack', () => {
const secret = 'error-stack-password-secret';
const error = new Error(`password=${secret}&operation=get_profile`);
const output = serialized(redactSensitiveData(error));
expect(output).not.toContain(secret);
expect(output).toContain(encodeURIComponent(REDACTED_VALUE));
expect(output).toContain('get_profile');
});
it('redacts credentials nested inside non-sensitive query values', () => {
const nestedUrl = `https://identity.example/callback?token=${TEST_SECRETS[3]}&step=authorize`;
const url = new URL('https://example.com/portal');
url.searchParams.set('redirect', nestedUrl);
url.searchParams.set(
'payload',
JSON.stringify({ password: TEST_SECRETS[2], action: 'profile' })
);
const output = serialized(redactSensitiveData(url));
expect(output).not.toContain(TEST_SECRETS[2]);
expect(output).not.toContain(TEST_SECRETS[3]);
expect(output).toContain('authorize');
expect(output).toContain('profile');
});
it('redacts credentials from URL fragments while retaining diagnostics', () => {
const token = 'fragment-token-secret';
const url = new URL(
`https://example.com/callback#access_token=${token}&state=diagnostic`
);
const output = serialized(redactSensitiveData(url));
expect(output).not.toContain(token);
expect(output).toContain('state=diagnostic');
});
it('redacts credentials from Map keys while retaining values', () => {
const username = 'map-key-user-secret';
const password = 'map-key-password-secret';
const token = 'map-key-token-secret';
const requests = new Map([
[
`https://example.com/live/${username}/${password}/101.ts?token=${token}`,
{ status: 200 },
],
]);
const output = serialized(redactSensitiveData(requests));
expect(output).not.toContain(username);
expect(output).not.toContain(password);
expect(output).not.toContain(token);
expect(output).toContain('101.ts');
expect(output).toContain('"status":200');
});
it('redacts Map values selected by sensitive keys', () => {
const authorization = 'map-authorization-secret';
const password = 'map-password-secret';
const headers = new Map([
['Authorization', `Bearer ${authorization}`],
['password', password],
['requestId', 'diagnostic-request-id'],
]);
const result = redactSensitiveData(headers);
const output = serialized(result);
expect(output).not.toContain(authorization);
expect(output).not.toContain(password);
expect(output).toContain('diagnostic-request-id');
expect(result).toEqual({
Authorization: REDACTED_VALUE,
password: REDACTED_VALUE,
requestId: 'diagnostic-request-id',
});
});
it('redacts Xtream credentials in playback URL paths', () => {
const username = 'xtream-path-user-secret';
const password = 'xtream-path-password-secret';
const urls = [
new URL(
`https://example.com/base/live/${username}/${password}/101.ts`
),
new URL(
`https://example.com/movie/${username}/${password}/202.mkv`
),
new URL(
`https://example.com/series/${username}/${password}/303.mp4`
),
new URL(
`https://example.com/timeshift/${username}/${password}/60/2026-07-18:12-00/404.ts`
),
];
const embedded = `Playback failed for https://example.com/live/${username}/${password}/505.m3u8`;
const ordinaryLiveUrl = 'https://example.com/live/channel.m3u8';
const output = serialized(
redactSensitiveData({ urls, embedded, ordinaryLiveUrl })
);
expect(output).not.toContain(username);
expect(output).not.toContain(password);
expect(output).toContain('101.ts');
expect(output).toContain('202.mkv');
expect(output).toContain('303.mp4');
expect(output).toContain('404.ts');
expect(output).toContain('505.m3u8');
expect(output).toContain(ordinaryLiveUrl);
});
it('redacts Xtream path credentials when no resource segment follows', () => {
const username = 'terminal-xtream-user-secret';
const password = 'terminal-xtream-password-secret';
const output = serialized(
redactSensitiveData(
new URL(`https://example.com/live/${username}/${password}`)
)
);
expect(output).not.toContain(username);
expect(output).not.toContain(password);
});
it('does not mutate input and safely bounds cycles, depth, arrays, objects, and strings', () => {
const input: Record<string, unknown> = {
status: 'ok',
password: TEST_SECRETS[2],
items: [1, 2, 3, 4],
long: 'abcdefghij',
nested: { level: { value: 'too deep' } },
extraA: 'a',
extraB: 'b',
};
input['self'] = input;
const originalItems = input['items'];
const result = redactSensitiveData(input, {
maxArrayItems: 2,
maxDepth: 2,
maxObjectKeys: 6,
maxStringLength: 8,
});
expect(input['password']).toBe(TEST_SECRETS[2]);
expect(input['items']).toBe(originalItems);
expect(result).not.toBe(input);
expect(() => serialized(result)).not.toThrow();
expect(serialized(result)).not.toContain(TEST_SECRETS[2]);
expect(serialized(result)).toContain('[Truncated');
});
it('serializes invalid dates without throwing', () => {
const invalidDate = new Date(Number.NaN);
expect(() => redactSensitiveData(invalidDate)).not.toThrow();
expect(redactSensitiveData([invalidDate])).toEqual(['[Invalid Date]']);
});
it('preserves repeated non-circular references while still redacting them', () => {
const shared = {
operation: 'get_profile',
password: TEST_SECRETS[2],
};
const result = redactSensitiveData({ first: shared, second: shared });
expect(result).toEqual({
first: {
operation: 'get_profile',
password: REDACTED_VALUE,
},
second: {
operation: 'get_profile',
password: REDACTED_VALUE,
},
});
});
});
@@ -0,0 +1,395 @@
export const REDACTED_VALUE = '[Redacted]';
const CIRCULAR_VALUE = '[Circular]';
const MAX_DEPTH_VALUE = '[MaxDepth]';
const DEFAULT_MAX_DEPTH = 6;
const DEFAULT_MAX_ARRAY_ITEMS = 50;
const DEFAULT_MAX_OBJECT_KEYS = 50;
const DEFAULT_MAX_STRING_LENGTH = 2_000;
const SENSITIVE_KEY_NAMES = new Set([
'apikey',
'auth',
'authorization',
'cookie',
'credentials',
'deviceid',
'deviceid2',
'login',
'mac',
'macaddress',
'mpvplayerarguments',
'passwd',
'password',
'pwd',
'secret',
'setcookie',
'signature',
'signature2',
'sn',
'token',
'username',
'vlcplayerarguments',
]);
const SENSITIVE_KEY_SUFFIXES = [
'apikey', 'authorization', 'cookie',
'deviceid', 'deviceid1', 'deviceid2',
'macaddress', 'passwd', 'password', 'prehash',
'serialnumber', 'signature', 'signature1', 'signature2',
'secret', 'token', 'username',
];
const XTREAM_CREDENTIAL_PATH_SEGMENTS = new Set([
'live',
'movie',
'series',
'timeshift',
]);
export interface RedactionOptions {
maxDepth?: number;
maxArrayItems?: number;
maxObjectKeys?: number;
maxStringLength?: number;
}
interface ResolvedRedactionOptions {
maxDepth: number;
maxArrayItems: number;
maxObjectKeys: number;
maxStringLength: number;
}
function normalizeKey(key: string): string {
return key.toLowerCase().replace(/[^a-z0-9]/g, '');
}
function isSensitiveKey(key: string): boolean {
const normalized = normalizeKey(key);
return (
SENSITIVE_KEY_NAMES.has(normalized) ||
SENSITIVE_KEY_SUFFIXES.some((suffix) => normalized.endsWith(suffix))
);
}
function resolveOptions(options: RedactionOptions): ResolvedRedactionOptions {
return {
maxDepth: Math.max(0, options.maxDepth ?? DEFAULT_MAX_DEPTH),
maxArrayItems: Math.max(
0,
options.maxArrayItems ?? DEFAULT_MAX_ARRAY_ITEMS
),
maxObjectKeys: Math.max(
0,
options.maxObjectKeys ?? DEFAULT_MAX_OBJECT_KEYS
),
maxStringLength: Math.max(
0,
options.maxStringLength ?? DEFAULT_MAX_STRING_LENGTH
),
};
}
function truncateString(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
const omitted = value.length - maxLength;
return `${value.slice(0, maxLength)}[Truncated ${omitted} chars]`;
}
function redactSearchParams(
params: URLSearchParams,
sanitizeValue: (value: string) => string
): URLSearchParams {
const redacted = new URLSearchParams();
params.forEach((value, key) => {
redacted.append(
key,
isSensitiveKey(key) ? REDACTED_VALUE : sanitizeValue(value)
);
});
return redacted;
}
function redactUrl(
value: URL,
sanitizeValue: (value: string) => string
): string {
const redacted = new URL(value.toString());
if (redacted.username) {
redacted.username = REDACTED_VALUE;
}
if (redacted.password) {
redacted.password = REDACTED_VALUE;
}
const pathSegments = redacted.pathname.split('/');
for (let index = 0; index < pathSegments.length - 2; index += 1) {
if (XTREAM_CREDENTIAL_PATH_SEGMENTS.has(pathSegments[index])) {
pathSegments[index + 1] = REDACTED_VALUE;
pathSegments[index + 2] = REDACTED_VALUE;
}
}
redacted.pathname = pathSegments.join('/');
const search = redactSearchParams(
redacted.searchParams,
sanitizeValue
).toString();
redacted.search = search ? `?${search}` : '';
const fragment = redacted.hash.slice(1);
if (looksLikeSearchParams(fragment)) {
const hash = redactSearchParams(
new URLSearchParams(fragment),
sanitizeValue
).toString();
redacted.hash = hash ? `#${hash}` : '';
}
return redacted.toString();
}
function redactUrlStrings(
value: string,
sanitizeValue: (value: string) => string
): string {
return value.replace(
/[a-z][a-z0-9+.-]*:\/\/[^\s"'<>]+/giu,
(candidate) => {
try {
return redactUrl(new URL(candidate), sanitizeValue);
} catch {
return candidate;
}
}
);
}
function redactEmbeddedSensitivePairs(value: string): string {
const redactedAssignments = value.replace(
/\b([a-z][a-z0-9_.-]*)(\s*=\s*)((?:Bearer\s+)?[^&\s,;]+)/giu,
(match, key: string, separator: string) =>
isSensitiveKey(key)
? `${key}${separator}${REDACTED_VALUE}`
: match
);
return redactedAssignments.replace(
/\b([a-z0-9_.-]*(?:api[-_.]?key|auth(?:orization)?|cookie|credentials|device[-_.]?id[12]?|login|mac(?:[-_.]?address)?|passwd|password|prehash|pwd|secret|serial[-_.]?number|set[-_.]?cookie|signature[12]?|sn|token|username))(\s*:\s*)(?:Bearer\s+)?[^;,\r\n]+/giu,
(_match, key: string, separator: string) =>
`${key}${separator}${REDACTED_VALUE}`
);
}
function looksLikeSearchParams(value: string): boolean {
return /^[^=&\s]+=[^&]*(?:&[^=&\s]+=[^&]*)*$/u.test(value);
}
export function redactSensitiveData(
value: unknown,
options: RedactionOptions = {}
): unknown {
const resolved = resolveOptions(options);
const seen = new WeakSet<object>();
const visitString = (input: string, depth: number): string => {
const trimmed = input.trim();
if (depth >= resolved.maxDepth) {
return MAX_DEPTH_VALUE;
}
if (
(trimmed.startsWith('{') && trimmed.endsWith('}')) ||
(trimmed.startsWith('[') && trimmed.endsWith(']'))
) {
try {
return truncateString(
JSON.stringify(visit(JSON.parse(trimmed), depth + 1)),
resolved.maxStringLength
);
} catch {
// Keep processing malformed or non-JSON diagnostic strings.
}
}
if (/^[a-z][a-z0-9+.-]*:\/\//iu.test(trimmed)) {
try {
return truncateString(
redactUrl(new URL(trimmed), (entry) =>
visitString(entry, depth + 1)
),
resolved.maxStringLength
);
} catch {
// Continue with embedded URL handling for malformed URLs.
}
}
if (looksLikeSearchParams(trimmed)) {
return truncateString(
redactSearchParams(new URLSearchParams(trimmed), (entry) =>
visitString(entry, depth + 1)
).toString(),
resolved.maxStringLength
);
}
const redactedText = redactEmbeddedSensitivePairs(input);
return truncateString(
redactUrlStrings(redactedText, (entry) =>
visitString(entry, depth + 1)
),
resolved.maxStringLength
);
};
const visitObject = (
input: Record<string, unknown>,
depth: number
): Record<string, unknown> => {
const output: Record<string, unknown> = {};
const keys = Object.keys(input);
for (const key of keys.slice(0, resolved.maxObjectKeys)) {
if (isSensitiveKey(key)) {
output[key] = REDACTED_VALUE;
continue;
}
try {
output[key] = visit(input[key], depth + 1);
} catch {
output[key] = '[Unserializable]';
}
}
if (keys.length > resolved.maxObjectKeys) {
output['__truncatedKeys'] = keys.length - resolved.maxObjectKeys;
}
return output;
};
const visitError = (
error: Error,
depth: number
): Record<string, unknown> => {
const output = visitObject(
error as Error & Record<string, unknown>,
depth
);
output['name'] = visitString(error.name, depth + 1);
output['message'] = visitString(error.message, depth + 1);
if (error.stack) {
const [, ...stackFrames] = error.stack.split('\n');
output['stack'] = visitString(
[`${output['name']}: ${output['message']}`, ...stackFrames].join(
'\n'
),
depth + 1
);
}
if ('cause' in error) {
output['cause'] = visit(error.cause, depth + 1);
}
return output;
};
const visit = (input: unknown, depth: number): unknown => {
if (
input == null ||
typeof input === 'boolean' ||
typeof input === 'number'
) {
return input;
}
if (typeof input === 'string') {
return visitString(input, depth);
}
if (typeof input === 'bigint') {
return input.toString();
}
if (typeof input === 'symbol') {
return input.toString();
}
if (typeof input === 'function') {
return undefined;
}
if (depth >= resolved.maxDepth) {
return MAX_DEPTH_VALUE;
}
const object = input as object;
if (seen.has(object)) {
return CIRCULAR_VALUE;
}
seen.add(object);
try {
if (input instanceof URL) {
return redactUrl(input, (entry) =>
visitString(entry, depth + 1)
);
}
if (input instanceof URLSearchParams) {
return redactSearchParams(input, (entry) =>
visitString(entry, depth + 1)
).toString();
}
if (input instanceof Error) {
return visitError(input, depth);
}
if (input instanceof Date) {
return Number.isNaN(input.getTime())
? '[Invalid Date]'
: input.toISOString();
}
if (Array.isArray(input)) {
const output = input
.slice(0, resolved.maxArrayItems)
.map((entry) => visit(entry, depth + 1));
if (input.length > resolved.maxArrayItems) {
output.push(
`[Truncated ${
input.length - resolved.maxArrayItems
} items]`
);
}
return output;
}
if (input instanceof Map) {
const entries: Record<string, unknown> = {};
const mapEntries = Array.from(input).slice(
0,
resolved.maxObjectKeys
);
for (const [key, entry] of mapEntries) {
const stringKey = String(key);
const redactedKey = visitString(stringKey, depth + 1);
entries[redactedKey] =
isSensitiveKey(stringKey) &&
!/[/:?=&]/u.test(stringKey)
? REDACTED_VALUE
: visit(entry, depth + 1);
}
if (input.size > resolved.maxObjectKeys) {
entries['__truncatedKeys'] =
input.size - resolved.maxObjectKeys;
}
return entries;
}
if (input instanceof Set) {
return visit(Array.from(input), depth);
}
return visitObject(input as Record<string, unknown>, depth);
} finally {
seen.delete(object);
}
};
return visit(value, 0);
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
"importHelpers": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{ "path": "./tsconfig.lib.json" },
{ "path": "./tsconfig.spec.json" }
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"moduleResolution": "node10",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}
@@ -217,6 +217,74 @@ describe('catchup.utils', () => {
);
});
it('applies negative sub-hour XMLTV offsets such as -0030', () => {
// Regression: Number('-00') === -0, so a Math.sign()-based conversion
// dropped the minutes' sign and treated -0030 as UTC.
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 -0030',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775809800&lutc=1775820000'
);
});
it('applies positive offsets with minutes such as +0530', () => {
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 +0530',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775788200&lutc=1775820000'
);
});
it('applies positive sub-hour XMLTV offsets such as +0030', () => {
// Regression: Number('+00') === 0, so a Math.sign()-based conversion
// dropped the minutes and treated +0030 as UTC.
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 +0030',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775806200&lutc=1775820000'
);
});
it('treats a -0000 offset as UTC', () => {
expect(
resolveM3uCatchupUrl(
baseChannel,
{
...archivedProgram,
start: '202604100800 -0000',
startTimestamp: null,
},
1_775_820_000
)
).toBe(
'https://streams.example.com/live/channel-1.m3u8?utc=1775808000&lutc=1775820000'
);
});
it('preserves catchup metadata when storing parsed playlist items', () => {
const playlist = createPlaylistObject('Catchup playlist', {
header: {
@@ -112,9 +112,13 @@ function getEpgProgramTimestampSeconds(
Number(hour),
Number(minute)
);
// Derive the sign from the string: for offsets like "-0030" the hour part
// is "-00", and Number("-00") === -0, so Math.sign() would drop the
// minutes' sign and yield a zero offset instead of -30 minutes.
const offsetSign = offsetHours.startsWith('-') ? -1 : 1;
const offsetTotalMinutes =
Number(offsetHours) * 60 +
Math.sign(Number(offsetHours)) * Number(offsetMinutes);
offsetSign *
(Math.abs(Number(offsetHours)) * 60 + Number(offsetMinutes));
return Math.floor((utcMillis - offsetTotalMinutes * 60_000) / 1000);
}
@@ -43,6 +43,36 @@ describe('stripCountryPrefix', () => {
expect(stripCountryPrefix('US: CNN')).toBe('CNN');
});
it('strips compound quality/provider tags', () => {
expect(stripCountryPrefix('4K-DE - The Pitt (2025)')).toBe(
'The Pitt (2025)'
);
expect(stripCountryPrefix('AR-SUBS - Fallout')).toBe('Fallout');
expect(stripCountryPrefix('4K-OSN+ - The Last of Us')).toBe(
'The Last of Us'
);
});
it('strips longer pipe-tagged prefixes', () => {
expect(stripCountryPrefix('EXYU| News')).toBe('News');
expect(stripCountryPrefix('MULTI| Movies')).toBe('Movies');
});
it('never treats numeric fragments as tags', () => {
expect(stripCountryPrefix('1917 - Documentary')).toBe(
'1917 - Documentary'
);
});
it('keeps bare 4-5 char words before a spaced dash (real titles)', () => {
expect(stripCountryPrefix('DUNE - Part Two')).toBe(
'DUNE - Part Two'
);
expect(stripCountryPrefix('ALIEN - Covenant')).toBe(
'ALIEN - Covenant'
);
});
it('only strips the first tag segment', () => {
expect(stripCountryPrefix('ES - A3 - Sports')).toBe('A3 - Sports');
});
@@ -3,9 +3,19 @@
* country/group tag — they routinely appear inside real channel names
* ("Sky - Sports F1", "Discovery - Science"). Pipes are conventionally
* used only as tag separators, so they always count.
*
* Before a dash a tag is either a compound ("4K-DE", "AR-SUBS", "4K-OSN+"
* — the inner hyphen is the tag signal) or a plain 2–3 char code ("US",
* "4K"). A bare 4–5 char word before a spaced dash is a real title
* ("DUNE - Part Two", "ALIEN - Covenant"), so it is not a tag. Colon tags
* stay 2–3 chars — longer acronyms are franchise titles ("NCIS: LA").
* Every segment must contain a letter so numbers ("1917 - ...") are safe.
*/
const DASH_OR_COLON_SEPARATORS = [' - ', '- ', ' -', ': '];
const TAG_PREFIX_PATTERN = /^[A-Z0-9]{2,3}$/;
const DASH_SEPARATORS = [' - ', '- ', ' -'];
const COLON_SEPARATOR = ': ';
const TAG_PREFIX_PATTERN =
/^(?:(?=[0-9+]*[A-Z])[A-Z0-9+]{2,5}(?:-(?=[0-9+]*[A-Z])[A-Z0-9+]{2,6}){1,2}|(?=[0-9+]*[A-Z])[A-Z0-9+]{2,3})$/;
const COLON_TAG_PATTERN = /^(?=[0-9+]*[A-Z])[A-Z0-9+]{2,3}$/;
interface SeparatorMatch {
index: number;
@@ -68,10 +78,14 @@ function findTagSeparator(name: string): SeparatorMatch | null {
best = { index: pipeIndex, length: 1 };
}
for (const separator of DASH_OR_COLON_SEPARATORS) {
for (const separator of [...DASH_SEPARATORS, COLON_SEPARATOR]) {
const index = name.indexOf(separator);
if (index === -1 || (best && best.index <= index)) continue;
if (!TAG_PREFIX_PATTERN.test(name.slice(0, index).trim())) continue;
const pattern =
separator === COLON_SEPARATOR
? COLON_TAG_PATTERN
: TAG_PREFIX_PATTERN;
if (!pattern.test(name.slice(0, index).trim())) continue;
best = { index, length: separator.length };
}