From 6829c667bd2e32721874db1f93989f32a3c4ebea Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 14:18:11 +0300 Subject: [PATCH] fix(pwa): restore xtream rail and vod navigation --- apps/web-e2e/src/self-hosted.e2e.ts | 26 ++++ .../lib/navigation/portal-rail-links.spec.ts | 29 ++-- .../src/lib/navigation/portal-rail-links.ts | 11 -- .../pwa-xtream-data-source.spec.ts | 141 ++++++++++++++++++ .../data-sources/pwa-xtream-data-source.ts | 99 ++++++++++-- .../src/lib/database-electron.service.spec.ts | 47 ++++++ .../src/lib/database-electron.service.ts | 20 ++- 7 files changed, 335 insertions(+), 38 deletions(-) create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts create mode 100644 libs/services/src/lib/database-electron.service.spec.ts diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 8a5abc5c4..216840ee4 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -133,11 +133,37 @@ test('@self-hosted Xtream portal loads through web-backend proxy', async ({ page, }) => { const xtreamRequests = collectBackendRequests(page, '/xtream'); + const consoleErrors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') { + consoleErrors.push(message.text()); + } + }); await addXtreamPortal(page); + const rail = page.locator('app-workspace-shell-rail'); + await expect(rail.locator('a[aria-label="Movies"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Live TV"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Series"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Recently added"]')).toBeVisible(); + await expect(rail.locator('a[aria-label="Advanced search"]')).toBeVisible(); + const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + const vodItem = page.locator('app-grid-list mat-card').first(); + await expect(vodItem).toBeVisible({ timeout: 30_000 }); + await vodItem.click(); + await expect(page).toHaveURL(/\/workspace\/xtreams\/[^/]+\/vod\/\d+\/\d+/); + await expect( + page.getByRole('button', { name: 'Play', exact: true }) + ).toBeVisible({ timeout: 15_000 }); + + expect( + consoleErrors.filter((message) => + /db(SetAppState|GetContentByXtreamId)/.test(message) + ) + ).toEqual([]); expectRequestsUseTargetId(xtreamRequests, '/xtream'); }); diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts index b5bb6f5a1..9bdd78e5c 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.spec.ts @@ -24,7 +24,7 @@ describe('buildPortalRailLinks', () => { expect(links.secondary[2]?.tooltip).toBe('Downloads (this playlist)'); }); - it('builds workspace Xtream links without downloads on web', () => { + it('builds workspace Xtream content links on web without downloads', () => { const links = buildPortalRailLinks({ provider: 'xtreams', playlistId: 'xtream-web', @@ -32,24 +32,26 @@ describe('buildPortalRailLinks', () => { workspace: true, }); - expect(links.primary).toEqual([ - { - icon: 'movie', - tooltip: 'Xtream library (this playlist)', - path: ['/workspace', 'xtreams', 'xtream-web'], - exact: true, - section: 'library', - }, + expect(links.primary.map((link) => link.section)).toEqual([ + 'vod', + 'live', + 'series', ]); - expect(links.secondary).toEqual([]); + expect(links.secondary.map((link) => link.section)).toEqual([ + 'recently-added', + 'search', + ]); + expect( + links.secondary.some((link) => link.section === 'downloads') + ).toBe(false); }); - it('builds Stalker links with scoped tooltip labels', () => { + it('builds workspace Stalker links with scoped tooltip labels on web', () => { const links = buildPortalRailLinks({ provider: 'stalker', playlistId: 'portal-1', isElectron: false, - workspace: false, + workspace: true, }); expect(links.primary.map((link) => link.section)).toEqual([ @@ -63,6 +65,9 @@ describe('buildPortalRailLinks', () => { expect(links.primary[1]?.tooltip).toBe('Live TV (this playlist)'); expect(links.primary[2]?.tooltip).toBe('Radio (this playlist)'); expect(links.secondary[0]?.tooltip).toBe('Search (this playlist)'); + expect( + links.secondary.some((link) => link.section === 'downloads') + ).toBe(false); }); it('builds M3U playlist links with scoped tooltip labels', () => { diff --git a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts index cb335cefb..895476704 100644 --- a/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts +++ b/libs/portal/shared/util/src/lib/navigation/portal-rail-links.ts @@ -46,17 +46,6 @@ export function buildPortalRailLinks( const primary: PortalRailLink[] = []; const secondary: PortalRailLink[] = []; - if (workspace && !isElectron) { - primary.push({ - icon: 'movie', - tooltip: 'Xtream library (this playlist)', - path: root, - exact: true, - section: 'library', - }); - return { primary, secondary }; - } - primary.push( { icon: 'movie', diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts new file mode 100644 index 000000000..32ad75e03 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.spec.ts @@ -0,0 +1,141 @@ +import { TestBed } from '@angular/core/testing'; +import { PwaXtreamDataSource } from './pwa-xtream-data-source'; +import { + XtreamApiService, + XtreamCredentials, +} from '../services/xtream-api.service'; + +describe('PwaXtreamDataSource', () => { + let dataSource: PwaXtreamDataSource; + let apiService: { + getStreams: jest.Mock; + }; + + const credentials: XtreamCredentials = { + serverUrl: 'http://localhost:3211', + username: 'demo', + password: 'secret', + }; + + beforeEach(() => { + localStorage.clear(); + + apiService = { + getStreams: jest.fn(), + }; + + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { + provide: XtreamApiService, + useValue: apiService, + }, + ], + }); + + dataSource = TestBed.inject(PwaXtreamDataSource); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it('normalizes Xtream API stream identifiers for PWA catalog navigation', async () => { + apiService.getStreams.mockImplementation( + (_credentials: XtreamCredentials, type: string) => { + switch (type) { + case 'live': + return Promise.resolve([ + { + stream_id: 101, + name: 'News Live', + stream_icon: 'live.png', + category_id: '10', + added: '1', + }, + ]); + case 'movie': + return Promise.resolve([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + added: '2', + }, + ]); + case 'series': + return Promise.resolve([ + { + series_id: 303, + name: 'Series One', + cover: 'series.png', + category_id: 30, + last_modified: '3', + }, + ]); + default: + return Promise.resolve([]); + } + } + ); + + const live = (await dataSource.getContent( + 'playlist-1', + credentials, + 'live' + )) as Array>; + const vod = (await dataSource.getContent( + 'playlist-1', + credentials, + 'movie' + )) as Array>; + const series = (await dataSource.getContent( + 'playlist-1', + credentials, + 'series' + )) as Array>; + + expect(live[0]).toEqual( + expect.objectContaining({ + id: 101, + stream_id: 101, + title: 'News Live', + poster_url: 'live.png', + type: 'live', + xtream_id: 101, + }) + ); + expect(vod[0]).toEqual( + expect.objectContaining({ + id: 202, + stream_id: 202, + title: 'Movie One', + poster_url: 'movie.png', + type: 'movie', + xtream_id: 202, + }) + ); + expect(series[0]).toEqual( + expect.objectContaining({ + id: 303, + series_id: 303, + title: 'Series One', + poster_url: 'series.png', + type: 'series', + xtream_id: 303, + }) + ); + + await expect( + dataSource.getContentByXtreamId(202, 'playlist-1', 'movie') + ).resolves.toEqual( + expect.objectContaining({ + title: 'Movie One', + type: 'movie', + xtream_id: 202, + }) + ); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts index 26d3b3e70..476ad009b 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-data-source.ts @@ -37,9 +37,15 @@ const STORAGE_KEYS = { interface XtreamCachedContentItem { readonly added?: string; readonly category_id?: string | number; - readonly id?: number; + readonly cover?: string; + readonly cover_big?: string; + readonly id?: number | string; + readonly last_modified?: string; + readonly movie_image?: string; readonly name?: string; + readonly poster?: string; readonly poster_url?: string; + readonly rating?: string | number; readonly series_id?: number; readonly stream_display_name?: string; readonly stream_id?: number; @@ -47,6 +53,7 @@ interface XtreamCachedContentItem { readonly title?: string; readonly type?: string; readonly viewed_at?: string; + readonly xtream_id?: number | string; } interface StoredRecentItem { @@ -219,7 +226,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { onProgress?: (count: number) => void, onTotal?: (total: number) => void, options?: XtreamOperationOptions - ): Promise { + ): Promise< + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[] + | XtreamContentItem[] + > { void options; const cacheKey = `${playlistId}-${type}-content`; @@ -233,7 +245,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } // Fetch from API - const content = await this.apiService.getStreams(credentials, type); + const content = this.normalizeContentItems( + await this.apiService.getStreams(credentials, type), + type + ); // Report total and progress (PWA doesn't have incremental save, so report all at once) if (onTotal) { @@ -270,15 +285,73 @@ export class PwaXtreamDataSource implements IXtreamDataSource { options?: XtreamOperationOptions ): Promise { void options; - // In PWA mode, we just cache in memory + // In PWA mode, we just cache normalized API items in memory. const cacheKey = `${playlistId}-${type}-content`; - this.contentCache.set(cacheKey, streams); + const normalizedStreams = this.normalizeContentItems(streams, type); + this.contentCache.set(cacheKey, normalizedStreams); if (onProgress) { - onProgress(streams.length); + onProgress(normalizedStreams.length); } - return streams.length; + return normalizedStreams.length; + } + + private normalizeContentItems( + streams: + | XtreamLiveStream[] + | XtreamVodStream[] + | XtreamSerieItem[] + | XtreamContentItem[], + type: 'live' | 'movie' | 'series' + ): XtreamContentItem[] { + return streams.map((item) => + this.normalizeContentItem(item as XtreamCachedContentItem, type) + ); + } + + private normalizeContentItem( + item: XtreamCachedContentItem, + type: 'live' | 'movie' | 'series' + ): XtreamContentItem { + const xtreamId = this.getItemIdentity(item, type); + const id = Number(item.id); + const title = item.title ?? item.name ?? item.stream_display_name ?? ''; + const posterUrl = + item.poster_url ?? + item.stream_icon ?? + item.cover ?? + item.cover_big ?? + item.movie_image ?? + item.poster ?? + ''; + + return { + ...item, + added: item.added ?? item.last_modified ?? '', + category_id: item.category_id ?? '', + id: Number.isFinite(id) ? id : xtreamId, + name: item.name ?? title, + poster_url: posterUrl, + rating: String(item.rating ?? ''), + title, + type, + xtream_id: xtreamId, + } as XtreamContentItem; + } + + private getItemIdentity( + item: XtreamCachedContentItem, + type?: 'live' | 'movie' | 'series' + ): number { + const preferredId = + item.xtream_id ?? + (type === 'series' ? item.series_id : item.stream_id) ?? + item.stream_id ?? + item.series_id ?? + item.id; + const numericId = Number(preferredId); + return Number.isFinite(numericId) ? numericId : 0; } // ========================================================================= @@ -326,7 +399,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; for (const item of content) { - const itemId = item.stream_id || item.series_id || item.id; + const itemId = this.getItemIdentity(item); if (playlistFavorites.includes(itemId)) { results.push(item); } @@ -343,6 +416,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // is electron-only. Accept the param for interface parity. _backdropUrl?: string ): Promise { + void _backdropUrl; const allFavorites = this.getFavoritesFromStorage(); if (!allFavorites[playlistId]) { allFavorites[playlistId] = []; @@ -533,7 +607,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; for (const item of content) { - const itemId = item.stream_id || item.series_id || item.id; + const itemId = this.getItemIdentity(item); const recentEntry = playlistRecent.find((r) => r.id === itemId); if (recentEntry) { results.push({ @@ -559,6 +633,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, _backdropUrl?: string ): Promise { + void _backdropUrl; const allRecent = this.getRecentItemsFromStorage(); if (!allRecent[playlistId]) { allRecent[playlistId] = []; @@ -640,8 +715,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; const found = content.find((item) => { - const itemXtreamId = - item.stream_id || item.series_id || item.id; + const itemXtreamId = this.getItemIdentity(item, type); return itemXtreamId === xtreamId; }); @@ -667,8 +741,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const content = this.contentCache.get(cacheKey) || []; const found = content.find((item) => { - const itemXtreamId = - item.stream_id || item.series_id || item.id; + const itemXtreamId = this.getItemIdentity(item, type); return itemXtreamId === xtreamId; }); diff --git a/libs/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts new file mode 100644 index 000000000..06effcad6 --- /dev/null +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -0,0 +1,47 @@ +import { DatabaseService } from './database-electron.service'; + +describe('DatabaseService browser guards', () => { + const originalElectron = window.electron; + let service: DatabaseService; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + + service = new DatabaseService(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); + }); + + it('treats app state persistence as unavailable without the Electron bridge', async () => { + await expect(service.getAppState('xtream-key')).resolves.toBeNull(); + await expect( + service.setAppState('xtream-key', 'completed') + ).resolves.toBe(false); + await expect( + service.getXtreamImportStatus('playlist-1', 'movie') + ).resolves.toBe('idle'); + await expect( + service.setXtreamImportStatus('playlist-1', 'movie', 'completed') + ).resolves.toBe(false); + await expect( + service.getContentByXtreamId(20229, 'playlist-1', 'movie') + ).resolves.toBeNull(); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 49d3cf002..8b0da609d 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -157,11 +157,11 @@ export class DatabaseService { } supportsDbOperationEvents(): boolean { - return typeof window.electron.onDbOperationEvent === 'function'; + return typeof window.electron?.onDbOperationEvent === 'function'; } supportsDbOperationCancellation(): boolean { - return typeof window.electron.dbCancelOperation === 'function'; + return typeof window.electron?.dbCancelOperation === 'function'; } async cancelOperation(operationId: string): Promise { @@ -496,6 +496,10 @@ export class DatabaseService { playlistId: string, type: 'live' | 'movie' | 'series' ): Promise { + if (typeof window.electron?.dbClearXtreamImportCache !== 'function') { + return false; + } + try { await window.electron.dbClearXtreamImportCache(playlistId, type); return true; @@ -506,6 +510,10 @@ export class DatabaseService { } async getAppState(key: string): Promise { + if (typeof window.electron?.dbGetAppState !== 'function') { + return null; + } + try { return await window.electron.dbGetAppState(key); } catch (error) { @@ -515,6 +523,10 @@ export class DatabaseService { } async setAppState(key: string, value: string): Promise { + if (typeof window.electron?.dbSetAppState !== 'function') { + return false; + } + try { await window.electron.dbSetAppState(key, value); return true; @@ -861,6 +873,10 @@ export class DatabaseService { playlistId: string, contentType?: 'live' | 'movie' | 'series' ): Promise { + if (typeof window.electron?.dbGetContentByXtreamId !== 'function') { + return null; + } + try { return await window.electron.dbGetContentByXtreamId( xtreamId,