mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
feat(epg): support playlist-scoped sources
Add playlist-scoped EPG source support for M3U playlists.
This commit is contained in:
1 parent
36a7ce9f3d
commit
e801028005
71 files changed
+4876
-373
No files matched your search
@@ -82,6 +82,9 @@ describe('database schema statements', () => {
|
||||
expect.arrayContaining([
|
||||
'ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0',
|
||||
'ALTER TABLE playlists ADD COLUMN payload TEXT',
|
||||
'ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT',
|
||||
'ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT',
|
||||
'ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT',
|
||||
'ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0',
|
||||
'ALTER TABLE content ADD COLUMN backdrop_url TEXT',
|
||||
])
|
||||
@@ -102,10 +105,26 @@ describe('database schema statements', () => {
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)',
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)',
|
||||
'CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)',
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('creates indexes for migrated EPG program columns only after column migrations run', () => {
|
||||
const createSchemaSql = createTableStatements.map(compactSql);
|
||||
|
||||
expect(createSchemaSql).not.toContain(
|
||||
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)'
|
||||
);
|
||||
expect(createSchemaSql).not.toContain(
|
||||
'CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)'
|
||||
);
|
||||
expect(columnMigrationStatements.map(compactSql)).toContain(
|
||||
'ALTER TABLE epg_programs ADD COLUMN source_url TEXT'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not define duplicate fresh-install schema object names', () => {
|
||||
const objectNames = [
|
||||
...createdObjectNames(
|
||||
@@ -307,4 +326,102 @@ describe('database schema statements', () => {
|
||||
expect.stringContaining('UPDATE content')
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills migrated EPG program source URLs after creating scoped EPG indexes', () => {
|
||||
const callOrder: string[] = [];
|
||||
const runMigrations = (
|
||||
__databaseConnectionTestHooks as unknown as {
|
||||
runMigrations: (sqlite: {
|
||||
exec: (statement: string) => void;
|
||||
prepare: (statement: string) => {
|
||||
all?: () => unknown[];
|
||||
get?: (...args: unknown[]) => unknown;
|
||||
run?: (...args: unknown[]) => unknown;
|
||||
};
|
||||
transaction: (callback: () => void) => () => void;
|
||||
}) => void;
|
||||
}
|
||||
).runMigrations;
|
||||
const sqlite = {
|
||||
exec: jest.fn((statement: string) => {
|
||||
if (statement.includes('idx_epg_programs_source')) {
|
||||
callOrder.push('epg-source-index');
|
||||
}
|
||||
}),
|
||||
prepare: jest.fn((statement: string) => {
|
||||
if (statement.includes('SELECT value FROM app_state')) {
|
||||
return {
|
||||
get: (): undefined => undefined,
|
||||
};
|
||||
}
|
||||
if (statement.includes('UPDATE epg_programs')) {
|
||||
return {
|
||||
run: () => {
|
||||
callOrder.push('epg-source-backfill');
|
||||
},
|
||||
};
|
||||
}
|
||||
if (statement.includes('INSERT INTO app_state')) {
|
||||
return { run: jest.fn() };
|
||||
}
|
||||
|
||||
return {
|
||||
all: () => [],
|
||||
get: (): undefined => undefined,
|
||||
run: jest.fn(),
|
||||
};
|
||||
}),
|
||||
transaction: jest.fn((callback: () => void) => callback),
|
||||
};
|
||||
|
||||
runMigrations(sqlite);
|
||||
|
||||
expect(callOrder).toContain('epg-source-backfill');
|
||||
expect(callOrder).toContain('epg-source-index');
|
||||
expect(callOrder.indexOf('epg-source-index')).toBeLessThan(
|
||||
callOrder.indexOf('epg-source-backfill')
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills migrated EPG program source URLs in bounded batches', () => {
|
||||
const { backfillEpgProgramSourceUrls } =
|
||||
__databaseConnectionTestHooks;
|
||||
let updateStatement = '';
|
||||
const backfillRun = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce({ changes: 50_000 })
|
||||
.mockReturnValueOnce({ changes: 12 });
|
||||
const stateRun = jest.fn();
|
||||
const prepare = jest.fn((statement: string) => {
|
||||
if (statement.includes('SELECT value FROM app_state')) {
|
||||
return {
|
||||
get: (): undefined => undefined,
|
||||
};
|
||||
}
|
||||
if (statement.includes('UPDATE epg_programs')) {
|
||||
updateStatement = compactSql(statement);
|
||||
return {
|
||||
run: backfillRun,
|
||||
};
|
||||
}
|
||||
if (statement.includes('INSERT INTO app_state')) {
|
||||
return { run: stateRun };
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected statement: ${compactSql(statement)}`);
|
||||
});
|
||||
const transaction = jest.fn((callback: () => void) => callback);
|
||||
const sqlite = {
|
||||
prepare,
|
||||
transaction,
|
||||
} as unknown as Parameters<typeof backfillEpgProgramSourceUrls>[0];
|
||||
|
||||
backfillEpgProgramSourceUrls(sqlite);
|
||||
|
||||
expect(updateStatement).toContain('LIMIT 50000');
|
||||
expect(backfillRun).toHaveBeenCalledTimes(2);
|
||||
expect(stateRun).toHaveBeenCalledWith(
|
||||
'migration:epg-program-source-url-backfill:v1'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,9 @@ const XTREAM_ADDED_EPOCH_SECONDS_MIGRATION_KEY =
|
||||
'migration:xtream-content-added-epoch-seconds:v1';
|
||||
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 EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE = 50_000;
|
||||
|
||||
function readTraceFlag(name: string): boolean {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
@@ -84,6 +87,10 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
origin TEXT,
|
||||
referrer TEXT,
|
||||
filePath TEXT,
|
||||
epg_urls TEXT,
|
||||
detected_epg_urls TEXT,
|
||||
manual_epg_urls TEXT,
|
||||
disabled_epg_urls TEXT,
|
||||
autoRefresh INTEGER DEFAULT 0,
|
||||
macAddress TEXT,
|
||||
url TEXT,
|
||||
@@ -210,6 +217,7 @@ const CREATE_TABLE_STATEMENTS = [
|
||||
icon_url TEXT,
|
||||
rating TEXT,
|
||||
episode_num TEXT,
|
||||
source_url TEXT,
|
||||
FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE
|
||||
)`,
|
||||
// EPG indexes
|
||||
@@ -304,6 +312,11 @@ const COLUMN_MIGRATION_STATEMENTS = [
|
||||
`ALTER TABLE playlists ADD COLUMN favorites TEXT`,
|
||||
`ALTER TABLE playlists ADD COLUMN recently_viewed TEXT`,
|
||||
`ALTER TABLE playlists ADD COLUMN payload TEXT`,
|
||||
// v1.2.1: Keep M3U-detected EPG URLs available in lightweight playlist metadata
|
||||
`ALTER TABLE playlists ADD COLUMN epg_urls TEXT`,
|
||||
`ALTER TABLE playlists ADD COLUMN detected_epg_urls TEXT`,
|
||||
`ALTER TABLE playlists ADD COLUMN manual_epg_urls TEXT`,
|
||||
`ALTER TABLE playlists ADD COLUMN disabled_epg_urls TEXT`,
|
||||
// v1.2.0 -> v1.3.0: Add position column to favorites for global favorites ordering
|
||||
`ALTER TABLE favorites ADD COLUMN position INTEGER DEFAULT 0`,
|
||||
// v1.4.0 -> v1.5.0: Preserve Xtream live metadata required for EPG/catch-up
|
||||
@@ -313,6 +326,8 @@ const COLUMN_MIGRATION_STATEMENTS = [
|
||||
`ALTER TABLE content ADD COLUMN direct_source TEXT`,
|
||||
// v1.5.0 -> v1.6.0: Cinematic backdrop persisted on first detail fetch
|
||||
`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`,
|
||||
];
|
||||
|
||||
const INDEX_MIGRATION_STATEMENTS = [
|
||||
@@ -321,6 +336,9 @@ const INDEX_MIGRATION_STATEMENTS = [
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
|
||||
// v1.6.0 -> v1.7.0: Query global favorites in stable display order
|
||||
`CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`,
|
||||
// v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_programs_source ON epg_programs(source_url)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_epg_programs_source_time_range ON epg_programs(source_url, channel_id, start, stop)`,
|
||||
];
|
||||
|
||||
export const __databaseConnectionTestHooks = {
|
||||
@@ -329,6 +347,7 @@ export const __databaseConnectionTestHooks = {
|
||||
indexMigrationStatements: INDEX_MIGRATION_STATEMENTS,
|
||||
normalizeXtreamContentAddedEpochs,
|
||||
ensureContentTitleFts,
|
||||
backfillEpgProgramSourceUrls,
|
||||
runMigrations,
|
||||
} as const;
|
||||
|
||||
@@ -594,6 +613,78 @@ function ensureContentTitleFts(sqliteDb: Database.Database): void {
|
||||
}
|
||||
}
|
||||
|
||||
function backfillEpgProgramSourceUrls(sqliteDb: Database.Database): void {
|
||||
try {
|
||||
const migrationState = sqliteDb
|
||||
.prepare(`SELECT value FROM app_state WHERE key = ?`)
|
||||
.get(EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY) as
|
||||
| { value?: unknown }
|
||||
| undefined;
|
||||
|
||||
if (migrationState?.value === 'done') {
|
||||
return;
|
||||
}
|
||||
|
||||
const backfillStatement = sqliteDb.prepare(
|
||||
`UPDATE epg_programs
|
||||
SET source_url = (
|
||||
SELECT epg_channels.source_url
|
||||
FROM epg_channels
|
||||
WHERE epg_channels.id = epg_programs.channel_id
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE id IN (
|
||||
SELECT pending_programs.id
|
||||
FROM epg_programs AS pending_programs
|
||||
JOIN epg_channels
|
||||
ON epg_channels.id = pending_programs.channel_id
|
||||
WHERE pending_programs.source_url IS NULL
|
||||
AND epg_channels.source_url IS NOT NULL
|
||||
AND epg_channels.source_url <> ''
|
||||
LIMIT ${EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE}
|
||||
)`
|
||||
);
|
||||
const markMigrationDoneStatement = 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`
|
||||
);
|
||||
|
||||
const executeBackfillBatch = sqliteDb.transaction((): number => {
|
||||
const result = backfillStatement.run();
|
||||
return typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'changes' in result &&
|
||||
typeof result.changes === 'number'
|
||||
? result.changes
|
||||
: 0;
|
||||
});
|
||||
const markMigrationDone = sqliteDb.transaction(() => {
|
||||
markMigrationDoneStatement.run(
|
||||
EPG_PROGRAM_SOURCE_URL_BACKFILL_MIGRATION_KEY
|
||||
);
|
||||
});
|
||||
|
||||
let updatedRows = 0;
|
||||
do {
|
||||
updatedRows = executeBackfillBatch();
|
||||
} while (updatedRows === EPG_PROGRAM_SOURCE_URL_BACKFILL_BATCH_SIZE);
|
||||
|
||||
markMigrationDone();
|
||||
} catch (error) {
|
||||
const message =
|
||||
typeof error === 'object' && error !== null && 'message' in error
|
||||
? String((error as { message?: unknown }).message ?? error)
|
||||
: String(error);
|
||||
|
||||
console.warn(
|
||||
`EPG program source URL backfill failed (continuing): ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runMigrationStatements(
|
||||
sqliteDb: Database.Database,
|
||||
statements: string[]
|
||||
@@ -631,6 +722,7 @@ function runMigrations(sqliteDb: Database.Database): void {
|
||||
deduplicateXtreamCache(sqliteDb);
|
||||
normalizeXtreamContentAddedEpochs(sqliteDb);
|
||||
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
|
||||
backfillEpgProgramSourceUrls(sqliteDb);
|
||||
}
|
||||
|
||||
export interface DatabaseOptions {
|
||||
|
||||
@@ -32,6 +32,10 @@ export const playlists = sqliteTable('playlists', {
|
||||
origin: text('origin'),
|
||||
referrer: text('referrer'),
|
||||
filePath: text('filePath'),
|
||||
epgUrls: text('epg_urls'),
|
||||
detectedEpgUrls: text('detected_epg_urls'),
|
||||
manualEpgUrls: text('manual_epg_urls'),
|
||||
disabledEpgUrls: text('disabled_epg_urls'),
|
||||
autoRefresh: integer('autoRefresh', { mode: 'boolean' }).default(false),
|
||||
macAddress: text('macAddress'),
|
||||
url: text('url'),
|
||||
@@ -204,9 +208,11 @@ export const epgPrograms = sqliteTable(
|
||||
iconUrl: text('icon_url'),
|
||||
rating: text('rating'),
|
||||
episodeNum: text('episode_num'),
|
||||
sourceUrl: text('source_url'),
|
||||
},
|
||||
(table) => ({
|
||||
channelIdx: index('idx_epg_programs_channel').on(table.channelId),
|
||||
sourceIdx: index('idx_epg_programs_source').on(table.sourceUrl),
|
||||
startIdx: index('idx_epg_programs_start').on(table.start),
|
||||
stopIdx: index('idx_epg_programs_stop').on(table.stop),
|
||||
timeRangeIdx: index('idx_epg_programs_time_range').on(
|
||||
@@ -214,6 +220,12 @@ export const epgPrograms = sqliteTable(
|
||||
table.start,
|
||||
table.stop
|
||||
),
|
||||
sourceTimeRangeIdx: index('idx_epg_programs_source_time_range').on(
|
||||
table.sourceUrl,
|
||||
table.channelId,
|
||||
table.start,
|
||||
table.stop
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -256,9 +268,10 @@ export const playbackPositions = sqliteTable(
|
||||
table.seriesXtreamId
|
||||
),
|
||||
updatedIdx: index('playback_positions_updated_idx').on(table.updatedAt),
|
||||
playlistUpdatedIdx: index(
|
||||
'playback_positions_playlist_updated_idx'
|
||||
).on(table.playlistId, sql`${table.updatedAt} DESC`),
|
||||
playlistUpdatedIdx: index('playback_positions_playlist_updated_idx').on(
|
||||
table.playlistId,
|
||||
sql`${table.updatedAt} DESC`
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -206,6 +206,10 @@ export interface ElectronBridgeEpgFreshnessResult {
|
||||
freshUrls: string[];
|
||||
}
|
||||
|
||||
export interface ElectronBridgeEpgLookupOptions {
|
||||
sourceUrls?: string[];
|
||||
}
|
||||
|
||||
export interface ElectronBridgeEpgProgressStats {
|
||||
totalChannels: number;
|
||||
totalPrograms: number;
|
||||
@@ -509,12 +513,17 @@ export interface ElectronBridgeApi {
|
||||
urls: string[],
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => Promise<ElectronBridgeEpgFetchResult>;
|
||||
getChannelPrograms: (channelId: string) => Promise<EpgProgram[]>;
|
||||
getChannelPrograms: (
|
||||
channelId: string,
|
||||
options?: ElectronBridgeEpgLookupOptions
|
||||
) => Promise<EpgProgram[]>;
|
||||
getCurrentProgramsBatch: (
|
||||
channelIds: string[]
|
||||
channelIds: string[],
|
||||
options?: ElectronBridgeEpgLookupOptions
|
||||
) => Promise<Record<string, EpgProgram | null>>;
|
||||
getEpgChannelMetadata: (
|
||||
channelIds: string[]
|
||||
channelIds: string[],
|
||||
options?: ElectronBridgeEpgLookupOptions
|
||||
) => Promise<Record<string, EpgChannelMetadata | null>>;
|
||||
getEpgChannels: () => Promise<ElectronBridgeEpgChannelListResult>;
|
||||
getEpgChannelsByRange: (
|
||||
@@ -526,6 +535,7 @@ export interface ElectronBridgeApi {
|
||||
options?: ElectronBridgeTrustOptions
|
||||
) => Promise<ElectronBridgeEpgFetchResult>;
|
||||
clearEpgData: () => Promise<ElectronBridgeResult>;
|
||||
clearEpgDataForSource: (sourceUrl: string) => Promise<ElectronBridgeResult>;
|
||||
checkEpgFreshness: (
|
||||
urls: string[],
|
||||
maxAgeHours?: number
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
export interface ParsedPlaylist {
|
||||
header: {
|
||||
attrs: {
|
||||
'x-tvg-url': string;
|
||||
};
|
||||
attrs: Record<string, string | undefined>;
|
||||
raw: string;
|
||||
};
|
||||
items: ParsedPlaylistItem[];
|
||||
|
||||
@@ -12,6 +12,10 @@ export type PlaylistMeta = Pick<
|
||||
| 'referrer'
|
||||
| 'origin'
|
||||
| 'filePath'
|
||||
| 'epgUrls'
|
||||
| 'detectedEpgUrls'
|
||||
| 'manualEpgUrls'
|
||||
| 'disabledEpgUrls'
|
||||
| 'updateDate'
|
||||
| 'updateState'
|
||||
| 'position'
|
||||
|
||||
@@ -33,6 +33,14 @@ export interface Playlist {
|
||||
referrer?: string;
|
||||
origin?: string;
|
||||
filePath?: string;
|
||||
/** M3U-scoped EPG source URLs enabled for automatic import and lookup. */
|
||||
epgUrls?: string[];
|
||||
/** All M3U EPG source URLs detected from the playlist header. */
|
||||
detectedEpgUrls?: string[];
|
||||
/** Playlist-local EPG source URLs explicitly enabled by the user. */
|
||||
manualEpgUrls?: string[];
|
||||
/** Detected playlist EPG source URLs explicitly disabled by the user. */
|
||||
disabledEpgUrls?: string[];
|
||||
autoRefresh: boolean;
|
||||
updateDate?: number;
|
||||
updateState?: PlaylistUpdateState;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Channel, Playlist } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
aggregateFavoriteChannels,
|
||||
createPlaylistObject,
|
||||
extractM3uEpgUrls,
|
||||
filterPlaylistEpgUrlsForFetch,
|
||||
getExtensionFromUrl,
|
||||
getStreamExtensionFromUrl,
|
||||
resolvePlaylistEpgSourceState,
|
||||
} from './playlist.utils';
|
||||
|
||||
function createChannel(id: string, url: string, name = id): Channel {
|
||||
@@ -105,4 +109,188 @@ describe('playlist utils', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('extractM3uEpgUrls', () => {
|
||||
it('extracts and deduplicates playlist-scoped EPG URLs from supported M3U header attributes', () => {
|
||||
expect(
|
||||
extractM3uEpgUrls({
|
||||
header: {
|
||||
attrs: {
|
||||
'x-tvg-url':
|
||||
' https://example.com/guide.xml, https://example.com/guide.xml https://example.com/extra.xml.gz ',
|
||||
'url-tvg': 'https://example.com/url-tvg.xml',
|
||||
},
|
||||
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml" url-tvg="https://example.com/url-tvg.xml"',
|
||||
},
|
||||
})
|
||||
).toEqual([
|
||||
'https://example.com/guide.xml',
|
||||
'https://example.com/extra.xml.gz',
|
||||
'https://example.com/url-tvg.xml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the raw header for tvg-url variants the parser does not expose as attrs', () => {
|
||||
expect(
|
||||
extractM3uEpgUrls({
|
||||
header: {
|
||||
attrs: {},
|
||||
raw: '#EXTM3U tvg-url="https://example.com/raw-guide.xml"',
|
||||
},
|
||||
})
|
||||
).toEqual(['https://example.com/raw-guide.xml']);
|
||||
});
|
||||
});
|
||||
|
||||
it('stores detected M3U EPG URLs on the created playlist', () => {
|
||||
const playlist = createPlaylistObject('Playlist with EPG', {
|
||||
header: {
|
||||
attrs: {
|
||||
'x-tvg-url': 'https://example.com/guide.xml',
|
||||
},
|
||||
raw: '#EXTM3U x-tvg-url="https://example.com/guide.xml"',
|
||||
},
|
||||
items: [],
|
||||
});
|
||||
|
||||
expect(playlist.epgUrls).toEqual(['https://example.com/guide.xml']);
|
||||
expect(playlist.detectedEpgUrls).toEqual([
|
||||
'https://example.com/guide.xml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps large detected EPG lists separate from the auto-imported playlist EPG subset', () => {
|
||||
const playlist = createPlaylistObject('Playlist with global EPG list', {
|
||||
header: {
|
||||
attrs: {
|
||||
'x-tvg-url': [
|
||||
'https://iptv-org.github.io/epg/guides/us/tvguide.com.epg.xml',
|
||||
'https://iptv-org.github.io/epg/guides/de/hd-plus.de.epg.xml',
|
||||
'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml',
|
||||
'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml',
|
||||
'https://iptv-org.github.io/epg/guides/fr/programme-tv.net.epg.xml',
|
||||
'https://iptv-org.github.io/epg/guides/uk/sky.com.epg.xml',
|
||||
].join(','),
|
||||
},
|
||||
raw: '#EXTM3U',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: '1+1',
|
||||
tvg: {
|
||||
id: '1Plus1.ua',
|
||||
name: '1+1',
|
||||
url: '',
|
||||
logo: '',
|
||||
rec: '',
|
||||
},
|
||||
group: { title: 'Undefined' },
|
||||
http: { referrer: '', 'user-agent': '' },
|
||||
raw: '#EXTINF:-1 tvg-id="1Plus1.ua" tvg-country="RU;UA" tvg-language="Ukrainian",1+1',
|
||||
url: 'https://example.com/stream.m3u8',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(playlist.detectedEpgUrls).toHaveLength(6);
|
||||
expect(playlist.epgUrls).toEqual([
|
||||
'https://iptv-org.github.io/epg/guides/ua/example.ua.epg.xml',
|
||||
'https://iptv-org.github.io/epg/guides/ru/tv.yandex.ru.epg.xml',
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to the first detected EPG URLs when a large list has no region recommendation match', () => {
|
||||
const epgUrls = [
|
||||
'https://provider.example.com/epg/source-1.xml',
|
||||
'https://provider.example.com/epg/source-2.xml',
|
||||
'https://provider.example.com/epg/source-3.xml',
|
||||
'https://provider.example.com/epg/source-4.xml',
|
||||
'https://provider.example.com/epg/source-5.xml',
|
||||
'https://provider.example.com/epg/source-6.xml',
|
||||
];
|
||||
const playlist = createPlaylistObject(
|
||||
'Playlist with generic EPG list',
|
||||
{
|
||||
header: {
|
||||
attrs: {
|
||||
'x-tvg-url': epgUrls.join(','),
|
||||
},
|
||||
raw: '#EXTM3U',
|
||||
},
|
||||
items: [
|
||||
{
|
||||
name: 'Generic Channel',
|
||||
tvg: {
|
||||
id: 'generic-channel',
|
||||
name: 'Generic Channel',
|
||||
url: '',
|
||||
logo: '',
|
||||
rec: '',
|
||||
},
|
||||
group: { title: 'Undefined' },
|
||||
http: { referrer: '', 'user-agent': '' },
|
||||
raw: '#EXTINF:-1 tvg-id="generic-channel",Generic Channel',
|
||||
url: 'https://example.com/stream.m3u8',
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
|
||||
expect(playlist.detectedEpgUrls).toEqual(epgUrls);
|
||||
expect(playlist.epgUrls).toEqual(epgUrls.slice(0, 5));
|
||||
});
|
||||
|
||||
it('keeps disabled playlist EPG sources out while preserving manually enabled URLs', () => {
|
||||
expect(
|
||||
resolvePlaylistEpgSourceState({
|
||||
detectedEpgUrls: [
|
||||
'https://playlist.example.com/auto.xml',
|
||||
'https://playlist.example.com/disabled.xml',
|
||||
],
|
||||
enabledEpgUrls: [
|
||||
'https://playlist.example.com/auto.xml',
|
||||
'https://playlist.example.com/disabled.xml',
|
||||
],
|
||||
manualEpgUrls: [
|
||||
' https://playlist.example.com/manual.xml ',
|
||||
'https://playlist.example.com/auto.xml',
|
||||
],
|
||||
disabledEpgUrls: [
|
||||
'https://playlist.example.com/disabled.xml',
|
||||
'',
|
||||
'https://playlist.example.com/disabled.xml',
|
||||
],
|
||||
})
|
||||
).toEqual({
|
||||
detectedEpgUrls: [
|
||||
'https://playlist.example.com/auto.xml',
|
||||
'https://playlist.example.com/disabled.xml',
|
||||
],
|
||||
epgUrls: [
|
||||
'https://playlist.example.com/auto.xml',
|
||||
'https://playlist.example.com/manual.xml',
|
||||
],
|
||||
manualEpgUrls: [
|
||||
'https://playlist.example.com/manual.xml',
|
||||
'https://playlist.example.com/auto.xml',
|
||||
],
|
||||
disabledEpgUrls: ['https://playlist.example.com/disabled.xml'],
|
||||
});
|
||||
});
|
||||
|
||||
it('filters playlist EPG fetch URLs that are already configured globally', () => {
|
||||
expect(
|
||||
filterPlaylistEpgUrlsForFetch(
|
||||
[
|
||||
'https://global.example.com/guide.xml',
|
||||
'https://playlist.example.com/local.xml',
|
||||
' https://playlist.example.com/local.xml ',
|
||||
],
|
||||
[
|
||||
' https://global.example.com/guide.xml ',
|
||||
'https://global.example.com/other.xml',
|
||||
]
|
||||
)
|
||||
).toEqual(['https://playlist.example.com/local.xml']);
|
||||
});
|
||||
});
|
||||
@@ -65,6 +65,306 @@ export const getFilenameFromUrl = (value: string): string => {
|
||||
return 'Untitled playlist';
|
||||
};
|
||||
|
||||
const M3U_EPG_HEADER_ATTRS = ['x-tvg-url', 'url-tvg', 'tvg-url'] as const;
|
||||
const M3U_EPG_URL_PATTERN = /\b(?:https?|file):\/\/[^\s,"']+/gi;
|
||||
export const M3U_AUTO_IMPORT_EPG_URL_LIMIT = 5;
|
||||
export const M3U_RECOMMENDED_EPG_URL_LIMIT = 12;
|
||||
|
||||
export interface M3uEpgUrlSelection {
|
||||
detectedEpgUrls: string[];
|
||||
enabledEpgUrls: string[];
|
||||
}
|
||||
|
||||
export interface PlaylistEpgSourceStateInput {
|
||||
detectedEpgUrls?: string[];
|
||||
enabledEpgUrls?: string[];
|
||||
manualEpgUrls?: string[];
|
||||
disabledEpgUrls?: string[];
|
||||
}
|
||||
|
||||
export interface PlaylistEpgSourceState {
|
||||
detectedEpgUrls: string[];
|
||||
epgUrls: string[];
|
||||
manualEpgUrls: string[];
|
||||
disabledEpgUrls: string[];
|
||||
}
|
||||
|
||||
export function normalizeEpgUrls(
|
||||
urls: readonly string[] | undefined
|
||||
): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
(urls ?? [])
|
||||
.map((url) => url.trim())
|
||||
.filter((url) => url.length > 0)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolvePlaylistEpgSourceState(
|
||||
input: PlaylistEpgSourceStateInput
|
||||
): PlaylistEpgSourceState {
|
||||
const detectedEpgUrls = normalizeEpgUrls(input.detectedEpgUrls);
|
||||
const manualEpgUrls = normalizeEpgUrls(input.manualEpgUrls);
|
||||
const disabledEpgUrls = normalizeEpgUrls(input.disabledEpgUrls);
|
||||
const disabledSet = new Set(disabledEpgUrls);
|
||||
const epgUrls = normalizeEpgUrls([
|
||||
...normalizeEpgUrls(input.enabledEpgUrls),
|
||||
...manualEpgUrls,
|
||||
]).filter((url) => !disabledSet.has(url));
|
||||
|
||||
return {
|
||||
detectedEpgUrls,
|
||||
epgUrls,
|
||||
manualEpgUrls,
|
||||
disabledEpgUrls,
|
||||
};
|
||||
}
|
||||
|
||||
export function filterPlaylistEpgUrlsForFetch(
|
||||
playlistEpgUrls: readonly string[] | undefined,
|
||||
globalEpgUrls: readonly string[] | undefined
|
||||
): string[] {
|
||||
const globalUrlSet = new Set(normalizeEpgUrls(globalEpgUrls));
|
||||
return normalizeEpgUrls(playlistEpgUrls).filter(
|
||||
(url) => !globalUrlSet.has(url)
|
||||
);
|
||||
}
|
||||
|
||||
export function extractM3uEpgUrls(
|
||||
playlist: Pick<ParsedPlaylist, 'header'> | null | undefined
|
||||
): string[] {
|
||||
const header = playlist?.header;
|
||||
if (!header) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: Array<string | undefined> = [];
|
||||
for (const attr of M3U_EPG_HEADER_ATTRS) {
|
||||
candidates.push(header.attrs?.[attr]);
|
||||
candidates.push(extractHeaderAttributeFromRaw(header.raw, attr));
|
||||
}
|
||||
|
||||
const urls: string[] = [];
|
||||
for (const candidate of candidates) {
|
||||
urls.push(...extractUrlsFromHeaderValue(candidate));
|
||||
}
|
||||
|
||||
return Array.from(new Set(urls));
|
||||
}
|
||||
|
||||
export function resolveM3uEpgUrlSelection(
|
||||
playlist: Pick<ParsedPlaylist, 'header' | 'items'> | null | undefined
|
||||
): M3uEpgUrlSelection {
|
||||
const detectedEpgUrls = extractM3uEpgUrls(playlist);
|
||||
|
||||
if (detectedEpgUrls.length <= M3U_AUTO_IMPORT_EPG_URL_LIMIT) {
|
||||
return {
|
||||
detectedEpgUrls,
|
||||
enabledEpgUrls: detectedEpgUrls,
|
||||
};
|
||||
}
|
||||
|
||||
const recommendedEpgUrls = selectRecommendedEpgUrls(
|
||||
detectedEpgUrls,
|
||||
playlist?.items ?? []
|
||||
);
|
||||
|
||||
return {
|
||||
detectedEpgUrls,
|
||||
enabledEpgUrls:
|
||||
recommendedEpgUrls.length > 0
|
||||
? recommendedEpgUrls
|
||||
: detectedEpgUrls.slice(0, M3U_AUTO_IMPORT_EPG_URL_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
function extractHeaderAttributeFromRaw(
|
||||
raw: string | undefined,
|
||||
attr: string
|
||||
): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = raw.match(
|
||||
new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s]+))`, 'i')
|
||||
);
|
||||
|
||||
return match?.[2] ?? match?.[3] ?? match?.[4];
|
||||
}
|
||||
|
||||
function extractUrlsFromHeaderValue(value: string | undefined): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const urls: string[] = [];
|
||||
const pattern = new RegExp(M3U_EPG_URL_PATTERN);
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = pattern.exec(value)) !== null) {
|
||||
const url = match[0].trim();
|
||||
if (url.length > 0) {
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
function selectRecommendedEpgUrls(
|
||||
urls: string[],
|
||||
items: ParsedPlaylistItem[]
|
||||
): string[] {
|
||||
const hints = collectPlaylistEpgRegionHints(items);
|
||||
const recommendations: string[] = [];
|
||||
|
||||
for (const url of urls) {
|
||||
const guideCodes = extractEpgGuideCodes(url);
|
||||
const matchesCountry =
|
||||
guideCodes.country !== undefined &&
|
||||
hints.countries.has(guideCodes.country);
|
||||
const matchesLanguageWithoutCountry =
|
||||
hints.countries.size === 0 &&
|
||||
guideCodes.language !== undefined &&
|
||||
hints.languages.has(guideCodes.language);
|
||||
|
||||
if (matchesCountry || matchesLanguageWithoutCountry) {
|
||||
recommendations.push(url);
|
||||
}
|
||||
|
||||
if (recommendations.length >= M3U_RECOMMENDED_EPG_URL_LIMIT) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
function collectPlaylistEpgRegionHints(items: ParsedPlaylistItem[]): {
|
||||
countries: Set<string>;
|
||||
languages: Set<string>;
|
||||
} {
|
||||
const countries = new Set<string>();
|
||||
const languages = new Set<string>();
|
||||
|
||||
for (const item of items) {
|
||||
addDelimitedCodes(
|
||||
countries,
|
||||
extractExtinfAttribute(item.raw, 'tvg-country')
|
||||
);
|
||||
addDelimitedCodes(countries, extractCountrySuffix(item.tvg?.id));
|
||||
|
||||
const languageCode = normalizeLanguageCode(
|
||||
extractExtinfAttribute(item.raw, 'tvg-language')
|
||||
);
|
||||
if (languageCode) {
|
||||
languages.add(languageCode);
|
||||
}
|
||||
}
|
||||
|
||||
return { countries, languages };
|
||||
}
|
||||
|
||||
function extractExtinfAttribute(
|
||||
raw: string | undefined,
|
||||
attr: string
|
||||
): string | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = raw.match(
|
||||
new RegExp(`\\b${attr}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s,]+))`, 'i')
|
||||
);
|
||||
|
||||
return match?.[2] ?? match?.[3] ?? match?.[4];
|
||||
}
|
||||
|
||||
function addDelimitedCodes(target: Set<string>, value: string | undefined) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const token of value.split(/[;,\s]+/)) {
|
||||
const code = normalizeRegionCode(token);
|
||||
if (code) {
|
||||
target.add(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRegionCode(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized && /^[a-z]{2}$/.test(normalized) ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeLanguageCode(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const languageByName: Record<string, string> = {
|
||||
arabic: 'ar',
|
||||
english: 'en',
|
||||
french: 'fr',
|
||||
german: 'de',
|
||||
italian: 'it',
|
||||
polish: 'pl',
|
||||
portuguese: 'pt',
|
||||
russian: 'ru',
|
||||
spanish: 'es',
|
||||
turkish: 'tr',
|
||||
ukrainian: 'uk',
|
||||
};
|
||||
|
||||
return normalizeRegionCode(normalized) ?? languageByName[normalized];
|
||||
}
|
||||
|
||||
function extractCountrySuffix(value: string | undefined): string | undefined {
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = normalized.match(/\.([a-z]{2})$/);
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function extractEpgGuideCodes(url: string): {
|
||||
country?: string;
|
||||
language?: string;
|
||||
} {
|
||||
let path = '';
|
||||
|
||||
try {
|
||||
path = new URL(url).pathname;
|
||||
} catch {
|
||||
path = url;
|
||||
}
|
||||
|
||||
const segments = path
|
||||
.split('/')
|
||||
.map((segment) => segment.trim().toLowerCase())
|
||||
.filter((segment) => segment.length > 0);
|
||||
const guidesIndex = segments.indexOf('guides');
|
||||
const guideSegment =
|
||||
guidesIndex >= 0 ? segments[guidesIndex + 1] : undefined;
|
||||
|
||||
if (!guideSegment) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const [country, language] = guideSegment.split('-');
|
||||
|
||||
return {
|
||||
country: normalizeRegionCode(country),
|
||||
language: normalizeRegionCode(language),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a playlist object
|
||||
* @param name name of the playlist
|
||||
@@ -78,6 +378,13 @@ export const createPlaylistObject = (
|
||||
urlOrPath?: string,
|
||||
uploadType?: 'URL' | 'FILE' | 'TEXT'
|
||||
): Playlist => {
|
||||
const { detectedEpgUrls, enabledEpgUrls } =
|
||||
resolveM3uEpgUrlSelection(playlist);
|
||||
const epgSourceState = resolvePlaylistEpgSourceState({
|
||||
detectedEpgUrls,
|
||||
enabledEpgUrls,
|
||||
});
|
||||
|
||||
return {
|
||||
_id: uuidv4(),
|
||||
filename: name,
|
||||
@@ -94,6 +401,12 @@ export const createPlaylistObject = (
|
||||
lastUsage: new Date().toISOString(),
|
||||
favorites: [],
|
||||
autoRefresh: false,
|
||||
...(epgSourceState.epgUrls.length > 0
|
||||
? { epgUrls: epgSourceState.epgUrls }
|
||||
: {}),
|
||||
...(epgSourceState.detectedEpgUrls.length > 0
|
||||
? { detectedEpgUrls: epgSourceState.detectedEpgUrls }
|
||||
: {}),
|
||||
...(uploadType === 'URL' ? { url: urlOrPath } : {}),
|
||||
...(uploadType === 'FILE' ? { filePath: urlOrPath } : {}),
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user