refactor(xtream): gate data source by sqlite capability

This commit is contained in:
4gray committed 2026-05-22 13:35:03 +03:00
1 parent 5cf7ab9358
commit 8cfc40f59b
5 files changed
+180 -6

No files matched your search

+9 -4
View File
@@ -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`
@@ -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<IXtreamDataSource> as IXtreamDataSource;
pwaSource = {
deletePlaylist: jest.fn().mockResolvedValue(undefined),
} as Partial<IXtreamDataSource> 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();
});
});
@@ -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.
@@ -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);
});
});
@@ -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');
}