From 8cfc40f59b596632522a5e49a284226470e80d17 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 22 May 2026 03:57:54 +0300 Subject: [PATCH] refactor(xtream): gate data source by sqlite capability --- docs/architecture/pwa-self-hosted.md | 13 ++- .../src/lib/data-sources/index.spec.ts | 91 +++++++++++++++++++ .../data-access/src/lib/data-sources/index.ts | 4 +- .../lib/runtime-capabilities.service.spec.ts | 40 ++++++++ .../src/lib/runtime-capabilities.service.ts | 38 ++++++++ 5 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index f479dc04f..3121766e1 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -68,8 +68,9 @@ Renderer code that needs to branch by runtime should use `RuntimeCapabilitiesService` from `@iptvnator/services` instead of adding new direct `window.electron` or `DataService.getAppEnvironment()` checks. Keep feature decisions expressed as capabilities such as `supportsEpg`, -`supportsSqlite`, `supportsDownloads`, or `supportsManagedExternalPlayers` so -PWA and Electron behavior stays auditable from one shared boundary. +`supportsSqlite`, `supportsXtreamSqliteDataSource`, `supportsDownloads`, or +`supportsManagedExternalPlayers` so PWA and Electron behavior stays auditable +from one shared boundary. ## Runtime Limitations @@ -109,8 +110,12 @@ certificate authorities, configure Node with `NODE_EXTRA_CA_CERTS`. ## PWA Portal User Data Xtream favorites and recently viewed items use the browser-side -`PwaXtreamDataSource` when Electron DB preload APIs are unavailable. The PWA -stores this user activity and sidecar state in localStorage: +`PwaXtreamDataSource` when Electron DB preload APIs are unavailable. The +`XTREAM_DATA_SOURCE` provider chooses the Electron SQLite-backed source only +when `RuntimeCapabilitiesService.supportsXtreamSqliteDataSource` is true; a +browser PWA or partial preload bridge must fall back to the PWA source and run +the browser cleanup hook on playlist deletion. The PWA stores this user activity +and sidecar state in localStorage: - `xtream-collection-items` - `xtream-favorites` diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts new file mode 100644 index 000000000..09226800f --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.spec.ts @@ -0,0 +1,91 @@ +import { TestBed } from '@angular/core/testing'; +import { + PLAYLIST_DELETE_CLEANUP, + PlaylistDeleteCleanup, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; +import { + ElectronXtreamDataSource, + PwaXtreamDataSource, + provideXtreamDataSource, +} from './index'; +import { + IXtreamDataSource, + XTREAM_DATA_SOURCE, +} from './xtream-data-source.interface'; + +describe('provideXtreamDataSource', () => { + let electronSource: IXtreamDataSource; + let pwaSource: IXtreamDataSource; + let runtime: { + supportsXtreamSqliteDataSource: boolean; + }; + + function configure(supportsXtreamSqliteDataSource: boolean): void { + electronSource = { + deletePlaylist: jest.fn().mockResolvedValue(undefined), + } as Partial as IXtreamDataSource; + pwaSource = { + deletePlaylist: jest.fn().mockResolvedValue(undefined), + } as Partial as IXtreamDataSource; + runtime = { + supportsXtreamSqliteDataSource, + }; + + TestBed.configureTestingModule({ + providers: [ + ...provideXtreamDataSource(), + { + provide: RuntimeCapabilitiesService, + useValue: runtime, + }, + { + provide: ElectronXtreamDataSource, + useValue: electronSource, + }, + { + provide: PwaXtreamDataSource, + useValue: pwaSource, + }, + ], + }); + } + + afterEach(() => TestBed.resetTestingModule()); + + it('uses the Electron data source only when the Xtream SQLite capability is available', () => { + configure(true); + + expect(TestBed.inject(XTREAM_DATA_SOURCE)).toBe(electronSource); + }); + + it('falls back to the PWA data source when the Electron bridge lacks Xtream SQLite methods', () => { + configure(false); + + expect(TestBed.inject(XTREAM_DATA_SOURCE)).toBe(pwaSource); + }); + + it('skips browser sidecar cleanup for SQLite-backed Xtream storage', async () => { + configure(true); + const [cleanup] = TestBed.inject( + PLAYLIST_DELETE_CLEANUP + ) as PlaylistDeleteCleanup[]; + + await cleanup('playlist-1'); + + expect(electronSource.deletePlaylist).not.toHaveBeenCalled(); + expect(pwaSource.deletePlaylist).not.toHaveBeenCalled(); + }); + + it('runs browser sidecar cleanup when SQLite-backed Xtream storage is unavailable', async () => { + configure(false); + const [cleanup] = TestBed.inject( + PLAYLIST_DELETE_CLEANUP + ) as PlaylistDeleteCleanup[]; + + await cleanup('playlist-1'); + + expect(pwaSource.deletePlaylist).toHaveBeenCalledWith('playlist-1'); + expect(electronSource.deletePlaylist).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts index 9abd9febb..4644a4d43 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/index.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/index.ts @@ -23,7 +23,7 @@ export { PwaXtreamDataSource } from './pwa-xtream-data-source'; export function xtreamDataSourceFactory(): IXtreamDataSource { const runtime = inject(RuntimeCapabilitiesService); - if (runtime.isElectron) { + if (runtime.supportsXtreamSqliteDataSource) { return inject(ElectronXtreamDataSource); } @@ -50,7 +50,7 @@ export function provideXtreamDataSource(): Provider[] { const runtime = inject(RuntimeCapabilitiesService); return (playlistId: string) => { - if (runtime.isElectron) { + if (runtime.supportsXtreamSqliteDataSource) { // Electron playlist deletion must use DatabaseService so // SQLite content and sidecars are cleaned together; do // not invoke this cleanup path through PlaylistsService. diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 206751d0d..db2067c97 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -22,6 +22,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.isMacOS).toBe(false); expect(service.supportsEpg).toBe(false); expect(service.supportsSqlite).toBe(false); + expect(service.supportsXtreamSqliteDataSource).toBe(false); expect(service.supportsDownloads).toBe(false); expect(service.supportsPortalActivityStorage).toBe(false); expect(service.supportsManagedExternalPlayers).toBe(false); @@ -52,6 +53,29 @@ describe('RuntimeCapabilitiesService', () => { dbClearPlaylistRecentItems: jest.fn(), dbRemoveRecentItemsBatch: jest.fn(), dbGetContentByXtreamId: jest.fn(), + dbGetPlaylist: jest.fn(), + dbCreatePlaylist: jest.fn(), + dbUpdatePlaylist: jest.fn(), + dbDeletePlaylist: jest.fn(), + dbHasCategories: jest.fn(), + dbGetCategories: jest.fn(), + dbSaveCategories: jest.fn(), + dbGetAllCategories: jest.fn(), + dbUpdateCategoryVisibility: jest.fn(), + dbHasContent: jest.fn(), + dbGetContent: jest.fn(), + dbSaveContent: jest.fn(), + dbSearchContent: jest.fn(), + dbIsFavorite: jest.fn(), + dbSavePlaybackPosition: jest.fn(), + dbGetPlaybackPosition: jest.fn(), + dbGetSeriesPlaybackPositions: jest.fn(), + dbGetRecentPlaybackPositions: jest.fn(), + dbGetAllPlaybackPositions: jest.fn(), + dbClearAllPlaybackPositions: jest.fn(), + dbClearPlaybackPosition: jest.fn(), + dbDeleteXtreamContent: jest.fn(), + dbRestoreXtreamUserData: jest.fn(), downloadsGetList: jest.fn(), prepareEmbeddedMpv: jest.fn(), saveFileDialog: jest.fn(), @@ -70,6 +94,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.isMacOS).toBe(true); expect(service.supportsEpg).toBe(true); expect(service.supportsSqlite).toBe(true); + expect(service.supportsXtreamSqliteDataSource).toBe(true); expect(service.supportsDownloads).toBe(true); expect(service.supportsPortalActivityStorage).toBe(true); expect(service.supportsManagedExternalPlayers).toBe(true); @@ -91,6 +116,7 @@ describe('RuntimeCapabilitiesService', () => { expect(service.isMacOS).toBe(false); expect(service.supportsEpg).toBe(true); expect(service.supportsSqlite).toBe(false); + expect(service.supportsXtreamSqliteDataSource).toBe(false); expect(service.supportsDownloads).toBe(false); expect(service.supportsPortalActivityStorage).toBe(false); expect(service.supportsEmbeddedMpv).toBe(false); @@ -110,4 +136,18 @@ describe('RuntimeCapabilitiesService', () => { expect(service.isElectron).toBe(true); expect(service.environment).toBe('electron'); }); + + 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(), + }; + + const service = new RuntimeCapabilitiesService(); + + expect(service.supportsSqlite).toBe(true); + expect(service.supportsXtreamSqliteDataSource).toBe(false); + }); }); diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index d1676ffc1..80d12546d 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -44,6 +44,44 @@ export class RuntimeCapabilitiesService { ); } + get supportsXtreamSqliteDataSource(): boolean { + return [ + 'dbGetPlaylist', + 'dbCreatePlaylist', + 'dbUpdatePlaylist', + 'dbDeletePlaylist', + 'dbHasCategories', + 'dbGetCategories', + 'dbSaveCategories', + 'dbGetAllCategories', + 'dbUpdateCategoryVisibility', + 'dbHasContent', + 'dbGetContent', + 'dbSaveContent', + 'dbGetAppState', + 'dbSetAppState', + 'dbSearchContent', + 'dbGetFavorites', + 'dbAddFavorite', + 'dbRemoveFavorite', + 'dbIsFavorite', + 'dbGetRecentItems', + 'dbAddRecentItem', + 'dbRemoveRecentItem', + 'dbClearPlaylistRecentItems', + 'dbGetContentByXtreamId', + 'dbSavePlaybackPosition', + 'dbGetPlaybackPosition', + 'dbGetSeriesPlaybackPositions', + 'dbGetRecentPlaybackPositions', + 'dbGetAllPlaybackPositions', + 'dbClearAllPlaybackPositions', + 'dbClearPlaybackPosition', + 'dbDeleteXtreamContent', + 'dbRestoreXtreamUserData', + ].every((methodName) => this.hasElectronMethod(methodName)); + } + get supportsDownloads(): boolean { return this.hasElectronMethod('downloadsGetList'); }