From 401d434bce038649d7e4db2bbff5224d5c873db6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 11:19:04 +0300 Subject: [PATCH 01/15] test(pwa): cover self-hosted provider target flow --- apps/web-e2e/src/self-hosted.e2e.ts | 61 +++++++++++++++++++++++++ apps/xtream-mock-server/src/main.ts | 41 +++++++++++++---- docs/architecture/xtream-mock-server.md | 10 ++++ 3 files changed, 104 insertions(+), 8 deletions(-) diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 0529f4baa..8a5abc5c4 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -48,6 +48,27 @@ async function addXtreamPortal(page: Page): Promise { await page.waitForURL(/xtreams.*vod/); } +async function addM3uPlaylist(page: Page): Promise { + await page.getByRole('button', { name: 'Add playlist' }).click(); + const dialog = page.locator('mat-dialog-container'); + await expect(dialog).toBeVisible(); + + await setInputValue( + dialog.getByRole('textbox', { name: /Playlist URL/ }), + `${XTREAM_MOCK_SERVER}/playlist.m3u` + ); + await setInputValue( + dialog.getByRole('textbox', { name: 'Playlist title' }), + 'Self-hosted M3U' + ); + + await dialog + .getByRole('button', { name: 'Add playlist', exact: true }) + .click(); + await expect(dialog).toBeHidden(); + await page.waitForURL(/playlists.*all/); +} + async function addStalkerPortal(page: Page): Promise { await page.getByRole('button', { name: 'Add playlist' }).click(); const dialog = page.locator('mat-dialog-container'); @@ -65,6 +86,27 @@ async function addStalkerPortal(page: Page): Promise { await page.waitForURL(/stalker.*vod/); } +function collectBackendRequests(page: Page, path: string): string[] { + const requests: string[] = []; + page.on('request', (request) => { + const requestUrl = request.url(); + if (requestUrl.startsWith(`${WEB_BACKEND_URL}${path}`)) { + requests.push(requestUrl); + } + }); + return requests; +} + +function expectRequestsUseTargetId(requests: string[], path: string): void { + expect(requests.length).toBeGreaterThan(0); + for (const requestUrl of requests) { + const url = new URL(requestUrl); + expect(url.pathname).toBe(path); + expect(url.searchParams.get('targetId')).toBeTruthy(); + expect(url.searchParams.has('url')).toBeFalsy(); + } +} + test.beforeEach(async ({ page, request }) => { await request.post(`${XTREAM_MOCK_SERVER}/reset`); await request.post(`${STALKER_MOCK_SERVER}/reset`); @@ -90,17 +132,36 @@ test('@self-hosted runtime config points PWA calls at the monorepo backend', asy test('@self-hosted Xtream portal loads through web-backend proxy', async ({ page, }) => { + const xtreamRequests = collectBackendRequests(page, '/xtream'); + await addXtreamPortal(page); const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + expectRequestsUseTargetId(xtreamRequests, '/xtream'); +}); + +test('@self-hosted M3U URL loads through web-backend proxy', async ({ + page, +}) => { + const parseRequests = collectBackendRequests(page, '/parse'); + + await addM3uPlaylist(page); + + await expect(page.getByText('4 channels')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByText('1. Channel 1')).toBeVisible(); + await expect(page.getByText('4. HappyKids TV')).toBeVisible(); + expectRequestsUseTargetId(parseRequests, '/parse'); }); test('@self-hosted Stalker portal loads through web-backend proxy', async ({ page, }) => { + const stalkerRequests = collectBackendRequests(page, '/stalker'); + await addStalkerPortal(page); const categoryItems = page.locator('.category-item'); await expect(categoryItems.first()).toBeVisible({ timeout: 15_000 }); + expectRequestsUseTargetId(stalkerRequests, '/stalker'); }); diff --git a/apps/xtream-mock-server/src/main.ts b/apps/xtream-mock-server/src/main.ts index 34d8b8e18..648a15c34 100644 --- a/apps/xtream-mock-server/src/main.ts +++ b/apps/xtream-mock-server/src/main.ts @@ -9,6 +9,16 @@ import { renderMarketingAssetSvg } from './app/generators/marketing.generator.js const app = express(); const PORT = parseInt(process.env['PORT'] ?? '3211', 10); +const M3U_FIXTURE = `#EXTM3U +#EXTINF:0 tvg-id="1" tvg-logo="http://channel.icons.url/img/1.png" group-title="News", Channel 1 +https://example.channels/path-to-file/1.m3u8 +#EXTINF:0 tvg-id="2" tvg-logo="http://channel.icons.url/img/2.png" group-title="News", Positive News TV +https://example.channels/path-to-file/2.m3u8 +#EXTINF:0 tvg-id="3" tvg-logo="http://channel.icons.url/img/3.png" group-title="Sport", Sport TVX +https://example.channels/path-to-file/3.m3u8 +#EXTINF:0 tvg-id="4" tvg-logo="http://channel.icons.url/img/4.png" group-title="Kids", HappyKids TV +https://example.channels/path-to-file/4.m3u8 +`; const marketingRasterAssetRoot = join( process.cwd(), 'apps/xtream-mock-server/public/marketing' @@ -28,6 +38,13 @@ app.post('/reset', (_req, res) => { res.json({ status: 'reset' }); }); +// ─── M3U fixture endpoint for self-hosted PWA URL import tests ─────────────── +app.get('/playlist.m3u', (_req, res) => { + res.type('audio/x-mpegurl') + .set('Cache-Control', 'no-store') + .send(M3U_FIXTURE); +}); + // ─── Local fictional artwork for release screenshots ────────────────────────── app.get('/assets/marketing/:kind/:slug', (req, res) => { const kind = req.params['kind'] as @@ -46,18 +63,20 @@ app.get('/assets/marketing/:kind/:slug', (req, res) => { } const rasterSlug = slug.replace(/\.(svg|png)$/i, ''); - const rasterPath = join(marketingRasterAssetRoot, kind, `${rasterSlug}.png`); + const rasterPath = join( + marketingRasterAssetRoot, + kind, + `${rasterSlug}.png` + ); if (existsSync(rasterPath)) { - res - .type('image/png') + res.type('image/png') .set('Cache-Control', 'public, max-age=3600') .send(readFileSync(rasterPath)); return; } - res - .type('image/svg+xml') + res.type('image/svg+xml') .set('Cache-Control', 'public, max-age=3600') .send(renderMarketingAssetSvg(kind, slug, size)); }); @@ -149,8 +168,12 @@ server.on('error', (err: NodeJS.ErrnoException) => { server.listen(PORT, () => { console.log(`[xtream-mock] Listening on http://localhost:${PORT}`); - console.log(`[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info`); - console.log(`[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info`); + console.log( + `[xtream-mock] Direct API: http://localhost:${PORT}/player_api.php?username=user1&password=pass1&action=get_account_info` + ); + console.log( + `[xtream-mock] PWA proxy: http://localhost:${PORT}/xtream?url=http://localhost:${PORT}&username=user1&password=pass1&action=get_account_info` + ); console.log(`[xtream-mock] Health: http://localhost:${PORT}/health`); }); @@ -173,4 +196,6 @@ process.on('unhandledRejection', (reason) => { // When Nx (or any process manager) closes stdin, prevent auto-exit. // The HTTP server handle is what keeps the process alive. process.stdin.resume(); -process.stdin.on('end', () => { /* ignore stdin close */ }); +process.stdin.on('end', () => { + /* ignore stdin close */ +}); diff --git a/docs/architecture/xtream-mock-server.md b/docs/architecture/xtream-mock-server.md index 7e52683b2..a41f89880 100644 --- a/docs/architecture/xtream-mock-server.md +++ b/docs/architecture/xtream-mock-server.md @@ -95,6 +95,16 @@ Response: `{ payload: , action: }` This mirrors the backend proxy in `apps/electron-backend` so the same Angular service code works in both environments. +### M3U fixture endpoint + +``` +GET /playlist.m3u +``` + +Returns a small deterministic four-channel playlist. The self-hosted PWA E2E +suite uses this endpoint to verify M3U URL imports through `apps/web-backend` +and the provider target registry. + ### Stream stub endpoints ``` From 89df1ada0a15128280e3412071d4d14cb6d93664 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 13:36:32 +0300 Subject: [PATCH 02/15] fix(docker): exclude agent worktrees from build context --- .dockerignore | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/.dockerignore b/.dockerignore index b1254b53c..103a72d8f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,13 +7,53 @@ .angular .electron .husky +.nx +.agent +.agents +.aider* +.amp +.augment +.claude +.codeium +.codex +.continue +.crush +.cursor +.entire +.gemini +.goose +.idea +.kiro +.opencode +.plans +.qodo +.roo +.serena +.supermaven +.tabnine +.windsurf +.zed +.worktrees +worktrees Dockerfile README.md dist e2e node_modules +**/node_modules screenshots test-results coverage build release +tmp +out-tsc +playwright-report +.astro +.cache +.next +.parcel-cache +.svelte-kit +.turbo +.vercel +apps/xtream-mock-server/public/marketing From 6829c667bd2e32721874db1f93989f32a3c4ebea Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 14:18:11 +0300 Subject: [PATCH 03/15] 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, From 362595525d96de5af7df368f42bb9ec7cf7857b4 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 18:44:42 +0300 Subject: [PATCH 04/15] test(pwa): tighten self-hosted proxy assertions --- apps/web-e2e/src/self-hosted.e2e.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web-e2e/src/self-hosted.e2e.ts b/apps/web-e2e/src/self-hosted.e2e.ts index 216840ee4..daaf7eb73 100644 --- a/apps/web-e2e/src/self-hosted.e2e.ts +++ b/apps/web-e2e/src/self-hosted.e2e.ts @@ -90,7 +90,8 @@ function collectBackendRequests(page: Page, path: string): string[] { const requests: string[] = []; page.on('request', (request) => { const requestUrl = request.url(); - if (requestUrl.startsWith(`${WEB_BACKEND_URL}${path}`)) { + const url = new URL(requestUrl); + if (url.origin === WEB_BACKEND_URL && url.pathname === path) { requests.push(requestUrl); } }); @@ -102,8 +103,9 @@ function expectRequestsUseTargetId(requests: string[], path: string): void { for (const requestUrl of requests) { const url = new URL(requestUrl); expect(url.pathname).toBe(path); - expect(url.searchParams.get('targetId')).toBeTruthy(); - expect(url.searchParams.has('url')).toBeFalsy(); + expect(url.searchParams.get('targetId')).not.toBeNull(); + expect(url.searchParams.get('targetId')).not.toBe(''); + expect(url.searchParams.has('url')).toBe(false); } } From 1d4d793aac6209c62600147c571c1a1cb0956bc0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 19:23:25 +0300 Subject: [PATCH 05/15] fix(pwa): restore xtream user collections --- .../unified-favorites-data.service.spec.ts | 94 +++++++++++ .../unified-favorites-data.service.ts | 126 +++++++++++++-- .../unified-recent-data.service.spec.ts | 108 +++++++++++++ .../collection/unified-recent-data.service.ts | 130 +++++++++++++-- .../pwa-xtream-data-source.spec.ts | 46 ++++++ .../data-sources/pwa-xtream-data-source.ts | 107 ++++++++++-- .../xtream-data-source.interface.ts | 1 + .../lib/services/favorites.service.spec.ts | 80 +++++++++ .../src/lib/services/favorites.service.ts | 19 ++- .../src/lib/with-favorites.feature.spec.ts | 131 +++++++++++---- .../src/lib/with-favorites.feature.ts | 152 +++++++++--------- .../src/lib/with-recent-items.feature.spec.ts | 147 +++++++++++++++-- .../data-access/src/lib/with-recent-items.ts | 128 ++++++++++----- .../src/lib/database-electron.service.spec.ts | 31 ++++ .../src/lib/database-electron.service.ts | 52 ++++++ .../src/lib/dashboard-data.service.spec.ts | 95 ++++++++++- .../src/lib/dashboard-data.service.ts | 97 ++++++++++- 17 files changed, 1339 insertions(+), 205 deletions(-) create mode 100644 libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index 91826b2af..66c182087 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -10,6 +10,7 @@ import { StalkerPortalItem, } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { UnifiedFavoritesDataService } from './unified-favorites-data.service'; describe('UnifiedFavoritesDataService', () => { @@ -24,6 +25,12 @@ describe('UnifiedFavoritesDataService', () => { getContentByXtreamId: jest.Mock; getFavorites: jest.Mock; }; + let xtreamDataSource: { + addFavorite: jest.Mock; + getContentByXtreamId: jest.Mock; + getFavorites: jest.Mock; + removeFavorite: jest.Mock; + }; let store: { dispatch: jest.Mock; select: jest.Mock; @@ -111,6 +118,12 @@ describe('UnifiedFavoritesDataService', () => { getContentByXtreamId: jest.fn().mockResolvedValue(null), getFavorites: jest.fn().mockResolvedValue([]), }; + xtreamDataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn().mockResolvedValue(null), + getFavorites: jest.fn().mockResolvedValue([]), + removeFavorite: jest.fn().mockResolvedValue(undefined), + }; store = { dispatch: jest.fn(), select: jest.fn(() => @@ -141,6 +154,10 @@ describe('UnifiedFavoritesDataService', () => { provide: DatabaseService, useValue: databaseService, }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSource, + }, { provide: PlaylistsService, useValue: playlistsService, @@ -223,6 +240,52 @@ describe('UnifiedFavoritesDataService', () => { ); }); + it('loads Xtream playlist favorites through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + xtreamDataSource.getFavorites.mockResolvedValue([ + { + id: 202, + category_id: 20, + title: 'Movie One', + type: 'movie', + poster_url: 'movie.png', + xtream_id: 202, + added_at: '2026-05-21T12:00:00.000Z', + }, + ]); + + const items = await service.getFavorites( + 'playlist', + 'xtream-1', + 'xtream' + ); + + expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith('xtream-1'); + expect(databaseService.getFavorites).not.toHaveBeenCalled(); + expect(items).toEqual([ + expect.objectContaining({ + uid: 'xtream::xtream-1::movie:202', + sourceType: 'xtream', + contentType: 'movie', + name: 'Movie One', + playlistName: 'Xtream PWA', + posterUrl: 'movie.png', + }), + ]); + }); + it('preserves persisted M3U favorites order when extracting playlist favorites', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ @@ -400,6 +463,37 @@ describe('UnifiedFavoritesDataService', () => { ); }); + it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSource.getContentByXtreamId.mockResolvedValue(null); + + await service.addFavorite({ + uid: 'xtream::xtream-1::movie:101', + name: 'Xtream Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + posterUrl: 'movie.png', + xtreamId: 101, + } satisfies UnifiedCollectionItem); + + expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith( + 101, + 'xtream-1', + 'movie' + ); + expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith( + 101, + 'xtream-1', + 'movie.png' + ); + expect(electronApi.dbAddFavorite).not.toHaveBeenCalled(); + }); + it('adds Stalker favorites through portal favorites', async () => { await service.addFavorite({ uid: 'stalker::stalker-1::101', diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index cb53d8c00..a623ffbb9 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -25,6 +25,10 @@ import { xtreamContentType, XtreamFavoriteRow, } from '@iptvnator/portal/shared/util'; +import { + XTREAM_DATA_SOURCE, + XtreamContentItem, +} from '@iptvnator/portal/xtream/data-access'; const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1'; @@ -43,6 +47,7 @@ export class UnifiedFavoritesDataService { private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); private readonly translate = inject(TranslateService); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); async getFavorites( scope: CollectionScope, @@ -86,12 +91,20 @@ export class UnifiedFavoritesDataService { break; } case 'xtream': - if (window.electron && item.contentId != null) { + if (item.contentId == null) { + return; + } + if (window.electron) { await window.electron.dbRemoveFavorite( item.contentId, item.playlistId ); + return; } + await this.xtreamDataSource.removeFavorite( + item.contentId, + item.playlistId + ); break; case 'stalker': { const sourceItemId = item.uid.split('::')[2]; @@ -134,26 +147,25 @@ 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 + ? await this.resolveXtreamContentId(item) : null); if (contentId == null) { return; } + if (!window.electron) { + await this.xtreamDataSource.addFavorite( + contentId, + item.playlistId, + item.posterUrl ?? item.logo ?? undefined + ); + return; + } + await window.electron.dbAddFavorite( contentId, item.playlistId, @@ -346,6 +358,22 @@ export class UnifiedFavoritesDataService { ): Promise { const electron = window.electron; if (!electron) { + await Promise.all( + items + .filter( + ( + item + ): item is UnifiedCollectionItem & { + readonly contentId: number; + } => item.contentId != null + ) + .map((item) => + this.xtreamDataSource.removeFavorite( + item.contentId, + item.playlistId + ) + ) + ); return; } @@ -486,6 +514,17 @@ export class UnifiedFavoritesDataService { } private async getXtreamAllFavorites(): Promise { + if (!window.electron) { + const allMeta = await this.getAllMeta(); + const results: UnifiedCollectionItem[] = []; + for (const meta of allMeta.filter((p) => p._id && p.serverUrl)) { + results.push( + ...(await this.getXtreamPlaylistFavorites(meta._id)) + ); + } + return results; + } + if (!window.electron?.dbGetAllGlobalFavorites) return []; try { const rows = @@ -499,10 +538,17 @@ export class UnifiedFavoritesDataService { private async getXtreamPlaylistFavorites( playlistId: string ): Promise { - if (!window.electron) return []; try { - const rows = await this.dbService.getFavorites(playlistId); const meta = await this.getPlaylistMeta(playlistId); + if (!window.electron) { + const rows = + await this.xtreamDataSource.getFavorites(playlistId); + return rows.map((row) => + this.mapXtreamContentItem(row, playlistId, meta?.title) + ); + } + + const rows = await this.dbService.getFavorites(playlistId); return (rows as unknown as XtreamFavoriteRow[]).map((r) => ({ ...this.mapXtreamRow(r), playlistId, @@ -513,6 +559,31 @@ export class UnifiedFavoritesDataService { } } + private async resolveXtreamContentId( + item: UnifiedCollectionItem + ): Promise { + if (item.xtreamId == null) { + return null; + } + + if (!window.electron) { + const content = await this.xtreamDataSource.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + return content?.id ?? item.xtreamId; + } + + const content = await this.dbService.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + + return content?.id ?? null; + } + private mapXtreamRow(row: XtreamFavoriteRow): UnifiedCollectionItem { const ct = xtreamContentType(row.type); return { @@ -535,6 +606,33 @@ export class UnifiedFavoritesDataService { }; } + private mapXtreamContentItem( + item: XtreamContentItem, + playlistId: string, + playlistName?: string + ): UnifiedCollectionItem { + const ct = xtreamContentType(item.type); + return { + uid: buildXtreamCollectionUid(playlistId, ct, item.xtream_id), + name: item.title, + contentType: ct, + sourceType: 'xtream', + playlistId, + playlistName: playlistName ?? item.playlist_name ?? 'Xtream', + logo: ct === 'live' ? (item.poster_url ?? null) : null, + posterUrl: ct !== 'live' ? (item.poster_url ?? null) : null, + xtreamId: item.xtream_id, + categoryId: item.category_id, + tvgId: ct === 'live' ? String(item.xtream_id) : undefined, + rating: item.rating ?? undefined, + addedAt: + normalizeStalkerDate(item.added_at ?? item.added) || + new Date(0).toISOString(), + position: item.position ?? 0, + contentId: item.id, + }; + } + private async getStalkerAllFavorites(): Promise { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 2557e7dd9..418f1fd61 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -9,6 +9,7 @@ import { PlaylistMeta, } from '@iptvnator/shared/interfaces'; import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { UnifiedRecentDataService } from './unified-recent-data.service'; describe('UnifiedRecentDataService', () => { @@ -33,6 +34,12 @@ describe('UnifiedRecentDataService', () => { addRecentItem: jest.Mock; getContentByXtreamId: jest.Mock; }; + let xtreamDataSource: { + addRecentItem: jest.Mock; + getContentByXtreamId: jest.Mock; + getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; + }; const playlistMeta = { _id: 'm3u-1', @@ -87,6 +94,10 @@ describe('UnifiedRecentDataService', () => { ]; beforeEach(() => { + Object.defineProperty(window, 'electron', { + value: {} as Window['electron'], + configurable: true, + }); store = { select: jest.fn(() => of([playlistMeta])), dispatch: jest.fn(), @@ -136,6 +147,12 @@ describe('UnifiedRecentDataService', () => { addRecentItem: jest.fn().mockResolvedValue(true), getContentByXtreamId: jest.fn().mockResolvedValue(null), }; + xtreamDataSource = { + addRecentItem: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn().mockResolvedValue(null), + getRecentItems: jest.fn().mockResolvedValue([]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; TestBed.configureTestingModule({ providers: [ @@ -143,6 +160,10 @@ describe('UnifiedRecentDataService', () => { { provide: Store, useValue: store }, { provide: PlaylistsService, useValue: playlistsService }, { provide: DatabaseService, useValue: dbService }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSource, + }, ], }); @@ -235,6 +256,43 @@ describe('UnifiedRecentDataService', () => { ); }); + it('uses the Xtream id as the PWA recent key when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSource.getContentByXtreamId.mockResolvedValue(null); + + const item = { + uid: 'xtream::xtream-1::movie:290', + name: 'PWA Movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 290, + } satisfies UnifiedCollectionItem; + + const recorded = await service.recordLivePlayback(item); + + expect(xtreamDataSource.getContentByXtreamId).toHaveBeenCalledWith( + 290, + 'xtream-1', + 'movie' + ); + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( + 290, + 'xtream-1' + ); + expect(dbService.addRecentItem).not.toHaveBeenCalled(); + expect(recorded).toEqual( + expect.objectContaining({ + contentId: 290, + viewedAt: expect.any(String), + }) + ); + }); + it('builds distinct Xtream recent UIDs when live and series share an xtream id', async () => { store.select.mockReturnValue( of([ @@ -288,6 +346,56 @@ describe('UnifiedRecentDataService', () => { ); }); + it('loads Xtream playlist recent items through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + xtreamDataSource.getRecentItems.mockResolvedValue([ + { + id: 202, + category_id: 20, + title: 'Movie One', + type: 'movie', + poster_url: 'movie.png', + backdrop_url: 'backdrop.png', + xtream_id: 202, + viewed_at: '2026-05-21T12:00:00.000Z', + }, + ]); + + const items = await service.getRecentItems( + 'playlist', + 'xtream-1', + 'xtream' + ); + + expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbService.getRecentItems).not.toHaveBeenCalled(); + expect(items).toEqual([ + expect.objectContaining({ + uid: 'xtream::xtream-1::movie:202', + sourceType: 'xtream', + contentType: 'movie', + name: 'Movie One', + playlistName: 'Xtream PWA', + posterUrl: 'movie.png', + viewedAt: '2026-05-21T12:00:00.000Z', + }), + ]); + }); + it('keeps Stalker radio recent items in the live collection with radio metadata', async () => { store.select.mockReturnValue( of([ diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index bb1f657dc..6c45392d0 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -26,6 +26,10 @@ import { UnifiedCollectionItem, xtreamContentType, } from '@iptvnator/portal/shared/util'; +import { + XTREAM_DATA_SOURCE, + XtreamContentItem, +} from '@iptvnator/portal/xtream/data-access'; type PlaylistWithChannels = Playlist & { readonly playlist?: { readonly items?: Channel[] }; @@ -36,6 +40,7 @@ export class UnifiedRecentDataService { private readonly store = inject(Store); private readonly dbService = inject(DatabaseService); private readonly playlistsService = inject(PlaylistsService); + private readonly xtreamDataSource = inject(XTREAM_DATA_SOURCE); async getRecentItems( scope: CollectionScope, @@ -55,6 +60,14 @@ export class UnifiedRecentDataService { return; } + if (!window.electron) { + await this.xtreamDataSource.removeRecentItem( + item.contentId, + item.playlistId + ); + return; + } + await this.dbService.removeRecentItem( item.contentId, item.playlistId @@ -126,7 +139,18 @@ export class UnifiedRecentDataService { const tasks: Promise[] = []; if (xtreamBatch.length > 0) { - tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + if (window.electron) { + tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + } else { + tasks.push( + ...xtreamBatch.map((item) => + this.xtreamDataSource.removeRecentItem( + item.contentId, + item.playlistId + ) + ) + ); + } } for (const [playlistId, identities] of groupedByPlaylist) { @@ -153,7 +177,11 @@ export class UnifiedRecentDataService { playlistId?: string ): Promise { if (scope === 'playlist' && playlistId) { - await this.dbService.clearPlaylistRecentItems(playlistId); + if (window.electron) { + await this.dbService.clearPlaylistRecentItems(playlistId); + } else { + await this.xtreamDataSource.clearRecentItems(playlistId); + } const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPlaylistRecentlyViewed(playlistId) ); @@ -161,7 +189,9 @@ export class UnifiedRecentDataService { return; } - await this.dbService.clearGlobalRecentlyViewed(); + if (window.electron) { + await this.dbService.clearGlobalRecentlyViewed(); + } const playlists = (await firstValueFrom( this.playlistsService.getAllPlaylists() )) as Playlist[]; @@ -223,17 +253,21 @@ export class UnifiedRecentDataService { const contentId = item.contentId ?? (item.xtreamId != null - ? ( - await this.dbService.getContentByXtreamId( - item.xtreamId, - item.playlistId, - item.contentType - ) - )?.id + ? await this.resolveXtreamContentId(item) : null); if (contentId != null) { - await this.dbService.addRecentItem(contentId, item.playlistId); + if (window.electron) { + await this.dbService.addRecentItem( + contentId, + item.playlistId + ); + } else { + await this.xtreamDataSource.addRecentItem( + contentId, + item.playlistId + ); + } } return { @@ -309,6 +343,17 @@ export class UnifiedRecentDataService { } private async getXtreamGlobalRecent(): Promise { + if (!window.electron) { + const allMeta = await this.getAllMeta(); + const results: UnifiedCollectionItem[] = []; + for (const meta of allMeta.filter( + (playlist) => playlist._id && playlist.serverUrl + )) { + results.push(...(await this.getXtreamPlaylistRecent(meta._id))); + } + return results; + } + try { const rows = await this.dbService.getGlobalRecentlyViewed(); return (rows || []).map((row) => ({ @@ -340,9 +385,16 @@ export class UnifiedRecentDataService { playlistId: string ): Promise { try { - const rows = await this.dbService.getRecentItems(playlistId); const meta = await this.getPlaylistMeta(playlistId); + if (!window.electron) { + const rows = + await this.xtreamDataSource.getRecentItems(playlistId); + return rows.map((row) => + this.mapXtreamContentItem(row, playlistId, meta?.title) + ); + } + const rows = await this.dbService.getRecentItems(playlistId); return (rows || []).map((row) => ({ uid: buildXtreamCollectionUid( playlistId, @@ -368,6 +420,60 @@ export class UnifiedRecentDataService { } } + private async resolveXtreamContentId( + item: UnifiedCollectionItem + ): Promise { + if (item.xtreamId == null) { + return null; + } + + if (!window.electron) { + const content = await this.xtreamDataSource.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + return content?.id ?? item.xtreamId; + } + + const content = await this.dbService.getContentByXtreamId( + item.xtreamId, + item.playlistId, + item.contentType + ); + + return content?.id ?? null; + } + + private mapXtreamContentItem( + item: XtreamContentItem, + playlistId: string, + playlistName?: string + ): UnifiedCollectionItem { + const contentType = xtreamContentType(item.type); + + return { + uid: buildXtreamCollectionUid( + playlistId, + contentType, + item.xtream_id + ), + name: item.title, + contentType, + sourceType: 'xtream', + playlistId, + playlistName: playlistName ?? item.playlist_name ?? 'Xtream', + logo: contentType === 'live' ? (item.poster_url ?? null) : null, + posterUrl: + contentType !== 'live' ? (item.poster_url ?? null) : null, + xtreamId: item.xtream_id, + categoryId: item.category_id, + tvgId: contentType === 'live' ? String(item.xtream_id) : undefined, + contentId: item.id, + viewedAt: normalizeStalkerDate(item.viewed_at), + }; + } + private async getM3uGlobalRecent(): Promise { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; 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 index 32ad75e03..0b12daf62 100644 --- 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 @@ -138,4 +138,50 @@ describe('PwaXtreamDataSource', () => { }) ); }); + + it('matches legacy string favorite and recent ids against numeric content identities', async () => { + apiService.getStreams.mockResolvedValue([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + added: '2', + }, + ]); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ 'playlist-1': ['202'] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + 'playlist-1': [ + { + id: '202', + viewedAt: '2026-05-21T12:00:00.000Z', + }, + ], + }) + ); + + await dataSource.getContent('playlist-1', credentials, 'movie'); + + await expect(dataSource.isFavorite(202, 'playlist-1')).resolves.toBe( + true + ); + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + viewed_at: '2026-05-21T12:00:00.000Z', + 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 476ad009b..8ffbdba90 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 @@ -417,35 +417,50 @@ export class PwaXtreamDataSource implements IXtreamDataSource { _backdropUrl?: string ): Promise { void _backdropUrl; + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allFavorites = this.getFavoritesFromStorage(); if (!allFavorites[playlistId]) { allFavorites[playlistId] = []; } - if (!allFavorites[playlistId].includes(contentId)) { - allFavorites[playlistId].push(contentId); + if (!allFavorites[playlistId].includes(normalizedContentId)) { + allFavorites[playlistId].push(normalizedContentId); } this.saveFavoritesToStorage(allFavorites); } async removeFavorite(contentId: number, playlistId: string): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allFavorites = this.getFavoritesFromStorage(); if (allFavorites[playlistId]) { allFavorites[playlistId] = allFavorites[playlistId].filter( - (id: number) => id !== contentId + (id: number) => id !== normalizedContentId ); } this.saveFavoritesToStorage(allFavorites); } async isFavorite(contentId: number, playlistId: string): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return false; + } + const allFavorites = this.getFavoritesFromStorage(); - return (allFavorites[playlistId] || []).includes(contentId); + return (allFavorites[playlistId] || []).includes(normalizedContentId); } private getFavoritesFromStorage(): Record { try { const data = localStorage.getItem(STORAGE_KEYS.FAVORITES); - return data ? JSON.parse(data) : {}; + return this.normalizeFavoriteStorage(data ? JSON.parse(data) : {}); } catch { return {}; } @@ -634,6 +649,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { _backdropUrl?: string ): Promise { void _backdropUrl; + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allRecent = this.getRecentItemsFromStorage(); if (!allRecent[playlistId]) { allRecent[playlistId] = []; @@ -641,12 +661,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Remove existing entry if present allRecent[playlistId] = allRecent[playlistId].filter( - (r) => r.id !== contentId + (r) => r.id !== normalizedContentId ); // Add new entry at the beginning allRecent[playlistId].unshift({ - id: contentId, + id: normalizedContentId, viewedAt: new Date().toISOString(), }); @@ -660,10 +680,15 @@ export class PwaXtreamDataSource implements IXtreamDataSource { contentId: number, playlistId: string ): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + if (normalizedContentId == null) { + return; + } + const allRecent = this.getRecentItemsFromStorage(); if (allRecent[playlistId]) { allRecent[playlistId] = allRecent[playlistId].filter( - (r) => r.id !== contentId + (r) => r.id !== normalizedContentId ); } this.saveRecentItemsToStorage(allRecent); @@ -676,7 +701,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private getRecentItemsFromStorage(): Record { try { const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS); - return data ? JSON.parse(data) : {}; + return this.normalizeRecentStorage(data ? JSON.parse(data) : {}); } catch { return {}; } @@ -697,6 +722,70 @@ export class PwaXtreamDataSource implements IXtreamDataSource { this.saveRecentItemsToStorage(allRecent); } + private normalizeStoredId(value: unknown): number | null { + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : null; + } + + private normalizeFavoriteStorage(value: unknown): Record { + if (!value || typeof value !== 'object') { + return {}; + } + + const normalized: Record = {}; + Object.entries(value as Record).forEach( + ([playlistId, ids]) => { + if (!Array.isArray(ids)) { + return; + } + + normalized[playlistId] = ids + .map((id) => this.normalizeStoredId(id)) + .filter((id): id is number => id !== null); + } + ); + return normalized; + } + + private normalizeRecentStorage( + value: unknown + ): Record { + if (!value || typeof value !== 'object') { + return {}; + } + + const normalized: Record = {}; + Object.entries(value as Record).forEach( + ([playlistId, items]) => { + if (!Array.isArray(items)) { + return; + } + + normalized[playlistId] = items + .map((item) => { + const rawItem = item as { + readonly id?: unknown; + readonly viewedAt?: unknown; + }; + const id = this.normalizeStoredId(rawItem.id); + if ( + id == null || + typeof rawItem.viewedAt !== 'string' + ) { + return null; + } + + return { + id, + viewedAt: rawItem.viewedAt, + }; + }) + .filter((item): item is StoredRecentItem => item !== null); + } + ); + return normalized; + } + // ========================================================================= // Content Lookup // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index af2ee954c..1978b39cf 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -47,6 +47,7 @@ export interface XtreamContentItem { rating: string; added: string; poster_url: string; + backdrop_url?: string | null; epg_channel_id?: string | null; tv_archive?: number | null; tv_archive_duration?: number | null; diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts new file mode 100644 index 000000000..db9f2ffe6 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.spec.ts @@ -0,0 +1,80 @@ +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom } from 'rxjs'; +import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface'; +import { FavoritesService } from './favorites.service'; + +describe('FavoritesService', () => { + let service: FavoritesService; + let dataSource: { + addFavorite: jest.Mock; + getFavorites: jest.Mock; + isFavorite: jest.Mock; + removeFavorite: jest.Mock; + }; + + beforeEach(() => { + dataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), + getFavorites: jest.fn().mockResolvedValue([ + { + id: 202, + type: 'movie', + title: 'Movie One', + poster_url: 'movie.png', + added_at: '2026-05-21T12:00:00.000Z', + category_id: '20', + xtream_id: 202, + }, + ]), + isFavorite: jest.fn().mockResolvedValue(true), + removeFavorite: jest.fn().mockResolvedValue(undefined), + }; + + TestBed.configureTestingModule({ + providers: [ + FavoritesService, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, + ], + }); + + service = TestBed.inject(FavoritesService); + }); + + it('uses the active Xtream data source for favorite mutations and reads', async () => { + await service.addToFavorites({ + content_id: 202, + playlist_id: 'playlist-1', + backdrop_url: 'backdrop.jpg', + }); + await service.removeFromFavorites(202, 'playlist-1'); + await expect(service.isFavorite(202, 'playlist-1')).resolves.toBe(true); + await expect( + firstValueFrom(service.getFavorites('playlist-1')) + ).resolves.toEqual([ + expect.objectContaining({ + content_id: 202, + playlist_id: 'playlist-1', + type: 'movie', + title: 'Movie One', + poster_url: 'movie.png', + category_id: 20, + xtream_id: 202, + }), + ]); + + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 202, + 'playlist-1', + 'backdrop.jpg' + ); + expect(dataSource.removeFavorite).toHaveBeenCalledWith( + 202, + 'playlist-1' + ); + expect(dataSource.isFavorite).toHaveBeenCalledWith(202, 'playlist-1'); + expect(dataSource.getFavorites).toHaveBeenCalledWith('playlist-1'); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts index 7d6ea7ff5..4929d4471 100644 --- a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts @@ -1,20 +1,25 @@ import { inject, Injectable } from '@angular/core'; import { from, map, Observable } from 'rxjs'; -import { DatabaseService } from '@iptvnator/services'; +import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface'; import { FavoriteItem } from './favorite-item.interface'; +function normalizeCategoryId(categoryId: string | number): number { + const numericCategoryId = Number(categoryId); + return Number.isFinite(numericCategoryId) ? numericCategoryId : 0; +} + @Injectable({ providedIn: 'root', }) export class FavoritesService { - private dbService = inject(DatabaseService); + private dataSource = inject(XTREAM_DATA_SOURCE); async addToFavorites(item: { content_id: number; playlist_id: string; backdrop_url?: string; }): Promise { - await this.dbService.addToFavorites( + await this.dataSource.addFavorite( item.content_id, item.playlist_id, item.backdrop_url @@ -25,15 +30,15 @@ export class FavoritesService { contentId: number, playlistId: string ): Promise { - await this.dbService.removeFromFavorites(contentId, playlistId); + await this.dataSource.removeFavorite(contentId, playlistId); } async isFavorite(contentId: number, playlistId: string): Promise { - return await this.dbService.isFavorite(contentId, playlistId); + return await this.dataSource.isFavorite(contentId, playlistId); } getFavorites(playlistId: string): Observable { - return from(this.dbService.getFavorites(playlistId)).pipe( + return from(this.dataSource.getFavorites(playlistId)).pipe( map((items) => items.map((item) => ({ content_id: item.id, @@ -42,7 +47,7 @@ export class FavoritesService { title: item.title, poster_url: item.poster_url, added_at: item.added_at, - category_id: item.category_id, + category_id: normalizeCategoryId(item.category_id), xtream_id: item.xtream_id, })) ) diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts index 8ebc10e40..3580e67ec 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts @@ -1,7 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { patchState, signalStore } from '@ngrx/signals'; -import { DatabaseService } from '@iptvnator/services'; -import { FavoritesService } from './services/favorites.service'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; import { withFavorites } from './with-favorites.feature'; jest.mock('@iptvnator/portal/shared/util', () => ({ @@ -16,36 +15,29 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ const TestFavoritesStore = signalStore(withFavorites()); describe('withFavorites', () => { + const originalElectron = window.electron; let store: InstanceType; - let databaseService: { + let dataSource: { + addFavorite: jest.Mock; getContentByXtreamId: jest.Mock; - }; - let favoritesService: { - addToFavorites: jest.Mock; isFavorite: jest.Mock; - removeFromFavorites: jest.Mock; + removeFavorite: jest.Mock; }; beforeEach(() => { - databaseService = { + dataSource = { + addFavorite: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), - }; - favoritesService = { - addToFavorites: jest.fn().mockResolvedValue(undefined), isFavorite: jest.fn().mockResolvedValue(false), - removeFromFavorites: jest.fn().mockResolvedValue(undefined), + removeFavorite: jest.fn().mockResolvedValue(undefined), }; TestBed.configureTestingModule({ providers: [ TestFavoritesStore, { - provide: DatabaseService, - useValue: databaseService, - }, - { - provide: FavoritesService, - useValue: favoritesService, + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, }, ], }); @@ -53,8 +45,16 @@ describe('withFavorites', () => { store = TestBed.inject(TestFavoritesStore); }); + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); + }); + it('looks favorites up with the requested content type before adding one', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -63,21 +63,72 @@ describe('withFavorites', () => { const result = await store.toggleFavorite(290, 'playlist-1', 'series'); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'series' ); - expect(favoritesService.addToFavorites).toHaveBeenCalledWith({ - content_id: 3941697, - playlist_id: 'playlist-1', - }); + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 3941697, + 'playlist-1', + undefined + ); expect(result).toBe(true); expect(store.isFavorite()).toBe(true); }); + it('uses the Xtream ID as the PWA favorite key when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + const result = await store.toggleFavorite( + 1767451, + 'playlist-1', + 'movie' + ); + + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + expect(result).toBe(true); + expect(store.isFavorite()).toBe(true); + }); + + it('normalizes route-param Xtream IDs before using the PWA favorite fallback', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + const result = await store.toggleFavorite( + '1767451', + 'playlist-1', + 'movie' + ); + + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + 'movie' + ); + expect(dataSource.addFavorite).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + expect(result).toBe(true); + }); + it('looks favorites up with the requested content type before removing one', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3867578, title: 'SE: V Film Premiere FHD', type: 'live', @@ -87,12 +138,12 @@ describe('withFavorites', () => { const result = await store.toggleFavorite(290, 'playlist-1', 'live'); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'live' ); - expect(favoritesService.removeFromFavorites).toHaveBeenCalledWith( + expect(dataSource.removeFavorite).toHaveBeenCalledWith( 3867578, 'playlist-1' ); @@ -101,25 +152,43 @@ describe('withFavorites', () => { }); it('checks favorite state against the matching content type', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3829429, title: 'Dragon Ball Heroes', type: 'series', xtream_id: 31, }); - favoritesService.isFavorite.mockResolvedValue(true); + dataSource.isFavorite.mockResolvedValue(true); await store.checkFavoriteStatus(31, 'playlist-1', 'series'); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 31, 'playlist-1', 'series' ); - expect(favoritesService.isFavorite).toHaveBeenCalledWith( + expect(dataSource.isFavorite).toHaveBeenCalledWith( 3829429, 'playlist-1' ); expect(store.isFavorite()).toBe(true); }); + + it('checks PWA favorite state against the Xtream ID when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: undefined, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + dataSource.isFavorite.mockResolvedValue(true); + + await store.checkFavoriteStatus(1767451, 'playlist-1', 'movie'); + + expect(dataSource.isFavorite).toHaveBeenCalledWith( + 1767451, + 'playlist-1' + ); + expect(store.isFavorite()).toBe(true); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts index 19dad0f54..49c3168f9 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts @@ -5,9 +5,8 @@ import { withMethods, withState, } from '@ngrx/signals'; -import { DatabaseService } from '@iptvnator/services'; -import { FavoritesService } from './services/favorites.service'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; export const withFavorites = function () { const logger = createLogger('withFavorites'); @@ -15,85 +14,94 @@ export const withFavorites = function () { withState({ isFavorite: false, }), - withMethods( - ( - store, - dbService = inject(DatabaseService), - favoritesService = inject(FavoritesService) - ) => ({ - async toggleFavorite( - xtreamId: number, - playlistId: string, - contentType: 'live' | 'movie' | 'series', - backdropUrl?: string + withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({ + async toggleFavorite( + xtreamId: number | string, + playlistId: string, + contentType: 'live' | 'movie' | 'series', + backdropUrl?: string + ) { + const normalizedXtreamId = Number(xtreamId); + if ( + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !playlistId ) { - if (!xtreamId || !playlistId) { - return false; - } + return false; + } - const content = await dbService.getContentByXtreamId( - xtreamId, - playlistId, - contentType + const content = await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!window.electron ? normalizedXtreamId : null); + + if (contentId == null) { + logger.error( + 'Content not found for xtream ID', + normalizedXtreamId ); - if (!content) { - logger.error( - 'Content not found for xtream ID', - xtreamId - ); - return false; - } + return false; + } - const currentStatus = store.isFavorite(); + const currentStatus = store.isFavorite(); - if (currentStatus) { - // Remove from favorites - await favoritesService.removeFromFavorites( - content.id, - playlistId - ); - patchState(store, { isFavorite: false }); - return false; - } else { - // Add to favorites - await favoritesService.addToFavorites({ - content_id: content.id, - playlist_id: playlistId, - backdrop_url: backdropUrl, - }); - patchState(store, { isFavorite: true }); - return true; - } - }, + if (currentStatus) { + // Remove from favorites + await dataSource.removeFavorite(contentId, playlistId); + patchState(store, { isFavorite: false }); + return false; + } else { + // Add to favorites + await dataSource.addFavorite( + contentId, + playlistId, + backdropUrl + ); + patchState(store, { isFavorite: true }); + return true; + } + }, - async checkFavoriteStatus( - xtreamId: number, - playlistId: string, - contentType: 'live' | 'movie' | 'series' + async checkFavoriteStatus( + xtreamId: number | string, + playlistId: string, + contentType: 'live' | 'movie' | 'series' + ) { + const normalizedXtreamId = Number(xtreamId); + if ( + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !playlistId ) { - if (!xtreamId || !playlistId) { - patchState(store, { isFavorite: false }); - return; - } + patchState(store, { isFavorite: false }); + return; + } - const content = await dbService.getContentByXtreamId( - xtreamId, - playlistId, - contentType - ); - if (!content) { - patchState(store, { isFavorite: false }); - return; - } + const content = await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!window.electron ? normalizedXtreamId : null); - const isFavorite = await favoritesService.isFavorite( - content.id, - playlistId - ); + if (contentId == null) { + patchState(store, { isFavorite: false }); + return; + } - patchState(store, { isFavorite }); - }, - }) - ) + const isFavorite = await dataSource.isFavorite( + contentId, + playlistId + ); + + patchState(store, { isFavorite }); + }, + })) ); }; diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts index c3b4e0bd3..9efc794de 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; import { signalStore } from '@ngrx/signals'; import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; import { withRecentItems } from './with-recent-items'; jest.mock('@iptvnator/portal/shared/util', () => ({ @@ -17,13 +18,22 @@ jest.mock('@iptvnator/portal/shared/util', () => ({ const TestRecentItemsStore = signalStore(withRecentItems()); describe('withRecentItems', () => { + const originalElectron = window.electron; let store: InstanceType; let databaseService: { - addRecentItem: jest.Mock; + clearPlaylistRecentItems: jest.Mock; getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; setContentBackdropIfMissing: jest.Mock; }; + let dataSource: { + addRecentItem: jest.Mock; + clearRecentItems: jest.Mock; + getContentByXtreamId: jest.Mock; + getRecentItems: jest.Mock; + removeRecentItem: jest.Mock; + }; beforeEach(() => { Object.defineProperty(window, 'electron', { @@ -32,7 +42,7 @@ describe('withRecentItems', () => { }); databaseService = { - addRecentItem: jest.fn().mockResolvedValue(undefined), + clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), getRecentItems: jest.fn().mockResolvedValue([ { @@ -46,8 +56,27 @@ describe('withRecentItems', () => { category_id: 17, }, ]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), }; + dataSource = { + addRecentItem: jest.fn().mockResolvedValue(undefined), + clearRecentItems: jest.fn().mockResolvedValue(undefined), + getContentByXtreamId: jest.fn(), + getRecentItems: jest.fn().mockResolvedValue([ + { + id: 3941697, + title: 'Krypton', + type: 'series', + poster_url: 'https://example.com/krypton.png', + backdrop_url: 'https://example.com/krypton-backdrop.png', + viewed_at: '2026-04-21T20:42:27.000Z', + xtream_id: 290, + category_id: 17, + }, + ]), + removeRecentItem: jest.fn().mockResolvedValue(undefined), + }; TestBed.configureTestingModule({ providers: [ @@ -56,12 +85,16 @@ describe('withRecentItems', () => { provide: DatabaseService, useValue: databaseService, }, + { + provide: XTREAM_DATA_SOURCE, + useValue: dataSource, + }, { provide: PlaylistsService, useValue: { - clearPlaylistRecentlyViewed: jest.fn().mockReturnValue( - of(undefined) - ), + clearPlaylistRecentlyViewed: jest + .fn() + .mockReturnValue(of(undefined)), getAllPlaylists: jest.fn().mockReturnValue(of([])), }, }, @@ -71,8 +104,16 @@ describe('withRecentItems', () => { store = TestBed.inject(TestRecentItemsStore); }); + afterEach(() => { + Object.defineProperty(window, 'electron', { + configurable: true, + writable: true, + value: originalElectron, + }); + }); + it('looks recent items up with the requested content type before saving one', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -86,12 +127,12 @@ describe('withRecentItems', () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'series' ); - expect(databaseService.addRecentItem).toHaveBeenCalledWith( + expect(dataSource.addRecentItem).toHaveBeenCalledWith( 3941697, 'playlist-1', undefined @@ -107,8 +148,56 @@ describe('withRecentItems', () => { ]); }); + it('uses the Xtream ID as the PWA recent key when cached content is cold', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + store.addRecentItem({ + xtreamId: 1767451, + contentType: 'movie', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.addRecentItem).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + expect(databaseService.getRecentItems).not.toHaveBeenCalled(); + }); + + it('normalizes route-param Xtream IDs before using the PWA recent fallback', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + dataSource.getContentByXtreamId.mockResolvedValue(null); + + store.addRecentItem({ + xtreamId: '1767451', + contentType: 'movie', + playlist: signal({ id: 'playlist-1' }), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + 'movie' + ); + expect(dataSource.addRecentItem).toHaveBeenCalledWith( + 1767451, + 'playlist-1', + undefined + ); + }); + it('forwards backdrop urls on recent-item saves', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -123,7 +212,7 @@ describe('withRecentItems', () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(databaseService.addRecentItem).toHaveBeenCalledWith( + expect(dataSource.addRecentItem).toHaveBeenCalledWith( 3941697, 'playlist-1', 'https://example.com/krypton-backdrop.png' @@ -156,6 +245,42 @@ describe('withRecentItems', () => { 3941697, 'https://example.com/krypton-backdrop.png' ); - expect(databaseService.addRecentItem).not.toHaveBeenCalled(); + expect(dataSource.addRecentItem).not.toHaveBeenCalled(); + }); + + it('clears recent items through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + + store.clearRecentItems({ id: 'playlist-1' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.clearRecentItems).toHaveBeenCalledWith('playlist-1'); + expect(databaseService.clearPlaylistRecentItems).not.toHaveBeenCalled(); + expect(store.recentItems()).toEqual([]); + }); + + it('removes recent items through the active data source in PWA', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + + store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.removeRecentItem).toHaveBeenCalledWith( + 3941697, + 'playlist-1' + ); + expect(databaseService.removeRecentItem).not.toHaveBeenCalled(); + expect(store.recentItems()).toEqual([ + expect.objectContaining({ + id: 3941697, + title: 'Krypton', + }), + ]); }); }); diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index a56bd5723..71eb926eb 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -14,6 +14,7 @@ import { PortalRecentItem, } from '@iptvnator/shared/interfaces'; import { createLogger } from '@iptvnator/portal/shared/util'; +import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; export interface RecentlyViewedItem extends PortalRecentItem { /** @deprecated Redundant — always equals `id`. Retained for compat. */ @@ -29,7 +30,7 @@ function mapDbRecentItem( backdrop_url?: string | null; viewed_at?: string; xtream_id: number; - category_id: number; + category_id: number | string; }, playlistId: string ): RecentlyViewedItem { @@ -53,11 +54,11 @@ export const withRecentItems = function () { withState({ recentItems: [], }), - withMethods((store, dbService = inject(DatabaseService)) => ({ + withMethods((store, dataSource = inject(XTREAM_DATA_SOURCE)) => ({ loadRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - const items = await dbService.getRecentItems( + const items = await dataSource.getRecentItems( playlist.id ); return items.map((item) => @@ -74,10 +75,11 @@ export const withRecentItems = function () { ( store, dbService = inject(DatabaseService), - playlistsService = inject(PlaylistsService) + playlistsService = inject(PlaylistsService), + dataSource = inject(XTREAM_DATA_SOURCE) ) => ({ addRecentItem: rxMethod<{ - xtreamId: number; + xtreamId: number | string; contentType: 'live' | 'movie' | 'series'; playlist: Signal<{ id: string }>; backdropUrl?: string; @@ -90,30 +92,49 @@ export const withRecentItems = function () { playlist, backdropUrl, }) => { - const playlistId = playlist().id; - const content = await dbService.getContentByXtreamId( - xtreamId, - playlistId, - contentType - ); - if (content) { - await dbService.addRecentItem( - content.id, - playlistId, - backdropUrl - ); + const playlistId = playlist().id; + const normalizedXtreamId = Number(xtreamId); + if ( + !playlistId || + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 + ) { + return; + } - // Reload after add/update so re-watched items - // immediately move to the top in recently-viewed. - const items = - await dbService.getRecentItems(playlistId); - patchState(store, { - recentItems: items.map((item) => - mapDbRecentItem(item, playlistId) - ), - }); + const content = + await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!window.electron + ? normalizedXtreamId + : null); + + if (contentId != null) { + await dataSource.addRecentItem( + contentId, + playlistId, + backdropUrl + ); + + // Reload after add/update so re-watched items + // immediately move to the top in recently-viewed. + const items = + await dataSource.getRecentItems( + playlistId + ); + patchState(store, { + recentItems: items.map((item) => + mapDbRecentItem(item, playlistId) + ), + }); + } } - }) + ) ) ), async backfillContentBackdrop({ @@ -122,7 +143,7 @@ export const withRecentItems = function () { playlist, backdropUrl, }: { - xtreamId: number; + xtreamId: number | string; contentType: 'live' | 'movie' | 'series'; playlist: Signal<{ id: string }>; backdropUrl?: string; @@ -132,13 +153,19 @@ export const withRecentItems = function () { } const playlistId = playlist().id; + const normalizedXtreamId = Number(xtreamId); const normalizedBackdropUrl = backdropUrl?.trim(); - if (!playlistId || !normalizedBackdropUrl) { + if ( + !playlistId || + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !normalizedBackdropUrl + ) { return; } const content = await dbService.getContentByXtreamId( - xtreamId, + normalizedXtreamId, playlistId, contentType ); @@ -154,9 +181,13 @@ export const withRecentItems = function () { clearRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - await dbService.clearPlaylistRecentItems( - playlist.id - ); + if (window.electron) { + await dbService.clearPlaylistRecentItems( + playlist.id + ); + } else { + await dataSource.clearRecentItems(playlist.id); + } patchState(store, { recentItems: [] }); }) ) @@ -167,13 +198,20 @@ export const withRecentItems = function () { }>( pipe( switchMap(async ({ itemId, playlistId }) => { - await dbService.removeRecentItem( - itemId, - playlistId - ); + if (window.electron) { + await dbService.removeRecentItem( + itemId, + playlistId + ); + } else { + await dataSource.removeRecentItem( + itemId, + playlistId + ); + } // Reload recent items to update UI const items = - await dbService.getRecentItems(playlistId); + await dataSource.getRecentItems(playlistId); patchState(store, { recentItems: items.map((item) => mapDbRecentItem(item, playlistId) @@ -189,14 +227,16 @@ export const withRecentItems = function () { const playlists = (await firstValueFrom( playlistsService.getAllPlaylists() )) as Playlist[]; - const playlistBackedItems = - buildPlaylistRecentItems(playlists, { + const playlistBackedItems = buildPlaylistRecentItems( + playlists, + { stalker: 'Stalker Portal', m3u: 'M3U', - }).map((item) => ({ - ...item, - content_id: item.id, - })) as RecentlyViewedItem[]; + } + ).map((item) => ({ + ...item, + content_id: item.id, + })) as RecentlyViewedItem[]; const normalizedXtream: RecentlyViewedItem[] = ( xtreamItems || [] diff --git a/libs/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts index 06effcad6..3c04e76d4 100644 --- a/libs/services/src/lib/database-electron.service.spec.ts +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -41,6 +41,37 @@ describe('DatabaseService browser guards', () => { await expect( service.getContentByXtreamId(20229, 'playlist-1', 'movie') ).resolves.toBeNull(); + await expect(service.getGlobalRecentlyViewed()).resolves.toEqual([]); + await expect(service.getGlobalFavorites()).resolves.toEqual([]); + await expect(service.getAllGlobalFavorites()).resolves.toEqual([]); + await expect( + service.clearGlobalRecentlyViewed() + ).resolves.toBeUndefined(); + await expect(service.addToFavorites(20229, 'playlist-1')).resolves.toBe( + false + ); + await expect( + service.removeFromFavorites(20229, 'playlist-1') + ).resolves.toBe(false); + await expect(service.isFavorite(20229, 'playlist-1')).resolves.toBe( + false + ); + await expect(service.getFavorites('playlist-1')).resolves.toEqual([]); + await expect(service.getRecentItems('playlist-1')).resolves.toEqual([]); + await expect(service.addRecentItem(20229, 'playlist-1')).resolves.toBe( + false + ); + await expect( + service.clearPlaylistRecentItems('playlist-1') + ).resolves.toBe(false); + await expect( + service.removeRecentItem(20229, 'playlist-1') + ).resolves.toBe(false); + await expect( + service.removeRecentItemsBatch([ + { contentId: 20229, playlistId: 'playlist-1' }, + ]) + ).resolves.toBe(false); 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 8b0da609d..e03a9489c 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -627,6 +627,10 @@ export class DatabaseService { * Get recently viewed items */ async getGlobalRecentlyViewed(): Promise { + if (typeof window.electron?.dbGetRecentlyViewed !== 'function') { + return []; + } + try { const items = await window.electron.dbGetRecentlyViewed(); return items || []; @@ -640,6 +644,10 @@ export class DatabaseService { * Get global favorites across all playlists */ async getGlobalFavorites(): Promise { + if (typeof window.electron?.dbGetGlobalFavorites !== 'function') { + return []; + } + try { const items = await window.electron.dbGetGlobalFavorites(); return items || []; @@ -653,6 +661,10 @@ export class DatabaseService { * Get global favorites across all playlists (all content types) */ async getAllGlobalFavorites(): Promise { + if (typeof window.electron?.dbGetAllGlobalFavorites !== 'function') { + return []; + } + try { const items = await window.electron.dbGetAllGlobalFavorites(); return items || []; @@ -666,6 +678,10 @@ export class DatabaseService { * Clear recently viewed items */ async clearGlobalRecentlyViewed(): Promise { + if (typeof window.electron?.dbClearRecentlyViewed !== 'function') { + return; + } + try { await window.electron.dbClearRecentlyViewed(); } catch (error) { @@ -706,6 +722,10 @@ export class DatabaseService { playlistId: string, backdropUrl?: string ): Promise { + if (typeof window.electron?.dbAddFavorite !== 'function') { + return false; + } + try { await window.electron.dbAddFavorite( contentId, @@ -726,6 +746,10 @@ export class DatabaseService { contentId: number, playlistId: string ): Promise { + if (typeof window.electron?.dbRemoveFavorite !== 'function') { + return false; + } + try { await window.electron.dbRemoveFavorite(contentId, playlistId); return true; @@ -739,6 +763,10 @@ export class DatabaseService { * Check if content is favorited */ async isFavorite(contentId: number, playlistId: string): Promise { + if (typeof window.electron?.dbIsFavorite !== 'function') { + return false; + } + try { return await window.electron.dbIsFavorite(contentId, playlistId); } catch (error) { @@ -751,6 +779,10 @@ export class DatabaseService { * Get all favorites for a playlist */ async getFavorites(playlistId: string): Promise { + if (typeof window.electron?.dbGetFavorites !== 'function') { + return []; + } + try { return await window.electron.dbGetFavorites(playlistId); } catch (error) { @@ -763,6 +795,10 @@ export class DatabaseService { * Get recently viewed items for a specific playlist */ async getRecentItems(playlistId: string): Promise { + if (typeof window.electron?.dbGetRecentItems !== 'function') { + return []; + } + try { return await window.electron.dbGetRecentItems(playlistId); } catch (error) { @@ -779,6 +815,10 @@ export class DatabaseService { playlistId: string, backdropUrl?: string ): Promise { + if (typeof window.electron?.dbAddRecentItem !== 'function') { + return false; + } + try { await window.electron.dbAddRecentItem( contentId, @@ -825,6 +865,10 @@ export class DatabaseService { * Clear recently viewed for a specific playlist */ async clearPlaylistRecentItems(playlistId: string): Promise { + if (typeof window.electron?.dbClearPlaylistRecentItems !== 'function') { + return false; + } + try { await window.electron.dbClearPlaylistRecentItems(playlistId); return true; @@ -841,6 +885,10 @@ export class DatabaseService { contentId: number, playlistId: string ): Promise { + if (typeof window.electron?.dbRemoveRecentItem !== 'function') { + return false; + } + try { await window.electron.dbRemoveRecentItem(contentId, playlistId); return true; @@ -856,6 +904,10 @@ export class DatabaseService { if (items.length === 0) { return true; } + if (typeof window.electron?.dbRemoveRecentItemsBatch !== 'function') { + return false; + } + try { await window.electron.dbRemoveRecentItemsBatch(items); return true; 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..52ccab9ba 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,10 +2,14 @@ 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 { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { DashboardDataService } from './dashboard-data.service'; describe('DashboardDataService', () => { @@ -68,6 +72,10 @@ describe('DashboardDataService', () => { removeFromFavorites: jest.fn().mockResolvedValue(undefined), removeRecentItem: jest.fn().mockResolvedValue(undefined), }; + const xtreamDataSourceMock = { + getFavorites: jest.fn().mockResolvedValue([]), + getRecentItems: jest.fn().mockResolvedValue([]), + }; const playlistMock: Playlist = { _id: 'm3u-1', title: 'M3U Playlist', @@ -138,6 +146,10 @@ describe('DashboardDataService', () => { dbServiceMock.getGlobalRecentlyViewed.mockResolvedValue([]); dbServiceMock.removeFromFavorites.mockClear(); dbServiceMock.removeRecentItem.mockClear(); + xtreamDataSourceMock.getFavorites.mockClear(); + xtreamDataSourceMock.getFavorites.mockResolvedValue([]); + xtreamDataSourceMock.getRecentItems.mockClear(); + xtreamDataSourceMock.getRecentItems.mockResolvedValue([]); storeMock.dispatch.mockClear(); TestBed.configureTestingModule({ @@ -145,6 +157,10 @@ describe('DashboardDataService', () => { DashboardDataService, { provide: Store, useValue: storeMock }, { provide: DatabaseService, useValue: dbServiceMock }, + { + provide: XTREAM_DATA_SOURCE, + useValue: xtreamDataSourceMock, + }, { provide: PlaylistsService, useValue: playlistsServiceMock, @@ -324,6 +340,44 @@ describe('DashboardDataService', () => { ).toHaveLength(1); }); + it('includes PWA Xtream favorites from the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSourceMock.getFavorites.mockResolvedValue([ + { + id: 51, + category_id: 12, + title: 'Action Movie', + rating: '8.0', + added_at: '2026-02-02T10:00:00.000Z', + poster_url: 'https://example.com/movie.png', + xtream_id: 5001, + type: 'movie', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbServiceMock.getAllGlobalFavorites).not.toHaveBeenCalled(); + expect(service.globalFavoriteItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 51, + title: 'Action Movie', + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + }), + ]) + ); + }); + it('builds the M3U favorites route', async () => { await service.reloadGlobalFavorites(); const m3uItem = service @@ -528,6 +582,45 @@ describe('DashboardDataService', () => { ).toEqual(['xtream-1', 'stalker-1', 'm3u-1']); }); + it('includes PWA Xtream recent items from the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSourceMock.getRecentItems.mockResolvedValue([ + { + id: 91, + category_id: 18, + title: 'Recent Movie', + rating: '7.8', + viewed_at: '2026-04-21T10:00:00.000Z', + poster_url: 'https://example.com/recent-movie.png', + xtream_id: 7001, + type: 'movie', + }, + ]); + + await service.reloadGlobalRecentItems(); + + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbServiceMock.getGlobalRecentlyViewed).not.toHaveBeenCalled(); + expect(service.globalRecentItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 91, + title: 'Recent Movie', + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + viewed_at: '2026-04-21T10:00:00.000Z', + }), + ]) + ); + }); + it('falls back to playlist metadata ordering when sources have no recent activity', 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..0dc8c58be 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, @@ -64,6 +68,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); @@ -298,7 +303,10 @@ export class DashboardDataService { } if (!window.electron) { - this.xtreamGlobalRecentItems.set([]); + const recentItems = await this.loadPwaXtreamGlobalRecentItems(); + this.ngZone.run(() => + this.xtreamGlobalRecentItems.set(recentItems) + ); this.globalRecentDbLoadedState.set(true); this.finishInitialGlobalRecentLoadIfReady(); return; @@ -400,7 +408,8 @@ export class DashboardDataService { private async reloadXtreamGlobalFavorites(): Promise { if (!window.electron) { - this.xtreamGlobalFavorites.set([]); + const favorites = await this.loadPwaXtreamGlobalFavorites(); + this.ngZone.run(() => this.xtreamGlobalFavorites.set(favorites)); this.finishInitialGlobalFavoritesLoadIfReady(); return; } @@ -422,6 +431,87 @@ export class DashboardDataService { } } + private async loadPwaXtreamGlobalRecentItems(): Promise< + GlobalRecentItem[] + > { + const results: GlobalRecentItem[] = []; + for (const playlist of this.getXtreamPlaylists()) { + const rows = await this.xtreamDataSource.getRecentItems( + playlist._id + ); + results.push( + ...rows.map((item) => + this.mapPwaXtreamRecentItem(item, playlist) + ) + ); + } + return results; + } + + private async loadPwaXtreamGlobalFavorites(): Promise< + DashboardFavoriteItem[] + > { + const results: DashboardFavoriteItem[] = []; + for (const playlist of this.getXtreamPlaylists()) { + const rows = await this.xtreamDataSource.getFavorites(playlist._id); + results.push( + ...rows.map((item) => + this.mapPwaXtreamFavoriteItem(item, playlist) + ) + ); + } + return results; + } + + private getXtreamPlaylists(): PlaylistMeta[] { + return this.playlists().filter((playlist) => !!playlist.serverUrl); + } + + private mapPwaXtreamRecentItem( + item: XtreamContentItem, + playlist: PlaylistMeta + ): GlobalRecentItem { + return { + id: item.id, + title: item.title, + type: this.normalizeXtreamActivityType(item.type), + playlist_id: playlist._id, + playlist_name: playlist.title || 'Xtream', + viewed_at: item.viewed_at ?? '', + category_id: item.category_id, + xtream_id: item.xtream_id, + poster_url: item.poster_url, + backdrop_url: item.backdrop_url ?? undefined, + source: 'xtream', + }; + } + + private mapPwaXtreamFavoriteItem( + item: XtreamContentItem, + playlist: PlaylistMeta + ): DashboardFavoriteItem { + return { + id: item.id, + title: item.title, + type: this.normalizeXtreamActivityType(item.type), + playlist_id: playlist._id, + playlist_name: playlist.title || 'Xtream', + added_at: item.added_at ?? item.added ?? new Date(0).toISOString(), + category_id: item.category_id, + xtream_id: item.xtream_id, + poster_url: item.poster_url, + backdrop_url: item.backdrop_url ?? undefined, + source: 'xtream', + }; + } + + private normalizeXtreamActivityType(type: string): PortalActivityType { + if (type === 'live' || type === 'series') { + return type; + } + return 'movie'; + } + private async refreshPlaylistBackedGlobalFavorites(): Promise { await this.reloadM3uGlobalFavorites(); this.finishInitialGlobalFavoritesLoadIfReady(); @@ -875,8 +965,7 @@ export class DashboardDataService { const matchedFavoriteId = channelIdFavoritePosition !== undefined && (channelUrlFavoritePosition === undefined || - channelIdFavoritePosition <= - channelUrlFavoritePosition) + channelIdFavoritePosition <= channelUrlFavoritePosition) ? channelId : channelUrlFavoritePosition !== undefined ? channelUrl From 1ea6914f981e98391ddc1e656286c90daf349a1b Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 19:27:40 +0300 Subject: [PATCH 06/15] fix(pwa): harden xtream identity normalization --- .../pwa-xtream-data-source.spec.ts | 71 +++++++++++++++++++ .../data-sources/pwa-xtream-data-source.ts | 8 ++- 2 files changed, 76 insertions(+), 3 deletions(-) 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 index 0b12daf62..2b7bcc5f0 100644 --- 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 @@ -48,6 +48,7 @@ describe('PwaXtreamDataSource', () => { case 'live': return Promise.resolve([ { + id: '0', stream_id: 101, name: 'News Live', stream_icon: 'live.png', @@ -58,6 +59,7 @@ describe('PwaXtreamDataSource', () => { case 'movie': return Promise.resolve([ { + id: 0, stream_id: 202, name: 'Movie One', stream_icon: 'movie.png', @@ -184,4 +186,73 @@ describe('PwaXtreamDataSource', () => { }), ]); }); + + it('does not persist zero or invalid Xtream identities as user collections', async () => { + apiService.getStreams.mockResolvedValue([ + { + id: 0, + name: 'Headless Zero', + category_id: '20', + }, + { + id: 'not-a-number', + name: 'Headless Invalid', + category_id: '20', + }, + ]); + + const content = (await dataSource.getContent( + 'playlist-1', + credentials, + 'movie' + )) as Array>; + + expect(content).toEqual([ + expect.objectContaining({ + id: -1, + xtream_id: -1, + }), + expect.objectContaining({ + id: -1, + xtream_id: -1, + }), + ]); + + await dataSource.addFavorite(-1, 'playlist-1'); + await dataSource.addRecentItem(-1, 'playlist-1'); + + expect(localStorage.getItem('xtream-favorites')).toBeNull(); + expect(localStorage.getItem('xtream-recent-items')).toBeNull(); + + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ 'playlist-1': [-1, 0, 'not-a-number'] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + 'playlist-1': [ + { + id: -1, + viewedAt: '2026-05-21T12:00:00.000Z', + }, + { + id: 0, + viewedAt: '2026-05-21T12:00:00.000Z', + }, + { + id: 'not-a-number', + viewedAt: '2026-05-21T12:00:00.000Z', + }, + ], + }) + ); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual( + [] + ); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual( + [] + ); + }); }); 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 8ffbdba90..102cb6fbd 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 @@ -330,7 +330,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ...item, added: item.added ?? item.last_modified ?? '', category_id: item.category_id ?? '', - id: Number.isFinite(id) ? id : xtreamId, + id: Number.isFinite(id) && id > 0 ? id : xtreamId, name: item.name ?? title, poster_url: posterUrl, rating: String(item.rating ?? ''), @@ -351,7 +351,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { item.series_id ?? item.id; const numericId = Number(preferredId); - return Number.isFinite(numericId) ? numericId : 0; + return Number.isFinite(numericId) && numericId > 0 ? numericId : -1; } // ========================================================================= @@ -724,7 +724,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private normalizeStoredId(value: unknown): number | null { const numericValue = Number(value); - return Number.isFinite(numericValue) ? numericValue : null; + return Number.isFinite(numericValue) && numericValue > 0 + ? numericValue + : null; } private normalizeFavoriteStorage(value: unknown): Record { From ec098f98b1ca69205108773fced0ffc07425fa32 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 19:35:36 +0300 Subject: [PATCH 07/15] fix(pwa): hydrate xtream user collections --- .../unified-recent-data.service.spec.ts | 44 ++++++ .../collection/unified-recent-data.service.ts | 27 +++- .../electron-xtream-data-source.ts | 12 ++ .../pwa-xtream-data-source.spec.ts | 100 +++++++++++++ .../data-sources/pwa-xtream-data-source.ts | 133 +++++++++++++++++- .../xtream-data-source.interface.ts | 10 ++ .../src/lib/with-recent-items.feature.spec.ts | 74 ++++++++-- .../data-access/src/lib/with-recent-items.ts | 42 +++--- 8 files changed, 404 insertions(+), 38 deletions(-) diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 418f1fd61..b570432a6 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -36,6 +36,7 @@ describe('UnifiedRecentDataService', () => { }; let xtreamDataSource: { addRecentItem: jest.Mock; + clearRecentItems: jest.Mock; getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; removeRecentItem: jest.Mock; @@ -149,6 +150,7 @@ describe('UnifiedRecentDataService', () => { }; xtreamDataSource = { addRecentItem: jest.fn().mockResolvedValue(undefined), + clearRecentItems: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn().mockResolvedValue(null), getRecentItems: jest.fn().mockResolvedValue([]), removeRecentItem: jest.fn().mockResolvedValue(undefined), @@ -396,6 +398,48 @@ describe('UnifiedRecentDataService', () => { ]); }); + it('clears Xtream recent localStorage during global PWA clear', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsService.getAllPlaylists.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'm3u-1', + title: 'M3U List', + }, + { + _id: 'stalker-1', + title: 'Stalker Portal', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ] satisfies Partial[]) + ); + + await service.clearRecentItems('all'); + + expect(dbService.clearGlobalRecentlyViewed).not.toHaveBeenCalled(); + expect(xtreamDataSource.clearRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('m3u-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('stalker-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).not.toHaveBeenCalledWith('xtream-1'); + }); + it('keeps Stalker radio recent items in the live collection with radio metadata', async () => { store.select.mockReturnValue( of([ diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index 6c45392d0..de428b516 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -197,12 +197,12 @@ export class UnifiedRecentDataService { )) as Playlist[]; await Promise.all( - playlists - .filter( - (playlist) => - Boolean(playlist.macAddress) || !playlist.serverUrl - ) - .map(async (playlist) => { + playlists.map(async (playlist) => { + if (!window.electron && this.isXtreamPlaylist(playlist)) { + await this.xtreamDataSource.clearRecentItems(playlist._id); + } + + if (this.isPlaylistBackedRecentPlaylist(playlist)) { const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPlaylistRecentlyViewed( playlist._id @@ -212,7 +212,8 @@ export class UnifiedRecentDataService { playlist._id, updatedPlaylist ); - }) + } + }) ); } @@ -695,4 +696,16 @@ export class UnifiedRecentDataService { ): boolean { return !playlist.serverUrl && !playlist.macAddress; } + + private isXtreamPlaylist( + playlist: Pick + ): boolean { + return Boolean(playlist.serverUrl) && !playlist.macAddress; + } + + private isPlaylistBackedRecentPlaylist( + playlist: Pick + ): boolean { + return Boolean(playlist.macAddress) || this.isM3uPlaylist(playlist); + } } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index 111180599..e2eabcedc 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -452,6 +452,18 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { ); } + async setContentBackdropIfMissing( + contentId: number, + playlistId: string, + backdropUrl: string + ): Promise { + void playlistId; + await this.dbService.setContentBackdropIfMissing( + contentId, + backdropUrl + ); + } + // ========================================================================= // Playback Position Operations // ========================================================================= 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 index 2b7bcc5f0..12f5bb943 100644 --- 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 @@ -255,4 +255,104 @@ describe('PwaXtreamDataSource', () => { [] ); }); + + it('hydrates stored favorites and recent items from the API when content cache is cold', async () => { + await dataSource.createPlaylist({ + id: 'playlist-1', + name: 'Xtream PWA', + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + type: 'xtream', + }); + apiService.getStreams.mockImplementation( + (_credentials: XtreamCredentials, type: string) => + Promise.resolve( + type === 'movie' + ? [ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ] + : [] + ) + ); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ 'playlist-1': [202] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + 'playlist-1': [ + { + id: 202, + viewedAt: '2026-05-21T12:00:00.000Z', + }, + ], + }) + ); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + viewed_at: '2026-05-21T12:00:00.000Z', + xtream_id: 202, + }), + ]); + expect(apiService.getStreams).toHaveBeenCalledWith( + credentials, + 'movie' + ); + }); + + it('backfills PWA recent-item backdrop metadata without rewriting recency', async () => { + apiService.getStreams.mockResolvedValue([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ]); + + await dataSource.getContent('playlist-1', credentials, 'movie'); + await dataSource.addRecentItem(202, 'playlist-1'); + const before = JSON.parse( + localStorage.getItem('xtream-recent-items') || '{}' + )['playlist-1'][0].viewedAt; + + await dataSource.setContentBackdropIfMissing( + 202, + 'playlist-1', + ' https://example.com/backdrop.png ' + ); + + const stored = JSON.parse( + localStorage.getItem('xtream-recent-items') || '{}' + )['playlist-1'][0]; + expect(stored).toEqual( + expect.objectContaining({ + id: 202, + backdropUrl: 'https://example.com/backdrop.png', + viewedAt: before, + }) + ); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + backdrop_url: 'https://example.com/backdrop.png', + title: 'Movie One', + 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 102cb6fbd..687a1b720 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 @@ -36,6 +36,7 @@ const STORAGE_KEYS = { interface XtreamCachedContentItem { readonly added?: string; + readonly backdrop_url?: string | null; readonly category_id?: string | number; readonly cover?: string; readonly cover_big?: string; @@ -59,6 +60,7 @@ interface XtreamCachedContentItem { interface StoredRecentItem { readonly id: number; readonly viewedAt: string; + readonly backdropUrl?: string; } /** @@ -70,6 +72,7 @@ interface StoredRecentItem { export class PwaXtreamDataSource implements IXtreamDataSource { private readonly apiService = inject(XtreamApiService); private readonly logger = createLogger('PwaXtreamDataSource'); + private readonly contentTypes = ['live', 'movie', 'series'] as const; // In-memory cache for the current session private categoryCache = new Map(); @@ -391,10 +394,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getFavorites(playlistId: string): Promise { const allFavorites = this.getFavoritesFromStorage(); const playlistFavorites = allFavorites[playlistId] || []; + await this.hydrateStoredCollectionContent( + playlistId, + playlistFavorites + ); // Match favorites with cached content const results: XtreamCachedContentItem[] = []; - for (const type of ['live', 'movie', 'series']) { + for (const type of this.contentTypes) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; @@ -614,10 +621,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getRecentItems(playlistId: string): Promise { const allRecent = this.getRecentItemsFromStorage(); const playlistRecent = allRecent[playlistId] || []; + await this.hydrateStoredCollectionContent( + playlistId, + playlistRecent.map((item) => item.id) + ); // Match recent items with cached content const results: (XtreamCachedContentItem & { viewed_at: string })[] = []; - for (const type of ['live', 'movie', 'series']) { + for (const type of this.contentTypes) { const cacheKey = `${playlistId}-${type}-content`; const content = this.contentCache.get(cacheKey) || []; @@ -627,6 +638,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { if (recentEntry) { results.push({ ...item, + backdrop_url: + recentEntry.backdropUrl ?? item.backdrop_url, viewed_at: recentEntry.viewedAt, }); } @@ -648,11 +661,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, _backdropUrl?: string ): Promise { - void _backdropUrl; const normalizedContentId = this.normalizeStoredId(contentId); if (normalizedContentId == null) { return; } + const normalizedBackdropUrl = _backdropUrl?.trim(); const allRecent = this.getRecentItemsFromStorage(); if (!allRecent[playlistId]) { @@ -668,6 +681,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource { allRecent[playlistId].unshift({ id: normalizedContentId, viewedAt: new Date().toISOString(), + ...(normalizedBackdropUrl + ? { backdropUrl: normalizedBackdropUrl } + : {}), }); // Keep only last 50 items @@ -768,6 +784,8 @@ export class PwaXtreamDataSource implements IXtreamDataSource { const rawItem = item as { readonly id?: unknown; readonly viewedAt?: unknown; + readonly backdropUrl?: unknown; + readonly backdrop_url?: unknown; }; const id = this.normalizeStoredId(rawItem.id); if ( @@ -780,6 +798,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return { id, viewedAt: rawItem.viewedAt, + ...this.normalizeStoredBackdrop(rawItem), }; }) .filter((item): item is StoredRecentItem => item !== null); @@ -788,6 +807,59 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return normalized; } + private normalizeStoredBackdrop(item: { + readonly backdropUrl?: unknown; + readonly backdrop_url?: unknown; + }): Pick | Record { + const value = item.backdropUrl ?? item.backdrop_url; + if (typeof value !== 'string') { + return {}; + } + + const backdropUrl = value.trim(); + return backdropUrl ? { backdropUrl } : {}; + } + + private async hydrateStoredCollectionContent( + playlistId: string, + ids: readonly number[] + ): Promise { + if (ids.length === 0) { + return; + } + + const missingTypes = this.contentTypes.filter( + (type) => !this.contentCache.has(`${playlistId}-${type}-content`) + ); + if (missingTypes.length === 0) { + return; + } + + const playlist = await this.getPlaylist(playlistId); + if (!playlist) { + return; + } + + const credentials: XtreamCredentials = { + serverUrl: playlist.serverUrl, + username: playlist.username, + password: playlist.password, + }; + + await Promise.all( + missingTypes.map(async (type) => { + try { + await this.getContent(playlistId, credentials, type); + } catch (error) { + this.logger.warn( + 'Failed to hydrate stored PWA Xtream collection content', + { playlistId, type, error } + ); + } + }) + ); + } + // ========================================================================= // Content Lookup // ========================================================================= @@ -818,6 +890,61 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return null; } + async setContentBackdropIfMissing( + contentId: number, + playlistId: string, + backdropUrl: string + ): Promise { + const normalizedContentId = this.normalizeStoredId(contentId); + const normalizedBackdropUrl = backdropUrl.trim(); + if (normalizedContentId == null || !normalizedBackdropUrl) { + return; + } + + for (const type of this.contentTypes) { + const cacheKey = `${playlistId}-${type}-content`; + const content = this.contentCache.get(cacheKey); + if (!content) { + continue; + } + + this.contentCache.set( + cacheKey, + content.map((item) => { + const itemId = this.getItemIdentity(item, type); + if (itemId !== normalizedContentId || item.backdrop_url) { + return item; + } + + return { + ...item, + backdrop_url: normalizedBackdropUrl, + }; + }) + ); + } + + const allRecent = this.getRecentItemsFromStorage(); + const playlistRecent = allRecent[playlistId]; + if (!playlistRecent) { + return; + } + + this.saveRecentItemsToStorage({ + ...allRecent, + [playlistId]: playlistRecent.map((item) => { + if (item.id !== normalizedContentId || item.backdropUrl) { + return item; + } + + return { + ...item, + backdropUrl: normalizedBackdropUrl, + }; + }), + }); + } + private findContentIdentity( playlistId: string, xtreamId: number, diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 1978b39cf..831ead825 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -359,6 +359,16 @@ export interface IXtreamDataSource { contentType?: 'live' | 'movie' | 'series' ): Promise; + /** + * Persist a backdrop URL for an already-known content item without changing + * favorites or recent ordering. + */ + setContentBackdropIfMissing( + contentId: number, + playlistId: string, + backdropUrl: string + ): Promise; + // ========================================================================= // Playback Position Operations // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts index 9efc794de..d1d63d17a 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts @@ -21,6 +21,7 @@ describe('withRecentItems', () => { const originalElectron = window.electron; let store: InstanceType; let databaseService: { + clearGlobalRecentlyViewed: jest.Mock; clearPlaylistRecentItems: jest.Mock; getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; @@ -33,6 +34,11 @@ describe('withRecentItems', () => { getContentByXtreamId: jest.Mock; getRecentItems: jest.Mock; removeRecentItem: jest.Mock; + setContentBackdropIfMissing: jest.Mock; + }; + let playlistsService: { + clearPlaylistRecentlyViewed: jest.Mock; + getAllPlaylists: jest.Mock; }; beforeEach(() => { @@ -42,6 +48,7 @@ describe('withRecentItems', () => { }); databaseService = { + clearGlobalRecentlyViewed: jest.fn().mockResolvedValue(undefined), clearPlaylistRecentItems: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), getRecentItems: jest.fn().mockResolvedValue([ @@ -76,6 +83,13 @@ describe('withRecentItems', () => { }, ]), removeRecentItem: jest.fn().mockResolvedValue(undefined), + setContentBackdropIfMissing: jest.fn().mockResolvedValue(undefined), + }; + playlistsService = { + clearPlaylistRecentlyViewed: jest + .fn() + .mockReturnValue(of(undefined)), + getAllPlaylists: jest.fn().mockReturnValue(of([])), }; TestBed.configureTestingModule({ @@ -91,12 +105,7 @@ describe('withRecentItems', () => { }, { provide: PlaylistsService, - useValue: { - clearPlaylistRecentlyViewed: jest - .fn() - .mockReturnValue(of(undefined)), - getAllPlaylists: jest.fn().mockReturnValue(of([])), - }, + useValue: playlistsService, }, ], }); @@ -220,7 +229,7 @@ describe('withRecentItems', () => { }); it('backfills a backdrop without rewriting recent ordering', async () => { - databaseService.getContentByXtreamId.mockResolvedValue({ + dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, title: 'Krypton', type: 'series', @@ -234,18 +243,21 @@ describe('withRecentItems', () => { backdropUrl: ' https://example.com/krypton-backdrop.png ', }); - expect(databaseService.getContentByXtreamId).toHaveBeenCalledWith( + expect(dataSource.getContentByXtreamId).toHaveBeenCalledWith( 290, 'playlist-1', 'series' ); - expect( - databaseService.setContentBackdropIfMissing - ).toHaveBeenCalledWith( + expect(dataSource.setContentBackdropIfMissing).toHaveBeenCalledWith( 3941697, + 'playlist-1', 'https://example.com/krypton-backdrop.png' ); expect(dataSource.addRecentItem).not.toHaveBeenCalled(); + expect(databaseService.getContentByXtreamId).not.toHaveBeenCalled(); + expect( + databaseService.setContentBackdropIfMissing + ).not.toHaveBeenCalled(); }); it('clears recent items through the active data source in PWA', async () => { @@ -262,6 +274,46 @@ describe('withRecentItems', () => { expect(store.recentItems()).toEqual([]); }); + it('clears Xtream recent items during global PWA clear', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsService.getAllPlaylists.mockReturnValue( + of([ + { + _id: 'xtream-1', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'm3u-1', + }, + { + _id: 'stalker-1', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ]) + ); + + await store.clearGlobalRecentlyViewed(); + + expect( + databaseService.clearGlobalRecentlyViewed + ).not.toHaveBeenCalled(); + expect(dataSource.clearRecentItems).toHaveBeenCalledWith('xtream-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('m3u-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).toHaveBeenCalledWith('stalker-1'); + expect( + playlistsService.clearPlaylistRecentlyViewed + ).not.toHaveBeenCalledWith('xtream-1'); + expect(store.recentItems()).toEqual([]); + }); + it('removes recent items through the active data source in PWA', async () => { Object.defineProperty(window, 'electron', { value: undefined, diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index 71eb926eb..5e7ed411b 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -148,10 +148,6 @@ export const withRecentItems = function () { playlist: Signal<{ id: string }>; backdropUrl?: string; }): Promise { - if (!window.electron) { - return; - } - const playlistId = playlist().id; const normalizedXtreamId = Number(xtreamId); const normalizedBackdropUrl = backdropUrl?.trim(); @@ -164,7 +160,7 @@ export const withRecentItems = function () { return; } - const content = await dbService.getContentByXtreamId( + const content = await dataSource.getContentByXtreamId( normalizedXtreamId, playlistId, contentType @@ -173,8 +169,9 @@ export const withRecentItems = function () { return; } - await dbService.setContentBackdropIfMissing( + await dataSource.setContentBackdropIfMissing( content.id, + playlistId, normalizedBackdropUrl ); }, @@ -276,24 +273,35 @@ export const withRecentItems = function () { }, async clearGlobalRecentlyViewed() { try { - await dbService.clearGlobalRecentlyViewed(); + if (window.electron) { + await dbService.clearGlobalRecentlyViewed(); + } const playlists = (await firstValueFrom( playlistsService.getAllPlaylists() )) as Playlist[]; await Promise.all( - playlists - .filter( - (playlist) => - Boolean(playlist.macAddress) || - !playlist.serverUrl - ) - .map((playlist) => - firstValueFrom( + playlists.map(async (playlist) => { + if ( + !window.electron && + playlist.serverUrl && + !playlist.macAddress + ) { + await dataSource.clearRecentItems( + playlist._id + ); + } + + if ( + Boolean(playlist.macAddress) || + !playlist.serverUrl + ) { + await firstValueFrom( playlistsService.clearPlaylistRecentlyViewed( playlist._id ) - ) - ) + ); + } + }) ); patchState(store, { recentItems: [] }); } catch (error) { From d1e057887f2c9ede37926e301fa82930a3b5f30f Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 19:41:16 +0300 Subject: [PATCH 08/15] fix(pwa): avoid persisting xtream passwords --- .../pwa-xtream-data-source.spec.ts | 25 ++++++ .../data-sources/pwa-xtream-data-source.ts | 78 ++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) 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 index 12f5bb943..cc220c222 100644 --- 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 @@ -41,6 +41,31 @@ describe('PwaXtreamDataSource', () => { localStorage.clear(); }); + it('keeps Xtream passwords out of localStorage playlist metadata', async () => { + await dataSource.createPlaylist({ + id: 'playlist-1', + name: 'Xtream PWA', + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + type: 'xtream', + }); + + const storedPlaylists = JSON.parse( + localStorage.getItem('xtream-playlists') || '[]' + ); + expect(storedPlaylists).toEqual([ + expect.not.objectContaining({ + password: credentials.password, + }), + ]); + await expect(dataSource.getPlaylist('playlist-1')).resolves.toEqual( + expect.objectContaining({ + password: credentials.password, + }) + ); + }); + it('normalizes Xtream API stream identifiers for PWA catalog navigation', async () => { apiService.getStreams.mockImplementation( (_credentials: XtreamCredentials, type: string) => { 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 687a1b720..768d9f2ab 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 @@ -63,6 +63,10 @@ interface StoredRecentItem { readonly backdropUrl?: string; } +type StoredXtreamPlaylistData = Omit & { + readonly password?: string; +}; + /** * PWA implementation of the Xtream data source. * Uses API-only strategy: always fetch from API, no database caching. @@ -77,6 +81,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // In-memory cache for the current session private categoryCache = new Map(); private contentCache = new Map(); + private playlistPasswords = new Map(); // ========================================================================= // Playlist Operations (localStorage) @@ -84,10 +89,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getPlaylist(playlistId: string): Promise { const playlists = this.getPlaylistsFromStorage(); - return playlists.find((p) => p.id === playlistId) || null; + const playlist = playlists.find((p) => p.id === playlistId); + return playlist?.password ? playlist : null; } async createPlaylist(playlist: XtreamPlaylistData): Promise { + this.rememberPlaylistPassword(playlist); const playlists = this.getPlaylistsFromStorage(); playlists.push(playlist); this.savePlaylistsToStorage(playlists); @@ -97,6 +104,10 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistId: string, updates: Partial ): Promise { + if (updates.password) { + this.playlistPasswords.set(playlistId, updates.password); + } + const playlists = this.getPlaylistsFromStorage(); const index = playlists.findIndex((p) => p.id === playlistId); if (index !== -1) { @@ -122,14 +133,75 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private getPlaylistsFromStorage(): XtreamPlaylistData[] { try { const data = localStorage.getItem(STORAGE_KEYS.PLAYLISTS); - return data ? JSON.parse(data) : []; + const playlists = data + ? (JSON.parse(data) as StoredXtreamPlaylistData[]) + : []; + + return playlists.map((playlist) => + this.fromStoredPlaylist(playlist) + ); } catch { return []; } } private savePlaylistsToStorage(playlists: XtreamPlaylistData[]): void { - localStorage.setItem(STORAGE_KEYS.PLAYLISTS, JSON.stringify(playlists)); + const persistedPlaylists = playlists.map((playlist) => + this.toStoredPlaylist(playlist) + ); + localStorage.setItem( + STORAGE_KEYS.PLAYLISTS, + JSON.stringify(persistedPlaylists) + ); + } + + private toStoredPlaylist( + playlist: XtreamPlaylistData + ): StoredXtreamPlaylistData { + return { + id: playlist.id, + name: playlist.name, + title: playlist.title, + updateDate: playlist.updateDate, + serverUrl: playlist.serverUrl, + username: playlist.username, + type: playlist.type, + userAgent: playlist.userAgent, + referrer: playlist.referrer, + origin: playlist.origin, + serverTimezone: playlist.serverTimezone, + }; + } + + private fromStoredPlaylist( + playlist: StoredXtreamPlaylistData + ): XtreamPlaylistData { + const legacyPassword = + typeof playlist.password === 'string' ? playlist.password : ''; + if (legacyPassword) { + this.playlistPasswords.set(playlist.id, legacyPassword); + } + + return { + id: playlist.id, + name: playlist.name, + title: playlist.title, + updateDate: playlist.updateDate, + serverUrl: playlist.serverUrl, + username: playlist.username, + password: this.playlistPasswords.get(playlist.id) ?? legacyPassword, + type: playlist.type, + userAgent: playlist.userAgent, + referrer: playlist.referrer, + origin: playlist.origin, + serverTimezone: playlist.serverTimezone, + }; + } + + private rememberPlaylistPassword(playlist: XtreamPlaylistData): void { + if (playlist.password) { + this.playlistPasswords.set(playlist.id, playlist.password); + } } // ========================================================================= From 9097ff5bc121c91fb7409f4242e135d6dd78651d Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 19:45:07 +0300 Subject: [PATCH 09/15] fix(pwa): align xtream recent clearing --- .../unified-recent-data.service.spec.ts | 8 ++++++-- .../collection/unified-recent-data.service.ts | 14 ++++++++++---- .../src/lib/with-recent-items.feature.spec.ts | 17 +++++++++++++++++ .../data-access/src/lib/with-recent-items.ts | 15 ++++----------- 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index b570432a6..59526df28 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -403,13 +403,17 @@ describe('UnifiedRecentDataService', () => { value: undefined, configurable: true, }); - playlistsService.getAllPlaylists.mockReturnValue( + store.select.mockReturnValue( of([ { _id: 'xtream-1', title: 'Xtream PWA', serverUrl: 'https://xtream.example.com', - }, + } satisfies Partial, + ]) + ); + playlistsService.getAllPlaylists.mockReturnValue( + of([ { _id: 'm3u-1', title: 'M3U List', diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index de428b516..3111261bb 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -191,6 +191,16 @@ export class UnifiedRecentDataService { if (window.electron) { await this.dbService.clearGlobalRecentlyViewed(); + } else { + const allMeta = await this.getAllMeta(); + await Promise.all( + allMeta + .filter((playlist) => playlist._id) + .filter((playlist) => this.isXtreamPlaylist(playlist)) + .map((playlist) => + this.xtreamDataSource.clearRecentItems(playlist._id) + ) + ); } const playlists = (await firstValueFrom( this.playlistsService.getAllPlaylists() @@ -198,10 +208,6 @@ export class UnifiedRecentDataService { await Promise.all( playlists.map(async (playlist) => { - if (!window.electron && this.isXtreamPlaylist(playlist)) { - await this.xtreamDataSource.clearRecentItems(playlist._id); - } - if (this.isPlaylistBackedRecentPlaylist(playlist)) { const updatedPlaylist = await firstValueFrom( this.playlistsService.clearPlaylistRecentlyViewed( diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts index d1d63d17a..ea2698fc8 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.feature.spec.ts @@ -335,4 +335,21 @@ describe('withRecentItems', () => { }), ]); }); + + it('removes recent items through the active data source in Electron mode', async () => { + store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(dataSource.removeRecentItem).toHaveBeenCalledWith( + 3941697, + 'playlist-1' + ); + expect(databaseService.removeRecentItem).not.toHaveBeenCalled(); + expect(store.recentItems()).toEqual([ + expect.objectContaining({ + id: 3941697, + title: 'Krypton', + }), + ]); + }); }); diff --git a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts index 5e7ed411b..8d7fda351 100644 --- a/libs/portal/xtream/data-access/src/lib/with-recent-items.ts +++ b/libs/portal/xtream/data-access/src/lib/with-recent-items.ts @@ -195,17 +195,10 @@ export const withRecentItems = function () { }>( pipe( switchMap(async ({ itemId, playlistId }) => { - if (window.electron) { - await dbService.removeRecentItem( - itemId, - playlistId - ); - } else { - await dataSource.removeRecentItem( - itemId, - playlistId - ); - } + await dataSource.removeRecentItem( + itemId, + playlistId + ); // Reload recent items to update UI const items = await dataSource.getRecentItems(playlistId); From 3ccbfe4f99edaf36bbac1bdf3bb046921a5064b7 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 19:52:39 +0300 Subject: [PATCH 10/15] fix(pwa): remove dashboard xtream items via data source --- .../src/lib/dashboard-data.service.spec.ts | 71 ++++++++++++++++++- .../src/lib/dashboard-data.service.ts | 30 +++++--- 2 files changed, 92 insertions(+), 9 deletions(-) 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 52ccab9ba..5bce017be 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 @@ -10,7 +10,11 @@ import { of } from 'rxjs'; import { DatabaseService, PlaylistsService } from '@iptvnator/services'; import { Playlist, PlaylistMeta } from '@iptvnator/shared/interfaces'; import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; -import { DashboardDataService } from './dashboard-data.service'; +import { + DashboardDataService, + DashboardFavoriteItem, + GlobalRecentItem, +} from './dashboard-data.service'; describe('DashboardDataService', () => { let service: DashboardDataService; @@ -75,6 +79,8 @@ describe('DashboardDataService', () => { 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', @@ -150,6 +156,10 @@ describe('DashboardDataService', () => { 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({ @@ -458,6 +468,35 @@ describe('DashboardDataService', () => { ); }); + it('removes PWA Xtream favorites through the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + const item = { + id: 51, + category_id: 12, + title: 'Action Movie', + poster_url: 'https://example.com/movie.png', + xtream_id: 5001, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + } satisfies DashboardFavoriteItem; + + await service.removeGlobalFavorite(item); + + expect(xtreamDataSourceMock.removeFavorite).toHaveBeenCalledWith( + 51, + 'xtream-1' + ); + expect(dbServiceMock.removeFromFavorites).not.toHaveBeenCalled(); + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + it('includes M3U recently viewed items in global recent items', () => { expect(service.globalRecentItems()).toEqual( expect.arrayContaining([ @@ -829,4 +868,34 @@ describe('DashboardDataService', () => { playlistsServiceMock.removeFromM3uRecentlyViewed ).toHaveBeenCalledWith('m3u-1', 'https://example.com/stream-1.m3u8'); }); + + it('removes PWA Xtream recently viewed through the active data source', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + const item = { + id: 91, + category_id: 18, + title: 'Recent Movie', + viewed_at: '2026-04-21T10:00:00.000Z', + poster_url: 'https://example.com/recent-movie.png', + xtream_id: 7001, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + source: 'xtream', + } satisfies GlobalRecentItem; + + await service.removeGlobalRecentItem(item); + + expect(xtreamDataSourceMock.removeRecentItem).toHaveBeenCalledWith( + 91, + 'xtream-1' + ); + expect(dbServiceMock.removeRecentItem).not.toHaveBeenCalled(); + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + }); }); 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 0dc8c58be..b2c300c4f 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 @@ -675,10 +675,17 @@ export class DashboardDataService { async removeGlobalRecentItem(item: GlobalRecentItem): Promise { if (item.source === 'xtream') { - await this.dbService.removeRecentItem( - item.id as number, - item.playlist_id - ); + if (window.electron) { + await this.dbService.removeRecentItem( + item.id as number, + item.playlist_id + ); + } else { + await this.xtreamDataSource.removeRecentItem( + item.id as number, + item.playlist_id + ); + } await this.reloadGlobalRecentItems(); return; } @@ -829,10 +836,17 @@ export class DashboardDataService { async removeGlobalFavorite(item: DashboardFavoriteItem): Promise { if (item.source === 'xtream') { - await this.dbService.removeFromFavorites( - item.id as number, - item.playlist_id - ); + if (window.electron) { + await this.dbService.removeFromFavorites( + item.id as number, + item.playlist_id + ); + } else { + await this.xtreamDataSource.removeFavorite( + item.id as number, + item.playlist_id + ); + } await this.reloadGlobalFavorites(); return; } From ed0b6833d6cacd9242a9d1cbcb9fa2bb0dd98978 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 20:00:38 +0300 Subject: [PATCH 11/15] fix(pwa): persist xtream collection snapshots --- .../pwa-xtream-data-source.spec.ts | 77 +++++++ .../data-sources/pwa-xtream-data-source.ts | 197 ++++++++++++++---- .../src/lib/dashboard-data.service.spec.ts | 54 +++++ .../src/lib/dashboard-data.service.ts | 4 +- 4 files changed, 295 insertions(+), 37 deletions(-) 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 index cc220c222..7810b8a53 100644 --- 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 @@ -365,6 +365,9 @@ describe('PwaXtreamDataSource', () => { const stored = JSON.parse( localStorage.getItem('xtream-recent-items') || '{}' )['playlist-1'][0]; + const storedSnapshot = JSON.parse( + localStorage.getItem('xtream-collection-items') || '{}' + )['playlist-1']['202']; expect(stored).toEqual( expect.objectContaining({ id: 202, @@ -372,6 +375,13 @@ describe('PwaXtreamDataSource', () => { viewedAt: before, }) ); + expect(storedSnapshot).toEqual( + expect.objectContaining({ + backdrop_url: 'https://example.com/backdrop.png', + title: 'Movie One', + xtream_id: 202, + }) + ); await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ expect.objectContaining({ backdrop_url: 'https://example.com/backdrop.png', @@ -380,4 +390,71 @@ describe('PwaXtreamDataSource', () => { }), ]); }); + + it('uses stored collection snapshots for fresh-session favorites and recent items', async () => { + await dataSource.createPlaylist({ + id: 'playlist-1', + name: 'Xtream PWA', + serverUrl: credentials.serverUrl, + username: credentials.username, + password: credentials.password, + type: 'xtream', + }); + apiService.getStreams.mockResolvedValue([ + { + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ]); + + await dataSource.getContent('playlist-1', credentials, 'movie'); + await dataSource.addFavorite(202, 'playlist-1'); + await dataSource.addRecentItem( + 202, + 'playlist-1', + 'https://example.com/backdrop.png' + ); + + const storedCollectionItems = JSON.parse( + localStorage.getItem('xtream-collection-items') || '{}' + ); + expect(storedCollectionItems['playlist-1']['202']).toEqual( + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }) + ); + + TestBed.resetTestingModule(); + apiService = { + getStreams: jest.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { + provide: XtreamApiService, + useValue: apiService, + }, + ], + }); + dataSource = TestBed.inject(PwaXtreamDataSource); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + title: 'Movie One', + xtream_id: 202, + }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + backdrop_url: 'https://example.com/backdrop.png', + title: 'Movie One', + xtream_id: 202, + }), + ]); + expect(apiService.getStreams).not.toHaveBeenCalled(); + }); }); 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 768d9f2ab..3457ea212 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 @@ -28,6 +28,7 @@ import { * LocalStorage keys for PWA persistence */ const STORAGE_KEYS = { + COLLECTION_ITEMS: 'xtream-collection-items', FAVORITES: 'xtream-favorites', RECENT_ITEMS: 'xtream-recent-items', PLAYLISTS: 'xtream-playlists', @@ -122,6 +123,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { this.savePlaylistsToStorage(filtered); // Also clear favorites and recent items for this playlist + this.clearCollectionItemsForPlaylist(playlistId); this.clearFavoritesForPlaylist(playlistId); this.clearRecentItemsForPlaylist(playlistId); this.clearPlaybackPositionsForPlaylist(playlistId); @@ -471,31 +473,16 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistFavorites ); - // Match favorites with cached content - const results: XtreamCachedContentItem[] = []; - for (const type of this.contentTypes) { - const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; - - for (const item of content) { - const itemId = this.getItemIdentity(item); - if (playlistFavorites.includes(itemId)) { - results.push(item); - } - } - } - - return results as XtreamContentItem[]; + return Array.from( + this.getCollectionItemsById(playlistId, playlistFavorites).values() + ); } async addFavorite( contentId: number, playlistId: string, - // PWA uses localStorage with no content table, so backdrop persistence - // is electron-only. Accept the param for interface parity. - _backdropUrl?: string + backdropUrl?: string ): Promise { - void _backdropUrl; const normalizedContentId = this.normalizeStoredId(contentId); if (normalizedContentId == null) { return; @@ -509,6 +496,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { allFavorites[playlistId].push(normalizedContentId); } this.saveFavoritesToStorage(allFavorites); + this.saveCollectionItemSnapshot( + playlistId, + normalizedContentId, + backdropUrl + ); } async removeFavorite(contentId: number, playlistId: string): Promise { @@ -698,24 +690,22 @@ export class PwaXtreamDataSource implements IXtreamDataSource { playlistRecent.map((item) => item.id) ); - // Match recent items with cached content - const results: (XtreamCachedContentItem & { viewed_at: string })[] = []; - for (const type of this.contentTypes) { - const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; - - for (const item of content) { - const itemId = this.getItemIdentity(item); - const recentEntry = playlistRecent.find((r) => r.id === itemId); - if (recentEntry) { - results.push({ - ...item, - backdrop_url: - recentEntry.backdropUrl ?? item.backdrop_url, - viewed_at: recentEntry.viewedAt, - }); - } + const contentById = this.getCollectionItemsById( + playlistId, + playlistRecent.map((item) => item.id) + ); + const results: (XtreamContentItem & { viewed_at: string })[] = []; + for (const recentEntry of playlistRecent) { + const item = contentById.get(recentEntry.id); + if (!item) { + continue; } + + results.push({ + ...item, + backdrop_url: recentEntry.backdropUrl ?? item.backdrop_url, + viewed_at: recentEntry.viewedAt, + }); } // Sort by viewed_at descending @@ -762,6 +752,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { allRecent[playlistId] = allRecent[playlistId].slice(0, 50); this.saveRecentItemsToStorage(allRecent); + this.saveCollectionItemSnapshot( + playlistId, + normalizedContentId, + normalizedBackdropUrl + ); } async removeRecentItem( @@ -892,6 +887,130 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return backdropUrl ? { backdropUrl } : {}; } + private getCollectionItemsFromStorage(): Record< + string, + Record + > { + try { + const data = localStorage.getItem(STORAGE_KEYS.COLLECTION_ITEMS); + const parsed = data ? JSON.parse(data) : {}; + if (!parsed || typeof parsed !== 'object') { + return {}; + } + return parsed as Record>; + } catch { + return {}; + } + } + + private saveCollectionItemsToStorage( + items: Record> + ): void { + localStorage.setItem( + STORAGE_KEYS.COLLECTION_ITEMS, + JSON.stringify(items) + ); + } + + private clearCollectionItemsForPlaylist(playlistId: string): void { + const allItems = this.getCollectionItemsFromStorage(); + delete allItems[playlistId]; + this.saveCollectionItemsToStorage(allItems); + } + + private saveCollectionItemSnapshot( + playlistId: string, + contentId: number, + backdropUrl?: string + ): void { + const item = this.findCachedContentItemById(playlistId, contentId); + if (!item) { + return; + } + + const normalizedBackdropUrl = backdropUrl?.trim(); + const allItems = this.getCollectionItemsFromStorage(); + const playlistItems = allItems[playlistId] ?? {}; + playlistItems[String(contentId)] = { + ...item, + ...(normalizedBackdropUrl && !item.backdrop_url + ? { backdrop_url: normalizedBackdropUrl } + : {}), + }; + this.saveCollectionItemsToStorage({ + ...allItems, + [playlistId]: playlistItems, + }); + } + + private setCollectionItemBackdropIfMissing( + playlistId: string, + contentId: number, + backdropUrl: string + ): void { + const allItems = this.getCollectionItemsFromStorage(); + const playlistItems = allItems[playlistId]; + const item = playlistItems?.[String(contentId)]; + if (!item || item.backdrop_url) { + return; + } + + this.saveCollectionItemsToStorage({ + ...allItems, + [playlistId]: { + ...playlistItems, + [String(contentId)]: { + ...item, + backdrop_url: backdropUrl, + }, + }, + }); + } + + private getCollectionItemsById( + playlistId: string, + ids: readonly number[] + ): Map { + const idSet = new Set(ids); + const results = new Map(); + + for (const type of this.contentTypes) { + const cacheKey = `${playlistId}-${type}-content`; + const content = this.contentCache.get(cacheKey) || []; + + for (const item of content) { + const itemId = this.getItemIdentity(item, type); + if (idSet.has(itemId)) { + results.set(itemId, item as XtreamContentItem); + } + } + } + + const storedItems = this.getCollectionItemsFromStorage()[playlistId]; + if (!storedItems) { + return results; + } + + for (const id of ids) { + if (!results.has(id) && storedItems[String(id)]) { + results.set(id, storedItems[String(id)]); + } + } + + return results; + } + + private findCachedContentItemById( + playlistId: string, + contentId: number + ): XtreamContentItem | null { + return ( + this.getCollectionItemsById(playlistId, [contentId]).get( + contentId + ) ?? null + ); + } + private async hydrateStoredCollectionContent( playlistId: string, ids: readonly number[] @@ -996,6 +1115,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ); } + this.setCollectionItemBackdropIfMissing( + playlistId, + normalizedContentId, + normalizedBackdropUrl + ); + const allRecent = this.getRecentItemsFromStorage(); const playlistRecent = allRecent[playlistId]; if (!playlistRecent) { 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 5bce017be..d94547821 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 @@ -388,6 +388,33 @@ describe('DashboardDataService', () => { ); }); + it('does not load Stalker playlists through the PWA Xtream favorites path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream Playlist', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Playlist', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledTimes(1); + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + it('builds the M3U favorites route', async () => { await service.reloadGlobalFavorites(); const m3uItem = service @@ -660,6 +687,33 @@ describe('DashboardDataService', () => { ); }); + it('does not load Stalker playlists through the PWA Xtream recent path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream Playlist', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Playlist', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ]); + + await service.reloadGlobalRecentItems(); + + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledTimes(1); + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + it('falls back to playlist metadata ordering when sources have no recent activity', 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 b2c300c4f..4555f6346 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 @@ -464,7 +464,9 @@ export class DashboardDataService { } private getXtreamPlaylists(): PlaylistMeta[] { - return this.playlists().filter((playlist) => !!playlist.serverUrl); + return this.playlists().filter( + (playlist) => !!playlist.serverUrl && !playlist.macAddress + ); } private mapPwaXtreamRecentItem( From 6438eec49fe51099bffe365f2c5cf6daac9e9126 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 20:18:39 +0300 Subject: [PATCH 12/15] fix(pwa): align xtream content identity --- .../pwa-xtream-data-source.spec.ts | 43 +++++++++++++++++++ .../data-sources/pwa-xtream-data-source.ts | 3 +- 2 files changed, 44 insertions(+), 2 deletions(-) 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 index 7810b8a53..d149d691b 100644 --- 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 @@ -340,6 +340,49 @@ describe('PwaXtreamDataSource', () => { ); }); + it('uses the Xtream stream ID as the PWA content ID when raw id differs', async () => { + apiService.getStreams.mockResolvedValue([ + { + id: 5, + stream_id: 202, + name: 'Movie One', + stream_icon: 'movie.png', + category_id: '20', + }, + ]); + + const content = (await dataSource.getContent( + 'playlist-1', + credentials, + 'movie' + )) as Array>; + + expect(content[0]).toEqual( + expect.objectContaining({ + id: 202, + xtream_id: 202, + }) + ); + + await dataSource.addFavorite(Number(content[0].id), 'playlist-1'); + await dataSource.addRecentItem(Number(content[0].id), 'playlist-1'); + + await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + id: 202, + title: 'Movie One', + xtream_id: 202, + }), + ]); + await expect(dataSource.getRecentItems('playlist-1')).resolves.toEqual([ + expect.objectContaining({ + id: 202, + title: 'Movie One', + xtream_id: 202, + }), + ]); + }); + it('backfills PWA recent-item backdrop metadata without rewriting recency', async () => { apiService.getStreams.mockResolvedValue([ { 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 3457ea212..0dc45b117 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 @@ -392,7 +392,6 @@ export class PwaXtreamDataSource implements IXtreamDataSource { 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 ?? @@ -407,7 +406,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ...item, added: item.added ?? item.last_modified ?? '', category_id: item.category_id ?? '', - id: Number.isFinite(id) && id > 0 ? id : xtreamId, + id: xtreamId, name: item.name ?? title, poster_url: posterUrl, rating: String(item.rating ?? ''), From 87dfaec03d7087ba4aaa8bd03f00e5aca90119ea Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 20:33:02 +0300 Subject: [PATCH 13/15] fix(pwa): filter stalker from xtream collections --- .../unified-favorites-data.service.spec.ts | 28 ++++++++++++++++++ .../unified-favorites-data.service.ts | 10 ++++++- .../unified-recent-data.service.spec.ts | 29 +++++++++++++++++++ .../collection/unified-recent-data.service.ts | 2 +- 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index 66c182087..79f3a90d8 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -286,6 +286,34 @@ describe('UnifiedFavoritesDataService', () => { ]); }); + it('does not load Stalker portals through the PWA Xtream global favorites path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Portal', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + favorites: stalkerFavorites, + }, + ] satisfies Partial[]) + ); + + await service.getFavorites('all'); + + expect(xtreamDataSource.getFavorites).toHaveBeenCalledTimes(1); + expect(xtreamDataSource.getFavorites).toHaveBeenCalledWith('xtream-1'); + }); + it('preserves persisted M3U favorites order when extracting playlist favorites', async () => { playlistsService.getPlaylistById.mockReturnValue( of({ diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index a623ffbb9..ee40affa2 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -517,7 +517,9 @@ export class UnifiedFavoritesDataService { if (!window.electron) { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; - for (const meta of allMeta.filter((p) => p._id && p.serverUrl)) { + for (const meta of allMeta.filter( + (p) => p._id && this.isXtreamPlaylist(p) + )) { results.push( ...(await this.getXtreamPlaylistFavorites(meta._id)) ); @@ -824,6 +826,12 @@ export class UnifiedFavoritesDataService { }, new Map()); } + private isXtreamPlaylist( + playlist: Pick + ): boolean { + return Boolean(playlist.serverUrl) && !playlist.macAddress; + } + private getStalkerFavoriteId( favorite: | Pick diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index 59526df28..017b3fc8a 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -398,6 +398,35 @@ describe('UnifiedRecentDataService', () => { ]); }); + it('does not load Stalker portals through the PWA Xtream global recent path', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://xtream.example.com', + }, + { + _id: 'stalker-1', + title: 'Stalker Portal', + serverUrl: 'https://stalker.example.com', + macAddress: '00:11:22:33:44:55', + }, + ] satisfies Partial[]) + ); + + await service.getRecentItems('all'); + + expect(xtreamDataSource.getRecentItems).toHaveBeenCalledTimes(1); + expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + }); + it('clears Xtream recent localStorage during global PWA clear', async () => { Object.defineProperty(window, 'electron', { value: undefined, diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index 3111261bb..163c59653 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -354,7 +354,7 @@ export class UnifiedRecentDataService { const allMeta = await this.getAllMeta(); const results: UnifiedCollectionItem[] = []; for (const meta of allMeta.filter( - (playlist) => playlist._id && playlist.serverUrl + (playlist) => playlist._id && this.isXtreamPlaylist(playlist) )) { results.push(...(await this.getXtreamPlaylistRecent(meta._id))); } From 69b7bca9df020370e08534a7e7b9d61c6d172aa0 Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 21:13:58 +0300 Subject: [PATCH 14/15] fix(pwa): prefer xtream collection snapshots --- .../data-sources/pwa-xtream-data-source.ts | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) 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 0dc45b117..4c07c657c 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 @@ -467,14 +467,12 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getFavorites(playlistId: string): Promise { const allFavorites = this.getFavoritesFromStorage(); const playlistFavorites = allFavorites[playlistId] || []; - await this.hydrateStoredCollectionContent( + const contentById = await this.getCollectionItemsWithHydration( playlistId, playlistFavorites ); - return Array.from( - this.getCollectionItemsById(playlistId, playlistFavorites).values() - ); + return Array.from(contentById.values()); } async addFavorite( @@ -684,12 +682,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async getRecentItems(playlistId: string): Promise { const allRecent = this.getRecentItemsFromStorage(); const playlistRecent = allRecent[playlistId] || []; - await this.hydrateStoredCollectionContent( - playlistId, - playlistRecent.map((item) => item.id) - ); - - const contentById = this.getCollectionItemsById( + const contentById = await this.getCollectionItemsWithHydration( playlistId, playlistRecent.map((item) => item.id) ); @@ -999,6 +992,20 @@ export class PwaXtreamDataSource implements IXtreamDataSource { return results; } + private async getCollectionItemsWithHydration( + playlistId: string, + ids: readonly number[] + ): Promise> { + const contentById = this.getCollectionItemsById(playlistId, ids); + const missingIds = ids.filter((id) => !contentById.has(id)); + if (missingIds.length === 0) { + return contentById; + } + + await this.hydrateStoredCollectionContent(playlistId, missingIds); + return this.getCollectionItemsById(playlistId, ids); + } + private findCachedContentItemById( playlistId: string, contentId: number From 4c2666520c686f140848c7fb8ea582f87041cc4e Mon Sep 17 00:00:00 2001 From: 4gray Date: Thu, 21 May 2026 21:40:24 +0300 Subject: [PATCH 15/15] fix(pwa): parallelize dashboard xtream loads --- .../src/lib/dashboard-data.service.spec.ts | 120 ++++++++++++++++++ .../src/lib/dashboard-data.service.ts | 48 ++++--- 2 files changed, 147 insertions(+), 21 deletions(-) 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 d94547821..9410ed0db 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 @@ -20,6 +20,20 @@ describe('DashboardDataService', () => { let service: DashboardDataService; const playlistsLoadedSignal = signal(true); + const createPendingItems = () => { + let resolvePending: (items: T[]) => void = () => { + throw new Error('Pending item promise resolved before init'); + }; + const promise = new Promise((resolve) => { + resolvePending = resolve; + }); + + return { + promise, + resolve: resolvePending, + }; + }; + const createDefaultPlaylists = (): PlaylistMeta[] => [ { _id: 'm3u-1', @@ -388,6 +402,74 @@ describe('DashboardDataService', () => { ); }); + it('loads PWA Xtream favorites for multiple playlists in parallel', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream One', + serverUrl: 'https://one.example.com', + }, + { + _id: 'xtream-2', + title: 'Xtream Two', + serverUrl: 'https://two.example.com', + }, + ]); + const firstFavorites = createPendingItems(); + xtreamDataSourceMock.getFavorites.mockImplementation((playlistId) => + playlistId === 'xtream-1' + ? firstFavorites.promise + : Promise.resolve([]) + ); + + const reload = service.reloadGlobalFavorites(); + await Promise.resolve(); + + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(xtreamDataSourceMock.getFavorites).toHaveBeenCalledWith( + 'xtream-2' + ); + + firstFavorites.resolve([]); + await reload; + }); + + it('uses the epoch fallback for PWA Xtream favorites with empty added dates', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + xtreamDataSourceMock.getFavorites.mockResolvedValue([ + { + id: 52, + category_id: 12, + title: 'Undated Movie', + rating: '8.0', + added: '', + poster_url: 'https://example.com/movie.png', + xtream_id: 5002, + type: 'movie', + }, + ]); + + await service.reloadGlobalFavorites(); + + expect(service.globalFavoriteItems()).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: 52, + added_at: new Date(0).toISOString(), + }), + ]) + ); + }); + it('does not load Stalker playlists through the PWA Xtream favorites path', async () => { Object.defineProperty(window, 'electron', { value: undefined, @@ -687,6 +769,44 @@ describe('DashboardDataService', () => { ); }); + it('loads PWA Xtream recent items for multiple playlists in parallel', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + playlistsSignal.set([ + { + _id: 'xtream-1', + title: 'Xtream One', + serverUrl: 'https://one.example.com', + }, + { + _id: 'xtream-2', + title: 'Xtream Two', + serverUrl: 'https://two.example.com', + }, + ]); + const firstRecentItems = createPendingItems(); + xtreamDataSourceMock.getRecentItems.mockImplementation((playlistId) => + playlistId === 'xtream-1' + ? firstRecentItems.promise + : Promise.resolve([]) + ); + + const reload = service.reloadGlobalRecentItems(); + await Promise.resolve(); + + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(xtreamDataSourceMock.getRecentItems).toHaveBeenCalledWith( + 'xtream-2' + ); + + firstRecentItems.resolve([]); + await reload; + }); + it('does not load Stalker playlists through the PWA Xtream recent path', async () => { Object.defineProperty(window, 'electron', { value: undefined, 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 4555f6346..5fac114de 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 @@ -434,33 +434,39 @@ export class DashboardDataService { private async loadPwaXtreamGlobalRecentItems(): Promise< GlobalRecentItem[] > { - const results: GlobalRecentItem[] = []; - for (const playlist of this.getXtreamPlaylists()) { - const rows = await this.xtreamDataSource.getRecentItems( - playlist._id - ); - results.push( - ...rows.map((item) => + const nested = await Promise.all( + this.getXtreamPlaylists().map(async (playlist) => { + const rows = await this.xtreamDataSource.getRecentItems( + playlist._id + ); + return rows.map((item) => this.mapPwaXtreamRecentItem(item, playlist) - ) - ); - } - return results; + ); + }) + ); + return nested.reduce( + (items, playlistItems) => items.concat(playlistItems), + [] + ); } private async loadPwaXtreamGlobalFavorites(): Promise< DashboardFavoriteItem[] > { - const results: DashboardFavoriteItem[] = []; - for (const playlist of this.getXtreamPlaylists()) { - const rows = await this.xtreamDataSource.getFavorites(playlist._id); - results.push( - ...rows.map((item) => + const nested = await Promise.all( + this.getXtreamPlaylists().map(async (playlist) => { + const rows = await this.xtreamDataSource.getFavorites( + playlist._id + ); + return rows.map((item) => this.mapPwaXtreamFavoriteItem(item, playlist) - ) - ); - } - return results; + ); + }) + ); + return nested.reduce( + (items, playlistItems) => items.concat(playlistItems), + [] + ); } private getXtreamPlaylists(): PlaylistMeta[] { @@ -498,7 +504,7 @@ export class DashboardDataService { type: this.normalizeXtreamActivityType(item.type), playlist_id: playlist._id, playlist_name: playlist.title || 'Xtream', - added_at: item.added_at ?? item.added ?? new Date(0).toISOString(), + added_at: item.added_at || item.added || new Date(0).toISOString(), category_id: item.category_id, xtream_id: item.xtream_id, poster_url: item.poster_url,