feat(storage): add SQLite-backed m3u playlists for Electron with IndexedDB migration

This commit is contained in:
4gray committed 2026-03-02 23:41:02 +01:00
1 parent 129848c4d4
commit 086eab9306
9 files changed
+780 -185

No files matched your search

@@ -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),
@@ -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';
@@ -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<PlaylistType>(
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<T>(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<string, unknown>): 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<string, unknown>
): 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<string, unknown> {
const payload = parseJsonValue<Record<string, unknown> | 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<unknown[]>(row.favorites, []);
const recentlyViewed = parseJsonValue<unknown[]>(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<string, unknown>) => {
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<string, unknown>[]) => {
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
*/
+13
View File
@@ -88,6 +88,14 @@ declare global {
// Database operations
dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
dbUpsertAppPlaylist: (
playlist: any
) => Promise<{ success: boolean }>;
dbUpsertAppPlaylists: (
playlists: any[]
) => Promise<{ success: boolean; count: number }>;
dbGetAppPlaylists: () => Promise<any[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<any | null>;
dbUpdatePlaylist: (
playlistId: string,
updates: any
@@ -192,6 +200,11 @@ declare global {
xtreamId: number,
playlistId: string
) => Promise<any | null>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (
key: string,
value: string
) => Promise<{ success: boolean }>;
// Remote control
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
Vendored
+13
View File
@@ -91,6 +91,14 @@ declare global {
// Database operations
dbCreatePlaylist: (playlist: any) => Promise<{ success: boolean }>;
dbGetPlaylist: (playlistId: string) => Promise<any>;
dbUpsertAppPlaylist: (
playlist: any
) => Promise<{ success: boolean }>;
dbUpsertAppPlaylists: (
playlists: any[]
) => Promise<{ success: boolean; count: number }>;
dbGetAppPlaylists: () => Promise<any[]>;
dbGetAppPlaylist: (playlistId: string) => Promise<any | null>;
dbUpdatePlaylist: (
playlistId: string,
updates: any
@@ -195,6 +203,11 @@ declare global {
xtreamId: number,
playlistId: string
) => Promise<any | null>;
dbGetAppState: (key: string) => Promise<string | null>;
dbSetAppState: (
key: string,
value: string
) => Promise<{ success: boolean }>;
// Remote control
onChannelChange?: (
callback: (data: { direction: 'up' | 'down' }) => void
+4 -65
View File
@@ -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,
+362 -113
View File
@@ -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<void> | 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<T>(operation: () => Promise<T>) {
return from(
this.ensureSqlitePlaylistMigration().then(() => operation())
);
}
private async ensureSqlitePlaylistMigration(): Promise<void> {
if (!this.isElectronStorageAvailable) {
return;
}
if (!this.migrationPromise) {
this.migrationPromise = this.migrateIndexedDbPlaylistsToSqlite();
}
return this.migrationPromise;
}
private async migrateIndexedDbPlaylistsToSqlite(): Promise<void> {
try {
const alreadyMigrated = await window.electron.dbGetAppState(
SQLITE_PLAYLIST_MIGRATION_FLAG
);
if (alreadyMigrated === '1') {
return;
}
const indexedDbPlaylists = await firstValueFrom(
this.dbService.getAll<Playlist>(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<Playlist> & { _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<Playlist>(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<Playlist>(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<Playlist>(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<Playlist>(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<XtreamItem>[];
}>(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<XtreamItem>[];
}),
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<XtreamItem>[];
}>(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<XtreamItem>[]).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<Playlist>(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<Playlist>(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<Playlist>(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<XtreamItem>[];
}>(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<XtreamItem>[];
}),
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);
})
);
}
}
@@ -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`,
];
/**
+17
View File
@@ -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;