feat(epg): support playlist-scoped sources

Add playlist-scoped EPG source support for M3U playlists.
This commit is contained in:
4gray authored and GitHub committed 2026-06-21 22:53:07 +02:00
1 parent 36a7ce9f3d
commit e801028005
71 files changed
+4876 -373

No files matched your search

@@ -68,6 +68,17 @@ describe('playlist.operations', () => {
importDate: '2026-04-01T00:00:00.000Z',
favorites: JSON.stringify(['channel-1']),
recentlyViewed: JSON.stringify([{ id: 'recent-1' }]),
epgUrls: JSON.stringify(['https://example.com/enabled.xml']),
detectedEpgUrls: JSON.stringify([
'https://example.com/enabled.xml',
'https://example.com/detected-only.xml',
]),
manualEpgUrls: JSON.stringify([
'https://example.com/manual.xml',
]),
disabledEpgUrls: JSON.stringify([
'https://example.com/disabled.xml',
]),
autoRefresh: false,
url: 'https://example.com/list.m3u',
},
@@ -84,6 +95,13 @@ describe('playlist.operations', () => {
count: 2,
favorites: ['channel-1'],
recentlyViewed: [{ id: 'recent-1' }],
epgUrls: ['https://example.com/enabled.xml'],
detectedEpgUrls: [
'https://example.com/enabled.xml',
'https://example.com/detected-only.xml',
],
manualEpgUrls: ['https://example.com/manual.xml'],
disabledEpgUrls: ['https://example.com/disabled.xml'],
url: 'https://example.com/list.m3u',
}),
]);
@@ -59,6 +59,16 @@ function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
}
}
function getStringArrayValue(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => (typeof item === 'string' ? item.trim() : ''))
.filter((item) => item.length > 0);
}
function getFavoriteChannelMatch(
channel: Channel,
favoritePositions: ReadonlyMap<string, number>
@@ -149,6 +159,22 @@ function buildPlaylistRow(
origin: getStringValue(playlist.origin),
referrer: getStringValue(playlist.referrer),
filePath: getStringValue(playlist.filePath),
epgUrls:
playlist.epgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.epgUrls))
: undefined,
detectedEpgUrls:
playlist.detectedEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.detectedEpgUrls))
: undefined,
manualEpgUrls:
playlist.manualEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.manualEpgUrls))
: undefined,
disabledEpgUrls:
playlist.disabledEpgUrls !== undefined
? JSON.stringify(getStringArrayValue(playlist.disabledEpgUrls))
: undefined,
autoRefresh: Boolean(playlist.autoRefresh),
macAddress: getStringValue(playlist.macAddress),
url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url,
@@ -170,7 +196,9 @@ function buildPlaylistRow(
};
}
export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown> {
export function parseAppPlaylist(
row: schema.Playlist
): Record<string, unknown> {
const payload = parseJsonValue<Record<string, unknown> | null>(
row.payload,
null
@@ -178,11 +206,28 @@ export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown>
const base = payload && typeof payload === 'object' ? payload : {};
const favorites = parseJsonValue<unknown[]>(row.favorites, []);
const recentlyViewed = parseJsonValue<unknown[]>(row.recentlyViewed, []);
const epgUrls = getStringArrayValue(
row.epgUrls ? parseJsonValue<unknown[]>(row.epgUrls, []) : base.epgUrls
);
const detectedEpgUrls = getStringArrayValue(
row.detectedEpgUrls
? parseJsonValue<unknown[]>(row.detectedEpgUrls, [])
: (base.detectedEpgUrls ?? epgUrls)
);
const manualEpgUrls = getStringArrayValue(
row.manualEpgUrls
? parseJsonValue<unknown[]>(row.manualEpgUrls, [])
: base.manualEpgUrls
);
const disabledEpgUrls = getStringArrayValue(
row.disabledEpgUrls
? parseJsonValue<unknown[]>(row.disabledEpgUrls, [])
: base.disabledEpgUrls
);
const importDate =
row.importDate ?? row.dateCreated ?? new Date().toISOString();
const portalUrl =
row.portalUrl ??
(row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
row.portalUrl ?? (row.type === PLAYLIST_TYPES.STALKER ? row.url : null);
const updateDate =
row.updateDate ??
(row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined);
@@ -191,9 +236,7 @@ export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown>
...base,
_id: row.id,
title:
getStringValue(base.title) ??
getStringValue(base.name) ??
row.name,
getStringValue(base.title) ?? getStringValue(base.name) ?? row.name,
count: row.count ?? getNumericValue(base.count) ?? 0,
importDate: getStringValue(base.importDate) ?? importDate,
lastUsage:
@@ -206,9 +249,13 @@ export function parseAppPlaylist(row: schema.Playlist): Record<string, unknown>
autoRefresh: row.autoRefresh ?? Boolean(base.autoRefresh),
url:
row.type === PLAYLIST_TYPES.M3U_URL
? row.url ?? getStringValue(base.url)
? (row.url ?? getStringValue(base.url))
: getStringValue(base.url),
filePath: row.filePath ?? getStringValue(base.filePath),
epgUrls,
detectedEpgUrls,
manualEpgUrls,
disabledEpgUrls,
userAgent: row.userAgent ?? getStringValue(base.userAgent),
referrer: row.referrer ?? getStringValue(base.referrer),
origin: row.origin ?? getStringValue(base.origin),
@@ -260,13 +307,10 @@ export async function upsertAppPlaylist(
throw new Error('Playlist ID is required for upsert');
}
await db
.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
await db.insert(schema.playlists).values(row).onConflictDoUpdate({
target: schema.playlists.id,
set: row,
});
return { success: true };
}
@@ -289,8 +333,7 @@ export async function upsertAppPlaylists(
await db.transaction((tx) => {
for (const row of rows) {
tx
.insert(schema.playlists)
tx.insert(schema.playlists)
.values(row)
.onConflictDoUpdate({
target: schema.playlists.id,
@@ -323,6 +366,10 @@ export async function getAppPlaylistMetas(db: AppDatabase) {
origin: schema.playlists.origin,
referrer: schema.playlists.referrer,
filePath: schema.playlists.filePath,
epgUrls: schema.playlists.epgUrls,
detectedEpgUrls: schema.playlists.detectedEpgUrls,
manualEpgUrls: schema.playlists.manualEpgUrls,
disabledEpgUrls: schema.playlists.disabledEpgUrls,
autoRefresh: schema.playlists.autoRefresh,
macAddress: schema.playlists.macAddress,
url: schema.playlists.url,
@@ -453,25 +500,29 @@ export async function deletePlaylist(
playlistId: string,
control?: OperationControl
): Promise<{ success: boolean }> {
const [favoriteRows, recentlyViewedRows, playbackPositionRows, downloadRows] =
await Promise.all([
db
.select({ id: schema.favorites.id })
.from(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId)),
db
.select({ id: schema.recentlyViewed.id })
.from(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
db
.select({ id: schema.playbackPositions.id })
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId)),
db
.select({ id: schema.downloads.id })
.from(schema.downloads)
.where(eq(schema.downloads.playlistId, playlistId)),
]);
const [
favoriteRows,
recentlyViewedRows,
playbackPositionRows,
downloadRows,
] = await Promise.all([
db
.select({ id: schema.favorites.id })
.from(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId)),
db
.select({ id: schema.recentlyViewed.id })
.from(schema.recentlyViewed)
.where(eq(schema.recentlyViewed.playlistId, playlistId)),
db
.select({ id: schema.playbackPositions.id })
.from(schema.playbackPositions)
.where(eq(schema.playbackPositions.playlistId, playlistId)),
db
.select({ id: schema.downloads.id })
.from(schema.downloads)
.where(eq(schema.downloads.playlistId, playlistId)),
]);
const categoryRows = await db
.select({ id: schema.categories.id })
@@ -543,7 +594,9 @@ export async function deletePlaylist(
}
await checkpointOperation(control);
await db.delete(schema.playlists).where(eq(schema.playlists.id, playlistId));
await db
.delete(schema.playlists)
.where(eq(schema.playlists.id, playlistId));
await reportOperationProgress(control, {
phase: 'deleting-playlist',
current: 1,