mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
refactor(storage): seams for server-backed playlist storage (#1896)
* docs(storage): server storage design and implementation plans Design for keeping playlists and user data on the self-hosted server (#305), revised against master, plus three implementation plans: storage seams, the web-backend storage API, and the PWA server mode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(database): share the playlist row codec Move buildPlaylistRow, parseAppPlaylist and the upsert conflict helpers from the Electron database operations into libs/shared/database (@iptvnator/shared/database/app-playlist) so the self-hosted web backend can write the same rows. The Electron modules re-export them; behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(database): read the data root from IPTVNATOR_DATA_DIR Deployments that keep the database on a mounted volume, such as the self-hosted web backend in Docker, can now set the data root. The E2E override still wins; without either variable the root stays ~/.iptvnator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(services): add the playlist storage backend resolver PlaylistStorageBackend becomes the single place that decides whether playlists persist through the Electron bridge or IndexedDB, and defines the PlaylistDocumentStore contract a server store will implement. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(services): resolve playlist storage through one backend PlaylistsService asks PlaylistStorageBackend for its document store instead of reading window.electron and supportsSqlite directly. Paths that only make sense on the desktop (legacy IndexedDB import, worker deletion with progress, the favorites-channel query) check for the Electron kind explicitly. Behavior is unchanged: the backend still resolves to the Electron bridge or IndexedDB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(xtream): store PWA user data through USER_DATA_STORAGE PwaXtreamDataSource and PwaCollectionStorage read and write favorites, recently viewed items, collection snapshots and playback positions through the new USER_DATA_STORAGE token (default: localStorage) instead of calling localStorage directly, so a deployment can substitute a server-synced storage. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(pwa): document the storage seams Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(database): bracket access in the shared playlist codec libs/shared/database compiles with noPropertyAccessFromIndexSignature, which the Electron backend did not enforce on the moved code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * build(electron): alias the shared playlist codec in the worker bundle The database worker is bundled by esbuild with an explicit alias table; without the new entry, @iptvnator/shared/database/app-playlist resolved under the library's index file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(services): read localStorage lazily in USER_DATA_STORAGE When the browser denies storage access, reading localStorage itself throws. The default factory returned the global, so injecting the token failed and PwaXtreamDataSource could not be constructed. The adapter now reads the global inside each call, where the data source already guards its reads. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(database): describe /data as an example data root The Docker image does not set IPTVNATOR_DATA_DIR yet; that arrives with the server storage backend. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
633fde3e0e
commit
94570bc387
30 files changed
+8271
-450
No files matched your search
@@ -13,6 +13,7 @@ export * from './lib/parental-lock';
|
||||
export * from './lib/playlist-cross-context-lock';
|
||||
export * from './lib/playlist-refresh.service';
|
||||
export * from './lib/playlists.service';
|
||||
export * from './lib/playlist-storage-backend';
|
||||
export * from './lib/portal-status.service';
|
||||
export * from './lib/runtime-capabilities.service';
|
||||
export * from './lib/settings-store.service';
|
||||
@@ -31,3 +32,4 @@ export * from './lib/source-health-evidence.service';
|
||||
|
||||
export * from './lib/source-activity.service';
|
||||
export * from './lib/translation-tick';
|
||||
export * from './lib/user-data-storage';
|
||||
@@ -36,7 +36,10 @@ describe('Electron legacy playlist migration', () => {
|
||||
) as PlaylistsService;
|
||||
Object.assign(service, {
|
||||
dbService,
|
||||
runtime: { supportsSqlite: true },
|
||||
storageBackend: {
|
||||
kind: () => 'electron',
|
||||
documentStore: () => window.electron,
|
||||
},
|
||||
electronMigrationPromise: null,
|
||||
});
|
||||
return { playlists, dbService, electron, service };
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlaylistStorageBackend } from './playlist-storage-backend';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
describe('PlaylistStorageBackend', () => {
|
||||
const testWindow = window as unknown as { electron?: unknown };
|
||||
const originalElectron = testWindow.electron;
|
||||
|
||||
afterEach(() => {
|
||||
testWindow.electron = originalElectron;
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
function create(supportsSqlite: boolean) {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: { supportsSqlite },
|
||||
},
|
||||
],
|
||||
});
|
||||
return TestBed.inject(PlaylistStorageBackend);
|
||||
}
|
||||
|
||||
it('uses the Electron bridge when the SQLite bridge is complete', () => {
|
||||
const bridge = { dbGetAppPlaylists: jest.fn() };
|
||||
testWindow.electron = bridge;
|
||||
|
||||
const backend = create(true);
|
||||
|
||||
expect(backend.kind()).toBe('electron');
|
||||
expect(backend.documentStore()).toBe(bridge);
|
||||
});
|
||||
|
||||
it('uses IndexedDB without the SQLite bridge', () => {
|
||||
testWindow.electron = { dbGetAppPlaylists: jest.fn() };
|
||||
|
||||
const backend = create(false);
|
||||
|
||||
expect(backend.kind()).toBe('indexeddb');
|
||||
expect(backend.documentStore()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import type { ElectronBridgeApi } from '@iptvnator/shared/interfaces';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
|
||||
/**
|
||||
* Whole-playlist document persistence. The Electron preload implements it
|
||||
* over the database worker; the self-hosted server storage implements the
|
||||
* same contract over HTTP.
|
||||
*/
|
||||
export type PlaylistDocumentStore = Pick<
|
||||
ElectronBridgeApi,
|
||||
| 'dbDeleteAllPlaylists'
|
||||
| 'dbDeletePlaylist'
|
||||
| 'dbGetAppPlaylist'
|
||||
| 'dbGetAppPlaylists'
|
||||
| 'dbGetAppState'
|
||||
| 'dbSetAppState'
|
||||
| 'dbUpsertAppPlaylist'
|
||||
| 'dbUpsertAppPlaylists'
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
ElectronBridgeApi,
|
||||
| 'dbGetAppPlaylistFavoriteChannels'
|
||||
| 'dbGetAppPlaylistMetas'
|
||||
| 'dbMigrateAppPlaylists'
|
||||
| 'dbRecoverLegacyPlaylists'
|
||||
>
|
||||
>;
|
||||
|
||||
export type PlaylistStorageKind = 'electron' | 'indexeddb';
|
||||
|
||||
/** Single place that decides where `PlaylistsService` persists playlists. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PlaylistStorageBackend {
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
|
||||
kind(): PlaylistStorageKind {
|
||||
return this.runtime.supportsSqlite ? 'electron' : 'indexeddb';
|
||||
}
|
||||
|
||||
/** The document store for `kind()`, or `null` for IndexedDB. */
|
||||
documentStore(): PlaylistDocumentStore | null {
|
||||
if (this.kind() !== 'electron' || typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return (window.electron as PlaylistDocumentStore | undefined) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,10 @@ describe('PlaylistsService inventory reads', () => {
|
||||
) as PlaylistsService;
|
||||
Object.assign(service, {
|
||||
dbService: { getAll: jest.fn(() => of([])) },
|
||||
runtime: { supportsSqlite: true },
|
||||
storageBackend: {
|
||||
kind: () => 'electron',
|
||||
documentStore: () => window.electron,
|
||||
},
|
||||
electronMigrationPromise: null,
|
||||
playlistWriteQueues: new Map(),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,23 @@ describe('PlaylistsService', () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function hasSqliteBridge(): boolean {
|
||||
const electron = testWindow.electron as
|
||||
Record<string, unknown> | undefined;
|
||||
return (
|
||||
!!electron &&
|
||||
typeof electron['dbGetAppPlaylists'] === 'function' &&
|
||||
typeof electron['dbUpsertAppPlaylist'] === 'function' &&
|
||||
typeof electron['dbGetAppState'] === 'function' &&
|
||||
typeof electron['dbSetAppState'] === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
const testStorageBackend = {
|
||||
kind: () => (hasSqliteBridge() ? 'electron' : 'indexeddb'),
|
||||
documentStore: () => (hasSqliteBridge() ? testWindow.electron : null),
|
||||
};
|
||||
|
||||
function createService(overrides: Record<string, unknown> = {}) {
|
||||
const service = Object.create(
|
||||
PlaylistsService.prototype
|
||||
@@ -45,19 +62,7 @@ describe('PlaylistsService', () => {
|
||||
translateService: {
|
||||
instant: jest.fn((key: string) => key),
|
||||
},
|
||||
runtime: {
|
||||
get supportsSqlite() {
|
||||
const electron = testWindow.electron as
|
||||
Record<string, unknown> | undefined;
|
||||
return (
|
||||
!!electron &&
|
||||
typeof electron['dbGetAppPlaylists'] === 'function' &&
|
||||
typeof electron['dbUpsertAppPlaylist'] === 'function' &&
|
||||
typeof electron['dbGetAppState'] === 'function' &&
|
||||
typeof electron['dbSetAppState'] === 'function'
|
||||
);
|
||||
},
|
||||
},
|
||||
storageBackend: testStorageBackend,
|
||||
electronMigrationPromise: null,
|
||||
indexedDbMigrationPromise: null,
|
||||
playlistWriteQueues: new Map(),
|
||||
@@ -135,9 +140,11 @@ describe('PlaylistsService', () => {
|
||||
finishCleanup = resolve;
|
||||
})
|
||||
);
|
||||
Object.defineProperties(service, {
|
||||
isElectronStorageAvailable: { value: true },
|
||||
electronApi: { value: { dbDeletePlaylist: jest.fn() } },
|
||||
Object.assign(service, {
|
||||
storageBackend: {
|
||||
kind: () => 'electron',
|
||||
documentStore: () => ({ dbDeletePlaylist: jest.fn() }),
|
||||
},
|
||||
});
|
||||
Object.assign(service, {
|
||||
ensureElectronPlaylistMigrations: jest
|
||||
|
||||
@@ -44,7 +44,10 @@ import {
|
||||
runWithPlaylistAuthorityMutation,
|
||||
runWithPlaylistAuthorityReset,
|
||||
} from './playlist-cross-context-lock';
|
||||
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
|
||||
import {
|
||||
PlaylistDocumentStore,
|
||||
PlaylistStorageBackend,
|
||||
} from './playlist-storage-backend';
|
||||
|
||||
const SQLITE_PLAYLIST_MIGRATION_FLAG = 'm3u-playlists-indexeddb-to-sqlite-v1';
|
||||
const STALKER_PLAYLIST_METADATA_MIGRATION_FLAG =
|
||||
@@ -60,33 +63,6 @@ type PlaylistRawItem = {
|
||||
raw?: string;
|
||||
};
|
||||
|
||||
type PlaylistStorageElectronApi = {
|
||||
dbDeleteAllPlaylists: () => Promise<unknown>;
|
||||
dbDeletePlaylist: (playlistId: string) => Promise<unknown>;
|
||||
dbGetAppPlaylist: (
|
||||
playlistId: string,
|
||||
operationId?: string
|
||||
) => Promise<Playlist | null>;
|
||||
dbGetAppPlaylistFavoriteChannels?: (
|
||||
playlistId: string
|
||||
) => Promise<M3uFavoriteChannel[]>;
|
||||
dbGetAppPlaylistMetas?: () => Promise<Playlist[]>;
|
||||
dbGetAppPlaylists: () => Promise<Playlist[]>;
|
||||
dbGetAppState: (key: string) => Promise<string | null>;
|
||||
dbSetAppState: (key: string, value: string) => Promise<unknown>;
|
||||
dbUpsertAppPlaylist: (
|
||||
playlist: Playlist,
|
||||
operationId?: string
|
||||
) => Promise<unknown>;
|
||||
dbMigrateAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
|
||||
dbRecoverLegacyPlaylists?: () => Promise<void>;
|
||||
dbUpsertAppPlaylists: (playlists: Playlist[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type PlaylistStorageWindow = Window & {
|
||||
electron?: PlaylistStorageElectronApi;
|
||||
};
|
||||
|
||||
type AddManyPlaylistsResult = Playlist[] | IDBValidKey[];
|
||||
|
||||
type PlaylistParserModule = Partial<typeof import('iptv-playlist-parser')> & {
|
||||
@@ -112,7 +88,7 @@ export class PlaylistsService {
|
||||
private readonly databaseService = inject(DatabaseService);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly translateService = inject(TranslateService);
|
||||
private readonly runtime = inject(RuntimeCapabilitiesService);
|
||||
private readonly storageBackend = inject(PlaylistStorageBackend);
|
||||
private readonly playlistDeleteCleanups =
|
||||
inject(PLAYLIST_DELETE_CLEANUP, { optional: true }) ?? [];
|
||||
private electronMigrationPromise: Promise<void> | null = null;
|
||||
@@ -125,16 +101,18 @@ export class PlaylistsService {
|
||||
private indexedDbMigrationPromise: Promise<void> | null = null;
|
||||
private readonly playlistWriteQueues = new Map<string, Promise<unknown>>();
|
||||
|
||||
private get electronApi(): PlaylistStorageElectronApi | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (window as PlaylistStorageWindow).electron ?? null;
|
||||
/** Electron bridge or server store; `null` means IndexedDB. */
|
||||
private get documentStore(): PlaylistDocumentStore | null {
|
||||
return this.storageBackend.documentStore();
|
||||
}
|
||||
|
||||
private get isElectronStorageAvailable(): boolean {
|
||||
return this.runtime.supportsSqlite;
|
||||
private get usesDocumentStore(): boolean {
|
||||
return this.storageBackend.kind() !== 'indexeddb';
|
||||
}
|
||||
|
||||
/** Desktop-only paths: legacy IndexedDB import, worker deletion. */
|
||||
private get usesElectronStorage(): boolean {
|
||||
return this.storageBackend.kind() === 'electron';
|
||||
}
|
||||
|
||||
private runOnSqlite<T>(operation: () => Promise<T>) {
|
||||
@@ -150,14 +128,14 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
private async ensureElectronPlaylistMigrations(): Promise<void> {
|
||||
if (!this.isElectronStorageAvailable) {
|
||||
if (!this.usesElectronStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.electronMigrationPromise) {
|
||||
this.electronMigrationPromise = (async () => {
|
||||
await this.migrateIndexedDbPlaylistsToSqlite();
|
||||
await this.electronApi?.dbRecoverLegacyPlaylists?.();
|
||||
await this.documentStore?.dbRecoverLegacyPlaylists?.();
|
||||
await this.migrateStalkerPlaylistMetadataInSqlite();
|
||||
})().catch((error) => {
|
||||
this.electronMigrationPromise = null;
|
||||
@@ -169,7 +147,7 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
private async ensureIndexedDbPlaylistMigrations(): Promise<void> {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -196,12 +174,9 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
private async migrateIndexedDbPlaylistsToSqlite(): Promise<void> {
|
||||
const electron = this.electronApi;
|
||||
if (!electron) return;
|
||||
if (
|
||||
(await electron.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) ===
|
||||
'1'
|
||||
)
|
||||
const store = this.documentStore;
|
||||
if (!store?.dbMigrateAppPlaylists) return;
|
||||
if ((await store.dbGetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG)) === '1')
|
||||
return;
|
||||
const playlists = await firstValueFrom(
|
||||
this.dbService.getAll<Playlist>(DbStores.Playlists)
|
||||
@@ -209,9 +184,9 @@ export class PlaylistsService {
|
||||
if (playlists.length) {
|
||||
// The worker commits rows and the receipt atomically. Keep the
|
||||
// original IndexedDB as a recovery source, even after success.
|
||||
await electron.dbMigrateAppPlaylists(playlists);
|
||||
await store.dbMigrateAppPlaylists(playlists);
|
||||
} else {
|
||||
await electron.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
|
||||
await store.dbSetAppState(SQLITE_PLAYLIST_MIGRATION_FLAG, '1');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,12 +290,12 @@ export class PlaylistsService {
|
||||
|
||||
private async migrateStalkerPlaylistMetadataInSqlite(): Promise<void> {
|
||||
try {
|
||||
const electron = this.electronApi;
|
||||
if (!electron) {
|
||||
const store = this.documentStore;
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadyMigrated = await electron.dbGetAppState(
|
||||
const alreadyMigrated = await store.dbGetAppState(
|
||||
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG
|
||||
);
|
||||
if (alreadyMigrated === '1') {
|
||||
@@ -328,15 +303,15 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
const storedPlaylists =
|
||||
(await electron.dbGetAppPlaylists()) as Playlist[];
|
||||
(await store.dbGetAppPlaylists()) as Playlist[];
|
||||
const updates =
|
||||
this.collectStalkerMetadataMigrationUpdates(storedPlaylists);
|
||||
|
||||
if (updates.length > 0) {
|
||||
await electron.dbUpsertAppPlaylists(updates);
|
||||
await store.dbUpsertAppPlaylists(updates);
|
||||
}
|
||||
|
||||
await electron.dbSetAppState(
|
||||
await store.dbSetAppState(
|
||||
STALKER_PLAYLIST_METADATA_MIGRATION_FLAG,
|
||||
'1'
|
||||
);
|
||||
@@ -398,16 +373,16 @@ export class PlaylistsService {
|
||||
|
||||
private upsertSqlitePlaylist(playlist: Playlist, operationId?: string) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
if (!electron) {
|
||||
const store = this.documentStore;
|
||||
if (!store) {
|
||||
return playlist;
|
||||
}
|
||||
|
||||
this.startupMetas = null;
|
||||
if (operationId === undefined) {
|
||||
await electron.dbUpsertAppPlaylist(playlist);
|
||||
await store.dbUpsertAppPlaylist(playlist);
|
||||
} else {
|
||||
await electron.dbUpsertAppPlaylist(playlist, operationId);
|
||||
await store.dbUpsertAppPlaylist(playlist, operationId);
|
||||
}
|
||||
this.healthEvidence?.connections.next({ id: playlist._id, playlist });
|
||||
return playlist;
|
||||
@@ -416,13 +391,13 @@ export class PlaylistsService {
|
||||
|
||||
private upsertManySqlitePlaylists(playlists: Playlist[]) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
if (!electron) {
|
||||
const store = this.documentStore;
|
||||
if (!store) {
|
||||
return playlists;
|
||||
}
|
||||
|
||||
this.startupMetas = null;
|
||||
await electron.dbUpsertAppPlaylists(playlists);
|
||||
await store.dbUpsertAppPlaylists(playlists);
|
||||
playlists.forEach((playlist) => this.healthEvidence?.connections.next({ id: playlist._id, playlist }));
|
||||
return playlists;
|
||||
});
|
||||
@@ -457,7 +432,7 @@ export class PlaylistsService {
|
||||
nextPlaylist: Playlist,
|
||||
operationId?: string
|
||||
) {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return firstValueFrom(
|
||||
this.upsertSqlitePlaylist(nextPlaylist, operationId)
|
||||
);
|
||||
@@ -499,16 +474,16 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
getAllPlaylists() {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
const shared = this.startupMetas;
|
||||
// A joining caller gets its own copy of the shared result.
|
||||
if (shared) return from(shared.then((p) => structuredClone(p)));
|
||||
const read = firstValueFrom(
|
||||
this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
const playlists = electron
|
||||
? await (electron.dbGetAppPlaylistMetas?.() ??
|
||||
electron.dbGetAppPlaylists())
|
||||
const store = this.documentStore;
|
||||
const playlists = store
|
||||
? await (store.dbGetAppPlaylistMetas?.() ??
|
||||
store.dbGetAppPlaylists())
|
||||
: [];
|
||||
return (playlists as Playlist[]).map((playlist) =>
|
||||
this.toPlaylistMeta(playlist)
|
||||
@@ -533,7 +508,7 @@ export class PlaylistsService {
|
||||
addPlaylist(playlist: Playlist) {
|
||||
return this.serializePlaylistWrite(playlist._id, () =>
|
||||
runWithPlaylistAuthorityMutation([playlist._id], async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return firstValueFrom(this.upsertSqlitePlaylist(playlist));
|
||||
}
|
||||
|
||||
@@ -570,12 +545,12 @@ export class PlaylistsService {
|
||||
playlistId,
|
||||
() =>
|
||||
runWithPlaylistAuthorityMutation([playlistId], async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
const store = this.documentStore;
|
||||
if (store) {
|
||||
this.startupMetas = null;
|
||||
if (options) {
|
||||
if (options && this.usesElectronStorage) {
|
||||
const deleted =
|
||||
await this.databaseService.deletePlaylist(
|
||||
playlistId,
|
||||
@@ -585,7 +560,7 @@ export class PlaylistsService {
|
||||
throw new Error(
|
||||
'Playlist deletion did not complete'
|
||||
);
|
||||
} else await electron.dbDeletePlaylist(playlistId);
|
||||
} else await store.dbDeletePlaylist(playlistId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -707,15 +682,15 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
getPlaylistById(id: string, operationId?: string) {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
const store = this.documentStore;
|
||||
let playlist: Playlist | null = null;
|
||||
if (electron) {
|
||||
if (store) {
|
||||
playlist =
|
||||
operationId === undefined
|
||||
? await electron.dbGetAppPlaylist(id)
|
||||
: await electron.dbGetAppPlaylist(id, operationId);
|
||||
? await store.dbGetAppPlaylist(id)
|
||||
: await store.dbGetAppPlaylist(id, operationId);
|
||||
}
|
||||
return playlist
|
||||
? this.createSqliteFallbackPlaylist(playlist as Playlist)
|
||||
@@ -754,7 +729,7 @@ export class PlaylistsService {
|
||||
isCurrent?: (playlist: Playlist) => boolean
|
||||
): Observable<Playlist | null> {
|
||||
return this.serializePlaylistWrite(updatedPlaylist._id, async () => {
|
||||
if (isCurrent && !this.isElectronStorageAvailable) {
|
||||
if (isCurrent && !this.usesDocumentStore) {
|
||||
await this.ensureIndexedDbPlaylistMigrations();
|
||||
return this.transformIndexedDbPlaylistMeta(
|
||||
updatedPlaylist._id,
|
||||
@@ -1010,7 +985,7 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
return this.serializePlaylistWrite(playlistId, async () => {
|
||||
if (!this.isElectronStorageAvailable) {
|
||||
if (!this.usesDocumentStore) {
|
||||
await this.ensureIndexedDbPlaylistMigrations();
|
||||
return this.transformIndexedDbPlaylistMeta(
|
||||
playlistId,
|
||||
@@ -1080,11 +1055,11 @@ export class PlaylistsService {
|
||||
getM3uFavoriteChannels(
|
||||
playlistId: string
|
||||
): Observable<M3uFavoriteChannel[] | null> {
|
||||
const electron = this.electronApi;
|
||||
const getFavoriteChannels = electron?.dbGetAppPlaylistFavoriteChannels;
|
||||
const store = this.documentStore;
|
||||
const getFavoriteChannels = store?.dbGetAppPlaylistFavoriteChannels;
|
||||
if (
|
||||
!electron ||
|
||||
!this.isElectronStorageAvailable ||
|
||||
!store ||
|
||||
!this.usesElectronStorage ||
|
||||
typeof getFavoriteChannels !== 'function'
|
||||
) {
|
||||
return of(null);
|
||||
@@ -1095,7 +1070,7 @@ export class PlaylistsService {
|
||||
// A favorite toggled just before this read may still be queued;
|
||||
// the queue tail never rejects.
|
||||
await this.playlistWriteQueues.get(playlistId);
|
||||
const alreadyMigrated = await electron.dbGetAppState(
|
||||
const alreadyMigrated = await store.dbGetAppState(
|
||||
SQLITE_PLAYLIST_MIGRATION_FLAG
|
||||
);
|
||||
if (alreadyMigrated !== '1') {
|
||||
@@ -1273,7 +1248,7 @@ export class PlaylistsService {
|
||||
runWithPlaylistAuthorityMutation(
|
||||
playlists.map((playlist) => playlist._id),
|
||||
async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return firstValueFrom(
|
||||
this.upsertManySqlitePlaylists(playlists)
|
||||
);
|
||||
@@ -1328,11 +1303,11 @@ export class PlaylistsService {
|
||||
}
|
||||
|
||||
getAllData() {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
return this.runOnSqlite(async () => {
|
||||
const electron = this.electronApi;
|
||||
return electron
|
||||
? ((await electron.dbGetAppPlaylists()) as Playlist[])
|
||||
const store = this.documentStore;
|
||||
return store
|
||||
? ((await store.dbGetAppPlaylists()) as Playlist[])
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -1345,12 +1320,12 @@ export class PlaylistsService {
|
||||
removeAll(): Observable<void> {
|
||||
return defer(() =>
|
||||
runWithPlaylistAuthorityReset(async () => {
|
||||
if (this.isElectronStorageAvailable) {
|
||||
if (this.usesDocumentStore) {
|
||||
await this.ensureElectronPlaylistMigrations();
|
||||
const electron = this.electronApi;
|
||||
if (electron) {
|
||||
const store = this.documentStore;
|
||||
if (store) {
|
||||
this.startupMetas = null;
|
||||
await electron.dbDeleteAllPlaylists();
|
||||
await store.dbDeleteAllPlaylists();
|
||||
this.healthEvidence?.connections.next({});
|
||||
}
|
||||
return undefined;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { USER_DATA_STORAGE } from './user-data-storage';
|
||||
|
||||
describe('USER_DATA_STORAGE', () => {
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
jest.restoreAllMocks();
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
it('delegates to localStorage by default', () => {
|
||||
const storage = TestBed.inject(USER_DATA_STORAGE);
|
||||
|
||||
storage.setItem('xtream-favorites', '{}');
|
||||
|
||||
expect(localStorage.getItem('xtream-favorites')).toBe('{}');
|
||||
expect(storage.getItem('xtream-favorites')).toBe('{}');
|
||||
storage.removeItem('xtream-favorites');
|
||||
expect(localStorage.getItem('xtream-favorites')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not touch localStorage until a method is called', () => {
|
||||
// A browser that denies storage throws on the localStorage getter
|
||||
// itself; reading it only inside calls keeps that failure in the
|
||||
// callers' guarded reads instead of in service construction.
|
||||
const getter = jest.spyOn(globalThis, 'localStorage', 'get');
|
||||
|
||||
const storage = TestBed.inject(USER_DATA_STORAGE);
|
||||
expect(getter).not.toHaveBeenCalled();
|
||||
|
||||
storage.getItem('xtream-favorites');
|
||||
expect(getter).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
/** The slice of the Web Storage API that PWA user-data stores rely on. */
|
||||
export type UserDataStorage = Pick<
|
||||
Storage,
|
||||
'getItem' | 'setItem' | 'removeItem'
|
||||
>;
|
||||
|
||||
/**
|
||||
* `localStorage` behind an adapter that reads the global only inside each
|
||||
* call: when the browser denies storage access, reading `localStorage`
|
||||
* itself throws, and that must happen in the callers' guarded reads and
|
||||
* writes, not while Angular constructs the service that injects this.
|
||||
*/
|
||||
export function createBrowserUserDataStorage(): UserDataStorage {
|
||||
return {
|
||||
getItem: (key) => globalThis.localStorage.getItem(key),
|
||||
setItem: (key, value) => globalThis.localStorage.setItem(key, value),
|
||||
removeItem: (key) => globalThis.localStorage.removeItem(key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser persistence for PWA user data such as Xtream favorites, recently
|
||||
* viewed items and playback positions. Defaults to `localStorage`; the
|
||||
* self-hosted server mode provides a server-synced implementation.
|
||||
*/
|
||||
export const USER_DATA_STORAGE = new InjectionToken<UserDataStorage>(
|
||||
'USER_DATA_STORAGE',
|
||||
{ providedIn: 'root', factory: createBrowserUserDataStorage }
|
||||
);
|
||||
Reference in new issue
Block a user