From 086eab9306f84f91d1fa12c4184c3b8fe2144443 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 2 Mar 2026 23:41:02 +0100 Subject: [PATCH] feat(storage): add SQLite-backed m3u playlists for Electron with IndexedDB migration --- .../src/app/api/main.preload.ts | 10 + .../src/app/database/schema.ts | 4 +- .../app/events/database/playlist.events.ts | 342 ++++++++++++- apps/web/src/typings.d.ts | 13 + global.d.ts | 13 + libs/m3u-state/src/lib/effects.ts | 69 +-- libs/services/src/lib/playlists.service.ts | 475 +++++++++++++----- libs/shared/database/src/lib/connection.ts | 22 + libs/shared/database/src/lib/schema.ts | 17 + 9 files changed, 780 insertions(+), 185 deletions(-) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index d1cbb70f5..732dcaf60 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -154,6 +154,13 @@ contextBridge.exposeInMainWorld('electron', { ipcRenderer.invoke('DB_CREATE_PLAYLIST', playlist), dbGetPlaylist: (playlistId: string) => ipcRenderer.invoke('DB_GET_PLAYLIST', playlistId), + dbUpsertAppPlaylist: (playlist: any) => + ipcRenderer.invoke('DB_UPSERT_APP_PLAYLIST', playlist), + dbUpsertAppPlaylists: (playlists: any[]) => + ipcRenderer.invoke('DB_UPSERT_APP_PLAYLISTS', playlists), + dbGetAppPlaylists: () => ipcRenderer.invoke('DB_GET_APP_PLAYLISTS'), + dbGetAppPlaylist: (playlistId: string) => + ipcRenderer.invoke('DB_GET_APP_PLAYLIST', playlistId), dbUpdatePlaylist: (playlistId: string, updates: any) => ipcRenderer.invoke('DB_UPDATE_PLAYLIST', playlistId, updates), dbDeletePlaylist: (playlistId: string) => @@ -234,6 +241,9 @@ contextBridge.exposeInMainWorld('electron', { dbGetContentByXtreamId: (xtreamId: number, playlistId: string) => ipcRenderer.invoke('DB_GET_CONTENT_BY_XTREAM_ID', xtreamId, playlistId), dbDeleteAllPlaylists: () => ipcRenderer.invoke('DB_DELETE_ALL_PLAYLISTS'), + dbGetAppState: (key: string) => ipcRenderer.invoke('DB_GET_APP_STATE', key), + dbSetAppState: (key: string, value: string) => + ipcRenderer.invoke('DB_SET_APP_STATE', key, value), // Playback Positions dbSavePlaybackPosition: (playlistId: string, data: any) => ipcRenderer.invoke('DB_SAVE_PLAYBACK_POSITION', playlistId, data), diff --git a/apps/electron-backend/src/app/database/schema.ts b/apps/electron-backend/src/app/database/schema.ts index 52c188f5c..26b30353c 100644 --- a/apps/electron-backend/src/app/database/schema.ts +++ b/apps/electron-backend/src/app/database/schema.ts @@ -14,9 +14,12 @@ export { epgPrograms, playbackPositions, downloads, + appState, // Types type Playlist, type NewPlaylist, + type AppState, + type NewAppState, type Category, type NewCategory, type Content, @@ -34,4 +37,3 @@ export { type Download, type NewDownload, } from 'database'; - diff --git a/apps/electron-backend/src/app/events/database/playlist.events.ts b/apps/electron-backend/src/app/events/database/playlist.events.ts index 190fc1050..0e4f698a8 100644 --- a/apps/electron-backend/src/app/events/database/playlist.events.ts +++ b/apps/electron-backend/src/app/events/database/playlist.events.ts @@ -8,6 +8,188 @@ import { ipcMain } from 'electron'; import { getDatabase } from '../../database/connection'; import * as schema from '../../database/schema'; +const PLAYLIST_TYPES = { + XTREAM: 'xtream', + STALKER: 'stalker', + M3U_FILE: 'm3u-file', + M3U_TEXT: 'm3u-text', + M3U_URL: 'm3u-url', +} as const; + +type PlaylistType = (typeof PLAYLIST_TYPES)[keyof typeof PLAYLIST_TYPES]; +const PLAYLIST_TYPE_VALUES = new Set( + Object.values(PLAYLIST_TYPES) +); + +function getStringValue(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +function getNumericValue(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + + return undefined; +} + +function parseJsonValue(value: string | null | undefined, fallback: T): T { + if (!value) { + return fallback; + } + + try { + return JSON.parse(value) as T; + } catch (error) { + console.warn('Failed to parse JSON value from DB:', error); + return fallback; + } +} + +function inferPlaylistType(playlist: Record): PlaylistType { + const explicitType = getStringValue(playlist.type); + if ( + explicitType && + PLAYLIST_TYPE_VALUES.has(explicitType as PlaylistType) + ) { + return explicitType as PlaylistType; + } + + if (getStringValue(playlist.serverUrl)) { + return PLAYLIST_TYPES.XTREAM; + } + + if (getStringValue(playlist.macAddress)) { + return PLAYLIST_TYPES.STALKER; + } + + if (getStringValue(playlist.filePath)) { + return PLAYLIST_TYPES.M3U_FILE; + } + + if (getStringValue(playlist.url)) { + return PLAYLIST_TYPES.M3U_URL; + } + + return PLAYLIST_TYPES.M3U_TEXT; +} + +function buildPlaylistRow( + playlist: Record +): schema.NewPlaylist | null { + const id = getStringValue(playlist._id) ?? getStringValue(playlist.id); + if (!id) { + return null; + } + + const type = inferPlaylistType(playlist); + const portalUrl = getStringValue(playlist.portalUrl); + const url = getStringValue(playlist.url); + const nowIso = new Date().toISOString(); + const updateDate = getNumericValue(playlist.updateDate); + + return { + id, + name: + getStringValue(playlist.title) ?? + getStringValue(playlist.name) ?? + id, + serverUrl: getStringValue(playlist.serverUrl), + username: getStringValue(playlist.username), + password: getStringValue(playlist.password), + lastUpdated: + typeof updateDate === 'number' + ? new Date(updateDate).toISOString() + : getStringValue(playlist.lastUpdated), + type, + userAgent: getStringValue(playlist.userAgent), + origin: getStringValue(playlist.origin), + referrer: getStringValue(playlist.referrer), + filePath: getStringValue(playlist.filePath), + autoRefresh: Boolean(playlist.autoRefresh), + macAddress: getStringValue(playlist.macAddress), + // Keep historical semantics: stalker portal URL has been stored in "url" + url: type === PLAYLIST_TYPES.STALKER ? (portalUrl ?? url) : url, + portalUrl, + count: getNumericValue(playlist.count), + importDate: getStringValue(playlist.importDate), + updateDate, + position: getNumericValue(playlist.position), + favorites: + playlist.favorites !== undefined + ? JSON.stringify(playlist.favorites) + : undefined, + recentlyViewed: + playlist.recentlyViewed !== undefined + ? JSON.stringify(playlist.recentlyViewed) + : undefined, + payload: JSON.stringify(playlist), + lastUsage: getStringValue(playlist.lastUsage) ?? nowIso, + }; +} + +function parseAppPlaylist(row: schema.Playlist): Record { + const payload = parseJsonValue | null>( + row.payload, + null + ); + if (payload && typeof payload === 'object') { + return { + ...payload, + _id: + getStringValue(payload._id) ?? + getStringValue(payload.id) ?? + row.id, + title: + getStringValue(payload.title) ?? + getStringValue(payload.name) ?? + row.name, + }; + } + + const favorites = parseJsonValue(row.favorites, []); + const recentlyViewed = parseJsonValue(row.recentlyViewed, []); + const importDate = row.importDate ?? row.dateCreated ?? new Date().toISOString(); + const portalUrl = + row.portalUrl ?? + (row.type === PLAYLIST_TYPES.STALKER ? row.url : null); + + return { + _id: row.id, + title: row.name, + count: row.count ?? 0, + importDate, + lastUsage: row.lastUsage ?? importDate, + favorites, + recentlyViewed, + autoRefresh: row.autoRefresh ?? false, + url: row.type === PLAYLIST_TYPES.M3U_URL ? row.url : undefined, + filePath: row.filePath ?? undefined, + userAgent: row.userAgent ?? undefined, + referrer: row.referrer ?? undefined, + origin: row.origin ?? undefined, + updateDate: + row.updateDate ?? + (row.lastUpdated ? new Date(row.lastUpdated).getTime() : undefined), + position: row.position ?? undefined, + serverUrl: row.serverUrl ?? undefined, + username: row.username ?? undefined, + password: row.password ?? undefined, + macAddress: row.macAddress ?? undefined, + portalUrl: portalUrl ?? undefined, + }; +} + /** * Create a new playlist */ @@ -36,13 +218,12 @@ ipcMain.handle( password: playlist.password, macAddress: playlist.macAddress, url: playlist.url, + portalUrl: + playlist.type === PLAYLIST_TYPES.STALKER + ? playlist.url + : undefined, // enforce supported types - type: playlist.type as - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url', + type: playlist.type as PlaylistType, }); return { success: true }; } catch (error) { @@ -52,6 +233,107 @@ ipcMain.handle( } ); +/** + * Upsert a full app playlist object (meta + optional content payload) + */ +ipcMain.handle( + 'DB_UPSERT_APP_PLAYLIST', + async (event, playlist: Record) => { + try { + const db = await getDatabase(); + const row = buildPlaylistRow(playlist); + if (!row) { + throw new Error('Playlist ID is required for upsert'); + } + + await db + .insert(schema.playlists) + .values(row) + .onConflictDoUpdate({ + target: schema.playlists.id, + set: row, + }); + + return { success: true }; + } catch (error) { + console.error('Error upserting app playlist:', error); + throw error; + } + } +); + +/** + * Bulk upsert full app playlist objects + */ +ipcMain.handle( + 'DB_UPSERT_APP_PLAYLISTS', + async (event, playlists: Record[]) => { + try { + if (!Array.isArray(playlists) || playlists.length === 0) { + return { success: true, count: 0 }; + } + + const db = await getDatabase(); + let count = 0; + + for (const playlist of playlists) { + const row = buildPlaylistRow(playlist); + if (!row) { + continue; + } + + await db + .insert(schema.playlists) + .values(row) + .onConflictDoUpdate({ + target: schema.playlists.id, + set: row, + }); + + count += 1; + } + + return { success: true, count }; + } catch (error) { + console.error('Error bulk upserting app playlists:', error); + throw error; + } + } +); + +/** + * Get all app playlists as playlist objects (parsed from payload) + */ +ipcMain.handle('DB_GET_APP_PLAYLISTS', async () => { + try { + const db = await getDatabase(); + const rows = await db.select().from(schema.playlists); + return rows.map((row) => parseAppPlaylist(row)); + } catch (error) { + console.error('Error getting app playlists:', error); + throw error; + } +}); + +/** + * Get app playlist by ID as playlist object + */ +ipcMain.handle('DB_GET_APP_PLAYLIST', async (event, playlistId: string) => { + try { + const db = await getDatabase(); + const rows = await db + .select() + .from(schema.playlists) + .where(eq(schema.playlists.id, playlistId)) + .limit(1); + + return rows[0] ? parseAppPlaylist(rows[0]) : null; + } catch (error) { + console.error('Error getting app playlist:', error); + throw error; + } +}); + /** * Get playlist by ID */ @@ -119,6 +401,54 @@ ipcMain.handle('DB_DELETE_PLAYLIST', async (event, playlistId: string) => { } }); +/** + * Read an app-level state value by key + */ +ipcMain.handle('DB_GET_APP_STATE', async (event, key: string) => { + try { + const db = await getDatabase(); + const rows = await db + .select() + .from(schema.appState) + .where(eq(schema.appState.key, key)) + .limit(1); + return rows[0]?.value ?? null; + } catch (error) { + console.error('Error getting app state:', error); + throw error; + } +}); + +/** + * Upsert an app-level state key/value pair + */ +ipcMain.handle( + 'DB_SET_APP_STATE', + async (event, key: string, value: string) => { + try { + const db = await getDatabase(); + const updatedAt = new Date().toISOString(); + + await db + .insert(schema.appState) + .values({ + key, + value, + updatedAt, + }) + .onConflictDoUpdate({ + target: schema.appState.key, + set: { value, updatedAt }, + }); + + return { success: true }; + } catch (error) { + console.error('Error setting app state:', error); + throw error; + } + } +); + /** * Delete all playlists and related data from SQLite */ diff --git a/apps/web/src/typings.d.ts b/apps/web/src/typings.d.ts index 7c950ddfb..852aded9e 100644 --- a/apps/web/src/typings.d.ts +++ b/apps/web/src/typings.d.ts @@ -88,6 +88,14 @@ declare global { // Database operations dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>; dbGetPlaylist: (playlistId: string) => Promise; + dbUpsertAppPlaylist: ( + playlist: any + ) => Promise<{ success: boolean }>; + dbUpsertAppPlaylists: ( + playlists: any[] + ) => Promise<{ success: boolean; count: number }>; + dbGetAppPlaylists: () => Promise; + dbGetAppPlaylist: (playlistId: string) => Promise; dbUpdatePlaylist: ( playlistId: string, updates: any @@ -192,6 +200,11 @@ declare global { xtreamId: number, playlistId: string ) => Promise; + dbGetAppState: (key: string) => Promise; + dbSetAppState: ( + key: string, + value: string + ) => Promise<{ success: boolean }>; // Remote control onChannelChange?: ( callback: (data: { direction: 'up' | 'down' }) => void diff --git a/global.d.ts b/global.d.ts index 3f3972c0b..525f31622 100644 --- a/global.d.ts +++ b/global.d.ts @@ -91,6 +91,14 @@ declare global { // Database operations dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>; dbGetPlaylist: (playlistId: string) => Promise; + dbUpsertAppPlaylist: ( + playlist: any + ) => Promise<{ success: boolean }>; + dbUpsertAppPlaylists: ( + playlists: any[] + ) => Promise<{ success: boolean; count: number }>; + dbGetAppPlaylists: () => Promise; + dbGetAppPlaylist: (playlistId: string) => Promise; dbUpdatePlaylist: ( playlistId: string, updates: any @@ -195,6 +203,11 @@ declare global { xtreamId: number, playlistId: string ) => Promise; + dbGetAppState: (key: string) => Promise; + dbSetAppState: ( + key: string, + value: string + ) => Promise<{ success: boolean }>; // Remote control onChannelChange?: ( callback: (data: { direction: 'up' | 'down' }) => void diff --git a/libs/m3u-state/src/lib/effects.ts b/libs/m3u-state/src/lib/effects.ts index 8c7a06993..53af20f3e 100644 --- a/libs/m3u-state/src/lib/effects.ts +++ b/libs/m3u-state/src/lib/effects.ts @@ -217,23 +217,9 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(PlaylistActions.removePlaylist), switchMap(async (action) => { - // Delete from IndexedDB await firstValueFrom( this.playlistsService.deletePlaylist(action.playlistId) ); - // Also delete from SQLite if running in Electron - if ((window as any).electron?.dbDeletePlaylist) { - try { - await (window as any).electron.dbDeletePlaylist( - action.playlistId - ); - } catch (error) { - console.error( - 'Error deleting playlist from SQLite:', - error - ); - } - } }) ); }, @@ -288,7 +274,7 @@ export class PlaylistEffects { return this.playlistsService.addPlaylist(action.playlist); }), map((playlist: Playlist) => { - if (playlist.serverUrl && !this.dataService.isElectron) { + if (playlist.serverUrl) { this.router.navigate([ '/workspace/xtreams/', playlist._id, @@ -305,35 +291,6 @@ export class PlaylistEffects { ]); } return playlist; - }), - map(async (playlist) => { - if (playlist.serverUrl && this.dataService.isElectron) { - // Create Xtream playlist in SQLite - await window.electron.dbCreatePlaylist({ - id: playlist._id.toString(), - name: playlist.title || '', - serverUrl: playlist.serverUrl || '', - username: playlist.username || '', - password: playlist.password || '', - type: 'xtream', - }); - this.router.navigate([ - '/workspace/xtreams/', - playlist._id, - ]); - } else if ( - playlist.macAddress && - this.dataService.isElectron - ) { - // Create Stalker playlist in SQLite - await window.electron.dbCreatePlaylist({ - id: playlist._id.toString(), - name: playlist.title || '', - macAddress: playlist.macAddress || '', - url: playlist.portalUrl || '', - type: 'stalker', - }); - } }) ); }, @@ -344,13 +301,9 @@ export class PlaylistEffects { () => { return this.actions$.pipe( ofType(PlaylistActions.updatePlaylistMeta), - switchMap((action) => { - // TODO update playlist in sqlite db - - return this.playlistsService.updatePlaylistMeta( - action.playlist - ); - }) + switchMap((action) => + this.playlistsService.updatePlaylistMeta(action.playlist) + ) ); }, { dispatch: false } @@ -399,21 +352,7 @@ export class PlaylistEffects { return this.actions$.pipe( ofType(PlaylistActions.removeAllPlaylists), switchMap(async () => { - // Delete from IndexedDB await firstValueFrom(this.playlistsService.removeAll()); - // Also delete from SQLite if running in Electron - if ((window as any).electron?.dbDeleteAllPlaylists) { - try { - await ( - window as any - ).electron.dbDeleteAllPlaylists(); - } catch (error) { - console.error( - 'Error deleting playlists from SQLite:', - error - ); - } - } this.snackBar.open( this.translate.instant('SETTINGS.PLAYLISTS_REMOVED'), undefined, diff --git a/libs/services/src/lib/playlists.service.ts b/libs/services/src/lib/playlists.service.ts index 41a9db55d..43e66e436 100644 --- a/libs/services/src/lib/playlists.service.ts +++ b/libs/services/src/lib/playlists.service.ts @@ -9,7 +9,14 @@ import { createPlaylistObject, } from 'm3u-utils'; import { NgxIndexedDBService } from 'ngx-indexed-db'; -import { combineLatest, map, switchMap } from 'rxjs'; +import { + combineLatest, + firstValueFrom, + from, + map, + of, + switchMap, +} from 'rxjs'; import { Channel, DbStores, @@ -20,15 +27,138 @@ import { XtreamSerieItem, } from 'shared-interfaces'; +const SQLITE_PLAYLIST_MIGRATION_FLAG = + 'm3u-playlists-indexeddb-to-sqlite-v1'; + @Injectable({ providedIn: 'root', }) export class PlaylistsService { - private dbService = inject(NgxIndexedDBService); - private snackBar = inject(MatSnackBar); - private translateService = inject(TranslateService); + private readonly dbService = inject(NgxIndexedDBService); + private readonly snackBar = inject(MatSnackBar); + private readonly translateService = inject(TranslateService); + private migrationPromise: Promise | null = null; + + private get isElectronStorageAvailable(): boolean { + return ( + typeof window !== 'undefined' && + !!window.electron && + typeof window.electron.dbGetAppPlaylists === 'function' && + typeof window.electron.dbUpsertAppPlaylist === 'function' && + typeof window.electron.dbGetAppState === 'function' && + typeof window.electron.dbSetAppState === 'function' + ); + } + + private runOnSqlite(operation: () => Promise) { + return from( + this.ensureSqlitePlaylistMigration().then(() => operation()) + ); + } + + private async ensureSqlitePlaylistMigration(): Promise { + if (!this.isElectronStorageAvailable) { + return; + } + + if (!this.migrationPromise) { + this.migrationPromise = this.migrateIndexedDbPlaylistsToSqlite(); + } + + return this.migrationPromise; + } + + private async migrateIndexedDbPlaylistsToSqlite(): Promise { + try { + const alreadyMigrated = await window.electron.dbGetAppState( + SQLITE_PLAYLIST_MIGRATION_FLAG + ); + if (alreadyMigrated === '1') { + return; + } + + const indexedDbPlaylists = await firstValueFrom( + this.dbService.getAll(DbStores.Playlists) + ); + + if (indexedDbPlaylists.length > 0) { + await window.electron.dbUpsertAppPlaylists(indexedDbPlaylists); + await firstValueFrom(this.dbService.clear(DbStores.Playlists)); + } + + await window.electron.dbSetAppState( + SQLITE_PLAYLIST_MIGRATION_FLAG, + '1' + ); + } catch (error) { + console.error( + 'Failed to migrate IndexedDB playlists to SQLite:', + error + ); + } + } + + private createSqliteFallbackPlaylist( + playlist: Partial & { _id?: string; id?: string } + ): Playlist { + const id = String(playlist._id ?? playlist.id ?? ''); + return { + _id: id, + title: playlist.title ?? '', + count: Number(playlist.count ?? 0), + importDate: playlist.importDate ?? new Date().toISOString(), + lastUsage: playlist.lastUsage ?? new Date().toISOString(), + favorites: playlist.favorites ?? [], + recentlyViewed: playlist.recentlyViewed ?? [], + autoRefresh: Boolean(playlist.autoRefresh), + playlist: playlist.playlist, + url: playlist.url, + filePath: playlist.filePath, + userAgent: playlist.userAgent, + referrer: playlist.referrer, + origin: playlist.origin, + updateDate: playlist.updateDate, + updateState: playlist.updateState, + position: playlist.position, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + macAddress: playlist.macAddress, + portalUrl: playlist.portalUrl, + stalkerSerialNumber: playlist.stalkerSerialNumber, + stalkerDeviceId1: playlist.stalkerDeviceId1, + stalkerDeviceId2: playlist.stalkerDeviceId2, + stalkerSignature1: playlist.stalkerSignature1, + stalkerSignature2: playlist.stalkerSignature2, + }; + } + + private upsertSqlitePlaylist(playlist: Playlist) { + return this.runOnSqlite(async () => { + await window.electron.dbUpsertAppPlaylist(playlist); + return playlist; + }); + } + + private upsertManySqlitePlaylists(playlists: Playlist[]) { + return this.runOnSqlite(async () => { + await window.electron.dbUpsertAppPlaylists(playlists); + return playlists; + }); + } getAllPlaylists() { + if (this.isElectronStorageAvailable) { + return this.runOnSqlite(async () => { + const playlists = await window.electron.dbGetAppPlaylists(); + return (playlists as Playlist[]).map( + ({ playlist, items, header, ...rest }) => ({ + ...rest, + }) + ); + }); + } + return this.dbService.getAll(DbStores.Playlists).pipe( map((data) => data.map(({ playlist, items, header, ...rest }) => ({ @@ -39,44 +169,74 @@ export class PlaylistsService { } addPlaylist(playlist: Playlist) { + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(playlist); + } + return this.dbService.add(DbStores.Playlists, playlist); } getPlaylist(id: string) { if (id === 'global-favorites') { return this.getPlaylistWithGlobalFavorites(); - } else { - return this.dbService.getByID(DbStores.Playlists, id); } + return this.getPlaylistById(id); } deletePlaylist(playlistId: string) { + if (this.isElectronStorageAvailable) { + return this.runOnSqlite(() => + window.electron.dbDeletePlaylist(playlistId) + ); + } + return this.dbService.delete(DbStores.Playlists, playlistId); } updatePlaylist(playlistId: string, updatedPlaylist: Playlist) { return this.getPlaylistById(playlistId).pipe( - switchMap((currentPlaylist: Playlist) => - this.dbService.update(DbStores.Playlists, { + switchMap((currentPlaylist: Playlist) => { + const mergedPlaylist: Playlist = { ...currentPlaylist, ...updatedPlaylist, - count: updatedPlaylist.playlist.items.length, + _id: playlistId, + count: + updatedPlaylist.playlist?.items?.length ?? + currentPlaylist.count, updateDate: Date.now(), updateState: PlaylistUpdateState.UPDATED, favorites: currentPlaylist.favorites, - }) - ) + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(mergedPlaylist); + } + + return this.dbService.update( + DbStores.Playlists, + mergedPlaylist + ); + }) ); } getPlaylistById(id: string) { + if (this.isElectronStorageAvailable) { + return this.runOnSqlite(async () => { + const playlist = await window.electron.dbGetAppPlaylist(id); + return playlist + ? this.createSqliteFallbackPlaylist(playlist as Playlist) + : (undefined as any); + }); + } + return this.dbService.getByID(DbStores.Playlists, id); } updatePlaylistMeta(updatedPlaylist: PlaylistMeta) { return this.getPlaylistById(updatedPlaylist._id).pipe( - switchMap((playlist) => - this.dbService.update(DbStores.Playlists, { + switchMap((playlist) => { + const nextPlaylist: Playlist = { ...playlist, title: updatedPlaylist.title, autoRefresh: updatedPlaylist.autoRefresh, @@ -99,7 +259,6 @@ export class PlaylistsService { ...(updatedPlaylist.updateDate !== undefined ? { updateDate: updatedPlaylist.updateDate } : {}), - // Stalker portal optional fields ...(updatedPlaylist.stalkerSerialNumber !== undefined ? { stalkerSerialNumber: @@ -124,23 +283,48 @@ export class PlaylistsService { updatedPlaylist.stalkerSignature2, } : {}), - }) - ) + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); + }) ); } updateFavorites(id: string, favorites: string[]) { return this.getPlaylistById(id).pipe( - switchMap((playlist) => - this.dbService.update(DbStores.Playlists, { + switchMap((playlist) => { + const nextPlaylist: Playlist = { ...playlist, favorites, - }) - ) + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); + }) ); } updateManyPlaylists(playlists: Playlist[]) { + if (playlists.length === 0) { + return of([]); + } + + if (this.isElectronStorageAvailable) { + const updatedPlaylists = playlists.map((playlist) => ({ + ...playlist, + updateDate: Date.now(), + autoRefresh: true, + })); + return this.upsertManySqlitePlaylists(updatedPlaylists); + } + return combineLatest( playlists.map((playlist) => { return this.dbService.update(DbStores.Playlists, { @@ -153,57 +337,45 @@ export class PlaylistsService { } getFavoriteChannels(playlistId: string) { - return this.dbService - .getByID(DbStores.Playlists, playlistId) - .pipe( - map((data) => - data.playlist.items.filter((channel: Channel) => - data.favorites?.includes((channel as Channel).id) - ) + return this.getPlaylistById(playlistId).pipe( + map((data) => + (data.playlist?.items ?? []).filter((channel: Channel) => + data.favorites?.includes(channel.id) ) - ); + ) + ); } getPortalFavorites(portalId: string) { if (!portalId) { throw new Error('Portal ID is required'); } - return this.dbService - .getByID<{ - favorites: Partial[]; - }>(DbStores.Playlists, portalId) - .pipe( - map((item) => { - if (!item || !item.favorites) return []; - return item.favorites; /* .filter( - (itm) => - itm && itm.stream_type && itm.stream_type !== 'live' - ); */ - }), - map((favorites) => - favorites.sort( - (a, b) => - new Date(b.added_at ?? '').getTime() - - new Date(a.added_at ?? '').getTime() - ) + + return this.getPlaylistById(portalId).pipe( + map((item) => { + if (!item || !item.favorites) return []; + return item.favorites as Partial[]; + }), + map((favorites) => + favorites.sort( + (a, b) => + new Date(b.added_at ?? '').getTime() - + new Date(a.added_at ?? '').getTime() ) - ); + ) + ); } getPortalLiveStreamFavorites(portalId: string) { - return this.dbService - .getByID<{ - favorites: Partial[]; - }>(DbStores.Playlists, portalId) - .pipe( - map((item) => { - if (!item || !item.favorites) return []; - return item.favorites.filter( - (itm) => - itm && itm.stream_type && itm.stream_type === 'live' - ); - }) - ); + return this.getPlaylistById(portalId).pipe( + map((item) => { + if (!item || !item.favorites) return []; + return (item.favorites as Partial[]).filter( + (itm) => + itm && itm.stream_type && itm.stream_type === 'live' + ); + }) + ); } addPortalFavorite(portalId: string, item: any) { @@ -211,12 +383,18 @@ export class PlaylistsService { throw new Error('Portal ID is required'); } return this.getPlaylistById(portalId).pipe( - switchMap((portal) => - this.dbService.update(DbStores.Playlists, { + switchMap((portal) => { + const nextPlaylist: Playlist = { ...portal, favorites: [...(portal.favorites ?? []), item], - }) - ) + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); + }) ); } @@ -225,8 +403,8 @@ export class PlaylistsService { throw new Error('Portal ID is required'); } return this.getPlaylistById(portalId).pipe( - switchMap((portal) => - this.dbService.update(DbStores.Playlists, { + switchMap((portal) => { + const nextPlaylist: Playlist = { ...portal, favorites: portal.favorites?.filter((i) => { const expectedId = String(favoriteId); @@ -248,8 +426,14 @@ export class PlaylistsService { itemId !== expectedId ); }), - }) - ) + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); + }) ); } @@ -259,8 +443,32 @@ export class PlaylistsService { changes: { position: number }; }[] ) { + if (positionUpdates.length === 0) { + return of([]); + } + + if (this.isElectronStorageAvailable) { + return this.runOnSqlite(async () => { + const playlists = + (await window.electron.dbGetAppPlaylists()) as Playlist[]; + const positionsById = new Map( + positionUpdates.map((item) => [item.id, item.changes.position]) + ); + + const updatedPlaylists = playlists + .filter((playlist) => positionsById.has(playlist._id)) + .map((playlist) => ({ + ...playlist, + position: positionsById.get(playlist._id), + })); + + await window.electron.dbUpsertAppPlaylists(updatedPlaylists); + return updatedPlaylists; + }); + } + return combineLatest( - positionUpdates.map((item, index) => { + positionUpdates.map((item) => { return this.dbService .getByID(DbStores.Playlists, item.id) .pipe( @@ -294,17 +502,15 @@ export class PlaylistsService { this.translateService.instant('HOME.PARSING_ERROR'), undefined, { duration: 2000 } - ); // TODO: translate + ); throw new Error(`Parsing failed, not a valid playlist: ${error}`); } } getPlaylistWithGlobalFavorites() { - return this.dbService.getAll(DbStores.Playlists).pipe( - map((playlists: unknown[]) => { - const favoriteChannels = aggregateFavoriteChannels( - playlists as Playlist[] - ); + return this.getAllData().pipe( + map((playlists: Playlist[]) => { + const favoriteChannels = aggregateFavoriteChannels(playlists); const favPlaylist = createFavoritesPlaylist(favoriteChannels); return favPlaylist; }) @@ -312,6 +518,10 @@ export class PlaylistsService { } addManyPlaylists(playlists: Playlist[]) { + if (this.isElectronStorageAvailable) { + return this.upsertManySqlitePlaylists(playlists); + } + return this.dbService.bulkAdd( DbStores.Playlists, playlists as unknown as Playlist[] @@ -319,10 +529,10 @@ export class PlaylistsService { } getPlaylistsForAutoUpdate() { - return this.dbService.getAll(DbStores.Playlists).pipe( - map((playlists: unknown[]) => { + return this.getAllData().pipe( + map((playlists: Playlist[]) => { return playlists - .filter((item: any) => item.autoRefresh) + .filter((item) => item.autoRefresh) .map( ({ playlist, @@ -330,7 +540,7 @@ export class PlaylistsService { items, favorites, ...rest - }: any) => rest + }: Playlist) => rest ); }) ); @@ -338,23 +548,28 @@ export class PlaylistsService { setFavorites(playlistId: string, favorites: string[]) { return this.getPlaylistById(playlistId).pipe( - switchMap((playlist) => - this.dbService.update(DbStores.Playlists, { + switchMap((playlist) => { + const nextPlaylist: Playlist = { ...playlist, favorites, - }) - ) + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); + }) ); } getRawPlaylistById(id: string) { - return this.dbService.getByID(DbStores.Playlists, id).pipe( + return this.getPlaylistById(id).pipe( map((playlist) => { return ( - // eslint-disable-next-line @typescript-eslint/restrict-plus-operands - playlist.playlist.header.raw + + `${playlist.playlist?.header?.raw ?? ''}` + '\n' + - playlist.playlist.items + (playlist.playlist?.items ?? []) .map((item: any) => item.raw) .join('\n') ); @@ -363,10 +578,20 @@ export class PlaylistsService { } getAllData() { + if (this.isElectronStorageAvailable) { + return this.runOnSqlite( + async () => (await window.electron.dbGetAppPlaylists()) as Playlist[] + ); + } + return this.dbService.getAll(DbStores.Playlists); } removeAll() { + if (this.isElectronStorageAvailable) { + return this.runOnSqlite(() => window.electron.dbDeleteAllPlaylists()); + } + return this.dbService.clear(DbStores.Playlists); } @@ -374,23 +599,19 @@ export class PlaylistsService { if (!portalId) { throw new Error('Portal ID is required'); } - return this.dbService - .getByID<{ - recentlyViewed: Partial[]; - }>(DbStores.Playlists, portalId) - .pipe( - map((item) => { - if (!item || !item.recentlyViewed) return []; - return item.recentlyViewed; - }), - map((items) => - items.sort( - (a, b) => - new Date(b.added_at ?? '').getTime() - - new Date(a.added_at ?? '').getTime() - ) + return this.getPlaylistById(portalId).pipe( + map((item) => { + if (!item || !item.recentlyViewed) return []; + return item.recentlyViewed as Partial[]; + }), + map((items) => + items.sort( + (a, b) => + new Date(b.added_at ?? '').getTime() - + new Date(a.added_at ?? '').getTime() ) - ); + ) + ); } addPortalRecentlyViewed( @@ -415,13 +636,23 @@ export class PlaylistsService { ...item, added_at: nowIso, }; - return this.dbService.update(DbStores.Playlists, { + + const nextPlaylist: Playlist = { ...portal, recentlyViewed: updatedRecentItems, - }); + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update( + DbStores.Playlists, + nextPlaylist + ); } - return this.dbService.update(DbStores.Playlists, { + const nextPlaylist: Playlist = { ...portal, recentlyViewed: [ ...recentItems, @@ -430,7 +661,13 @@ export class PlaylistsService { added_at: (item as any).added_at ?? nowIso, }, ], - }); + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); }) ); } @@ -447,7 +684,7 @@ export class PlaylistsService { const expectedId = String(id); const expectedNormalized = normalizePortalItemId(id); - return this.dbService.update(DbStores.Playlists, { + const nextPlaylist: Playlist = { ...portal, recentlyViewed: portal.recentlyViewed?.filter((i: any) => { const itemId = String(i?.id ?? ''); @@ -457,7 +694,13 @@ export class PlaylistsService { itemNormalized !== expectedNormalized ); }), - }); + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); }) ); } @@ -467,12 +710,18 @@ export class PlaylistsService { throw new Error('Portal ID is required'); } return this.getPlaylistById(portalId).pipe( - switchMap((portal) => - this.dbService.update(DbStores.Playlists, { + switchMap((portal) => { + const nextPlaylist: Playlist = { ...portal, recentlyViewed: [], - }) - ) + }; + + if (this.isElectronStorageAvailable) { + return this.upsertSqlitePlaylist(nextPlaylist); + } + + return this.dbService.update(DbStores.Playlists, nextPlaylist); + }) ); } } diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 86c1f16e0..09e6906a3 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -56,7 +56,20 @@ const CREATE_TABLE_STATEMENTS = [ autoRefresh INTEGER DEFAULT 0, macAddress TEXT, url TEXT, + portal_url TEXT, + count INTEGER, + import_date TEXT, + update_date INTEGER, + position INTEGER, + favorites TEXT, + recently_viewed TEXT, + payload TEXT, last_usage TEXT + )`, + `CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) )`, `CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -209,6 +222,15 @@ const CREATE_TABLE_STATEMENTS = [ const MIGRATION_STATEMENTS = [ // v1.0.0 -> v1.1.0: Add hidden column to categories for category management `ALTER TABLE categories ADD COLUMN hidden INTEGER DEFAULT 0`, + // v1.1.0 -> v1.2.0: Add playlist metadata/payload columns for M3U + unified playlist persistence + `ALTER TABLE playlists ADD COLUMN portal_url TEXT`, + `ALTER TABLE playlists ADD COLUMN count INTEGER`, + `ALTER TABLE playlists ADD COLUMN import_date TEXT`, + `ALTER TABLE playlists ADD COLUMN update_date INTEGER`, + `ALTER TABLE playlists ADD COLUMN position INTEGER`, + `ALTER TABLE playlists ADD COLUMN favorites TEXT`, + `ALTER TABLE playlists ADD COLUMN recently_viewed TEXT`, + `ALTER TABLE playlists ADD COLUMN payload TEXT`, ]; /** diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 3581226f4..f775038ad 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -35,9 +35,24 @@ export const playlists = sqliteTable('playlists', { autoRefresh: integer('autoRefresh', { mode: 'boolean' }).default(false), macAddress: text('macAddress'), url: text('url'), + portalUrl: text('portal_url'), + count: integer('count'), + importDate: text('import_date'), + updateDate: integer('update_date'), + position: integer('position'), + favorites: text('favorites'), + recentlyViewed: text('recently_viewed'), + payload: text('payload'), lastUsage: text('last_usage'), }); +// App key-value state table (e.g. one-time migration flags) +export const appState = sqliteTable('app_state', { + key: text('key').primaryKey(), + value: text('value').notNull(), + updatedAt: text('updated_at').default(sql`CURRENT_TIMESTAMP`), +}); + // Categories table export const categories = sqliteTable( 'categories', @@ -214,6 +229,8 @@ export const playbackPositions = sqliteTable( // Type exports for TypeScript export type Playlist = typeof playlists.$inferSelect; export type NewPlaylist = typeof playlists.$inferInsert; +export type AppState = typeof appState.$inferSelect; +export type NewAppState = typeof appState.$inferInsert; export type Category = typeof categories.$inferSelect; export type NewCategory = typeof categories.$inferInsert;