refactor(portal): gate activity storage by runtime capability

This commit is contained in:
4gray committed 2026-05-22 13:34:16 +03:00
1 parent 03586d934a
commit 790dbb7062
7 files changed
+179 -38

No files matched your search

@@ -17,8 +17,19 @@ describe('UnifiedFavoritesDataService', () => {
let service: UnifiedFavoritesDataService;
let electronApi: {
dbAddFavorite: jest.Mock;
dbAddRecentItem: jest.Mock;
dbClearPlaylistRecentItems: jest.Mock;
dbClearRecentlyViewed: jest.Mock;
dbGetAllGlobalFavorites: jest.Mock;
dbGetContentByXtreamId: jest.Mock;
dbGetFavorites: jest.Mock;
dbGetGlobalRecentlyAdded: jest.Mock;
dbGetRecentItems: jest.Mock;
dbGetRecentlyViewed: jest.Mock;
dbReorderGlobalFavorites: jest.Mock;
dbRemoveFavorite: jest.Mock;
dbRemoveRecentItem: jest.Mock;
dbRemoveRecentItemsBatch: jest.Mock;
};
let databaseService: {
getAllGlobalFavorites: jest.Mock;
@@ -99,8 +110,21 @@ describe('UnifiedFavoritesDataService', () => {
beforeEach(() => {
electronApi = {
dbAddFavorite: jest.fn().mockResolvedValue({ success: true }),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
dbGetFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbGetRecentItems: jest.fn(),
dbGetRecentlyViewed: jest.fn(),
dbReorderGlobalFavorites: jest.fn().mockResolvedValue({
success: true,
}),
dbRemoveFavorite: jest.fn().mockResolvedValue(undefined),
dbRemoveRecentItem: jest.fn(),
dbRemoveRecentItemsBatch: jest.fn(),
};
Object.defineProperty(window, 'electron', {
value: electronApi as Window['electron'],
@@ -522,6 +546,31 @@ describe('UnifiedFavoritesDataService', () => {
expect(electronApi.dbAddFavorite).not.toHaveBeenCalled();
});
it('uses the active Xtream data source when the Electron bridge lacks activity storage methods', async () => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
await service.removeFavorite({
uid: 'xtream::xtream-1::movie:101',
name: 'Partial Bridge Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
posterUrl: 'movie.png',
xtreamId: 101,
contentId: 1010,
} satisfies UnifiedCollectionItem);
expect(xtreamDataSource.removeFavorite).toHaveBeenCalledWith(
1010,
'xtream-1'
);
expect(electronApi.dbRemoveFavorite).not.toHaveBeenCalled();
});
it('adds Stalker favorites through portal favorites', async () => {
await service.addFavorite({
uid: 'stalker::stalker-1::101',
@@ -3,7 +3,11 @@ import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
Channel,
extractStalkerItemId,
@@ -47,6 +51,7 @@ export class UnifiedFavoritesDataService {
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly translate = inject(TranslateService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
async getFavorites(
@@ -90,12 +95,13 @@ export class UnifiedFavoritesDataService {
await this.setM3uFavorites(item.playlistId, filtered);
break;
}
case 'xtream':
case 'xtream': {
if (item.contentId == null) {
return;
}
if (window.electron) {
await window.electron.dbRemoveFavorite(
const electron = this.electronActivityBridge;
if (electron) {
await electron.dbRemoveFavorite(
item.contentId,
item.playlistId
);
@@ -106,6 +112,7 @@ export class UnifiedFavoritesDataService {
item.playlistId
);
break;
}
case 'stalker': {
const sourceItemId = item.uid.split('::')[2];
await firstValueFrom(
@@ -157,7 +164,8 @@ export class UnifiedFavoritesDataService {
return;
}
if (!window.electron) {
const electron = this.electronActivityBridge;
if (!electron) {
await this.xtreamDataSource.addFavorite(
contentId,
item.playlistId,
@@ -166,7 +174,7 @@ export class UnifiedFavoritesDataService {
return;
}
await window.electron.dbAddFavorite(
await electron.dbAddFavorite(
contentId,
item.playlistId,
item.posterUrl ?? item.logo ?? undefined
@@ -287,21 +295,22 @@ export class UnifiedFavoritesDataService {
}
const xtreamUpdates = this.buildXtreamPositionUpdates(items);
const electron = this.electronActivityBridge;
if (
options?.scope === 'playlist' &&
options.playlistId &&
options.portalType === 'xtream'
) {
if (xtreamUpdates.length > 0 && window.electron) {
await window.electron.dbReorderGlobalFavorites(xtreamUpdates);
if (xtreamUpdates.length > 0 && electron) {
await electron.dbReorderGlobalFavorites(xtreamUpdates);
}
return;
}
await Promise.all([
xtreamUpdates.length > 0 && window.electron
? window.electron.dbReorderGlobalFavorites(xtreamUpdates)
xtreamUpdates.length > 0 && electron
? electron.dbReorderGlobalFavorites(xtreamUpdates)
: Promise.resolve(),
this.saveOrder(items.map((item) => item.uid)),
]);
@@ -356,7 +365,7 @@ export class UnifiedFavoritesDataService {
private async clearXtreamFavorites(
items: UnifiedCollectionItem[]
): Promise<void> {
const electron = window.electron;
const electron = this.electronActivityBridge;
if (!electron) {
await Promise.all(
items
@@ -514,7 +523,7 @@ export class UnifiedFavoritesDataService {
}
private async getXtreamAllFavorites(): Promise<UnifiedCollectionItem[]> {
if (!window.electron) {
if (!this.electronActivityBridge) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter(
@@ -527,7 +536,6 @@ export class UnifiedFavoritesDataService {
return results;
}
if (!window.electron?.dbGetAllGlobalFavorites) return [];
try {
const rows =
(await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[];
@@ -542,7 +550,7 @@ export class UnifiedFavoritesDataService {
): Promise<UnifiedCollectionItem[]> {
try {
const meta = await this.getPlaylistMeta(playlistId);
if (!window.electron) {
if (!this.electronActivityBridge) {
const rows =
await this.xtreamDataSource.getFavorites(playlistId);
return rows.map((row) =>
@@ -568,7 +576,7 @@ export class UnifiedFavoritesDataService {
return null;
}
if (!window.electron) {
if (!this.electronActivityBridge) {
const content = await this.xtreamDataSource.getContentByXtreamId(
item.xtreamId,
item.playlistId,
@@ -721,9 +729,9 @@ export class UnifiedFavoritesDataService {
}
private async getSavedOrder(): Promise<string[]> {
if (!window.electron?.dbGetAppState) return [];
if (!this.runtime.hasElectronMethod('dbGetAppState')) return [];
try {
const raw = await window.electron.dbGetAppState(
const raw = await window.electron?.dbGetAppState(
GLOBAL_FAVORITES_ORDER_KEY
);
return raw ? (JSON.parse(raw) as string[]) : [];
@@ -733,9 +741,9 @@ export class UnifiedFavoritesDataService {
}
private async saveOrder(uidOrder: string[]): Promise<void> {
if (!window.electron?.dbSetAppState) return;
if (!this.runtime.hasElectronMethod('dbSetAppState')) return;
try {
await window.electron.dbSetAppState(
await window.electron?.dbSetAppState(
GLOBAL_FAVORITES_ORDER_KEY,
JSON.stringify(uidOrder)
);
@@ -845,4 +853,10 @@ export class UnifiedFavoritesDataService {
return extractStalkerItemId(favorite);
}
private get electronActivityBridge(): Window['electron'] | undefined {
return this.runtime.supportsPortalActivityStorage
? window.electron
: undefined;
}
}
@@ -94,9 +94,27 @@ describe('UnifiedRecentDataService', () => {
},
];
const createPortalActivityElectronApi = (): Window['electron'] =>
({
dbGetRecentlyViewed: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbAddFavorite: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbGetFavorites: jest.fn(),
dbReorderGlobalFavorites: jest.fn(),
dbGetRecentItems: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRemoveRecentItemsBatch: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
}) as unknown as Window['electron'];
beforeEach(() => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
value: createPortalActivityElectronApi(),
configurable: true,
});
store = {
@@ -295,6 +313,30 @@ describe('UnifiedRecentDataService', () => {
);
});
it('uses the active Xtream data source when the Electron bridge lacks activity storage methods', async () => {
Object.defineProperty(window, 'electron', {
value: {} as Window['electron'],
configurable: true,
});
await service.removeRecentItem({
uid: 'xtream::xtream-1::movie:290',
name: 'Partial Bridge Movie',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream One',
xtreamId: 290,
contentId: 444,
} satisfies UnifiedCollectionItem);
expect(xtreamDataSource.removeRecentItem).toHaveBeenCalledWith(
444,
'xtream-1'
);
expect(dbService.removeRecentItem).not.toHaveBeenCalled();
});
it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => {
store.select.mockReturnValue(
of([
@@ -2,7 +2,11 @@ import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
import { firstValueFrom, map } from 'rxjs';
import { DatabaseService, PlaylistsService } from '@iptvnator/services';
import {
DatabaseService,
PlaylistsService,
RuntimeCapabilitiesService,
} from '@iptvnator/services';
import {
Channel,
extractStalkerItemId,
@@ -40,6 +44,7 @@ export class UnifiedRecentDataService {
private readonly store = inject(Store);
private readonly dbService = inject(DatabaseService);
private readonly playlistsService = inject(PlaylistsService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE);
async getRecentItems(
@@ -60,15 +65,15 @@ export class UnifiedRecentDataService {
return;
}
if (!window.electron) {
await this.xtreamDataSource.removeRecentItem(
if (this.hasPortalActivityStorage) {
await this.dbService.removeRecentItem(
item.contentId,
item.playlistId
);
return;
}
await this.dbService.removeRecentItem(
await this.xtreamDataSource.removeRecentItem(
item.contentId,
item.playlistId
);
@@ -139,7 +144,7 @@ export class UnifiedRecentDataService {
const tasks: Promise<unknown>[] = [];
if (xtreamBatch.length > 0) {
if (window.electron) {
if (this.hasPortalActivityStorage) {
tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch));
} else {
tasks.push(
@@ -177,7 +182,7 @@ export class UnifiedRecentDataService {
playlistId?: string
): Promise<void> {
if (scope === 'playlist' && playlistId) {
if (window.electron) {
if (this.hasPortalActivityStorage) {
await this.dbService.clearPlaylistRecentItems(playlistId);
} else {
await this.xtreamDataSource.clearRecentItems(playlistId);
@@ -189,7 +194,7 @@ export class UnifiedRecentDataService {
return;
}
if (window.electron) {
if (this.hasPortalActivityStorage) {
await this.dbService.clearGlobalRecentlyViewed();
} else {
const allMeta = await this.getAllMeta();
@@ -264,7 +269,7 @@ export class UnifiedRecentDataService {
: null);
if (contentId != null) {
if (window.electron) {
if (this.hasPortalActivityStorage) {
await this.dbService.addRecentItem(
contentId,
item.playlistId
@@ -349,8 +354,12 @@ export class UnifiedRecentDataService {
return this.getM3uPlaylistRecent(playlistId);
}
private get hasPortalActivityStorage(): boolean {
return this.runtime.supportsPortalActivityStorage;
}
private async getXtreamGlobalRecent(): Promise<UnifiedCollectionItem[]> {
if (!window.electron) {
if (!this.hasPortalActivityStorage) {
const allMeta = await this.getAllMeta();
const results: UnifiedCollectionItem[] = [];
for (const meta of allMeta.filter(
@@ -393,7 +402,7 @@ export class UnifiedRecentDataService {
): Promise<UnifiedCollectionItem[]> {
try {
const meta = await this.getPlaylistMeta(playlistId);
if (!window.electron) {
if (!this.hasPortalActivityStorage) {
const rows =
await this.xtreamDataSource.getRecentItems(playlistId);
return rows.map((row) =>
@@ -434,7 +443,7 @@ export class UnifiedRecentDataService {
return null;
}
if (!window.electron) {
if (!this.hasPortalActivityStorage) {
const content = await this.xtreamDataSource.getContentByXtreamId(
item.xtreamId,
item.playlistId,
@@ -39,10 +39,19 @@ describe('RuntimeCapabilitiesService', () => {
dbGetAppState: jest.fn(),
dbSetAppState: jest.fn(),
dbGetRecentlyViewed: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbAddFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbGetFavorites: jest.fn(),
dbReorderGlobalFavorites: jest.fn(),
dbGetRecentItems: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbRemoveRecentItemsBatch: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
downloadsGetList: jest.fn(),
prepareEmbeddedMpv: jest.fn(),
saveFileDialog: jest.fn(),
@@ -49,13 +49,22 @@ export class RuntimeCapabilitiesService {
}
get supportsPortalActivityStorage(): boolean {
return (
this.hasElectronMethod('dbGetRecentlyViewed') &&
this.hasElectronMethod('dbGetAllGlobalFavorites') &&
this.hasElectronMethod('dbGetGlobalRecentlyAdded') &&
this.hasElectronMethod('dbRemoveRecentItem') &&
this.hasElectronMethod('dbRemoveFavorite')
);
return [
'dbGetRecentlyViewed',
'dbClearRecentlyViewed',
'dbGetAllGlobalFavorites',
'dbGetGlobalRecentlyAdded',
'dbAddFavorite',
'dbRemoveFavorite',
'dbGetFavorites',
'dbReorderGlobalFavorites',
'dbGetRecentItems',
'dbAddRecentItem',
'dbClearPlaylistRecentItems',
'dbRemoveRecentItem',
'dbRemoveRecentItemsBatch',
'dbGetContentByXtreamId',
].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsManagedExternalPlayers(): boolean {
@@ -159,10 +159,19 @@ describe('DashboardDataService', () => {
Object.defineProperty(window, 'electron', {
value: {
dbGetRecentlyViewed: jest.fn(),
dbClearRecentlyViewed: jest.fn(),
dbGetAllGlobalFavorites: jest.fn(),
dbGetGlobalRecentlyAdded: jest.fn(),
dbAddFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRemoveFavorite: jest.fn(),
dbGetFavorites: jest.fn(),
dbReorderGlobalFavorites: jest.fn(),
dbGetRecentItems: jest.fn(),
dbAddRecentItem: jest.fn(),
dbClearPlaylistRecentItems: jest.fn(),
dbRemoveRecentItemsBatch: jest.fn(),
dbGetContentByXtreamId: jest.fn(),
} as unknown as Window['electron'],
configurable: true,
});