mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46: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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user