From 98784815b17cb488be09faa9e725e11d5dacb2c8 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:27:03 +0200 Subject: [PATCH] fix(xtream): preserve content identity in PWA collections (#1870) * fix(xtream): invalidate detail initialization on close * fix(xtream): preserve typed PWA collection identity * fix(xtream): complete typed live collection actions * test(xtream): cover PWA collection identity through reloads * fix(xtream): keep typed collection actions local * fix(xtream): tolerate quota limits during collection migration * fix(xtream): prioritize requested collection saves * fix(xtream): align collection identity with current playback contracts * test(xtream): confirm playback before collection history assertions --- .changes/xtream-pwa-collection-identity.md | 6 + apps/web-e2e/src/xtream-collection-helpers.ts | 368 +++++++++++ apps/web-e2e/src/xtream.e2e.ts | 115 ++++ .../xtream-portal-compatibility.md | 52 ++ .../unified-favorites-data.service.spec.ts | 4 +- .../unified-favorites-data.service.ts | 9 +- .../unified-recent-data.service.spec.ts | 4 +- .../collection/unified-recent-data.service.ts | 22 +- .../electron-xtream-data-source.ts | 52 +- ...ctron-xtream-data-source.user-data.spec.ts | 49 ++ .../data-sources/pwa-collection-identity.ts | 159 +++++ .../data-sources/pwa-collection-storage.ts | 271 ++++++++ .../pwa-xtream-collection-identity.spec.ts | 611 ++++++++++++++++++ .../pwa-xtream-data-source.spec.ts | 38 +- .../data-sources/pwa-xtream-data-source.ts | 564 ++++++---------- .../xtream-data-source.interface.ts | 35 +- .../src/lib/services/favorites.service.ts | 14 +- .../src/lib/with-favorites.feature.spec.ts | 44 +- .../src/lib/with-favorites.feature.ts | 175 ++--- .../src/lib/with-recent-items.feature.spec.ts | 27 +- .../data-access/src/lib/with-recent-items.ts | 56 +- .../live-stream-layout-history.spec-data.ts | 70 ++ .../live-stream-layout.component.spec.ts | 5 + .../live-stream-layout.component.ts | 11 + .../serial-details-menu.service.spec.ts | 18 +- .../serial-details-menu.service.ts | 5 +- .../vod-details-playback.service.spec.ts | 4 +- ...xtream-collection-detail.component.spec.ts | 81 ++- .../feature/src/lib/xtream-recent-history.ts | 17 +- .../src/lib/dashboard-data.service.spec.ts | 4 +- .../src/lib/dashboard-data.service.ts | 10 +- 31 files changed, 2349 insertions(+), 551 deletions(-) create mode 100644 .changes/xtream-pwa-collection-identity.md create mode 100644 apps/web-e2e/src/xtream-collection-helpers.ts create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-identity.ts create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts create mode 100644 libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-history.spec-data.ts diff --git a/.changes/xtream-pwa-collection-identity.md b/.changes/xtream-pwa-collection-identity.md new file mode 100644 index 000000000..69ba4b0dc --- /dev/null +++ b/.changes/xtream-pwa-collection-identity.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +Xtream favorites and recently viewed in the web app keep movies, live channels, and series separate when providers reuse IDs, including after reloading. Collection changes no longer wait for unrelated catalog loads. Saved items remain readable and removable when storage is full, and new saves take priority over cache updates. Confirmed live playback also appears in recently viewed. diff --git a/apps/web-e2e/src/xtream-collection-helpers.ts b/apps/web-e2e/src/xtream-collection-helpers.ts new file mode 100644 index 000000000..36d8a31bd --- /dev/null +++ b/apps/web-e2e/src/xtream-collection-helpers.ts @@ -0,0 +1,368 @@ +import { expect, type APIRequestContext, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type ContentType = 'movie' | 'series' | 'live'; +type CatalogItem = { + category_id: string | number; + stream_id?: number; + series_id?: number; +}; +type Detail = { + info: Record; + movie_data: Record; + episodes?: Record>>; +}; + +export const collisionItems = [ + { type: 'movie', label: 'Movies', title: 'Collision Movie' }, + { type: 'series', label: 'Series', title: 'Collision Series' }, + { type: 'live', label: 'Live TV', title: 'Collision Live' }, +] as const; + +export type CollisionItem = (typeof collisionItems)[number]; + +/** Keep mock shapes, with colliding IDs and native-playable VOD extensions. */ +export async function interceptCollidingXtreamItems( + page: Page, + request: APIRequestContext, + mockServer: string +): Promise> { + async function fetchFixture( + action: string, + parameters: Record = {} + ): Promise { + const url = new URL(`${mockServer}/player_api.php`); + url.search = new URLSearchParams({ + username: 'minimal', + password: 'minimal', + action, + ...parameters, + }).toString(); + const response = await request.get(url.toString()); + expect(response.ok()).toBeTruthy(); + return response.json(); + } + + const [[movie], [series], [live]] = await Promise.all([ + fetchFixture('get_vod_streams'), + fetchFixture('get_series'), + fetchFixture('get_live_streams'), + ]); + const [movieDetails, seriesDetails] = await Promise.all([ + fetchFixture('get_vod_info', { + vod_id: String(movie.stream_id), + }), + fetchFixture('get_series_info', { + series_id: String(series.series_id), + }), + ]); + for (const episodes of Object.values(seriesDetails.episodes ?? {})) { + for (const episode of episodes) { + episode['container_extension'] = 'mp4'; + } + } + const payloads: Record = { + get_vod_streams: [ + { + ...movie, + stream_id: 103, + name: 'Collision Movie', + container_extension: 'mp4', + }, + ], + get_series: [{ ...series, series_id: 103, name: 'Collision Series' }], + get_live_streams: [{ ...live, stream_id: 103, name: 'Collision Live' }], + get_vod_info: { + ...movieDetails, + info: { ...movieDetails.info, name: 'Collision Movie' }, + movie_data: { + ...movieDetails.movie_data, + stream_id: 103, + name: 'Collision Movie', + container_extension: 'mp4', + }, + }, + get_series_info: { + ...seriesDetails, + info: { ...seriesDetails.info, name: 'Collision Series' }, + }, + get_short_epg: { epg_listings: [] }, + get_simple_data_table: { epg_listings: [] }, + }; + + // Fixtures may include remote artwork. Keep this regression fully local. + await page.route('https://**', (route) => route.abort()); + await page.route('**/localhost:3000/xtream**', async (route) => { + const action = new URL(route.request().url()).searchParams.get( + 'action' + ); + if (!action || !(action in payloads)) { + await route.fallback(); + return; + } + await route.fulfill({ json: { action, payload: payloads[action] } }); + }); + + return { + movie: String(movie.category_id), + series: String(series.category_id), + live: String(live.category_id), + }; +} + +/** Fail initial attempts, then serve only repository-owned synthetic media. */ +export async function routeCollisionMedia( + page: Page, + mockServer: string +): Promise<() => void> { + let playable = false; + const webm = readFileSync( + join(__dirname, 'fixtures/playback/episode.webm') + ); + const transportStream = readFileSync( + join(__dirname, '../../xtream-mock-server/src/fixtures/live.mpegts') + ); + await page.route( + (url) => + url.origin === mockServer && + /^\/(live|movie|series)\//.test(url.pathname), + async (route) => { + if (!playable) { + await route.fulfill({ + status: 503, + body: 'Fixture unavailable', + }); + return; + } + const path = new URL(route.request().url()).pathname; + if (path.endsWith('.m3u8')) { + await route.fulfill({ + contentType: 'application/vnd.apple.mpegurl', + body: [ + '#EXTM3U', + '#EXT-X-VERSION:3', + '#EXT-X-TARGETDURATION:6', + '#EXT-X-MEDIA-SEQUENCE:0', + '#EXTINF:6,', + `${mockServer}/live/collection-fixture.ts`, + '#EXT-X-ENDLIST', + '', + ].join('\n'), + }); + return; + } + if (path.endsWith('.ts')) { + await route.fulfill({ + contentType: 'video/mp2t', + body: transportStream, + }); + return; + } + // Native media reads use ranges, even when the clip is small. + const range = /^bytes=(\d*)-(\d*)$/.exec( + route.request().headers()['range'] ?? '' + ); + const last = webm.length - 1; + const start = range?.[1] + ? Number(range[1]) + : range?.[2] + ? Math.max(0, webm.length - Number(range[2])) + : 0; + const end = + range?.[1] && range[2] + ? Math.min(Number(range[2]), last) + : last; + await route.fulfill({ + status: range ? 206 : 200, + headers: { + 'accept-ranges': 'bytes', + 'content-length': String(end - start + 1), + 'content-type': 'video/webm', + ...(range + ? { + 'content-range': `bytes ${start}-${end}/${webm.length}`, + } + : {}), + }, + body: webm.subarray(start, end + 1), + }); + } + ); + return () => { + playable = true; + }; +} + +export async function openCollisionCatalogItem( + page: Page, + item: CollisionItem, + categoryId: string +): Promise { + await page.getByRole('link', { name: item.label, exact: true }).click(); + await page + .locator( + `app-workspace-context-panel .category-item[data-category-id="${categoryId}"]:visible` + ) + .click(); + if (item.type !== 'live') { + await page + .locator('.category-content-layout mat-card') + .filter({ hasText: item.title }) + .click(); + await expect( + page.getByRole('heading', { name: item.title, exact: true }) + ).toBeVisible(); + } +} + +export function collisionCollectionItem(page: Page, item: CollisionItem) { + return item.type === 'live' + ? page.locator('app-channel-list-item').filter({ + has: page.locator('.channel-name', { hasText: item.title }), + }) + : page.locator('app-content-card').filter({ + has: page.getByRole('heading', { name: item.title, exact: true }), + }); +} + +export async function addCollisionFavorite( + page: Page, + item: CollisionItem, + categoryId: string +): Promise { + await openCollisionCatalogItem(page, item, categoryId); + if (item.type === 'live') { + const channel = collisionCollectionItem(page, item); + const favorite = channel.locator('.favorite-button').first(); + // Other content sharing this ID must not preselect the channel. + await expect(favorite.locator('mat-icon')).toHaveText('star_outline'); + await channel.hover(); + await favorite.click(); + await expect(favorite.locator('mat-icon')).toHaveText('star'); + return; + } + const addButton = page.getByRole('button', { name: /add to favorites/i }); + await expect(addButton).toBeVisible(); + await addButton.click(); + await expect( + page.getByRole('button', { name: /remove from favorites/i }) + ).toBeVisible(); +} + +export async function playCollisionItem( + page: Page, + item: CollisionItem, + categoryId: string, + confirmPlayback = true +): Promise { + await openCollisionCatalogItem(page, item, categoryId); + if (item.type === 'live') { + await collisionCollectionItem(page, item).click(); + } else { + await page + .getByTestId( + item.type === 'movie' + ? 'vod-primary-action' + : 'series-quick-start' + ) + .click(); + } + await expect(page.locator('app-web-player-view')).toBeVisible(); + if (confirmPlayback) { + const video = page.locator('app-web-player-view video').first(); + await expect + .poll( + () => + video.evaluate( + (media: HTMLVideoElement) => + !media.paused && media.readyState >= 2 + ), + { timeout: 15_000 } + ) + .toBe(true); + const start = await video.evaluate( + (media: HTMLVideoElement) => media.currentTime + ); + // Observe real decoder progress beyond the two-second history gate. + await expect + .poll( + () => + video.evaluate( + (media: HTMLVideoElement, initial) => + media.paused ? 0 : media.currentTime - initial, + start + ), + { timeout: 15_000 } + ) + .toBeGreaterThanOrEqual(2.5); + } +} + +export async function removeCollisionFavorite( + page: Page, + item: CollisionItem, + remainingTypeCount: number +): Promise { + if (remainingTypeCount > 1) { + await selectCollectionType(page, item); + } + const row = collisionCollectionItem(page, item); + await row.hover(); + await row + .locator(item.type === 'live' ? '.favorite-button' : '.remove-button') + .first() + .click(); +} + +export async function selectCollectionType( + page: Page, + item: CollisionItem +): Promise { + await page.getByRole('radio', { name: item.label, exact: true }).click(); +} + +export async function expectCollisionCollection( + page: Page, + items: readonly CollisionItem[] +): Promise { + // Full navigation/reload passes the startup splash before mounting the list. + await expect(page.locator('app-unified-collection-page')).toBeAttached({ + timeout: 15_000, + }); + const toggles = page.getByRole('radio', { + name: /^(Movies|Series|Live TV)$/, + }); + await expect(toggles).toHaveCount(items.length > 1 ? items.length : 0); + for (const item of items) { + if (items.length > 1) { + await selectCollectionType(page, item); + } + await expect(collisionCollectionItem(page, item)).toBeVisible(); + } + for (const removed of collisionItems.filter( + (item) => !items.includes(item) + )) { + await expect(collisionCollectionItem(page, removed)).toHaveCount(0); + await expect(toggles.filter({ hasText: removed.label })).toHaveCount(0); + } + if (items.length === 0) { + await expect(page.locator('app-empty-state')).toBeVisible(); + } +} + +export async function expectCollectionDetailRoundTrip( + page: Page, + item: CollisionItem +): Promise { + await selectCollectionType(page, item); + const collectionUrl = page.url(); + await collisionCollectionItem(page, item).click(); + await expect( + page.getByRole('heading', { name: item.title, exact: true }) + ).toBeVisible(); + await expect(page).toHaveURL(collectionUrl); + await page.locator('[data-test-id="workspace-header-back"]').click(); + await expect(page).toHaveURL(collectionUrl); + await expect(collisionCollectionItem(page, item)).toBeVisible(); +} diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index 7c08e8c6e..193eba2d8 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -15,7 +15,20 @@ import { DEFAULT_USERNAME, interceptXtreamRequests, MOCK_SERVER, + selectWebPlayer, } from './xtream-series-playback.fixture'; +import { + addCollisionFavorite, + collisionCollectionItem, + collisionItems, + expectCollectionDetailRoundTrip, + expectCollisionCollection, + interceptCollidingXtreamItems, + playCollisionItem, + removeCollisionFavorite, + routeCollisionMedia, + selectCollectionType, +} from './xtream-collection-helpers'; /** * Xtream Codes E2E Tests @@ -413,6 +426,108 @@ test('@xtream add portal and see it in the playlist list', async ({ page }) => { ).toBeVisible(); }); +test('@xtream @collections PWA favorites keep colliding movie, series and live IDs independent after reload and removal', async ({ + page, + request, +}) => { + test.setTimeout(90_000); + const categories = await interceptCollidingXtreamItems( + page, + request, + MOCK_SERVER + ); + await addXtreamPortal(page, { + name: 'Collision Favorites Portal', + username: 'minimal', + password: 'minimal', + }); + const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, ''); + + for (const item of collisionItems) { + await addCollisionFavorite(page, item, categories[item.type]); + } + + await page.goto(`${playlistPath}/favorites`); + await expectCollisionCollection(page, collisionItems); + await page.reload(); + await expectCollisionCollection(page, collisionItems); + await expectCollectionDetailRoundTrip(page, collisionItems[0]); + await expectCollectionDetailRoundTrip(page, collisionItems[1]); + + for (let index = 0; index < collisionItems.length; index++) { + const item = collisionItems[index]; + await removeCollisionFavorite( + page, + item, + collisionItems.length - index + ); + await expect(collisionCollectionItem(page, item)).toHaveCount(0); + await page.reload(); + await expectCollisionCollection(page, collisionItems.slice(index + 1)); + } +}); + +test('@xtream @collections PWA recent items require confirmed playback and retain colliding content IDs', async ({ + page, + request, + browserName, +}) => { + test.skip( + browserName !== 'chromium', + 'Synthetic live media uses Chromium codecs' + ); + test.setTimeout(90_000); + const categories = await interceptCollidingXtreamItems( + page, + request, + MOCK_SERVER + ); + const allowPlayback = await routeCollisionMedia(page, MOCK_SERVER); + await selectWebPlayer(page, 'HTML5 video player'); + await page.goto('/'); + await addXtreamPortal(page, { + name: 'Collision Recent Portal', + username: 'minimal', + password: 'minimal', + }); + const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, ''); + + for (const item of collisionItems) { + const failedMedia = page.waitForResponse( + (response) => + response.url().startsWith(`${MOCK_SERVER}/${item.type}/`) && + response.status() === 503 + ); + await playCollisionItem(page, item, categories[item.type], false); + await failedMedia; + await page.goto(`${playlistPath}/recent`); + await expectCollisionCollection(page, []); + } + + allowPlayback(); + for (let index = 0; index < collisionItems.length; index++) { + const item = collisionItems[index]; + await playCollisionItem(page, item, categories[item.type]); + await page.goto(`${playlistPath}/recent`); + await expectCollisionCollection( + page, + collisionItems.slice(0, index + 1) + ); + } + await page.reload(); + await expectCollisionCollection(page, collisionItems); + await expectCollectionDetailRoundTrip(page, collisionItems[0]); + await expectCollectionDetailRoundTrip(page, collisionItems[1]); + + await selectCollectionType(page, collisionItems[0]); + const movie = collisionCollectionItem(page, collisionItems[0]); + await movie.hover(); + await movie.locator('.remove-button').click(); + await expect(movie).toHaveCount(0); + await page.reload(); + await expectCollisionCollection(page, collisionItems.slice(1)); +}); + test('@xtream playlist details edit is retained in the PWA browser context', async ({ page, }) => { diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 38e83ef43..b1e1ec00f 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -37,6 +37,58 @@ See [Nx Workspace Boundaries](./nx-workspace-boundaries.md), [Portal Detail Navigation](./portal-detail-navigation.md), and [VOD Multi-Source](./vod-multi-source.md). +## PWA Collection Persistence + +Collection operations carry `{ id, type }`: `id` remains the SQLite row ID +on Electron and the provider ID on PWA. Electron unwraps the row ID at its +existing database boundary. Typed lookups, favorite toggles, recent updates, +removals, backdrops and backup/restore retain the content type. A favorite toggle +reads persisted status for its exact typed target instead of borrowing the last +detail's favorite state. Live history uses the same confirmed-playback gate as +VOD and series: inline playback confirms its playlist-scoped session key, while +external playback confirms its URL. The initiating playlist and typed identity +remain captured across delayed confirmation. Selection without playback or a +resolved URL does not update history. See the +[playback-history contract](./embedded-inline-playback.md#recently-viewed-confirmation). + +PWA keeps the existing localStorage keys. `xtream-favorites` contains ordered +`type:id` keys (for example `movie:42`); `xtream-recent-items` uses the same +keys in each entry's `id`, retaining `viewedAt` and backdrop metadata. +`xtream-collection-items` stores snapshots under those typed keys while each +snapshot's `id` and `xtream_id` remain numeric provider IDs. Hydration fetches +only the required content types; snapshots support collection reads offline. +Typed add/remove/status operations use synchronous local migration and never +wait for unrelated catalog hydration. `PwaCollectionStorage` owns that local +migration and snapshot persistence; the data source owns provider hydration for +collection loading and legacy numeric callers that still need identity evidence. + +Legacy numeric and numeric-string references migrate lazily. A valid legacy +snapshot supplies its saved content type; otherwise all three catalogs must +be loaded successfully and exactly one type must match (repeated rows within +that same type are one identity). A partial catalog, +failed hydration, missing item or colliding ID never chooses a type. Such +references remain stored but hidden until identity can be resolved or the +user explicitly adds a typed item. Legacy numeric snapshot entries remain as +migration evidence for the other collection. Already-overwritten snapshots +cannot recover information lost by an older version. Ambiguous references +cannot be included in typed backup exports; the original browser storage is +retained. Migration merges into current storage after hydration, preserving +concurrent additions/removals, and duplicate recent references keep the newest +timestamp. + +Migration writes and snapshot-cache writes are best effort when browser storage +is full. Reads still resolve the retained legacy evidence in memory. Typed +status and mutations match original numeric references through that evidence; +removal filters the original stored keys without first expanding the remaining +keys. Typed favorite-status checks never write storage. Additions save the +requested reference before attempting optional migration or snapshot copies, so +those copies cannot consume space needed by the requested save. Recent aliases +are deduplicated in memory before the 50-item limit, keeping the newest timestamp +and its original saved key until optional migration. Favorite/recent +writes requested by the user still propagate storage failures. A new reference +that fits can be saved without its optional snapshot and hydrated on a later +collection load. + ## Connection Input Xtream server URLs are normalized through 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 8a5d2defe..4c1dd412f 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 @@ -652,7 +652,7 @@ describe('UnifiedFavoritesDataService', () => { 'movie' ); expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith( - 101, + { id: 101, type: 'movie' }, 'xtream-1', 'movie.png' ); @@ -678,7 +678,7 @@ describe('UnifiedFavoritesDataService', () => { } satisfies UnifiedCollectionItem); expect(xtreamDataSource.removeFavorite).toHaveBeenCalledWith( - 1010, + { id: 1010, type: 'movie' }, 'xtream-1' ); expect(electronApi.dbRemoveFavorite).not.toHaveBeenCalled(); 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 3a8c7bdf8..6f50e98da 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 @@ -104,7 +104,7 @@ export class UnifiedFavoritesDataService { return; } await this.xtreamDataSource.removeFavorite( - item.contentId, + { id: item.contentId, type: item.contentType }, item.playlistId ); break; @@ -149,7 +149,7 @@ export class UnifiedFavoritesDataService { const electron = this.electronActivityBridge; if (!electron) { await this.xtreamDataSource.addFavorite( - contentId, + { id: contentId, type: item.contentType }, item.playlistId, item.posterUrl ?? item.logo ?? undefined ); @@ -365,7 +365,7 @@ export class UnifiedFavoritesDataService { ) .map((item) => this.xtreamDataSource.removeFavorite( - item.contentId, + { id: item.contentId, type: item.contentType }, item.playlistId ) ) @@ -849,8 +849,7 @@ export class UnifiedFavoritesDataService { private getStalkerFavoriteId( favorite: - | Pick - | StalkerPortalItem + Pick | StalkerPortalItem ): string { if ('uid' in favorite) { return String( 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 e8b87fae2..be491d092 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 @@ -305,7 +305,7 @@ describe('UnifiedRecentDataService', () => { 'movie' ); expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( - 290, + { id: 290, type: 'movie' }, 'xtream-1' ); expect(dbService.addRecentItem).not.toHaveBeenCalled(); @@ -335,7 +335,7 @@ describe('UnifiedRecentDataService', () => { } satisfies UnifiedCollectionItem); expect(xtreamDataSource.removeRecentItem).toHaveBeenCalledWith( - 444, + { id: 444, type: 'movie' }, 'xtream-1' ); expect(dbService.removeRecentItem).not.toHaveBeenCalled(); 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 3f75a3094..2a085b4eb 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 @@ -74,7 +74,7 @@ export class UnifiedRecentDataService { } await this.xtreamDataSource.removeRecentItem( - item.contentId, + { id: item.contentId, type: item.contentType }, item.playlistId ); return; @@ -113,7 +113,11 @@ export class UnifiedRecentDataService { return; } - const xtreamBatch: { contentId: number; playlistId: string }[] = []; + const xtreamBatch: { + contentId: number; + playlistId: string; + contentType: UnifiedCollectionItem['contentType']; + }[] = []; const groupedByPlaylist = new Map(); for (const item of items) { @@ -122,6 +126,7 @@ export class UnifiedRecentDataService { xtreamBatch.push({ contentId: item.contentId, playlistId: item.playlistId, + contentType: item.contentType, }); } continue; @@ -145,12 +150,19 @@ export class UnifiedRecentDataService { if (xtreamBatch.length > 0) { if (this.hasPortalActivityStorage) { - tasks.push(this.dbService.removeRecentItemsBatch(xtreamBatch)); + tasks.push( + this.dbService.removeRecentItemsBatch( + xtreamBatch.map(({ contentId, playlistId }) => ({ + contentId, + playlistId, + })) + ) + ); } else { tasks.push( ...xtreamBatch.map((item) => this.xtreamDataSource.removeRecentItem( - item.contentId, + { id: item.contentId, type: item.contentType }, item.playlistId ) ) @@ -276,7 +288,7 @@ export class UnifiedRecentDataService { ); } else { await this.xtreamDataSource.addRecentItem( - contentId, + { id: contentId, type: item.contentType }, item.playlistId ); } 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 85b88c213..3f98305aa 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 @@ -26,6 +26,8 @@ import { import { DbCategoryType, IXtreamDataSource, + XtreamCollectionRef, + xtreamCollectionId, mapCategoryTypeToDbType, ProgressCallback, XtreamOperationOptions, @@ -471,19 +473,35 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { } async addFavorite( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string, backdropUrl?: string ): Promise { - await this.dbService.addToFavorites(contentId, playlistId, backdropUrl); + await this.dbService.addToFavorites( + xtreamCollectionId(contentId), + playlistId, + backdropUrl + ); } - async removeFavorite(contentId: number, playlistId: string): Promise { - await this.dbService.removeFromFavorites(contentId, playlistId); + async removeFavorite( + contentId: XtreamCollectionRef, + playlistId: string + ): Promise { + await this.dbService.removeFromFavorites( + xtreamCollectionId(contentId), + playlistId + ); } - async isFavorite(contentId: number, playlistId: string): Promise { - return this.dbService.isFavorite(contentId, playlistId); + async isFavorite( + contentId: XtreamCollectionRef, + playlistId: string + ): Promise { + return this.dbService.isFavorite( + xtreamCollectionId(contentId), + playlistId + ); } // ========================================================================= @@ -495,18 +513,25 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { } async addRecentItem( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string, backdropUrl?: string ): Promise { - await this.dbService.addRecentItem(contentId, playlistId, backdropUrl); + await this.dbService.addRecentItem( + xtreamCollectionId(contentId), + playlistId, + backdropUrl + ); } async removeRecentItem( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string ): Promise { - await this.dbService.removeRecentItem(contentId, playlistId); + await this.dbService.removeRecentItem( + xtreamCollectionId(contentId), + playlistId + ); } async clearRecentItems(playlistId: string): Promise { @@ -530,12 +555,15 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { } async setContentMetadataIfMissing( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string, patch: ContentMetadataPatch ): Promise { void playlistId; - await this.dbService.setContentMetadataIfMissing(contentId, patch); + await this.dbService.setContentMetadataIfMissing( + xtreamCollectionId(contentId), + patch + ); } // ========================================================================= diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts index 6a9cfe685..8934d48df 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -21,6 +21,55 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { }); describe('favorites and recently viewed', () => { + it.each(['live', 'movie', 'series'] as const)( + 'forwards only the SQLite row id for typed %s references', + async (type) => { + const ref = { id: 202, type }; + await harness.dataSource.addFavorite( + ref, + playlistId, + 'backdrop.jpg' + ); + await harness.dataSource.removeFavorite(ref, playlistId); + await harness.dataSource.isFavorite(ref, playlistId); + await harness.dataSource.addRecentItem( + ref, + playlistId, + 'backdrop.jpg' + ); + await harness.dataSource.removeRecentItem(ref, playlistId); + await harness.dataSource.setContentMetadataIfMissing( + ref, + playlistId, + { backdropUrl: 'backdrop.jpg' } + ); + expect(harness.dbService.addToFavorites).toHaveBeenCalledWith( + 202, + playlistId, + 'backdrop.jpg' + ); + expect( + harness.dbService.removeFromFavorites + ).toHaveBeenCalledWith(202, playlistId); + expect(harness.dbService.isFavorite).toHaveBeenCalledWith( + 202, + playlistId + ); + expect(harness.dbService.addRecentItem).toHaveBeenCalledWith( + 202, + playlistId, + 'backdrop.jpg' + ); + expect(harness.dbService.removeRecentItem).toHaveBeenCalledWith( + 202, + playlistId + ); + expect( + harness.dbService.setContentMetadataIfMissing + ).toHaveBeenCalledWith(202, { backdropUrl: 'backdrop.jpg' }); + } + ); + it('delegates favorites operations to the DB', async () => { harness.dbService.isFavorite.mockResolvedValue(true); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-identity.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-identity.ts new file mode 100644 index 000000000..4b0c87a67 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-identity.ts @@ -0,0 +1,159 @@ +import { + TypedXtreamCollectionRef, + XTREAM_CONTENT_TYPES, + XtreamContentItem, +} from './xtream-data-source.interface'; + +export type CollectionKey = number | string; +export interface StoredRecentItem { + readonly id: CollectionKey; + readonly viewedAt: string; + readonly backdropUrl?: string; +} + +export function typedCollectionRef( + value: unknown +): TypedXtreamCollectionRef | null { + if (typeof value === 'string') { + const [type, id, extra] = value.split(':'); + return extra === undefined && id !== undefined + ? typedCollectionRef({ type, id: Number(id) }) + : null; + } + if (!value || typeof value !== 'object') return null; + const ref = value as Partial; + return ref.type !== undefined && + typeof ref.id === 'number' && + XTREAM_CONTENT_TYPES.includes(ref.type) && + Number.isSafeInteger(ref.id) && + ref.id > 0 + ? { type: ref.type, id: ref.id } + : null; +} + +export function collectionKey(value: unknown): CollectionKey | null { + const typed = typedCollectionRef(value); + if (typed) return `${typed.type}:${typed.id}`; + if (typeof value !== 'number' && typeof value !== 'string') return null; + const id = Number(value); + return Number.isSafeInteger(id) && id > 0 ? id : null; +} + +export function itemCollectionKey( + item: XtreamContentItem +): CollectionKey | null { + return item ? collectionKey({ id: item.xtream_id, type: item.type }) : null; +} + +export function normalizeFavoriteStorage( + value: unknown +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const normalized: Record = {}; + for (const [playlistId, ids] of Object.entries(value)) { + if (!Array.isArray(ids)) continue; + normalized[playlistId] = [ + ...new Set( + ids + .map(collectionKey) + .filter((id): id is CollectionKey => id !== null) + ), + ]; + } + return normalized; +} + +export function normalizeStoredBackdrop(item: { + readonly backdropUrl?: unknown; + readonly backdrop_url?: unknown; +}): Pick { + const value = item.backdropUrl ?? item.backdrop_url; + return typeof value === 'string' && value.trim() + ? { backdropUrl: value.trim() } + : {}; +} + +export function normalizeRecentStorage( + value: unknown +): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + const normalized: Record = {}; + for (const [playlistId, items] of Object.entries(value)) { + if (!Array.isArray(items)) continue; + normalized[playlistId] = []; + for (const item of items as (Partial | null)[]) { + const id = collectionKey(item?.id); + if (id !== null && typeof item?.viewedAt === 'string') { + normalized[playlistId].push({ + id, + viewedAt: item.viewedAt, + ...normalizeStoredBackdrop(item), + }); + } + } + } + return normalized; +} + +/** Never infer a legacy number from a partially loaded catalog. */ +export function findCollectionItems( + ids: readonly CollectionKey[], + cached: readonly XtreamContentItem[], + snapshots: Record, + completeCatalog: boolean +): Map { + const wantedIds = new Set( + ids.map((key) => typedCollectionRef(key)?.id ?? key) + ); + const typedItems = new Map(); + for (const item of [...Object.values(snapshots), ...cached]) { + if (!item || !wantedIds.has(item.xtream_id)) continue; + const key = itemCollectionKey(item); + if (key !== null) typedItems.set(key, item); + } + const uniqueIds = new Map(); + for (const item of cached) { + if (!wantedIds.has(item.xtream_id)) continue; + const previous = uniqueIds.get(item.xtream_id); + uniqueIds.set( + item.xtream_id, + previous === null || (previous && previous.type !== item.type) + ? null + : item + ); + } + const results = new Map(); + for (const key of ids) { + if (typeof key === 'string') { + const item = typedItems.get(key); + if (item) results.set(key, item); + continue; + } + const snapshot = snapshots[String(key)]; + const snapshotKey = snapshot ? itemCollectionKey(snapshot) : null; + if (snapshot && snapshot.xtream_id === key && snapshotKey !== null) { + results.set(key, typedItems.get(snapshotKey) ?? snapshot); + } else if (completeCatalog) { + const candidate = uniqueIds.get(key); + if (candidate) results.set(key, candidate); + } + } + return results; +} + +export function deduplicateRecentItems( + items: StoredRecentItem[], + keyForItem: (item: StoredRecentItem) => CollectionKey = (item) => item.id +): StoredRecentItem[] { + const unique = new Map(); + for (const item of items) { + const key = keyForItem(item); + const previous = unique.get(key); + if ( + !previous || + Date.parse(item.viewedAt) > Date.parse(previous.viewedAt) + ) + unique.set(key, item); + } + return [...unique.values()]; +} diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts new file mode 100644 index 000000000..d15f0bd11 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-collection-storage.ts @@ -0,0 +1,271 @@ +import { + CollectionKey, + StoredRecentItem, + deduplicateRecentItems, + itemCollectionKey, + normalizeFavoriteStorage, + normalizeRecentStorage, +} from './pwa-collection-identity'; +import { XtreamContentItem } from './xtream-data-source.interface'; + +const STORAGE_KEYS = { + COLLECTION_ITEMS: 'xtream-collection-items', + FAVORITES: 'xtream-favorites', + RECENT_ITEMS: 'xtream-recent-items', +}; + +/** Synchronous persistence and migration using only snapshots and cached identity evidence. */ +export class PwaCollectionStorage { + constructor( + private readonly resolveItems: ( + playlistId: string, + ids: readonly CollectionKey[] + ) => Map + ) {} + + getLocalFavorites(playlistId: string): XtreamContentItem[] { + const allFavorites = this.getFavoritesFromStorage(); + const playlistFavorites = allFavorites[playlistId] || []; + const contentById = this.resolveItems(playlistId, playlistFavorites); + const migrated = playlistFavorites.map((id) => { + const item = contentById.get(id); + return item ? (itemCollectionKey(item) ?? id) : id; + }); + if (migrated.some((id, index) => id !== playlistFavorites[index])) { + allFavorites[playlistId] = [...new Set(migrated)]; + this.tryStorageMigration(() => + this.saveFavoritesToStorage(allFavorites) + ); + } + this.persistCollectionSnapshots(playlistId, contentById); + return [ + ...new Map( + Array.from(contentById.values()).map((item) => [ + itemCollectionKey(item), + item, + ]) + ).values(), + ]; + } + + getFavoritesFromStorage(): Record { + try { + const data = localStorage.getItem(STORAGE_KEYS.FAVORITES); + return normalizeFavoriteStorage(data ? JSON.parse(data) : {}); + } catch { + return {}; + } + } + + saveFavoritesToStorage(favorites: Record): void { + localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites)); + } + + clearFavoritesForPlaylist(playlistId: string): void { + const allFavorites = this.getFavoritesFromStorage(); + delete allFavorites[playlistId]; + this.saveFavoritesToStorage(allFavorites); + } + + getLocalRecentItems(playlistId: string): XtreamContentItem[] { + const allRecent = this.getRecentItemsFromStorage(); + const playlistRecent = allRecent[playlistId] || []; + const contentById = this.resolveItems( + playlistId, + playlistRecent.map((item) => item.id) + ); + const migrated = deduplicateRecentItems( + playlistRecent.map((entry) => { + const item = contentById.get(entry.id); + return item + ? { ...entry, id: itemCollectionKey(item) ?? entry.id } + : entry; + }) + ); + if ( + migrated.length !== playlistRecent.length || + migrated.some( + (entry, index) => entry.id !== playlistRecent[index].id + ) + ) { + allRecent[playlistId] = migrated; + this.tryStorageMigration(() => + this.saveRecentItemsToStorage(allRecent) + ); + } + this.persistCollectionSnapshots(playlistId, contentById); + const results: (XtreamContentItem & { viewed_at: string })[] = []; + const migratedContent = this.resolveItems( + playlistId, + migrated.map((item) => item.id) + ); + for (const recentEntry of migrated) { + const item = migratedContent.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 + results.sort( + (a, b) => + new Date(b.viewed_at).getTime() - + new Date(a.viewed_at).getTime() + ); + + return results as XtreamContentItem[]; + } + + getRecentItemsFromStorage(): Record { + try { + const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS); + return normalizeRecentStorage(data ? JSON.parse(data) : {}); + } catch { + return {}; + } + } + + saveRecentItemsToStorage( + recentItems: Record + ): void { + localStorage.setItem( + STORAGE_KEYS.RECENT_ITEMS, + JSON.stringify(recentItems) + ); + } + + clearRecentItemsForPlaylist(playlistId: string): void { + const allRecent = this.getRecentItemsFromStorage(); + delete allRecent[playlistId]; + this.saveRecentItemsToStorage(allRecent); + } + + 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) + ); + } + + /** Optional migration/cache writes must not block readable legacy collections. */ + private tryStorageMigration(write: () => void): void { + try { + write(); + } catch (error) { + if ( + !(error instanceof DOMException) || + error.name !== 'QuotaExceededError' + ) { + throw error; + } + } + } + + private trySaveCollectionItemsToStorage( + items: Record> + ): void { + this.tryStorageMigration(() => + this.saveCollectionItemsToStorage(items) + ); + } + + clearCollectionItemsForPlaylist(playlistId: string): void { + const allItems = this.getCollectionItemsFromStorage(); + delete allItems[playlistId]; + this.saveCollectionItemsToStorage(allItems); + } + + private persistCollectionSnapshots( + playlistId: string, + items: Map + ): void { + if (!items.size) return; + const allItems = this.getCollectionItemsFromStorage(); + const snapshots = allItems[playlistId] ?? {}; + for (const item of items.values()) { + const key = itemCollectionKey(item); + if (key === null) continue; + snapshots[key] = { + ...item, + backdrop_url: item.backdrop_url ?? snapshots[key]?.backdrop_url, + }; + } + this.trySaveCollectionItemsToStorage({ + ...allItems, + [playlistId]: snapshots, + }); + } + + saveCollectionItemSnapshot( + playlistId: string, + contentId: CollectionKey, + backdropUrl?: string + ): void { + const item = this.resolveItems(playlistId, [contentId]).get(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.trySaveCollectionItemsToStorage({ + ...allItems, + [playlistId]: playlistItems, + }); + } + + setCollectionItemBackdropIfMissing( + playlistId: string, + contentId: CollectionKey, + backdropUrl: string + ): void { + const allItems = this.getCollectionItemsFromStorage(); + const playlistItems = allItems[playlistId]; + const item = playlistItems?.[String(contentId)]; + if (!item || item.backdrop_url) { + return; + } + + this.trySaveCollectionItemsToStorage({ + ...allItems, + [playlistId]: { + ...playlistItems, + [String(contentId)]: { + ...item, + backdrop_url: backdropUrl, + }, + }, + }); + } +} diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts new file mode 100644 index 000000000..ef96a02d9 --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/data-sources/pwa-xtream-collection-identity.spec.ts @@ -0,0 +1,611 @@ +import { TestBed } from '@angular/core/testing'; +import { ParentalLockService, PlaylistsService } from '@iptvnator/services'; +import { of } from 'rxjs'; +import { XtreamApiService } from '../services/xtream-api.service'; +import { PwaXtreamDataSource } from './pwa-xtream-data-source'; + +const credentials = { + serverUrl: 'http://localhost:3211', + username: 'demo', + password: 'test', +}; +const types = ['live', 'movie', 'series'] as const; + +describe('PWA Xtream collection identity', () => { + let source: PwaXtreamDataSource; + const api = { getStreams: jest.fn() }; + const playlists = { getPlaylistById: jest.fn() }; + const reload = () => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + PwaXtreamDataSource, + { + provide: ParentalLockService, + useValue: { + active: () => false, + lockedXtreamIds: () => [], + }, + }, + { provide: XtreamApiService, useValue: api }, + { provide: PlaylistsService, useValue: playlists }, + ], + }); + source = TestBed.inject(PwaXtreamDataSource); + }; + const loadAll = () => + Promise.all( + types.map((type) => source.getContent('p1', credentials, type)) + ); + const read = (key: string) => JSON.parse(localStorage.getItem(key) ?? '{}'); + const seedLegacyCollection = (otherIds: number[] = []) => { + const ids = [42, ...otherIds]; + localStorage.setItem('xtream-favorites', JSON.stringify({ p1: ids })); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + p1: ids.map((id) => ({ + id, + viewedAt: '2026-10-01T00:00:00.000Z', + })), + }) + ); + const snapshots: Record = {}; + for (const id of ids.filter((id) => id !== 77)) { + snapshots[id] = { + id, + xtream_id: id, + type: id === 42 ? 'movie' : 'series', + title: 'Saved item '.repeat(100), + }; + } + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ p1: snapshots }) + ); + }; + const limitStorageGrowth = (extraCharacters: number) => { + const storedSize = () => + Object.keys(localStorage).reduce( + (size, key) => + size + + key.length + + (localStorage.getItem(key)?.length ?? 0), + 0 + ); + const quota = storedSize() + extraCharacters; + const setItem = Storage.prototype.setItem; + jest.spyOn(Storage.prototype, 'setItem').mockImplementation(function ( + this: Storage, + key, + value + ) { + const oldValue = this.getItem(key); + const growth = + value.length - + (oldValue?.length ?? 0) + + (oldValue === null ? key.length : 0); + if (storedSize() + growth > quota) + throw new DOMException('Storage full', 'QuotaExceededError'); + setItem.call(this, key, value); + }); + }; + + beforeEach(() => { + localStorage.clear(); + api.getStreams.mockReset().mockImplementation((_credentials, type) => + Promise.resolve([ + { + stream_id: 42, + series_id: 42, + name: `${type} 42`, + category_id: '1', + }, + ]) + ); + playlists.getPlaylistById.mockReturnValue( + of({ _id: 'p1', title: 'Test', ...credentials }) + ); + reload(); + }); + afterEach(() => { + jest.restoreAllMocks(); + localStorage.clear(); + }); + + it.each([0, 512])( + 'keeps legacy collection reads and status usable with only %i characters of storage headroom', + async (headroom) => { + seedLegacyCollection(); + limitStorageGrowth(headroom); + for (let attempt = 0; attempt < 2; attempt++) { + expect(await source.getFavorites('p1')).toEqual([ + expect.objectContaining({ xtream_id: 42, type: 'movie' }), + ]); + expect(await source.getRecentItems('p1')).toEqual([ + expect.objectContaining({ xtream_id: 42, type: 'movie' }), + ]); + expect( + await source.isFavorite({ id: 42, type: 'movie' }, 'p1') + ).toBe(true); + expect( + await source.isFavorite({ id: 42, type: 'live' }, 'p1') + ).toBe(false); + reload(); + } + expect(read('xtream-collection-items').p1[42].type).toBe('movie'); + expect(api.getStreams).not.toHaveBeenCalled(); + } + ); + + it.each([ + ['removeFavorite', 0], + ['removeFavorite', 512], + ['removeRecentItem', 0], + ['removeRecentItem', 512], + ] as const)( + '%s persists deletion with only %i characters of storage headroom', + async (operation, headroom) => { + seedLegacyCollection([ + 77, + ...Array.from({ length: 12 }, (_, index) => 100 + index), + ]); + limitStorageGrowth(headroom); + await source[operation]({ id: 42, type: 'movie' }, 'p1'); + expect(api.getStreams).not.toHaveBeenCalled(); + const key = + operation === 'removeFavorite' + ? 'xtream-favorites' + : 'xtream-recent-items'; + const storedIds = + operation === 'removeFavorite' + ? read(key).p1 + : read(key).p1.map((entry: { id: unknown }) => entry.id); + expect(storedIds).not.toContain(42); + expect(storedIds).not.toContain('movie:42'); + expect(storedIds).toContain(77); + expect(read('xtream-collection-items').p1[42].type).toBe('movie'); + reload(); + const favorites = await source.getFavorites('p1'); + const recent = await source.getRecentItems('p1'); + expect(favorites.some((item) => item.xtream_id === 42)).toBe( + operation !== 'removeFavorite' + ); + expect(recent.some((item) => item.xtream_id === 42)).toBe( + operation !== 'removeRecentItem' + ); + } + ); + + it.each(['addFavorite', 'addRecentItem'] as const)( + '%s reports an authoritative write that cannot fit', + async (operation) => { + seedLegacyCollection(); + limitStorageGrowth(0); + await expect( + source[operation]({ id: 99, type: 'series' }, 'p1') + ).rejects.toMatchObject({ name: 'QuotaExceededError' }); + expect(read('xtream-favorites').p1).toEqual([42]); + expect( + read('xtream-recent-items').p1.map( + (entry: { id: unknown }) => entry.id + ) + ).toEqual([42]); + } + ); + + it.each(['addFavorite', 'addRecentItem'] as const)( + '%s succeeds when the reference fits but its optional snapshot does not', + async (operation) => { + seedLegacyCollection(); + api.getStreams.mockResolvedValue([ + { series_id: 99, name: 'New series '.repeat(100) }, + ]); + await source.getContent('p1', credentials, 'series'); + limitStorageGrowth(512); + await source[operation]({ id: 99, type: 'series' }, 'p1'); + const storedIds = + operation === 'addFavorite' + ? read('xtream-favorites').p1 + : read('xtream-recent-items').p1.map( + (entry: { id: unknown }) => entry.id + ); + expect(storedIds).toContain('series:99'); + expect( + read('xtream-collection-items').p1['series:99'] + ).toBeUndefined(); + reload(); + const items = + operation === 'addFavorite' + ? await source.getFavorites('p1') + : await source.getRecentItems('p1'); + expect(items).toContainEqual( + expect.objectContaining({ xtream_id: 99, type: 'series' }) + ); + } + ); + + it.each(['addFavorite', 'addRecentItem', 'statusThenAddFavorite'] as const)( + '%s saves the requested reference before optional copies consume its space', + async (operation) => { + seedLegacyCollection(); + const snapshots = read('xtream-collection-items'); + const copyGrowth = + JSON.stringify({ + p1: { ...snapshots.p1, 'movie:42': snapshots.p1[42] }, + }).length - JSON.stringify(snapshots).length; + const keyGrowth = + JSON.stringify({ p1: ['movie:42'] }).length - + JSON.stringify({ p1: [42] }).length; + // Both optional writes fit exactly, but then leave no room for an addition. + limitStorageGrowth(copyGrowth + keyGrowth); + const target = { id: 99, type: 'series' } as const; + if (operation === 'statusThenAddFavorite') { + expect(await source.isFavorite(target, 'p1')).toBe(false); + expect( + await source.isFavorite({ id: 42, type: 'movie' }, 'p1') + ).toBe(true); + expect(Storage.prototype.setItem).not.toHaveBeenCalled(); + } + const mutation = + operation === 'addRecentItem' ? operation : 'addFavorite'; + await expect( + source[mutation](target, 'p1') + ).resolves.toBeUndefined(); + const storedIds = + mutation === 'addFavorite' + ? read('xtream-favorites').p1 + : read('xtream-recent-items').p1.map( + (entry: { id: unknown }) => entry.id + ); + expect(storedIds).toContain('series:99'); + expect(read('xtream-collection-items').p1[42]).toEqual( + snapshots.p1[42] + ); + expect(api.getStreams).not.toHaveBeenCalled(); + } + ); + + it.each([42, 'movie:42'])( + 'deduplicates before the 50-item limit while preserving the newer %s key until the save', + async (newerKey) => { + seedLegacyCollection(); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + p1: [ + { + id: newerKey === 42 ? 'movie:42' : 42, + viewedAt: '2026-09-30T00:00:00.000Z', + }, + { id: newerKey, viewedAt: '2026-10-02T00:00:00.000Z' }, + ...Array.from({ length: 48 }, (_, index) => ({ + id: `series:${100 + index}`, + viewedAt: '2026-10-01T00:00:00.000Z', + })), + ], + }) + ); + const writes = jest.spyOn(Storage.prototype, 'setItem'); + await source.addRecentItem({ id: 99, type: 'series' }, 'p1'); + expect(writes.mock.calls[0][0]).toBe('xtream-recent-items'); + expect(JSON.parse(writes.mock.calls[0][1]).p1).toContainEqual({ + id: newerKey, + viewedAt: '2026-10-02T00:00:00.000Z', + }); + const recent = read('xtream-recent-items').p1; + expect(recent).toHaveLength(50); + expect(recent).toContainEqual({ + id: 'series:147', + viewedAt: '2026-10-01T00:00:00.000Z', + }); + expect(recent).toContainEqual({ + id: 'movie:42', + viewedAt: '2026-10-02T00:00:00.000Z', + }); + expect(recent[0].id).toBe('series:99'); + expect(api.getStreams).not.toHaveBeenCalled(); + } + ); + + it('preserves restored colliding types through hydration and clear/restore', async () => { + await source.restoreUserData('p1', { + hiddenCategories: [], + playbackPositions: [], + favorites: types.map((contentType) => ({ + contentType, + xtreamId: 42, + })), + recentlyViewed: types.map((contentType, i) => ({ + contentType, + xtreamId: 42, + viewedAt: `2026-10-0${i + 1}T12:00:00.000Z`, + })), + }); + expect( + (await source.getFavorites('p1')).map((item) => item.type) + ).toEqual(types); + expect( + (await source.getRecentItems('p1')).map((item) => item.type) + ).toEqual(['series', 'movie', 'live']); + const backup = await source.clearPlaylistContent('p1'); + expect(backup.favorites).toEqual( + types.map((contentType) => ({ contentType, xtreamId: 42 })) + ); + expect(backup.recentlyViewed.map((item) => item.contentType)).toEqual( + types + ); + reload(); + await source.restoreUserData('p1', backup); + expect( + (await source.getFavorites('p1')).map((item) => item.type) + ).toEqual(types); + }); + + it('preserves ambiguous numeric legacy entries without selecting the last matching type', async () => { + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['42'] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + p1: [{ id: 42, viewedAt: '2026-10-01T12:00:00.000Z' }], + }) + ); + await loadAll(); + expect(await source.getFavorites('p1')).toEqual([]); + expect(await source.getRecentItems('p1')).toEqual([]); + expect(read('xtream-favorites').p1).toEqual(['42']); + expect(read('xtream-recent-items').p1[0].id).toBe(42); + }); + it('does not infer a legacy type from a partially loaded catalog', async () => { + localStorage.setItem('xtream-favorites', JSON.stringify({ p1: [42] })); + await source.getContent('p1', credentials, 'movie'); + expect(await source.getFavorites('p1')).toEqual([]); + expect(api.getStreams).toHaveBeenCalledWith(credentials, 'live'); + expect(api.getStreams).toHaveBeenCalledWith(credentials, 'series'); + }); + + it('migrates a numeric reference using its stored typed snapshot offline', async () => { + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['42'] }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + p1: { + 42: { + id: 42, + xtream_id: 42, + type: 'movie', + title: 'Saved Movie', + }, + }, + }) + ); + expect( + (await source.getFavorites('p1')).map((item) => item.title) + ).toEqual(['Saved Movie']); + expect(read('xtream-favorites').p1).toEqual(['movie:42']); + expect(read('xtream-collection-items').p1['movie:42'].title).toBe( + 'Saved Movie' + ); + expect(api.getStreams).not.toHaveBeenCalled(); + }); + + it('keeps additions, status, removal and snapshot backdrops isolated after reload', async () => { + await loadAll(); + for (const type of types) { + await source.addFavorite({ id: 42, type }, 'p1'); + await source.addRecentItem({ id: 42, type }, 'p1'); + } + await source.setContentMetadataIfMissing( + { id: 42, type: 'movie' }, + 'p1', + { backdropUrl: 'movie.jpg' } + ); + expect( + (await source.getFavorites('p1')).map((item) => [ + item.type, + item.backdrop_url, + ]) + ).toEqual([ + ['live', undefined], + ['movie', 'movie.jpg'], + ['series', undefined], + ]); + reload(); + api.getStreams.mockClear(); + expect( + (await source.getFavorites('p1')).map((item) => item.type) + ).toEqual(types); + expect( + (await source.getRecentItems('p1')).map((item) => item.type) + ).toEqual(['series', 'movie', 'live']); + await source.removeFavorite({ id: 42, type: 'movie' }, 'p1'); + await source.removeRecentItem({ id: 42, type: 'series' }, 'p1'); + expect(await source.isFavorite({ id: 42, type: 'movie' }, 'p1')).toBe( + false + ); + expect(await source.isFavorite({ id: 42, type: 'series' }, 'p1')).toBe( + true + ); + expect( + (await source.getRecentItems('p1')).map((item) => item.type) + ).toEqual(['movie', 'live']); + expect(api.getStreams).not.toHaveBeenCalled(); + }); + + it('hydrates only the exact typed category for a cold typed favorite', async () => { + await source.addFavorite({ id: 42, type: 'movie' }, 'p1'); + reload(); + api.getStreams.mockClear(); + expect( + (await source.getFavorites('p1')).map((item) => item.type) + ).toEqual(['movie']); + expect(api.getStreams.mock.calls.map((call) => call[1])).toEqual([ + 'movie', + ]); + }); + + it.each([ + 'addFavorite', + 'removeFavorite', + 'isFavorite', + 'addRecentItem', + 'removeRecentItem', + ] as const)( + '%s completes locally while unrelated catalog hydration is pending', + async (operation) => { + const savedIds = [42, 77, 'series:99']; + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: savedIds }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + p1: savedIds.map((id) => ({ + id, + viewedAt: '2026-10-01T00:00:00.000Z', + })), + }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + p1: { + 42: { + id: 42, + xtream_id: 42, + type: 'movie', + title: 'Saved Movie', + }, + }, + }) + ); + let releaseCatalog!: (items: unknown[]) => void; + const catalog = new Promise((resolve) => { + releaseCatalog = resolve; + }); + api.getStreams.mockReturnValue(catalog); + const loading = Promise.all([ + source.getFavorites('p1'), + source.getRecentItems('p1'), + ]); + for (let i = 0; i < 20; i++) await Promise.resolve(); + const requestsBeforeAction = api.getStreams.mock.calls.length; + expect(requestsBeforeAction).toBeGreaterThan(0); + const adding = + operation === 'addFavorite' || operation === 'addRecentItem'; + let settled = false; + let result: boolean | void = undefined; + const action = source[operation]( + { id: 42, type: adding ? 'live' : 'movie' }, + 'p1' + ).then((value) => { + result = value; + settled = true; + }); + try { + for (let i = 0; i < 20; i++) await Promise.resolve(); + expect(settled).toBe(true); + expect(api.getStreams).toHaveBeenCalledTimes( + requestsBeforeAction + ); + if (operation === 'isFavorite') expect(result).toBe(true); + const storedIds = operation.includes('Recent') + ? read('xtream-recent-items').p1.map( + (entry: { id: unknown }) => entry.id + ) + : read('xtream-favorites').p1; + const expectedIds = operation.startsWith('remove') + ? [77, 'series:99'] + : adding + ? ['movie:42', 77, 'series:99', 'live:42'] + : [42, 77, 'series:99']; + expect([...storedIds].sort()).toEqual(expectedIds.sort()); + } finally { + releaseCatalog([]); + await Promise.all([loading, action]); + } + } + ); + + it('preserves newer storage changes while legacy hydration is pending', async () => { + localStorage.setItem('xtream-favorites', JSON.stringify({ p1: [42] })); + let resolveMovie!: (items: unknown[]) => void; + api.getStreams.mockImplementation((_credentials, type) => + type === 'movie' + ? new Promise((resolve) => { + resolveMovie = resolve; + }) + : Promise.resolve([]) + ); + const pending = source.getFavorites('p1'); + for (let i = 0; i < 10 && !resolveMovie; i++) await Promise.resolve(); + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ p1: ['series:99'], other: ['live:77'] }) + ); + resolveMovie([{ stream_id: 42, name: 'Movie' }]); + await pending; + expect(read('xtream-favorites')).toEqual({ + p1: ['series:99'], + other: ['live:77'], + }); + }); + + it('deduplicates migrated recents using the most recent timestamp', async () => { + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + p1: [ + { id: 42, viewedAt: '2026-10-01T00:00:00.000Z' }, + { id: 'movie:42', viewedAt: '2026-10-02T00:00:00.000Z' }, + ], + }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + p1: { + 42: { + id: 42, + xtream_id: 42, + type: 'movie', + title: 'Saved Movie', + }, + }, + }) + ); + expect(await source.getRecentItems('p1')).toEqual([ + expect.objectContaining({ + type: 'movie', + viewed_at: '2026-10-02T00:00:00.000Z', + }), + ]); + expect(read('xtream-recent-items').p1).toEqual([ + { id: 'movie:42', viewedAt: '2026-10-02T00:00:00.000Z' }, + ]); + }); + it('resolves a unique legacy type even when that catalog repeats its provider row', async () => { + localStorage.setItem('xtream-favorites', JSON.stringify({ p1: [42] })); + api.getStreams.mockImplementation((_credentials, type) => + Promise.resolve( + type === 'movie' + ? [ + { stream_id: 42, name: 'Movie' }, + { stream_id: 42, name: 'Movie' }, + ] + : [] + ) + ); + expect( + (await source.getFavorites('p1')).map((item) => item.type) + ).toEqual(['movie']); + expect(read('xtream-favorites').p1).toEqual(['movie:42']); + }); +}); 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 36dccdc3e..f82fae18c 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 @@ -447,6 +447,9 @@ describe('PwaXtreamDataSource', () => { ); await dataSource.getContent('playlist-1', credentials, 'movie'); + apiService.getStreams.mockResolvedValue([]); + await dataSource.getContent('playlist-1', credentials, 'live'); + await dataSource.getContent('playlist-1', credentials, 'series'); await expect(dataSource.isFavorite(202, 'playlist-1')).resolves.toBe( true @@ -618,8 +621,14 @@ describe('PwaXtreamDataSource', () => { }) ); - await dataSource.addFavorite(Number(content[0].id), 'playlist-1'); - await dataSource.addRecentItem(Number(content[0].id), 'playlist-1'); + await dataSource.addFavorite( + { id: Number(content[0].id), type: 'movie' }, + 'playlist-1' + ); + await dataSource.addRecentItem( + { id: Number(content[0].id), type: 'movie' }, + 'playlist-1' + ); await expect(dataSource.getFavorites('playlist-1')).resolves.toEqual([ expect.objectContaining({ @@ -648,24 +657,31 @@ describe('PwaXtreamDataSource', () => { ]); await dataSource.getContent('playlist-1', credentials, 'movie'); - await dataSource.addRecentItem(202, 'playlist-1'); + await dataSource.addRecentItem( + { id: 202, type: 'movie' }, + 'playlist-1' + ); const before = JSON.parse( localStorage.getItem('xtream-recent-items') || '{}' )['playlist-1'][0].viewedAt; - await dataSource.setContentMetadataIfMissing(202, 'playlist-1', { - backdropUrl: ' https://example.com/backdrop.png ', - }); + await dataSource.setContentMetadataIfMissing( + { id: 202, type: 'movie' }, + 'playlist-1', + { + backdropUrl: ' https://example.com/backdrop.png ', + } + ); const stored = JSON.parse( localStorage.getItem('xtream-recent-items') || '{}' )['playlist-1'][0]; const storedSnapshot = JSON.parse( localStorage.getItem('xtream-collection-items') || '{}' - )['playlist-1']['202']; + )['playlist-1']['movie:202']; expect(stored).toEqual( expect.objectContaining({ - id: 202, + id: 'movie:202', backdropUrl: 'https://example.com/backdrop.png', viewedAt: before, }) @@ -705,9 +721,9 @@ describe('PwaXtreamDataSource', () => { ]); await dataSource.getContent('playlist-1', credentials, 'movie'); - await dataSource.addFavorite(202, 'playlist-1'); + await dataSource.addFavorite({ id: 202, type: 'movie' }, 'playlist-1'); await dataSource.addRecentItem( - 202, + { id: 202, type: 'movie' }, 'playlist-1', 'https://example.com/backdrop.png' ); @@ -715,7 +731,7 @@ describe('PwaXtreamDataSource', () => { const storedCollectionItems = JSON.parse( localStorage.getItem('xtream-collection-items') || '{}' ); - expect(storedCollectionItems['playlist-1']['202']).toEqual( + expect(storedCollectionItems['playlist-1']['movie:202']).toEqual( expect.objectContaining({ 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 1d3bf56e2..6c86ccde7 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 @@ -23,6 +23,7 @@ import { firstValueFrom } from 'rxjs'; import { DbCategoryType, IXtreamDataSource, + XtreamCollectionRef, ProgressCallback, XtreamOperationOptions, XtreamCategoryFromDb, @@ -30,13 +31,20 @@ import { XtreamPlaylistData, } from './xtream-data-source.interface'; +import { + CollectionKey, + collectionKey, + deduplicateRecentItems, + typedCollectionRef, + itemCollectionKey, + findCollectionItems, +} from './pwa-collection-identity'; +import { PwaCollectionStorage } from './pwa-collection-storage'; + /** * LocalStorage keys for PWA persistence */ const STORAGE_KEYS = { - COLLECTION_ITEMS: 'xtream-collection-items', - FAVORITES: 'xtream-favorites', - RECENT_ITEMS: 'xtream-recent-items', PLAYLISTS: 'xtream-playlists', PLAYBACK_POSITIONS: 'xtream-playback-positions', }; @@ -64,12 +72,6 @@ interface XtreamCachedContentItem { readonly xtream_id?: number | string; } -interface StoredRecentItem { - readonly id: number; - readonly viewedAt: string; - readonly backdropUrl?: string; -} - type StoredXtreamPlaylistData = Omit & { readonly password?: string; }; @@ -86,6 +88,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private readonly parentalLock = inject(ParentalLockService); private readonly logger = createLogger('PwaXtreamDataSource'); private readonly contentTypes = ['live', 'movie', 'series'] as const; + private readonly collectionStorage = new PwaCollectionStorage( + (playlistId, ids) => this.getCollectionItemsById(playlistId, ids) + ); // In-memory cache for the current session private categoryCache = new Map(); @@ -165,9 +170,9 @@ 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.collectionStorage.clearCollectionItemsForPlaylist(playlistId); + this.collectionStorage.clearFavoritesForPlaylist(playlistId); + this.collectionStorage.clearRecentItemsForPlaylist(playlistId); this.clearPlaybackPositionsForPlaylist(playlistId); // Clear cache @@ -682,83 +687,93 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // ========================================================================= async getFavorites(playlistId: string): Promise { - const allFavorites = this.getFavoritesFromStorage(); - const playlistFavorites = allFavorites[playlistId] || []; - const contentById = await this.getCollectionItemsWithHydration( + await this.getCollectionItemsWithHydration( playlistId, - playlistFavorites + this.collectionStorage.getFavoritesFromStorage()[playlistId] || [] ); - - return Array.from(contentById.values()); + return this.collectionStorage.getLocalFavorites(playlistId); } async addFavorite( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string, backdropUrl?: string ): Promise { - const normalizedContentId = this.normalizeStoredId(contentId); + const normalizedContentId = await this.resolveCollectionKey( + playlistId, + contentId + ); if (normalizedContentId == null) { return; } - const allFavorites = this.getFavoritesFromStorage(); + const allFavorites = this.collectionStorage.getFavoritesFromStorage(); if (!allFavorites[playlistId]) { allFavorites[playlistId] = []; } - if (!allFavorites[playlistId].includes(normalizedContentId)) { + const matchingKeys = this.getMatchingCollectionKeys( + playlistId, + allFavorites[playlistId], + normalizedContentId + ); + if (!allFavorites[playlistId].some((id) => matchingKeys.has(id))) { allFavorites[playlistId].push(normalizedContentId); } - this.saveFavoritesToStorage(allFavorites); - this.saveCollectionItemSnapshot( + this.collectionStorage.saveFavoritesToStorage(allFavorites); + this.collectionStorage.getLocalFavorites(playlistId); + this.collectionStorage.saveCollectionItemSnapshot( playlistId, normalizedContentId, backdropUrl ); } - async removeFavorite(contentId: number, playlistId: string): Promise { - const normalizedContentId = this.normalizeStoredId(contentId); + async removeFavorite( + contentId: XtreamCollectionRef, + playlistId: string + ): Promise { + const normalizedContentId = await this.resolveCollectionKey( + playlistId, + contentId + ); if (normalizedContentId == null) { return; } - const allFavorites = this.getFavoritesFromStorage(); + const allFavorites = this.collectionStorage.getFavoritesFromStorage(); if (allFavorites[playlistId]) { + const matchingKeys = this.getMatchingCollectionKeys( + playlistId, + allFavorites[playlistId], + normalizedContentId + ); allFavorites[playlistId] = allFavorites[playlistId].filter( - (id: number) => id !== normalizedContentId + (id) => !matchingKeys.has(id) ); } - this.saveFavoritesToStorage(allFavorites); + this.collectionStorage.saveFavoritesToStorage(allFavorites); } - async isFavorite(contentId: number, playlistId: string): Promise { - const normalizedContentId = this.normalizeStoredId(contentId); + async isFavorite( + contentId: XtreamCollectionRef, + playlistId: string + ): Promise { + const normalizedContentId = await this.resolveCollectionKey( + playlistId, + contentId + ); if (normalizedContentId == null) { return false; } - const allFavorites = this.getFavoritesFromStorage(); - return (allFavorites[playlistId] || []).includes(normalizedContentId); - } - - private getFavoritesFromStorage(): Record { - try { - const data = localStorage.getItem(STORAGE_KEYS.FAVORITES); - return this.normalizeFavoriteStorage(data ? JSON.parse(data) : {}); - } catch { - return {}; - } - } - - private saveFavoritesToStorage(favorites: Record): void { - localStorage.setItem(STORAGE_KEYS.FAVORITES, JSON.stringify(favorites)); - } - - private clearFavoritesForPlaylist(playlistId: string): void { - const allFavorites = this.getFavoritesFromStorage(); - delete allFavorites[playlistId]; - this.saveFavoritesToStorage(allFavorites); + const allFavorites = this.collectionStorage.getFavoritesFromStorage(); + const ids = allFavorites[playlistId] || []; + const matchingKeys = this.getMatchingCollectionKeys( + playlistId, + ids, + normalizedContentId + ); + return ids.some((id) => matchingKeys.has(id)); } // ========================================================================= @@ -950,55 +965,57 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // ========================================================================= async getRecentItems(playlistId: string): Promise { - const allRecent = this.getRecentItemsFromStorage(); - const playlistRecent = allRecent[playlistId] || []; - const contentById = await this.getCollectionItemsWithHydration( + await this.getCollectionItemsWithHydration( playlistId, - playlistRecent.map((item) => item.id) + ( + this.collectionStorage.getRecentItemsFromStorage()[ + playlistId + ] || [] + ).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 - results.sort( - (a, b) => - new Date(b.viewed_at).getTime() - - new Date(a.viewed_at).getTime() - ); - - return results as XtreamContentItem[]; + return this.collectionStorage.getLocalRecentItems(playlistId); } async addRecentItem( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string, _backdropUrl?: string ): Promise { - const normalizedContentId = this.normalizeStoredId(contentId); + const normalizedContentId = await this.resolveCollectionKey( + playlistId, + contentId + ); if (normalizedContentId == null) { return; } const normalizedBackdropUrl = _backdropUrl?.trim(); - const allRecent = this.getRecentItemsFromStorage(); + const allRecent = this.collectionStorage.getRecentItemsFromStorage(); if (!allRecent[playlistId]) { allRecent[playlistId] = []; } + // Compare logical identities without expanding legacy keys before the save. + const storedItems = this.getCollectionItemsById( + playlistId, + allRecent[playlistId].map((item) => item.id) + ); + allRecent[playlistId] = deduplicateRecentItems( + allRecent[playlistId], + (entry) => { + const item = storedItems.get(entry.id); + return item ? (itemCollectionKey(item) ?? entry.id) : entry.id; + } + ); + // Remove existing entry if present + const matchingKeys = this.getMatchingCollectionKeys( + playlistId, + allRecent[playlistId].map((item) => item.id), + normalizedContentId + ); allRecent[playlistId] = allRecent[playlistId].filter( - (r) => r.id !== normalizedContentId + (r) => !matchingKeys.has(r.id) ); // Add new entry at the beginning @@ -1013,8 +1030,9 @@ export class PwaXtreamDataSource implements IXtreamDataSource { // Keep only last 50 items allRecent[playlistId] = allRecent[playlistId].slice(0, 50); - this.saveRecentItemsToStorage(allRecent); - this.saveCollectionItemSnapshot( + this.collectionStorage.saveRecentItemsToStorage(allRecent); + this.collectionStorage.getLocalRecentItems(playlistId); + this.collectionStorage.saveCollectionItemSnapshot( playlistId, normalizedContentId, normalizedBackdropUrl @@ -1022,250 +1040,91 @@ export class PwaXtreamDataSource implements IXtreamDataSource { } async removeRecentItem( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string ): Promise { - const normalizedContentId = this.normalizeStoredId(contentId); + const normalizedContentId = await this.resolveCollectionKey( + playlistId, + contentId + ); if (normalizedContentId == null) { return; } - const allRecent = this.getRecentItemsFromStorage(); + const allRecent = this.collectionStorage.getRecentItemsFromStorage(); if (allRecent[playlistId]) { + const matchingKeys = this.getMatchingCollectionKeys( + playlistId, + allRecent[playlistId].map((item) => item.id), + normalizedContentId + ); allRecent[playlistId] = allRecent[playlistId].filter( - (r) => r.id !== normalizedContentId + (r) => !matchingKeys.has(r.id) ); } - this.saveRecentItemsToStorage(allRecent); + this.collectionStorage.saveRecentItemsToStorage(allRecent); } async clearRecentItems(playlistId: string): Promise { - this.clearRecentItemsForPlaylist(playlistId); + this.collectionStorage.clearRecentItemsForPlaylist(playlistId); } - private getRecentItemsFromStorage(): Record { - try { - const data = localStorage.getItem(STORAGE_KEYS.RECENT_ITEMS); - return this.normalizeRecentStorage(data ? JSON.parse(data) : {}); - } catch { - return {}; - } - } - - private saveRecentItemsToStorage( - recentItems: Record - ): void { - localStorage.setItem( - STORAGE_KEYS.RECENT_ITEMS, - JSON.stringify(recentItems) - ); - } - - private clearRecentItemsForPlaylist(playlistId: string): void { - const allRecent = this.getRecentItemsFromStorage(); - delete allRecent[playlistId]; - this.saveRecentItemsToStorage(allRecent); - } - - private normalizeStoredId(value: unknown): number | null { - const numericValue = Number(value); - return Number.isFinite(numericValue) && numericValue > 0 - ? 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; - readonly backdropUrl?: unknown; - readonly backdrop_url?: unknown; - }; - const id = this.normalizeStoredId(rawItem.id); - if ( - id == null || - typeof rawItem.viewedAt !== 'string' - ) { - return null; - } - - return { - id, - viewedAt: rawItem.viewedAt, - ...this.normalizeStoredBackdrop(rawItem), - }; - }) - .filter((item): item is StoredRecentItem => item !== null); - } - ); - 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 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( + /** Match original keys even when optional migration could not fit in storage. */ + private getMatchingCollectionKeys( playlistId: string, - contentId: number, - backdropUrl?: string - ): void { - const item = this.findCachedContentItemById(playlistId, contentId); - if (!item) { - return; + ids: readonly CollectionKey[], + target: CollectionKey + ): Set { + const matchingKeys = new Set([target]); + for (const [key, item] of this.getCollectionItemsById( + playlistId, + ids + )) { + if (itemCollectionKey(item) === target) matchingKeys.add(key); } - - 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, - }, - }, - }); + return matchingKeys; } private getCollectionItemsById( playlistId: string, - ids: readonly number[] - ): Map { - const idSet = new Set(ids); - const results = new Map(); - + ids: readonly CollectionKey[] + ): Map { + if (!ids.length) return new Map(); + const cached: XtreamContentItem[] = []; 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 content = + this.contentCache.get(`${playlistId}-${type}-content`) ?? []; + for (const item of content) cached.push(item as XtreamContentItem); } + return findCollectionItems( + ids, + cached, + this.collectionStorage.getCollectionItemsFromStorage()[ + playlistId + ] ?? {}, + this.contentTypes.every((type) => + this.contentCache.has(`${playlistId}-${type}-content`) + ) + ); + } - 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 async resolveCollectionKey( + playlistId: string, + ref: XtreamCollectionRef + ): Promise { + const key = collectionKey(ref); + if (typeof key !== 'number') return key; + const items = await this.getCollectionItemsWithHydration(playlistId, [ + key, + ]); + const item = items.get(key); + return item ? itemCollectionKey(item) : null; } private async getCollectionItemsWithHydration( playlistId: string, - ids: readonly number[] - ): Promise> { + ids: readonly CollectionKey[] + ): Promise> { const contentById = this.getCollectionItemsById(playlistId, ids); const missingIds = ids.filter((id) => !contentById.has(id)); if (missingIds.length === 0) { @@ -1278,7 +1137,7 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private findCachedContentItemById( playlistId: string, - contentId: number + contentId: CollectionKey ): XtreamContentItem | null { return ( this.getCollectionItemsById(playlistId, [contentId]).get( @@ -1289,14 +1148,20 @@ export class PwaXtreamDataSource implements IXtreamDataSource { private async hydrateStoredCollectionContent( playlistId: string, - ids: readonly number[] + ids: readonly CollectionKey[] ): Promise { if (ids.length === 0) { return; } const missingTypes = this.contentTypes.filter( - (type) => !this.contentCache.has(`${playlistId}-${type}-content`) + (type) => + !this.contentCache.has(`${playlistId}-${type}-content`) && + ids.some( + (id) => + typeof id === 'number' || + typedCollectionRef(id)?.type === type + ) ); if (missingTypes.length === 0) { return; @@ -1367,11 +1232,14 @@ export class PwaXtreamDataSource implements IXtreamDataSource { * lookups stay on the documented title-only fallback. */ async setContentMetadataIfMissing( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string, patch: ContentMetadataPatch ): Promise { - const normalizedContentId = this.normalizeStoredId(contentId); + const normalizedContentId = await this.resolveCollectionKey( + playlistId, + contentId + ); const normalizedBackdropUrl = patch.backdropUrl?.trim(); if (normalizedContentId == null || !normalizedBackdropUrl) { return; @@ -1388,7 +1256,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { cacheKey, content.map((item) => { const itemId = this.getItemIdentity(item, type); - if (itemId !== normalizedContentId || item.backdrop_url) { + if ( + collectionKey({ id: itemId, type }) !== + normalizedContentId || + item.backdrop_url + ) { return item; } @@ -1400,19 +1272,19 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ); } - this.setCollectionItemBackdropIfMissing( + this.collectionStorage.setCollectionItemBackdropIfMissing( playlistId, normalizedContentId, normalizedBackdropUrl ); - const allRecent = this.getRecentItemsFromStorage(); + const allRecent = this.collectionStorage.getRecentItemsFromStorage(); const playlistRecent = allRecent[playlistId]; if (!playlistRecent) { return; } - this.saveRecentItemsToStorage({ + this.collectionStorage.saveRecentItemsToStorage({ ...allRecent, [playlistId]: playlistRecent.map((item) => { if (item.id !== normalizedContentId || item.backdropUrl) { @@ -1427,33 +1299,16 @@ export class PwaXtreamDataSource implements IXtreamDataSource { }); } - private findContentIdentity( - playlistId: string, - xtreamId: number, - contentType?: 'live' | 'movie' | 'series' - ): { contentType: 'live' | 'movie' | 'series'; xtreamId: number } | null { - const types = contentType - ? [contentType] - : (['live', 'movie', 'series'] as const); - - for (const type of types) { - const cacheKey = `${playlistId}-${type}-content`; - const content = this.contentCache.get(cacheKey) || []; - - const found = content.find((item) => { - const itemXtreamId = this.getItemIdentity(item, type); - return itemXtreamId === xtreamId; - }); - - if (found) { - return { - contentType: type, - xtreamId, - }; - } - } - - return null; + private findContentIdentity(playlistId: string, key: CollectionKey) { + const ref = typedCollectionRef(key); + if (ref) return { contentType: ref.type, xtreamId: ref.id }; + const item = this.findCachedContentItemById(playlistId, key); + const identity = item + ? typedCollectionRef({ id: item.xtream_id, type: item.type }) + : null; + return identity + ? { contentType: identity.type, xtreamId: identity.id } + : null; } // ========================================================================= @@ -1463,9 +1318,11 @@ export class PwaXtreamDataSource implements IXtreamDataSource { async clearPlaylistContent( playlistId: string ): Promise { - // Get current favorites and recent items - const favorites = this.getFavoritesFromStorage(); - const recentItems = this.getRecentItemsFromStorage(); + // Resolve only unambiguous legacy references before producing typed backup data. + await this.getFavorites(playlistId); + await this.getRecentItems(playlistId); + const favorites = this.collectionStorage.getFavoritesFromStorage(); + const recentItems = this.collectionStorage.getRecentItemsFromStorage(); const playbackPositions = await this.getAllPlaybackPositions(playlistId); @@ -1521,19 +1378,26 @@ export class PwaXtreamDataSource implements IXtreamDataSource { ): Promise { void options; // Restore favorites - const favorites = this.getFavoritesFromStorage(); - favorites[playlistId] = restoreState.favorites.map( - (item) => item.xtreamId - ); - this.saveFavoritesToStorage(favorites); + const favorites = this.collectionStorage.getFavoritesFromStorage(); + favorites[playlistId] = restoreState.favorites + .map((item) => + collectionKey({ id: item.xtreamId, type: item.contentType }) + ) + .filter((key): key is CollectionKey => key !== null); + this.collectionStorage.saveFavoritesToStorage(favorites); // Restore recent items - const recentItems = this.getRecentItemsFromStorage(); - recentItems[playlistId] = restoreState.recentlyViewed.map((item) => ({ - id: item.xtreamId, - viewedAt: item.viewedAt, - })); - this.saveRecentItemsToStorage(recentItems); + const recentItems = this.collectionStorage.getRecentItemsFromStorage(); + recentItems[playlistId] = []; + for (const item of restoreState.recentlyViewed) { + const id = collectionKey({ + id: item.xtreamId, + type: item.contentType, + }); + if (id !== null) + recentItems[playlistId].push({ id, viewedAt: item.viewedAt }); + } + this.collectionStorage.saveRecentItemsToStorage(recentItems); // Restore playback positions this.clearPlaybackPositionsForPlaylist(playlistId); 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 1728416a6..38c1eddd5 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 @@ -18,6 +18,20 @@ import { // Re-export for backward compatibility export type { PlaybackPositionData }; +export const XTREAM_CONTENT_TYPES = ['live', 'movie', 'series'] as const; +export type XtreamContentType = (typeof XTREAM_CONTENT_TYPES)[number]; + +/** SQLite row id on Electron; provider id on PWA. Always carry type for PWA. */ +export interface TypedXtreamCollectionRef { + readonly id: number; + readonly type: XtreamContentType; +} +export type XtreamCollectionRef = number | TypedXtreamCollectionRef; + +export function xtreamCollectionId(ref: XtreamCollectionRef): number { + return typeof ref === 'number' ? ref : ref.id; +} + /** * Playlist representation in the data source */ @@ -327,7 +341,7 @@ export interface IXtreamDataSource { * a cinematic backdrop without a separate round-trip. */ addFavorite( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string, backdropUrl?: string ): Promise; @@ -335,12 +349,18 @@ export interface IXtreamDataSource { /** * Remove content from favorites */ - removeFavorite(contentId: number, playlistId: string): Promise; + removeFavorite( + contentId: XtreamCollectionRef, + playlistId: string + ): Promise; /** * Check if content is favorited */ - isFavorite(contentId: number, playlistId: string): Promise; + isFavorite( + contentId: XtreamCollectionRef, + playlistId: string + ): Promise; // ========================================================================= // Recently Viewed Operations @@ -355,7 +375,7 @@ export interface IXtreamDataSource { * Add item to recently viewed. See `addFavorite` for `backdropUrl`. */ addRecentItem( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string, backdropUrl?: string ): Promise; @@ -363,7 +383,10 @@ export interface IXtreamDataSource { /** * Remove item from recently viewed */ - removeRecentItem(contentId: number, playlistId: string): Promise; + removeRecentItem( + contentId: XtreamCollectionRef, + playlistId: string + ): Promise; /** * Clear recently viewed for a playlist @@ -391,7 +414,7 @@ export interface IXtreamDataSource { * ordering, and never overwrites a column that already has a value. */ setContentMetadataIfMissing( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string, patch: ContentMetadataPatch ): Promise; 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 4929d4471..ce3a53b11 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,6 +1,9 @@ import { inject, Injectable } from '@angular/core'; import { from, map, Observable } from 'rxjs'; -import { XTREAM_DATA_SOURCE } from '../data-sources/xtream-data-source.interface'; +import { + XTREAM_DATA_SOURCE, + XtreamCollectionRef, +} from '../data-sources/xtream-data-source.interface'; import { FavoriteItem } from './favorite-item.interface'; function normalizeCategoryId(categoryId: string | number): number { @@ -15,7 +18,7 @@ export class FavoritesService { private dataSource = inject(XTREAM_DATA_SOURCE); async addToFavorites(item: { - content_id: number; + content_id: XtreamCollectionRef; playlist_id: string; backdrop_url?: string; }): Promise { @@ -27,13 +30,16 @@ export class FavoritesService { } async removeFromFavorites( - contentId: number, + contentId: XtreamCollectionRef, playlistId: string ): Promise { await this.dataSource.removeFavorite(contentId, playlistId); } - async isFavorite(contentId: number, playlistId: string): Promise { + async isFavorite( + contentId: XtreamCollectionRef, + playlistId: string + ): Promise { return await this.dataSource.isFavorite(contentId, playlistId); } 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 523a07790..f70fc357d 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,3 +1,4 @@ +import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { TestBed } from '@angular/core/testing'; import { patchState, signalStore } from '@ngrx/signals'; import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; @@ -19,6 +20,7 @@ const TestFavoritesStore = signalStore( describe('withFavorites', () => { const originalElectron = window.electron; + const runtime = { supportsXtreamSqliteDataSource: false }; let store: InstanceType; let dataSource: { addFavorite: jest.Mock; @@ -28,6 +30,7 @@ describe('withFavorites', () => { }; beforeEach(() => { + runtime.supportsXtreamSqliteDataSource = false; dataSource = { addFavorite: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn(), @@ -38,6 +41,7 @@ describe('withFavorites', () => { TestBed.configureTestingModule({ providers: [ TestFavoritesStore, + { provide: RuntimeCapabilitiesService, useValue: runtime }, { provide: XTREAM_DATA_SOURCE, useValue: dataSource, @@ -56,6 +60,31 @@ describe('withFavorites', () => { }); }); + it.each([false, true])( + 'toggles the typed target using its persisted status %s rather than the previous detail state', + async (persistedStatus) => { + patchState(store, { isFavorite: !persistedStatus }); + dataSource.getContentByXtreamId.mockResolvedValue({ + id: 42, + xtream_id: 42, + type: 'live', + }); + dataSource.isFavorite.mockResolvedValue(persistedStatus); + expect(await store.toggleFavorite(42, 'playlist-1', 'live')).toBe( + !persistedStatus + ); + expect(dataSource.isFavorite).toHaveBeenCalledWith( + { id: 42, type: 'live' }, + 'playlist-1' + ); + expect( + persistedStatus + ? dataSource.removeFavorite + : dataSource.addFavorite + ).toHaveBeenCalled(); + } + ); + it('looks favorites up with the requested content type before adding one', async () => { dataSource.getContentByXtreamId.mockResolvedValue({ id: 3941697, @@ -72,7 +101,7 @@ describe('withFavorites', () => { 'series' ); expect(dataSource.addFavorite).toHaveBeenCalledWith( - 3941697, + { id: 3941697, type: 'series' }, 'playlist-1', undefined ); @@ -95,7 +124,7 @@ describe('withFavorites', () => { ); expect(dataSource.addFavorite).toHaveBeenCalledWith( - 1767451, + { id: 1767451, type: 'movie' }, 'playlist-1', undefined ); @@ -123,7 +152,7 @@ describe('withFavorites', () => { 'movie' ); expect(dataSource.addFavorite).toHaveBeenCalledWith( - 1767451, + { id: 1767451, type: 'movie' }, 'playlist-1', undefined ); @@ -138,6 +167,7 @@ describe('withFavorites', () => { xtream_id: 290, }); patchState(store, { isFavorite: true }); + dataSource.isFavorite.mockResolvedValue(true); const result = await store.toggleFavorite(290, 'playlist-1', 'live'); @@ -147,7 +177,7 @@ describe('withFavorites', () => { 'live' ); expect(dataSource.removeFavorite).toHaveBeenCalledWith( - 3867578, + { id: 3867578, type: 'live' }, 'playlist-1' ); expect(result).toBe(false); @@ -171,7 +201,7 @@ describe('withFavorites', () => { 'series' ); expect(dataSource.isFavorite).toHaveBeenCalledWith( - 3829429, + { id: 3829429, type: 'series' }, 'playlist-1' ); expect(store.isFavorite()).toBe(true); @@ -189,7 +219,7 @@ describe('withFavorites', () => { await store.checkFavoriteStatus(1767451, 'playlist-1', 'movie'); expect(dataSource.isFavorite).toHaveBeenCalledWith( - 1767451, + { id: 1767451, type: 'movie' }, 'playlist-1' ); expect(store.isFavorite()).toBe(true); @@ -211,6 +241,7 @@ describe('withFavorites', () => { }); it('does not toggle Electron favorites when the cached content is missing', async () => { + runtime.supportsXtreamSqliteDataSource = true; Object.defineProperty(window, 'electron', { configurable: true, writable: true, @@ -237,6 +268,7 @@ describe('withFavorites', () => { }); it('resets Electron favorite state when the cached content is missing', async () => { + runtime.supportsXtreamSqliteDataSource = true; Object.defineProperty(window, 'electron', { configurable: true, writable: 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 4bcadd4fe..35eec0b72 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 @@ -1,3 +1,4 @@ +import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { inject } from '@angular/core'; import { patchState, @@ -14,94 +15,110 @@ export const withFavorites = function () { withState({ isFavorite: false, }), - 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 + withMethods( + ( + store, + dataSource = inject(XTREAM_DATA_SOURCE), + runtime = inject(RuntimeCapabilitiesService) + ) => ({ + async toggleFavorite( + xtreamId: number | string, + playlistId: string, + contentType: 'live' | 'movie' | 'series', + backdropUrl?: string ) { - return false; - } + const normalizedXtreamId = Number(xtreamId); + if ( + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !playlistId + ) { + return false; + } - 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 - ); - return false; - } - - const currentStatus = store.isFavorite(); - - if (currentStatus) { - // Remove from favorites - await dataSource.removeFavorite(contentId, playlistId); - patchState(store, { isFavorite: false }); - return false; - } else { - // Add to favorites - await dataSource.addFavorite( - contentId, + const content = await dataSource.getContentByXtreamId( + normalizedXtreamId, playlistId, - backdropUrl + contentType ); - patchState(store, { isFavorite: true }); - return true; - } - }, + const contentId = + content?.id ?? + (!runtime.supportsXtreamSqliteDataSource + ? normalizedXtreamId + : null); - async checkFavoriteStatus( - xtreamId: number | string, - playlistId: string, - contentType: 'live' | 'movie' | 'series' - ) { - const normalizedXtreamId = Number(xtreamId); - if ( - !Number.isFinite(normalizedXtreamId) || - normalizedXtreamId <= 0 || - !playlistId + if (contentId == null) { + logger.error( + 'Content not found for xtream ID', + normalizedXtreamId + ); + return false; + } + + const currentStatus = await dataSource.isFavorite( + { id: contentId, type: contentType }, + playlistId + ); + + if (currentStatus) { + // Remove from favorites + await dataSource.removeFavorite( + { id: contentId, type: contentType }, + playlistId + ); + patchState(store, { isFavorite: false }); + return false; + } else { + // Add to favorites + await dataSource.addFavorite( + { id: contentId, type: contentType }, + playlistId, + backdropUrl + ); + patchState(store, { isFavorite: true }); + return true; + } + }, + + async checkFavoriteStatus( + xtreamId: number | string, + playlistId: string, + contentType: 'live' | 'movie' | 'series' ) { - patchState(store, { isFavorite: false }); - return; - } + const normalizedXtreamId = Number(xtreamId); + if ( + !Number.isFinite(normalizedXtreamId) || + normalizedXtreamId <= 0 || + !playlistId + ) { + patchState(store, { isFavorite: false }); + return; + } - const content = await dataSource.getContentByXtreamId( - normalizedXtreamId, - playlistId, - contentType - ); - const contentId = - content?.id ?? - (!window.electron ? normalizedXtreamId : null); + const content = await dataSource.getContentByXtreamId( + normalizedXtreamId, + playlistId, + contentType + ); + const contentId = + content?.id ?? + (!runtime.supportsXtreamSqliteDataSource + ? normalizedXtreamId + : null); - if (contentId == null) { - patchState(store, { isFavorite: false }); - return; - } + if (contentId == null) { + patchState(store, { isFavorite: false }); + return; + } - const isFavorite = await dataSource.isFavorite( - contentId, - playlistId - ); + const isFavorite = await dataSource.isFavorite( + { id: contentId, type: contentType }, + playlistId + ); - patchState(store, { isFavorite }); - }, - })) + 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 7486245ad..2b4fe3d91 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 @@ -2,7 +2,11 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { signalStore } from '@ngrx/signals'; import { of } from 'rxjs'; -import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; import { withRecentItems } from './with-recent-items'; @@ -19,6 +23,7 @@ const TestRecentItemsStore = signalStore(withRecentItems()); describe('withRecentItems', () => { const originalElectron = window.electron; + const runtime = { supportsXtreamSqliteDataSource: false }; let store: InstanceType; let databaseService: { clearGlobalRecentlyViewed: jest.Mock; @@ -42,6 +47,7 @@ describe('withRecentItems', () => { }; beforeEach(() => { + runtime.supportsXtreamSqliteDataSource = false; Object.defineProperty(window, 'electron', { value: {} as Window['electron'], configurable: true, @@ -95,6 +101,7 @@ describe('withRecentItems', () => { TestBed.configureTestingModule({ providers: [ TestRecentItemsStore, + { provide: RuntimeCapabilitiesService, useValue: runtime }, { provide: DatabaseService, useValue: databaseService, @@ -142,7 +149,7 @@ describe('withRecentItems', () => { 'series' ); expect(dataSource.addRecentItem).toHaveBeenCalledWith( - 3941697, + { id: 3941697, type: 'series' }, 'playlist-1', undefined ); @@ -172,7 +179,7 @@ describe('withRecentItems', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(dataSource.addRecentItem).toHaveBeenCalledWith( - 1767451, + { id: 1767451, type: 'movie' }, 'playlist-1', undefined ); @@ -199,7 +206,7 @@ describe('withRecentItems', () => { 'movie' ); expect(dataSource.addRecentItem).toHaveBeenCalledWith( - 1767451, + { id: 1767451, type: 'movie' }, 'playlist-1', undefined ); @@ -222,7 +229,7 @@ describe('withRecentItems', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(dataSource.addRecentItem).toHaveBeenCalledWith( - 3941697, + { id: 3941697, type: 'series' }, 'playlist-1', 'https://example.com/krypton-backdrop.png' ); @@ -251,7 +258,7 @@ describe('withRecentItems', () => { 'series' ); expect(dataSource.setContentMetadataIfMissing).toHaveBeenCalledWith( - 3941697, + { id: 3941697, type: 'series' }, 'playlist-1', { backdropUrl: 'https://example.com/krypton-backdrop.png' } ); @@ -322,11 +329,15 @@ describe('withRecentItems', () => { configurable: true, }); - store.removeRecentItem({ itemId: 3941697, playlistId: 'playlist-1' }); + store.removeRecentItem({ + itemId: 3941697, + contentType: 'series', + playlistId: 'playlist-1', + }); await new Promise((resolve) => setTimeout(resolve, 0)); expect(dataSource.removeRecentItem).toHaveBeenCalledWith( - 3941697, + { id: 3941697, type: 'series' }, 'playlist-1' ); expect(databaseService.removeRecentItem).not.toHaveBeenCalled(); 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 7bfa11129..59394ba67 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 @@ -7,7 +7,11 @@ import { } from '@ngrx/signals'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { firstValueFrom, pipe, switchMap, tap } from 'rxjs'; -import { DatabaseService, PlaylistsService } from '@iptvnator/services'; +import { + DatabaseService, + PlaylistsService, + RuntimeCapabilitiesService, +} from '@iptvnator/services'; import { buildPlaylistRecentItems, ContentMetadataPatch, @@ -78,7 +82,8 @@ export const withRecentItems = function () { store, dbService = inject(DatabaseService), playlistsService = inject(PlaylistsService), - dataSource = inject(XTREAM_DATA_SOURCE) + dataSource = inject(XTREAM_DATA_SOURCE), + runtime = inject(RuntimeCapabilitiesService) ) => ({ addRecentItem: rxMethod<{ xtreamId: number | string; @@ -112,13 +117,13 @@ export const withRecentItems = function () { ); const contentId = content?.id ?? - (!window.electron + (!runtime.supportsXtreamSqliteDataSource ? normalizedXtreamId : null); if (contentId != null) { await dataSource.addRecentItem( - contentId, + { id: contentId, type: contentType }, playlistId, backdropUrl ); @@ -172,7 +177,7 @@ export const withRecentItems = function () { } await dataSource.setContentMetadataIfMissing( - content.id, + { id: content.id, type: contentType }, playlistId, normalized ); @@ -180,36 +185,35 @@ export const withRecentItems = function () { clearRecentItems: rxMethod<{ id: string }>( pipe( switchMap(async (playlist) => { - if (window.electron) { - await dbService.clearPlaylistRecentItems( - playlist.id - ); - } else { - await dataSource.clearRecentItems(playlist.id); - } + await dataSource.clearRecentItems(playlist.id); patchState(store, { recentItems: [] }); }) ) ), removeRecentItem: rxMethod<{ itemId: number; + contentType?: 'live' | 'movie' | 'series'; playlistId: string; }>( pipe( - switchMap(async ({ itemId, playlistId }) => { - await dataSource.removeRecentItem( - itemId, - playlistId - ); - // Reload recent items to update UI - const items = - await dataSource.getRecentItems(playlistId); - patchState(store, { - recentItems: items.map((item) => - mapDbRecentItem(item, playlistId) - ), - }); - }) + switchMap( + async ({ itemId, playlistId, contentType }) => { + await dataSource.removeRecentItem( + contentType + ? { id: itemId, type: contentType } + : itemId, + playlistId + ); + // Reload recent items to update UI + const items = + await dataSource.getRecentItems(playlistId); + patchState(store, { + recentItems: items.map((item) => + mapDbRecentItem(item, playlistId) + ), + }); + } + ) ) ), async loadGlobalRecentItems() { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-history.spec-data.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-history.spec-data.ts new file mode 100644 index 000000000..03274796c --- /dev/null +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout-history.spec-data.ts @@ -0,0 +1,70 @@ +import { TestBed } from '@angular/core/testing'; +import { PlaybackHistoryGate } from '@iptvnator/playback/data-access'; +import { LiveStreamLayoutComponent } from './live-stream-layout.component'; +import { playlist, sampleChannel } from './live-stream-layout-stubs.spec-data'; + +interface LiveHistoryContext { + component: LiveStreamLayoutComponent; + xtreamStore: { + constructStreamUrl: jest.Mock; + addRecentItem: jest.Mock; + }; + portalPlayer: { isEmbeddedPlayer: jest.Mock }; +} + +/** Collection-history cases share the main live host's component fixture. */ +export function liveHistoryTestCases(context: () => LiveHistoryContext): void { + it('records typed live history only after its own playback session confirms', () => { + const { component, xtreamStore } = context(); + component.playLive(sampleChannel, true); + const gate = TestBed.inject(PlaybackHistoryGate); + expect(xtreamStore.addRecentItem).not.toHaveBeenCalled(); + gate.confirm({ + sessionKey: 'other-playlist', + streamUrls: ['https://example.com/live.ts'], + }); + expect(xtreamStore.addRecentItem).not.toHaveBeenCalled(); + gate.confirm({ sessionKey: component.playbackSessionKey() }); + expect(xtreamStore.addRecentItem).toHaveBeenCalledWith( + expect.objectContaining({ + xtreamId: 101, + contentType: 'live', + }) + ); + expect(xtreamStore.addRecentItem.mock.calls[0][0].playlist()).toEqual( + playlist + ); + }); + + it('records external live playback after URL confirmation', () => { + const { component, xtreamStore, portalPlayer } = context(); + portalPlayer.isEmbeddedPlayer.mockReturnValue(false); + component.playLive(sampleChannel, true); + expect(xtreamStore.addRecentItem).not.toHaveBeenCalled(); + TestBed.inject(PlaybackHistoryGate).confirm({ + streamUrls: ['https://example.com/live.ts'], + }); + expect(xtreamStore.addRecentItem).toHaveBeenCalledWith( + expect.objectContaining({ + xtreamId: 101, + contentType: 'live', + }) + ); + }); + + it('does not record a live start when no stream URL can be resolved', () => { + const { component, xtreamStore } = context(); + xtreamStore.constructStreamUrl.mockReturnValueOnce(''); + component.playLive(sampleChannel, true); + expect(xtreamStore.addRecentItem).not.toHaveBeenCalled(); + }); + + it('does not record a live channel selected without starting playback', () => { + const { component, xtreamStore } = context(); + component.playLive(sampleChannel, false); + TestBed.inject(PlaybackHistoryGate).confirm({ + sessionKey: component.playbackSessionKey(), + }); + expect(xtreamStore.addRecentItem).not.toHaveBeenCalled(); + }); +} diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts index d871f27eb..e96576305 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.spec.ts @@ -42,6 +42,7 @@ import { PortalChannelsListComponent } from '../portal-channels-list/portal-chan import { LiveStreamLayoutComponent } from './live-stream-layout.component'; import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import { liveHistoryTestCases } from './live-stream-layout-history.spec-data'; import { sampleChannel, @@ -97,6 +98,7 @@ describe('LiveStreamLayoutComponent', () => { ]), constructStreamUrl: jest.fn(() => 'https://example.com/live.ts'), openPlayer: jest.fn(), + addRecentItem: jest.fn(), setSelectedItem: jest.fn(), setSelectedCategory: jest.fn(), loadMoreContent: jest.fn(), @@ -147,6 +149,7 @@ describe('LiveStreamLayoutComponent', () => { router = { events: routerEvents, navigate: jest.fn() }; xtreamStore.constructStreamUrl.mockClear(); xtreamStore.openPlayer.mockClear(); + xtreamStore.addRecentItem.mockClear(); xtreamStore.setSelectedItem.mockClear(); xtreamStore.setSelectedCategory.mockClear(); xtreamStore.loadMoreContent.mockClear(); @@ -344,6 +347,8 @@ describe('LiveStreamLayoutComponent', () => { settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests }); + liveHistoryTestCases(() => ({ component, xtreamStore, portalPlayer })); + it('hides the EPG panel in browser/PWA playback', () => { fixture.destroy(); window.electron = undefined as unknown as typeof window.electron; diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 6c45da2fe..b4a3e002b 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -104,6 +104,7 @@ import { } from '@iptvnator/services'; import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service'; import { createPlaybackSessionKey } from '@iptvnator/playback/util'; +import { injectXtreamRecentHistory } from '../xtream-recent-history'; import { XtreamLiveChannelNavigationService, @@ -155,6 +156,7 @@ export class LiveStreamLayoutComponent private readonly router = inject(Router); private readonly favoritesService = inject(FavoritesService); private readonly xtreamStore = inject(XtreamStore); + private readonly recordRecentItem = injectXtreamRecentHistory(); readonly archiveContextKey = computed(() => JSON.stringify([ this.xtreamStore.currentPlaylist()?.id, @@ -694,6 +696,15 @@ export class LiveStreamLayoutComponent thumbnail: item.poster_url ?? item.stream_icon ?? null, isLive: true, }); + if (startPlayback && streamUrl) { + this.recordRecentItem( + streamUrl, + { xtreamId: item.xtream_id, contentType: 'live' }, + this.usesEmbeddedPlayer() + ? this.playbackSessionKey() + : undefined + ); + } if (this.usesEmbeddedPlayer() || !startPlayback) { return; } diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts index b08e3bf34..66df0fcda 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.spec.ts @@ -63,7 +63,12 @@ describe('SerialDetailsMenuService', () => { }, { provide: XTREAM_DATA_SOURCE, - useValue: { removeRecentItem: jest.fn() }, + useValue: { + removeRecentItem: jest.fn(), + getContentByXtreamId: jest + .fn() + .mockResolvedValue({ id: 103 }), + }, }, { provide: RuntimeCapabilitiesService, @@ -161,6 +166,17 @@ describe('SerialDetailsMenuService', () => { }); }); + it('removes only the typed series when hiding it from Continue Watching', async () => { + await service.run(SERIES_MENU_ACTION.HideFromContinueWatching); + + expect( + TestBed.inject(XTREAM_DATA_SOURCE).getContentByXtreamId + ).toHaveBeenCalledWith(103, 'xtream-1', 'series'); + expect( + TestBed.inject(XTREAM_DATA_SOURCE).removeRecentItem + ).toHaveBeenCalledWith({ id: 103, type: 'series' }, 'xtream-1'); + }); + it('is offered once an episode is finished while the next one remains, as the rail lists the series with it', () => { episodePositionsSignal.set(new Map([watched(1001)])); diff --git a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts index 33ad8b727..51da61dc9 100644 --- a/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts +++ b/libs/portal/xtream/feature/src/lib/serial-details/serial-details-menu.service.ts @@ -330,7 +330,10 @@ export class SerialDetailsMenuService { if (!content?.id) { return; } - await this.dataSource.removeRecentItem(content.id, playlistId); + await this.dataSource.removeRecentItem( + { id: content.id, type: 'series' }, + playlistId + ); // The refreshed list drops the row, and brings it back once the // series is played again. this.xtreamStore.loadRecentItems({ id: playlistId }); diff --git a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts index d60edcd25..5a1ca7482 100644 --- a/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts +++ b/libs/portal/xtream/feature/src/lib/vod-details/vod-details-playback.service.spec.ts @@ -277,7 +277,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { 'movie' ); expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( - 77, + { id: 77, type: 'movie' }, ROUTE_PLAYLIST, undefined ); @@ -311,7 +311,7 @@ describe('VodDetailsPlaybackService — external session ownership', () => { expect(xtreamDataSource.addRecentItem).toHaveBeenCalledTimes(saves); if (saves) { expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( - ROUTE_VOD_ID, + { id: ROUTE_VOD_ID, type: 'movie' }, ROUTE_PLAYLIST, undefined ); diff --git a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts index 69f051266..68aea621e 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-collection-detail.component.spec.ts @@ -33,9 +33,34 @@ describe('XtreamCollectionDetailComponent', () => { let cancelDetailsRequest: jest.Mock; let routerNavigate: jest.Mock; + const seriesItem: UnifiedCollectionItem = { + uid: 'xtream::xtream-1::series:103', + name: 'Series One', + contentType: 'series', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream Portal', + xtreamId: 103, + categoryId: 3, + }; + const detailPlaylist = { + _id: 'xtream-1', + title: 'Xtream Portal', + serverUrl: 'http://xtream.example', + username: 'user', + password: 'pass', + } as Playlist; + beforeEach(async () => { - playlistId = signal(''); - currentPlaylist = signal(null); + playlistId = signal('original-playlist'); + currentPlaylist = signal({ + id: 'original-playlist', + name: 'Original Portal', + type: 'xtream', + serverUrl: 'http://original.example', + username: 'original-user', + password: 'original-pass', + }); selectedContentType = signal<'live' | 'vod' | 'series'>('vod'); selectedCategoryId = signal(null); selectedItem = signal(null); @@ -93,15 +118,7 @@ describe('XtreamCollectionDetailComponent', () => { { provide: PlaylistsService, useValue: { - getPlaylistById: jest.fn(() => - of({ - _id: 'xtream-1', - title: 'Xtream Portal', - serverUrl: 'http://xtream.example', - username: 'user', - password: 'pass', - } as Playlist) - ), + getPlaylistById: jest.fn(() => of(detailPlaylist)), }, }, ], @@ -127,16 +144,7 @@ describe('XtreamCollectionDetailComponent', () => { seasonNumber: 2, episodeNumber: 1, }; - fixture.componentRef.setInput('item', { - uid: 'xtream::xtream-1::series:103', - name: 'Series One', - contentType: 'series', - sourceType: 'xtream', - playlistId: 'xtream-1', - playlistName: 'Xtream Portal', - xtreamId: 103, - categoryId: 3, - } satisfies UnifiedCollectionItem); + fixture.componentRef.setInput('item', seriesItem); fixture.componentRef.setInput('seriesResume', seriesResume); fixture.detectChanges(); @@ -174,6 +182,37 @@ describe('XtreamCollectionDetailComponent', () => { ).toEqual(seriesResume); }); + it('keeps the newer selection when an earlier playlist resolves last', fakeAsync(() => { + const pendingPlaylist = new Subject(); + jest.spyOn(TestBed.inject(PlaylistsService), 'getPlaylistById') + .mockReturnValueOnce(pendingPlaylist) + .mockReturnValueOnce(of({ ...detailPlaylist, _id: 'xtream-2' })); + fixture.componentRef.setInput('item', seriesItem); + fixture.detectChanges(); + fixture.componentRef.setInput('item', { + ...seriesItem, + uid: 'xtream::xtream-2::series:203', + playlistId: 'xtream-2', + xtreamId: 203, + categoryId: 6, + } satisfies UnifiedCollectionItem); + fixture.detectChanges(); + flushMicrotasks(); + const detailInjector = fixture.componentInstance.detailInjector(); + + pendingPlaylist.next(detailPlaylist); + flushMicrotasks(); + + expect(playlistId()).toBe('xtream-2'); + expect(currentPlaylist()?.id).toBe('xtream-2'); + expect(selectedCategoryId()).toBe(6); + expect(fixture.componentInstance.detailInjector()).toBe(detailInjector); + expect(detailInjector?.get(ActivatedRoute).snapshot.params).toEqual({ + categoryId: '6', + serialId: '203', + }); + })); + it('invalidates detail loading before restoring the underlying store', () => { fixture.componentInstance.ngOnDestroy(); diff --git a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts index 222935734..9e552f9ca 100644 --- a/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts +++ b/libs/portal/xtream/feature/src/lib/xtream-recent-history.ts @@ -9,12 +9,12 @@ import { export interface XtreamRecentItemRequest { readonly xtreamId: number | string; - readonly contentType: 'movie' | 'series'; + readonly contentType: 'live' | 'movie' | 'series'; readonly backdropUrl?: string; } /** - * Records a movie or series as recently viewed once `streamUrl` has really + * Records Xtream content as recently viewed once `streamUrl` has really * played — inline for a couple of seconds, or launched in MPV/VLC — so a * source that fails straight away never reaches history or the dashboard * hero. The playlist is captured when playback starts, so navigating to @@ -29,15 +29,16 @@ export interface XtreamRecentItemRequest { */ export function injectXtreamRecentHistory(): ( streamUrl: string, - request: XtreamRecentItemRequest + request: XtreamRecentItemRequest, + sessionKey?: string ) => void { const gate = inject(PlaybackHistoryGate); const store = inject(XtreamStore); const injector = inject(Injector); - return (streamUrl, request) => { + return (streamUrl, request, sessionKey) => { const playlist = signal(store.currentPlaylist()).asReadonly(); - gate.defer({ streamUrls: [streamUrl] }, () => { + gate.defer({ sessionKey, streamUrls: [streamUrl] }, () => { const playlistId = playlist()?.id; if (store.currentPlaylist()?.id === playlistId) { store.addRecentItem({ ...request, playlist }); @@ -80,6 +81,10 @@ async function saveWithoutListRefresh( ); const contentId = content?.id ?? (keysByXtreamId ? id : null); if (contentId != null) { - await dataSource.addRecentItem(contentId, playlistId, backdropUrl); + await dataSource.addRecentItem( + { id: contentId, type: contentType }, + playlistId, + backdropUrl + ); } } 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 eef39b6e8..3ff8cc9b4 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 @@ -880,7 +880,7 @@ describe('DashboardDataService', () => { await service.removeGlobalFavorite(item); expect(xtreamDataSourceMock.removeFavorite).toHaveBeenCalledWith( - 51, + { id: 51, type: 'movie' }, 'xtream-1' ); expect(dbServiceMock.removeFromFavorites).not.toHaveBeenCalled(); @@ -2784,7 +2784,7 @@ describe('DashboardDataService', () => { await service.removeGlobalRecentItem(item); expect(xtreamDataSourceMock.removeRecentItem).toHaveBeenCalledWith( - 91, + { id: 91, type: 'movie' }, 'xtream-1' ); expect(dbServiceMock.removeRecentItem).not.toHaveBeenCalled(); 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 4b0c5784a..3be7f5b81 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 @@ -1046,7 +1046,10 @@ export class DashboardDataService { ); } else { await this.xtreamDataSource.removeRecentItem( - item.id as number, + { + id: Number(item.id), + type: item.type as 'live' | 'movie' | 'series', + }, item.playlist_id ); } @@ -1121,7 +1124,10 @@ export class DashboardDataService { ); } else { await this.xtreamDataSource.removeFavorite( - item.id as number, + { + id: Number(item.id), + type: item.type as 'live' | 'movie' | 'series', + }, item.playlist_id ); }