diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index 612c98168..e6c106a29 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -70,9 +70,11 @@ direct `window.electron` or `DataService.getAppEnvironment()` checks. Keep feature decisions expressed as capabilities such as `supportsEpg`, `supportsSqlite`, `supportsXtreamSqliteDataSource`, `supportsDownloads`, or `supportsManagedExternalPlayers` so PWA and Electron behavior stays auditable -from one shared boundary. `supportsDownloads` requires the complete downloads -preload API surface used by `DownloadsService`, `supportsPlaylistRefresh` -requires the native playlist refresh/cancel/progress bridge, and +from one shared boundary. `supportsSqlite` requires the complete playlist +storage preload API surface used by `PlaylistsService`, `supportsDownloads` +requires the complete downloads preload API surface used by `DownloadsService`, +`supportsPlaylistRefresh` requires the native playlist refresh/cancel/progress +bridge, and `supportsManagedExternalPlayers` requires the MPV and VLC preload launch methods (`openInMpv` and `openInVlc`); a partial Electron bridge must not expose desktop-only actions in the PWA/shared UI. diff --git a/libs/services/src/lib/playlist-delete-action.service.spec.ts b/libs/services/src/lib/playlist-delete-action.service.spec.ts index e9bfc0f38..123ecf137 100644 --- a/libs/services/src/lib/playlist-delete-action.service.spec.ts +++ b/libs/services/src/lib/playlist-delete-action.service.spec.ts @@ -26,7 +26,8 @@ describe('PlaylistDeleteActionService', () => { deletePlaylist: jest.Mock; }; let runtime: { - isElectron: boolean; + supportsSqlite: boolean; + supportsXtreamSqliteDataSource: boolean; }; let injector: EnvironmentInjector; @@ -41,7 +42,8 @@ describe('PlaylistDeleteActionService', () => { deletePlaylist: jest.fn(() => of({ success: true })), }; runtime = { - isElectron: false, + supportsSqlite: false, + supportsXtreamSqliteDataSource: false, }; injector = createEnvironmentInjector( @@ -76,8 +78,8 @@ describe('PlaylistDeleteActionService', () => { expect(databaseService.deletePlaylist).not.toHaveBeenCalled(); }); - it('deletes Electron Xtream playlists through DatabaseService with progress options', async () => { - runtime.isElectron = true; + it('deletes SQLite-backed Xtream playlists through DatabaseService with progress options', async () => { + runtime.supportsXtreamSqliteDataSource = true; const onEvent = jest.fn(); const service = createService(); @@ -98,8 +100,8 @@ describe('PlaylistDeleteActionService', () => { expect(playlistsService.deletePlaylist).not.toHaveBeenCalled(); }); - it('deletes Electron non-Xtream playlists without progress options', async () => { - runtime.isElectron = true; + it('deletes SQLite-backed non-Xtream playlists without progress options', async () => { + runtime.supportsSqlite = true; const service = createService(); await expect( @@ -115,4 +117,17 @@ describe('PlaylistDeleteActionService', () => { undefined ); }); + + it('uses browser playlist storage when an Xtream playlist lacks Xtream SQLite support', async () => { + runtime.supportsSqlite = true; + runtime.supportsXtreamSqliteDataSource = false; + const service = createService(); + + await expect(service.deletePlaylist(playlist)).resolves.toBe(true); + + expect(playlistsService.deletePlaylist).toHaveBeenCalledWith( + 'playlist-1' + ); + expect(databaseService.deletePlaylist).not.toHaveBeenCalled(); + }); }); diff --git a/libs/services/src/lib/playlist-delete-action.service.ts b/libs/services/src/lib/playlist-delete-action.service.ts index 7487341be..2f44089fb 100644 --- a/libs/services/src/lib/playlist-delete-action.service.ts +++ b/libs/services/src/lib/playlist-delete-action.service.ts @@ -27,7 +27,11 @@ export class PlaylistDeleteActionService { playlist: PlaylistMeta, options: PlaylistDeleteActionOptions = {} ): Promise { - if (this.runtime.isElectron) { + const supportsElectronDelete = playlist.serverUrl + ? this.runtime.supportsXtreamSqliteDataSource + : this.runtime.supportsSqlite; + + if (supportsElectronDelete) { return this.deletePlaylistInElectron(playlist, options); } diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 4f73ac3d2..7e4f4e1e1 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -36,8 +36,12 @@ describe('RuntimeCapabilitiesService', () => { it('reports Electron capabilities from the available preload bridge methods', () => { testWindow.electron = { platform: 'darwin', + dbDeleteAllPlaylists: jest.fn(), + dbDeletePlaylist: jest.fn(), + dbGetAppPlaylist: jest.fn(), dbGetAppPlaylists: jest.fn(), dbUpsertAppPlaylist: jest.fn(), + dbUpsertAppPlaylists: jest.fn(), dbGetAppState: jest.fn(), dbSetAppState: jest.fn(), dbGetRecentlyViewed: jest.fn(), @@ -57,7 +61,6 @@ describe('RuntimeCapabilitiesService', () => { dbGetPlaylist: jest.fn(), dbCreatePlaylist: jest.fn(), dbUpdatePlaylist: jest.fn(), - dbDeletePlaylist: jest.fn(), dbHasCategories: jest.fn(), dbGetCategories: jest.fn(), dbSaveCategories: jest.fn(), @@ -158,12 +161,7 @@ describe('RuntimeCapabilitiesService', () => { }); it('keeps the Xtream SQLite data source disabled when only generic SQLite methods exist', () => { - testWindow.electron = { - dbGetAppPlaylists: jest.fn(), - dbUpsertAppPlaylist: jest.fn(), - dbGetAppState: jest.fn(), - dbSetAppState: jest.fn(), - }; + testWindow.electron = createPlaylistStorageBridge(); const service = new RuntimeCapabilitiesService(); @@ -235,4 +233,35 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsPlaylistRefresh).toBe(true); }); + + it('requires the complete playlist storage SQLite preload surface', () => { + testWindow.electron = { + dbGetAppPlaylists: jest.fn(), + dbUpsertAppPlaylist: jest.fn(), + dbGetAppState: jest.fn(), + dbSetAppState: jest.fn(), + }; + + const service = new RuntimeCapabilitiesService(); + + expect(service.isElectron).toBe(true); + expect(service.supportsSqlite).toBe(false); + + testWindow.electron = createPlaylistStorageBridge(); + + expect(service.supportsSqlite).toBe(true); + }); }); + +function createPlaylistStorageBridge(): Record { + return { + dbDeleteAllPlaylists: jest.fn(), + dbDeletePlaylist: jest.fn(), + dbGetAppPlaylist: jest.fn(), + dbGetAppPlaylists: jest.fn(), + dbGetAppState: jest.fn(), + dbSetAppState: jest.fn(), + dbUpsertAppPlaylist: jest.fn(), + dbUpsertAppPlaylists: jest.fn(), + }; +} diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 713019060..6892d8cfa 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -36,12 +36,16 @@ export class RuntimeCapabilitiesService { } get supportsSqlite(): boolean { - return ( - this.hasElectronMethod('dbGetAppPlaylists') && - this.hasElectronMethod('dbUpsertAppPlaylist') && - this.hasElectronMethod('dbGetAppState') && - this.hasElectronMethod('dbSetAppState') - ); + return [ + 'dbDeleteAllPlaylists', + 'dbDeletePlaylist', + 'dbGetAppPlaylist', + 'dbGetAppPlaylists', + 'dbGetAppState', + 'dbSetAppState', + 'dbUpsertAppPlaylist', + 'dbUpsertAppPlaylists', + ].every((methodName) => this.hasElectronMethod(methodName)); } get supportsXtreamSqliteDataSource(): boolean {