mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(storage): add SQLite-backed m3u playlists for Electron with IndexedDB migration
This commit is contained in:
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
|
||||
*/
|
||||
|
||||
Vendored
+13
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user