diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 70bb84b3f..a39a0cfaf 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -6,6 +6,7 @@ import { import { ApplicationConfig, importProvidersFrom, + inject, provideZoneChangeDetection, } from '@angular/core'; import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; @@ -24,9 +25,13 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { PORTAL_EXTERNAL_PLAYBACK, PORTAL_PLAYER, + XTREAM_COLLECTION_DATA_SOURCE, } from '@iptvnator/portal/shared/util'; import { PLAYLIST_PLAYER_ACTIONS } from '@iptvnator/playlist/shared/util'; -import { provideXtreamDataSource } from '@iptvnator/portal/xtream/data-access'; +import { + provideXtreamDataSource, + XTREAM_DATA_SOURCE, +} from '@iptvnator/portal/xtream/data-access'; import { DataService } from '@iptvnator/services'; import { dbConfig } from '@iptvnator/shared/interfaces'; import { AppConfig } from '../environments/environment'; @@ -153,6 +158,10 @@ export const appConfig: ApplicationConfig = { }, ...provideWorkspaceShellActions(), ...provideXtreamDataSource(), + { + provide: XTREAM_COLLECTION_DATA_SOURCE, + useFactory: () => inject(XTREAM_DATA_SOURCE), + }, { provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: { appearance: 'outline', subscriptSizing: 'dynamic' }, diff --git a/libs/portal/shared/util/src/lib/collection/index.ts b/libs/portal/shared/util/src/lib/collection/index.ts index 50330be65..032061832 100644 --- a/libs/portal/shared/util/src/lib/collection/index.ts +++ b/libs/portal/shared/util/src/lib/collection/index.ts @@ -4,3 +4,4 @@ export * from './scope-toggle.service'; export * from './unified-favorites-data.service'; export * from './unified-recent-data.service'; export * from './stream-resolver.service'; +export * from './xtream-collection-data-source.token'; diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts index 8aa5664ab..1cab76dbe 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.spec.ts @@ -11,12 +11,17 @@ import { } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from './unified-collection-item.interface'; import { UnifiedFavoritesDataService } from './unified-favorites-data.service'; +import { + XTREAM_COLLECTION_DATA_SOURCE, + XtreamCollectionDataSourceItem, +} from './xtream-collection-data-source.token'; describe('UnifiedFavoritesDataService', () => { let service: UnifiedFavoritesDataService; let electronApi: { dbAddFavorite: jest.Mock; dbGetAllGlobalFavorites: jest.Mock; + dbGetFavorites: jest.Mock; dbRemoveFavorite: jest.Mock; }; let databaseService: { @@ -34,6 +39,11 @@ describe('UnifiedFavoritesDataService', () => { setFavorites: jest.Mock; setPortalFavorites: jest.Mock; }; + let xtreamDataSource: { + addFavorite: jest.Mock; + getFavorites: jest.Mock; + removeFavorite: jest.Mock; + }; const m3uChannels: Channel[] = [ { @@ -93,6 +103,7 @@ describe('UnifiedFavoritesDataService', () => { electronApi = { dbAddFavorite: jest.fn().mockResolvedValue({ success: true }), dbGetAllGlobalFavorites: jest.fn(), + dbGetFavorites: jest.fn(), dbRemoveFavorite: jest.fn().mockResolvedValue(undefined), }; Object.defineProperty(window, 'electron', { @@ -126,9 +137,21 @@ describe('UnifiedFavoritesDataService', () => { macAddress: '00:11:22:33:44:55', favorites: stalkerFavorites, }, + { + _id: 'xtream-1', + title: 'Xtream One', + serverUrl: 'https://xtream.example.com', + username: 'user', + password: 'pass', + }, ] satisfies PlaylistMeta[]) ), }; + xtreamDataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), + getFavorites: jest.fn().mockResolvedValue([]), + removeFavorite: jest.fn().mockResolvedValue(undefined), + }; TestBed.configureTestingModule({ providers: [ @@ -145,6 +168,10 @@ describe('UnifiedFavoritesDataService', () => { provide: PlaylistsService, useValue: playlistsService, }, + { + provide: XTREAM_COLLECTION_DATA_SOURCE, + useValue: xtreamDataSource, + }, { provide: TranslateService, useValue: { @@ -400,6 +427,80 @@ describe('UnifiedFavoritesDataService', () => { ); }); + it('adds Xtream favorites through the PWA data source when Electron favorites APIs are absent', async () => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); + + await service.addFavorite({ + uid: 'xtream::xtream-1::movie:20203', + name: 'PWA Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + posterUrl: 'movie.png', + xtreamId: 20203, + } satisfies UnifiedCollectionItem); + + expect(databaseService.getContentByXtreamId).not.toHaveBeenCalled(); + expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith( + 20203, + 'xtream-1', + 'movie.png' + ); + }); + + it('maps PWA Xtream favorites from the shared data source without Electron DB APIs', async () => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream One', + serverUrl: 'https://xtream.example.com', + } satisfies Partial, + ]) + ); + xtreamDataSource.getFavorites.mockResolvedValue([ + { + id: 20203, + stream_id: 20203, + name: 'PWA Movie', + stream_type: 'movie', + stream_icon: 'movie.png', + category_id: '2', + rating: '8.1', + added_at: '2026-03-26T08:00:00.000Z', + } satisfies XtreamCollectionDataSourceItem, + ]); + + const items = await service.getFavorites('all'); + + expect(databaseService.getAllGlobalFavorites).not.toHaveBeenCalled(); + expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(items).toEqual([ + expect.objectContaining({ + uid: 'xtream::xtream-1::movie:20203', + name: 'PWA Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + posterUrl: 'movie.png', + xtreamId: 20203, + contentId: 20203, + rating: '8.1', + }), + ]); + }); + it('adds Stalker favorites through portal favorites', async () => { await service.addFavorite({ uid: 'stalker::stalker-1::101', diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts index 3ca6b5e9e..61ea822ca 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorites-data.service.ts @@ -27,6 +27,10 @@ import { xtreamContentType, XtreamFavoriteRow, } from './collection-helpers'; +import { + XTREAM_COLLECTION_DATA_SOURCE, + XtreamCollectionDataSourceItem, +} from './xtream-collection-data-source.token'; const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1'; @@ -44,6 +48,7 @@ export class UnifiedFavoritesDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); + private readonly xtreamDataSource = inject(XTREAM_COLLECTION_DATA_SOURCE); private readonly translate = inject(TranslateService); async getFavorites( @@ -88,11 +93,19 @@ export class UnifiedFavoritesDataService { break; } case 'xtream': - if (window.electron && item.contentId != null) { + if (this.hasElectronFavoritesApi() && item.contentId != null) { await window.electron.dbRemoveFavorite( item.contentId, item.playlistId ); + } else { + const contentId = this.getXtreamItemId(item); + if (contentId != null) { + await this.xtreamDataSource.removeFavorite( + contentId, + item.playlistId + ); + } } break; case 'stalker': { @@ -136,31 +149,36 @@ export class UnifiedFavoritesDataService { private async addXtreamFavorite( item: UnifiedCollectionItem ): Promise { - if (!window.electron) { - return; - } - - const contentId = - item.contentId ?? - (item.xtreamId != null - ? ( - await this.dbService.getContentByXtreamId( - item.xtreamId, - item.playlistId, - item.contentType - ) - )?.id - : null); + const contentId = this.hasElectronFavoritesApi() + ? (item.contentId ?? + (item.xtreamId != null + ? ( + await this.dbService.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ) + )?.id + : null)) + : this.getXtreamItemId(item); if (contentId == null) { return; } - await window.electron.dbAddFavorite( - contentId, - item.playlistId, - item.posterUrl ?? item.logo ?? undefined - ); + if (this.hasElectronFavoritesApi()) { + await window.electron.dbAddFavorite( + contentId, + item.playlistId, + item.posterUrl ?? item.logo ?? undefined + ); + } else { + await this.xtreamDataSource.addFavorite( + contentId, + item.playlistId, + item.posterUrl ?? item.logo ?? undefined + ); + } } private async addStalkerFavorite( @@ -283,14 +301,14 @@ export class UnifiedFavoritesDataService { options.playlistId && options.portalType === 'xtream' ) { - if (xtreamUpdates.length > 0 && window.electron) { + if (xtreamUpdates.length > 0 && this.hasElectronReorderApi()) { await window.electron.dbReorderGlobalFavorites(xtreamUpdates); } return; } await Promise.all([ - xtreamUpdates.length > 0 && window.electron + xtreamUpdates.length > 0 && this.hasElectronReorderApi() ? window.electron.dbReorderGlobalFavorites(xtreamUpdates) : Promise.resolve(), this.saveOrder(items.map((item) => item.uid)), @@ -346,16 +364,35 @@ export class UnifiedFavoritesDataService { private async clearXtreamFavorites( items: UnifiedCollectionItem[] ): Promise { - if (!window.electron) { + if (this.hasElectronFavoritesApi()) { + await Promise.all( + items + .filter((item) => item.contentId != null) + .map((item) => + window.electron!.dbRemoveFavorite( + item.contentId!, + item.playlistId + ) + ) + ); return; } await Promise.all( items - .filter((item) => item.contentId != null) + .map((item) => ({ + playlistId: item.playlistId, + contentId: this.getXtreamItemId(item), + })) + .filter( + ( + item + ): item is { playlistId: string; contentId: number } => + item.contentId != null + ) .map((item) => - window.electron!.dbRemoveFavorite( - item.contentId!, + this.xtreamDataSource.removeFavorite( + item.contentId, item.playlistId ) ) @@ -484,7 +521,19 @@ export class UnifiedFavoritesDataService { } private async getXtreamAllFavorites(): Promise { - if (!window.electron?.dbGetAllGlobalFavorites) return []; + if (!this.hasElectronFavoritesApi()) { + const allMeta = await this.getAllMeta(); + const results: UnifiedCollectionItem[] = []; + + for (const meta of allMeta.filter((playlist) => + Boolean(playlist.serverUrl) + )) { + results.push(...(await this.getPwaXtreamFavorites(meta))); + } + + return results; + } + try { const rows = (await this.dbService.getAllGlobalFavorites()) as XtreamFavoriteRow[]; @@ -497,7 +546,11 @@ export class UnifiedFavoritesDataService { private async getXtreamPlaylistFavorites( playlistId: string ): Promise { - if (!window.electron) return []; + if (!this.hasElectronPlaylistFavoritesApi()) { + const meta = await this.getPlaylistMeta(playlistId); + return meta ? this.getPwaXtreamFavorites(meta) : []; + } + try { const rows = await this.dbService.getFavorites(playlistId); const meta = await this.getPlaylistMeta(playlistId); @@ -511,6 +564,122 @@ export class UnifiedFavoritesDataService { } } + private async getPwaXtreamFavorites( + meta: PlaylistMeta + ): Promise { + try { + const rows = await this.xtreamDataSource.getFavorites(meta._id); + return rows.map((row, index) => + this.mapPwaXtreamFavoriteItem(row, meta, index) + ); + } catch { + return []; + } + } + + private mapPwaXtreamFavoriteItem( + row: XtreamCollectionDataSourceItem, + meta: PlaylistMeta, + index: number + ): UnifiedCollectionItem { + const record = row as unknown as Record; + const contentType = this.getPwaXtreamContentType(record); + const xtreamId = this.getXtreamNumericValue(record, [ + 'xtream_id', + 'stream_id', + 'series_id', + 'id', + ]); + const contentId = this.getXtreamNumericValue(record, [ + 'id', + 'stream_id', + 'series_id', + 'xtream_id', + ]); + const title = + this.getXtreamString(record['title']) ?? + this.getXtreamString(record['name']) ?? + this.getXtreamString(record['stream_display_name']) ?? + 'Unknown'; + const image = + this.getXtreamString(record['poster_url']) ?? + this.getXtreamString(record['stream_icon']) ?? + this.getXtreamString(record['cover']) ?? + null; + + return { + uid: buildXtreamCollectionUid(meta._id, contentType, xtreamId), + name: title, + contentType, + sourceType: 'xtream', + playlistId: meta._id, + playlistName: meta.title || meta.filename || 'Xtream', + logo: contentType === 'live' ? image : null, + posterUrl: contentType !== 'live' ? image : null, + xtreamId, + categoryId: record['category_id'] as string | number, + tvgId: contentType === 'live' ? String(xtreamId) : undefined, + rating: this.getXtreamString(record['rating']), + addedAt: + normalizeStalkerDate( + this.getXtreamString(record['added_at']) ?? + this.getXtreamString(record['added']) ?? + '' + ) || new Date(0).toISOString(), + position: index, + contentId, + }; + } + + private hasElectronFavoritesApi(): boolean { + return typeof window.electron?.dbGetAllGlobalFavorites === 'function'; + } + + private hasElectronPlaylistFavoritesApi(): boolean { + return typeof window.electron?.dbGetFavorites === 'function'; + } + + private hasElectronReorderApi(): boolean { + return typeof window.electron?.dbReorderGlobalFavorites === 'function'; + } + + private getXtreamItemId(item: UnifiedCollectionItem): number | null { + const value = Number(item.contentId ?? item.xtreamId); + return Number.isFinite(value) && value > 0 ? value : null; + } + + private getPwaXtreamContentType( + item: Record + ): UnifiedCollectionItem['contentType'] { + if (item['series_id'] != null) { + return 'series'; + } + + return xtreamContentType( + String(item['type'] ?? item['stream_type'] ?? 'movie') + ); + } + + private getXtreamString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 + ? value + : undefined; + } + + private getXtreamNumericValue( + item: Record, + keys: string[] + ): number { + for (const key of keys) { + const value = Number(item[key]); + if (Number.isFinite(value) && value > 0) { + return value; + } + } + + return 0; + } + private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem { const ct = xtreamContentType(row.type); return { diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts index f7c3e50fd..083a11322 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.spec.ts @@ -10,6 +10,7 @@ import { } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from './unified-collection-item.interface'; import { UnifiedRecentDataService } from './unified-recent-data.service'; +import { XTREAM_COLLECTION_DATA_SOURCE } from './xtream-collection-data-source.token'; describe('UnifiedRecentDataService', () => { let service: UnifiedRecentDataService; @@ -33,6 +34,12 @@ describe('UnifiedRecentDataService', () => { addRecentItem: jest.Mock; getContentByXtreamId: jest.Mock; }; + let xtreamDataSource: { + addRecentItem: jest.Mock; + clearRecentItems: jest.Mock; + getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; + }; const playlistMeta = { _id: 'm3u-1', @@ -87,6 +94,12 @@ describe('UnifiedRecentDataService', () => { ]; beforeEach(() => { + Object.defineProperty(window, 'electron', { + value: { + dbGetRecentlyViewed: jest.fn(), + } as unknown as Window['electron'], + configurable: true, + }); store = { select: jest.fn(() => of([playlistMeta])), dispatch: jest.fn(), @@ -136,6 +149,12 @@ describe('UnifiedRecentDataService', () => { addRecentItem: jest.fn().mockResolvedValue(true), getContentByXtreamId: jest.fn().mockResolvedValue(null), }; + xtreamDataSource = { + addRecentItem: jest.fn().mockResolvedValue(undefined), + clearRecentItems: jest.fn().mockResolvedValue(undefined), + getRecentItems: jest.fn().mockResolvedValue([]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; TestBed.configureTestingModule({ providers: [ @@ -143,6 +162,10 @@ describe('UnifiedRecentDataService', () => { { provide: Store, useValue: store }, { provide: PlaylistsService, useValue: playlistsService }, { provide: DatabaseService, useValue: dbService }, + { + provide: XTREAM_COLLECTION_DATA_SOURCE, + useValue: xtreamDataSource, + }, ], }); diff --git a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts index 4264c067d..84a51e474 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-recent-data.service.ts @@ -26,6 +26,10 @@ import { } from './unified-collection-item.interface'; import { CollectionScope } from './scope-toggle.service'; import { xtreamContentType } from './collection-helpers'; +import { + XTREAM_COLLECTION_DATA_SOURCE, + XtreamCollectionDataSourceItem, +} from './xtream-collection-data-source.token'; type PlaylistWithChannels = Playlist & { readonly playlist?: { readonly items?: Channel[] }; @@ -35,6 +39,7 @@ type PlaylistWithChannels = Playlist & { export class UnifiedRecentDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); + private readonly xtreamDataSource = inject(XTREAM_COLLECTION_DATA_SOURCE); private readonly playlistsService = inject(PlaylistsService); async getRecentItems( @@ -55,10 +60,17 @@ export class UnifiedRecentDataService { return; } - await this.dbService.removeRecentItem( - item.contentId, - item.playlistId - ); + if (this.hasElectronRecentApi()) { + await this.dbService.removeRecentItem( + item.contentId, + item.playlistId + ); + } else { + await this.xtreamDataSource.removeRecentItem( + item.contentId, + item.playlistId + ); + } return; } @@ -126,7 +138,20 @@ export class UnifiedRecentDataService { const tasks: Promise[] = []; if (xtreamBatch.length > 0) { - tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + if (this.hasElectronRecentApi()) { + tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + } else { + tasks.push( + Promise.all( + xtreamBatch.map((item) => + this.xtreamDataSource.removeRecentItem( + item.contentId, + item.playlistId + ) + ) + ) + ); + } } for (const [playlistId, identities] of groupedByPlaylist) { @@ -153,7 +178,11 @@ export class UnifiedRecentDataService { playlistId?: string ): Promise { if (scope === 'playlist' && playlistId) { - await this.dbService.clearPlaylistRecentItems(playlistId); + if (this.hasElectronRecentApi()) { + await this.dbService.clearPlaylistRecentItems(playlistId); + } else { + await this.xtreamDataSource.clearRecentItems(playlistId); + } const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPlaylistRecentlyViewed(playlistId) ); @@ -161,7 +190,18 @@ export class UnifiedRecentDataService { return; } - await this.dbService.clearGlobalRecentlyViewed(); + if (this.hasElectronRecentApi()) { + await this.dbService.clearGlobalRecentlyViewed(); + } else { + const allMeta = await this.getAllMeta(); + await Promise.all( + allMeta + .filter((playlist) => Boolean(playlist.serverUrl)) + .map((playlist) => + this.xtreamDataSource.clearRecentItems(playlist._id) + ) + ); + } const playlists = (await firstValueFrom( this.playlistsService.getAllPlaylists() )) as Playlist[]; @@ -223,17 +263,29 @@ export class UnifiedRecentDataService { const contentId = item.contentId ?? (item.xtreamId != null - ? ( - await this.dbService.getContentByXtreamId( - item.xtreamId, - item.playlistId, - item.contentType - ) - )?.id + ? this.hasElectronRecentApi() + ? ( + await this.dbService.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ) + )?.id + : item.xtreamId : null); if (contentId != null) { - await this.dbService.addRecentItem(contentId, item.playlistId); + if (this.hasElectronRecentApi()) { + await this.dbService.addRecentItem( + contentId, + item.playlistId + ); + } else { + await this.xtreamDataSource.addRecentItem( + contentId, + item.playlistId + ); + } } return { @@ -309,6 +361,19 @@ export class UnifiedRecentDataService { } private async getXtreamGlobalRecent(): Promise { + if (!this.hasElectronRecentApi()) { + const allMeta = await this.getAllMeta(); + const results: UnifiedCollectionItem[] = []; + + for (const meta of allMeta.filter((playlist) => + Boolean(playlist.serverUrl) + )) { + results.push(...(await this.getPwaXtreamRecent(meta))); + } + + return results; + } + try { const rows = await this.dbService.getGlobalRecentlyViewed(); return (rows || []).map((row) => ({ @@ -339,6 +404,11 @@ export class UnifiedRecentDataService { private async getXtreamPlaylistRecent( playlistId: string ): Promise { + if (!this.hasElectronRecentApi()) { + const meta = await this.getPlaylistMeta(playlistId); + return meta ? this.getPwaXtreamRecent(meta) : []; + } + try { const rows = await this.dbService.getRecentItems(playlistId); const meta = await this.getPlaylistMeta(playlistId); @@ -368,6 +438,101 @@ export class UnifiedRecentDataService { } } + private async getPwaXtreamRecent( + meta: PlaylistMeta + ): Promise { + try { + const rows = await this.xtreamDataSource.getRecentItems(meta._id); + return rows.map((row) => this.mapPwaXtreamRecentItem(row, meta)); + } catch { + return []; + } + } + + private mapPwaXtreamRecentItem( + row: XtreamCollectionDataSourceItem, + meta: PlaylistMeta + ): UnifiedCollectionItem { + const record = row as unknown as Record; + const contentType = this.getPwaXtreamContentType(record); + const xtreamId = this.getXtreamNumericValue(record, [ + 'xtream_id', + 'stream_id', + 'series_id', + 'id', + ]); + const contentId = this.getXtreamNumericValue(record, [ + 'id', + 'stream_id', + 'series_id', + 'xtream_id', + ]); + const title = + this.getXtreamString(record['title']) ?? + this.getXtreamString(record['name']) ?? + this.getXtreamString(record['stream_display_name']) ?? + 'Unknown'; + const image = + this.getXtreamString(record['poster_url']) ?? + this.getXtreamString(record['stream_icon']) ?? + this.getXtreamString(record['cover']) ?? + null; + + return { + uid: buildXtreamCollectionUid(meta._id, contentType, xtreamId), + name: title, + contentType, + sourceType: 'xtream', + playlistId: meta._id, + playlistName: meta.title || meta.filename || 'Xtream', + logo: contentType === 'live' ? image : null, + posterUrl: contentType !== 'live' ? image : null, + xtreamId, + categoryId: record['category_id'] as string | number, + tvgId: contentType === 'live' ? String(xtreamId) : undefined, + contentId, + viewedAt: normalizeStalkerDate( + this.getXtreamString(record['viewed_at']) ?? '' + ), + }; + } + + private hasElectronRecentApi(): boolean { + return typeof window.electron?.dbGetRecentlyViewed === 'function'; + } + + private getPwaXtreamContentType( + item: Record + ): UnifiedCollectionItem['contentType'] { + if (item['series_id'] != null) { + return 'series'; + } + + return xtreamContentType( + String(item['type'] ?? item['stream_type'] ?? 'movie') + ); + } + + private getXtreamString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 + ? value + : undefined; + } + + private getXtreamNumericValue( + item: Record, + keys: string[] + ): number { + for (const key of keys) { + const value = Number(item[key]); + if (Number.isFinite(value) && value > 0) { + return value; + } + } + + return 0; + } + private async getM3uGlobalRecent(): Promise { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; diff --git a/libs/portal/shared/util/src/lib/collection/xtream-collection-data-source.token.ts b/libs/portal/shared/util/src/lib/collection/xtream-collection-data-source.token.ts new file mode 100644 index 000000000..29406aae5 --- /dev/null +++ b/libs/portal/shared/util/src/lib/collection/xtream-collection-data-source.token.ts @@ -0,0 +1,61 @@ +import { InjectionToken } from '@angular/core'; + +export interface XtreamCollectionDataSourceItem { + readonly id?: number; + readonly category_id?: number | string; + readonly title?: string; + readonly name?: string; + readonly rating?: string; + readonly added?: string; + readonly added_at?: string; + readonly viewed_at?: string; + readonly poster_url?: string; + readonly backdrop_url?: string | null; + readonly xtream_id?: number; + readonly type?: string; + readonly stream_type?: 'live' | 'movie'; + readonly stream_id?: number; + readonly stream_icon?: string; + readonly stream_display_name?: string; + readonly cover?: string; + readonly series_id?: number; +} + +export interface XtreamCollectionDataSource { + getFavorites(playlistId: string): Promise; + addFavorite( + contentId: number, + playlistId: string, + backdropUrl?: string + ): Promise; + removeFavorite(contentId: number, playlistId: string): Promise; + getRecentItems( + playlistId: string + ): Promise; + addRecentItem( + contentId: number, + playlistId: string, + backdropUrl?: string + ): Promise; + removeRecentItem(contentId: number, playlistId: string): Promise; + clearRecentItems(playlistId: string): Promise; +} + +const noopXtreamCollectionDataSource: XtreamCollectionDataSource = { + getFavorites: async () => [], + addFavorite: async () => undefined, + removeFavorite: async () => undefined, + getRecentItems: async () => [], + addRecentItem: async () => undefined, + removeRecentItem: async () => undefined, + clearRecentItems: async () => undefined, +}; + +export const XTREAM_COLLECTION_DATA_SOURCE = + new InjectionToken( + 'XtreamCollectionDataSource', + { + providedIn: 'root', + factory: () => noopXtreamCollectionDataSource, + } + ); diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 350f89d1e..6098810e9 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -2,7 +2,11 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; -import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state'; +import { + selectAllPlaylistsMeta, + selectPlaylistsLoadingFlag, +} from '@iptvnator/m3u-state'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; @@ -68,6 +72,12 @@ describe('DashboardDataService', () => { removeFromFavorites: jest.fn().mockResolvedValue(undefined), removeRecentItem: jest.fn().mockResolvedValue(undefined), }; + const xtreamDataSourceMock = { + getFavorites: jest.fn().mockResolvedValue([]), + getRecentItems: jest.fn().mockResolvedValue([]), + removeFavorite: jest.fn().mockResolvedValue(undefined), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; const playlistMock: Playlist = { _id: 'm3u-1', title: 'M3U Playlist', @@ -114,7 +124,11 @@ describe('DashboardDataService', () => { beforeEach(() => { Object.defineProperty(window, 'electron', { - value: {} as Window['electron'], + value: { + dbGetAllGlobalFavorites: jest.fn(), + dbGetGlobalRecentlyAdded: jest.fn(), + dbGetRecentlyViewed: jest.fn(), + } as unknown as Window['electron'], configurable: true, }); playlistsLoadedSignal.set(true); @@ -138,6 +152,14 @@ describe('DashboardDataService', () => { dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]); dbServiceMock.removeFromFavorites.mockClear(); dbServiceMock.removeRecentItem.mockClear(); + xtreamDataSourceMock.getFavorites.mockClear(); + xtreamDataSourceMock.getFavorites.mockResolvedValue([]); + xtreamDataSourceMock.getRecentItems.mockClear(); + xtreamDataSourceMock.getRecentItems.mockResolvedValue([]); + xtreamDataSourceMock.removeFavorite.mockClear(); + xtreamDataSourceMock.removeFavorite.mockResolvedValue(undefined); + xtreamDataSourceMock.removeRecentItem.mockClear(); + xtreamDataSourceMock.removeRecentItem.mockResolvedValue(undefined); storeMock.dispatch.mockClear(); TestBed.configureTestingModule({ @@ -149,6 +171,10 @@ describe('DashboardDataService', () => { provide: PlaylistsService, useValue: playlistsServiceMock, }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSourceMock, + }, { provide: TranslateService, useValue: { @@ -324,6 +350,48 @@ describe('DashboardDataService', () => { ).toHaveLength(1); }); + it('includes PWA Xtream favorites from the data source when Electron DB APIs are unavailable', async () => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); + xtreamDataSourceMock.getFavorites.mockResolvedValue([ + { + id: 20203, + stream_id: 20203, + category_id: 206, + title: 'PWA Movie', + type: 'movie', + poster_url: 'https://example.com/pwa-movie.png', + backdrop_url: 'https://example.com/pwa-movie-backdrop.png', + xtream_id: 20203, + added: '1774298340', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled(); + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(service.globalFavoriteItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 20203, + title: 'PWA Movie', + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + poster_url: 'https://example.com/pwa-movie.png', + backdrop_url: 'https://example.com/pwa-movie-backdrop.png', + source: 'xtream', + xtream_id: 20203, + }), + ]) + ); + }); + it('builds the M3U favorites route', async () => { await service.reloadGlobalFavorites(); const m3uItem = service @@ -419,6 +487,47 @@ describe('DashboardDataService', () => { ); }); + it('includes PWA Xtream recent items from the data source when Electron DB APIs are unavailable', async () => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); + xtreamDataSourceMock.getRecentItems.mockResolvedValue([ + { + id: 20203, + stream_id: 20203, + category_id: 206, + title: 'PWA Recent Movie', + type: 'movie', + poster_url: 'https://example.com/pwa-recent.png', + backdrop_url: 'https://example.com/pwa-recent-backdrop.png', + xtream_id: 20203, + viewed_at: '2026-05-15T15:04:54.176Z', + }, + ]); + + await service.reloadGlobalRecentItems(); + + expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled(); + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(service.globalRecentItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 20203, + title: 'PWA Recent Movie', + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + viewed_at: '2026-05-15T15:04:54.176Z', + source: 'xtream', + xtream_id: 20203, + }), + ]) + ); + }); + it('prioritizes recently used Xtream sources over newer imported M3U sources', async () => { playlistsSignal.set([ { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index c06a46ca8..b8a92391e 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -20,6 +20,10 @@ import { GlobalRecentlyAddedKind, PlaylistsService, } from '@iptvnator/services'; +import { + XTREAM_DATA_SOURCE, + XtreamContentItem, +} from '@iptvnator/portal/xtream/data-access'; import { buildPlaylistRecentItems, Channel, @@ -38,6 +42,7 @@ import { mapDbFavoriteToItem, mapDbRecentlyAddedToItem, mapDbRecentToItem, + normalizeActivityType, toDateTimestamp, toTimestamp, } from './dashboard-mappers'; @@ -64,6 +69,7 @@ export type DashboardRecentlyAddedFilterKind = GlobalRecentlyAddedKind; export class DashboardDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); private readonly playlistsService = inject(PlaylistsService); private readonly ngZone = inject(NgZone); private readonly translate = inject(TranslateService); @@ -297,8 +303,8 @@ export class DashboardDataService { this.globalRecentLoadingState.set(true); } - if (!window.electron) { - this.xtreamGlobalRecentItems.set([]); + if (!this.hasElectronGlobalRecentApi()) { + await this.reloadPwaXtreamGlobalRecentItems(); this.globalRecentDbLoadedState.set(true); this.finishInitialGlobalRecentLoadIfReady(); return; @@ -399,8 +405,9 @@ export class DashboardDataService { } private async reloadXtreamGlobalFavorites(): Promise { - if (!window.electron) { - this.xtreamGlobalFavorites.set([]); + if (!this.hasElectronGlobalFavoritesApi()) { + const favorites = await this.getPwaXtreamGlobalFavorites(); + this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites)); this.finishInitialGlobalFavoritesLoadIfReady(); return; } @@ -427,6 +434,184 @@ export class DashboardDataService { this.finishInitialGlobalFavoritesLoadIfReady(); } + private async reloadPwaXtreamGlobalRecentItems(): Promise { + const items = await this.getPwaXtreamGlobalRecentItems(); + this.ngZone.run(() => this.xtreamGlobalRecentItems.set(items)); + } + + private async getPwaXtreamGlobalFavorites(): Promise< + DashboardFavoriteItem[] + > { + const results: DashboardFavoriteItem[] = []; + + for (const playlist of this.getXtreamPlaylists()) { + try { + const favorites = await this.xtreamDataSource.getFavorites( + playlist._id + ); + results.push( + ...favorites.map((item) => + this.mapXtreamDataSourceFavorite(item, playlist) + ) + ); + } catch { + // Keep the dashboard usable if one PWA playlist cache is stale. + } + } + + return results; + } + + private async getPwaXtreamGlobalRecentItems(): Promise { + const results: GlobalRecentItem[] = []; + + for (const playlist of this.getXtreamPlaylists()) { + try { + const recentItems = await this.xtreamDataSource.getRecentItems( + playlist._id + ); + results.push( + ...recentItems.map((item) => + this.mapXtreamDataSourceRecent(item, playlist) + ) + ); + } catch { + // Keep the dashboard usable if one PWA playlist cache is stale. + } + } + + return results; + } + + private getXtreamPlaylists(): PlaylistMeta[] { + return this.playlists().filter((playlist) => !!playlist.serverUrl); + } + + private mapXtreamDataSourceFavorite( + item: XtreamContentItem, + playlist: PlaylistMeta + ): DashboardFavoriteItem { + return { + ...this.mapXtreamDataSourceActivity(item, playlist), + added_at: this.getXtreamDate(item, 'added_at'), + }; + } + + private mapXtreamDataSourceRecent( + item: XtreamContentItem, + playlist: PlaylistMeta + ): GlobalRecentItem { + return { + ...this.mapXtreamDataSourceActivity(item, playlist), + viewed_at: this.getXtreamDate(item, 'viewed_at'), + }; + } + + private mapXtreamDataSourceActivity( + item: XtreamContentItem, + playlist: PlaylistMeta + ): Omit { + const record = item as unknown as Record; + const id = this.getXtreamNumericValue(record, [ + 'id', + 'stream_id', + 'series_id', + 'xtream_id', + ]); + const xtreamId = + this.getXtreamNumericValue(record, [ + 'xtream_id', + 'stream_id', + 'series_id', + 'id', + ]) ?? id; + + return { + id: id ?? String(record['stream_id'] ?? record['series_id'] ?? ''), + title: this.getXtreamTitle(record), + type: normalizeActivityType(this.getPwaXtreamActivityType(record)), + playlist_id: playlist._id, + playlist_name: + playlist.title || + playlist.filename || + this.translateText('WORKSPACE.DASHBOARD.XTREAM'), + category_id: (record['category_id'] as string | number) ?? '', + xtream_id: + xtreamId ?? + String(record['stream_id'] ?? record['series_id'] ?? ''), + poster_url: this.getXtreamImage(record), + backdrop_url: this.getXtreamString(record['backdrop_url']), + source: 'xtream', + }; + } + + private getPwaXtreamActivityType(item: Record): string { + if (item['series_id'] != null) { + return 'series'; + } + + return String(item['type'] ?? item['stream_type'] ?? 'movie'); + } + + private getXtreamTitle(item: Record): string { + return ( + this.getXtreamString(item['title']) ?? + this.getXtreamString(item['name']) ?? + this.getXtreamString(item['stream_display_name']) ?? + this.translateText('WORKSPACE.DASHBOARD.UNKNOWN_TITLE') + ); + } + + private getXtreamImage(item: Record): string { + return ( + this.getXtreamString(item['poster_url']) ?? + this.getXtreamString(item['stream_icon']) ?? + this.getXtreamString(item['cover']) ?? + '' + ); + } + + private getXtreamDate( + item: XtreamContentItem, + key: 'added_at' | 'viewed_at' + ): string { + const record = item as unknown as Record; + const value = + record[key] ?? + (key === 'added_at' ? record['added'] : undefined) ?? + new Date(0).toISOString(); + const timestamp = toTimestamp(value as string | number); + return timestamp ? new Date(timestamp).toISOString() : String(value); + } + + private getXtreamString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 + ? value + : undefined; + } + + private getXtreamNumericValue( + item: Record, + keys: string[] + ): number | null { + for (const key of keys) { + const value = Number(item[key]); + if (Number.isFinite(value) && value > 0) { + return value; + } + } + + return null; + } + + private hasElectronGlobalRecentApi(): boolean { + return typeof window.electron?.dbGetRecentlyViewed === 'function'; + } + + private hasElectronGlobalFavoritesApi(): boolean { + return typeof window.electron?.dbGetAllGlobalFavorites === 'function'; + } + private async reloadM3uGlobalFavorites(): Promise { const m3uPlaylists = this.playlists().filter( (playlist) => @@ -875,8 +1060,7 @@ export class DashboardDataService { const matchedFavoriteId = channelIdFavoritePosition !== undefined && (channelUrlFavoritePosition === undefined || - channelIdFavoritePosition <= - channelUrlFavoritePosition) + channelIdFavoritePosition <= channelUrlFavoritePosition) ? channelId : channelUrlFavoritePosition !== undefined ? channelUrl