From 1ed192833f6c7f82a0ec7877bb6f89219d3ca06f Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 01:49:40 +0200 Subject: [PATCH] fix(xtream): preserve backup metadata and recent favorite identity --- .../src/favorites-conflict.e2e.ts | 53 ++++++++++++- .../src/support/xtream-favorites-catalog.ts | 7 +- .../xtream-portal-compatibility.md | 7 ++ .../unified-collection-data.service.ts | 20 ++++- .../unified-favorites-data.service.spec.ts | 59 ++++++++++++++- .../unified-favorites-data.service.ts | 14 ++++ .../unified-collection-page.component.spec.ts | 1 + ...playlist-backup.service.pwa-xtream.spec.ts | 73 +++++++++++++++++- .../lib/pwa-xtream-backup-state.service.ts | 74 +++++++++++++++++-- 9 files changed, 295 insertions(+), 13 deletions(-) diff --git a/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts b/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts index 4677163fc..06ad0193e 100644 --- a/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts @@ -1,3 +1,4 @@ +import { createServer, get as httpGet } from 'node:http'; import { addXtreamPortal, clickCategoryByNameExact, @@ -16,7 +17,57 @@ import { type LaunchedElectronApp, } from './electron-test-fixtures'; import { fetchXtreamVodFixture, getXtreamTitle } from './portal-mock-fixtures'; -import { readSavedFavorites } from './support/xtream-favorites-catalog'; +import { + createFavoritesCatalogProxy, + readSavedFavorites, +} from './support/xtream-favorites-catalog'; + +test('@xtream catalog proxy keeps absolute request targets on the mock upstream', async () => { + let redirectedRequests = 0; + const trap = createServer((_request, response) => { + redirectedRequests++; + response.end('{"unexpected":"upstream"}'); + }); + await new Promise((resolve) => trap.listen(0, '127.0.0.1', resolve)); + const address = trap.address(); + if (!address || typeof address === 'string') + throw new Error('Expected TCP address'); + const proxy = await createFavoritesCatalogProxy(); + try { + const target = new URL(proxy.origin); + const response = await new Promise<{ status: number; body: string }>( + (resolve, reject) => { + const request = httpGet( + { + hostname: target.hostname, + port: target.port, + path: `http://127.0.0.1:${address.port}/player_api.php?username=minimal&password=minimal`, + }, + (incoming) => { + let body = ''; + incoming.setEncoding('utf8'); + incoming.on('data', (chunk) => { + body += chunk; + }); + incoming.on('end', () => + resolve({ status: incoming.statusCode ?? 0, body }) + ); + incoming.on('error', reject); + } + ); + request.on('error', reject); + } + ); + expect(response.status).toBe(200); + expect(redirectedRequests).toBe(0); + expect(JSON.parse(response.body)).toHaveProperty('user_info'); + } finally { + await proxy.close(); + await new Promise((resolve, reject) => + trap.close((error) => (error ? reject(error) : resolve())) + ); + } +}); test('@critical @persistence @xtream @electron rejects a reused favorite identity until the older saved entry is explicitly removed', async ({ dataDir, diff --git a/apps/electron-backend-e2e/src/support/xtream-favorites-catalog.ts b/apps/electron-backend-e2e/src/support/xtream-favorites-catalog.ts index ce7501373..3219e37bc 100644 --- a/apps/electron-backend-e2e/src/support/xtream-favorites-catalog.ts +++ b/apps/electron-backend-e2e/src/support/xtream-favorites-catalog.ts @@ -27,7 +27,12 @@ export async function createFavoritesCatalogProxy(): Promise(); const pending = new Set(); const server = createServer((request, response) => { - const url = new URL(request.url ?? '/', xtreamMockServer); + const incoming = new URL(request.url ?? '/', xtreamMockServer); + // Only path/query may vary: an absolute request target must never select + // a different upstream host for this local test proxy. + const url = new URL(xtreamMockServer); + url.pathname = incoming.pathname; + url.search = incoming.search; const action = url.searchParams.get('action') ?? 'get_account_info'; const requestMode = mode; response.once('finish', () => { diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 4d1bdbfb8..322ad7d23 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -63,6 +63,9 @@ Adding a conflicting replacement returns an explicit failure and retains the older favorite. Detail and recent-collection toggles stay inactive and explain that the older entry must be removed from Favorites before the replacement can be saved. Collection hearts change only after the storage write succeeds. +Recent collections also check the current catalog row's favorite status through +the data source when loading: a retained favorite's matching provider key alone +cannot mark an incompatible replacement as saved. `catalog_status` is `present`, `missing`, or `unknown`. Only a completed import of the relevant type can prove absence. Failed, cancelled and in-progress @@ -94,6 +97,10 @@ unfiltered read; a failed favorites read fails export rather than producing an empty collection. New backup imports materialize favorites before waiting for an online catalog. Existing history, hidden-category and playback-position restore contracts remain separate. +Browser imports convert desktop favorite snapshots into typed collection-cache +rows so saved titles and artwork remain visible offline. Browser exports include +the normalized nested favorite snapshots as well as their optional collection +cache, preserving metadata when the archive returns to Electron. Both Xtream data-source and portal-activity capabilities require the pending favorite conversion bridge; activity reads also require playlist lookup. The diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts index 1b03ae617..c6ec980de 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts @@ -89,7 +89,7 @@ export class UnifiedCollectionDataService { const favoriteUids = params.mode === 'favorites' ? new Set(items.map((item) => item.uid)) - : await this.loadFavoriteUidSet(params); + : await this.loadFavoriteUidSet(params, items); if (requestId !== this.requestId) { return null; } @@ -188,7 +188,8 @@ export class UnifiedCollectionDataService { } private async loadFavoriteUidSet( - params: CollectionLoadRequest + params: CollectionLoadRequest, + recentItems: UnifiedCollectionItem[] ): Promise> { try { const favorites = await this.favoritesData.getFavorites( @@ -196,7 +197,20 @@ export class UnifiedCollectionDataService { params.playlistId, params.portalType ); - return new Set(favorites.map((item) => item.uid)); + const uids = new Set(favorites.map((item) => item.uid)); + await Promise.all( + recentItems.map(async (item) => { + if (item.sourceType !== 'xtream' || !uids.has(item.uid)) + return; + // Favorites can retain an older, incompatible movie with this UID. + // Let the owning data source check this exact recent catalog row. + const matches = await this.favoritesData + .isXtreamFavorite(item) + .catch(() => false); + if (!matches) uids.delete(item.uid); + }) + ); + return uids; } catch { return new Set(); } 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 9cf17123d..0e575dc36 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 @@ -36,11 +36,13 @@ describe('UnifiedFavoritesDataService', () => { dbRemoveRecentItemsBatch: jest.Mock; }; let databaseService: { + isFavorite: jest.Mock; getAllGlobalFavorites: jest.Mock; getContentByXtreamId: jest.Mock; getFavorites: jest.Mock; }; let xtreamDataSource: { + isFavorite: jest.Mock; addFavorite: jest.Mock; getContentByXtreamId: jest.Mock; getFavorites: jest.Mock; @@ -166,11 +168,13 @@ describe('UnifiedFavoritesDataService', () => { ), }; databaseService = { + isFavorite: jest.fn().mockResolvedValue(true), getAllGlobalFavorites: jest.fn().mockResolvedValue([]), getContentByXtreamId: jest.fn().mockResolvedValue(null), getFavorites: jest.fn().mockResolvedValue([]), }; xtreamDataSource = { + isFavorite: jest.fn().mockResolvedValue(true), addFavorite: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn().mockResolvedValue(null), getFavorites: jest.fn().mockResolvedValue([]), @@ -205,7 +209,10 @@ describe('UnifiedFavoritesDataService', () => { providers: [ UnifiedFavoritesDataService, UnifiedCollectionDataService, - { provide: UnifiedRecentDataService, useValue: {} }, + { + provide: UnifiedRecentDataService, + useValue: { getRecentItems: jest.fn() }, + }, { provide: Store, useValue: store, @@ -710,6 +717,56 @@ describe('UnifiedFavoritesDataService', () => { expect(collection.favoriteUidSet().has(item.uid)).toBe(true); }); + it.each([undefined, 99])( + 'checks the recent row identity against a saved favorite resolved to %s before setting its heart', + async (savedContentId) => { + const item: UnifiedCollectionItem = { + uid: 'xtream::xtream-1::movie:101', + name: 'Replacement movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 101, + contentId: 42, + }; + jest.spyOn(service, 'getFavorites').mockResolvedValue([ + { + ...item, + name: 'Older favorite', + contentId: savedContentId, + favoriteId: 5, + }, + ]); + jest.spyOn( + TestBed.inject(UnifiedRecentDataService), + 'getRecentItems' + ).mockResolvedValue([item]); + databaseService.isFavorite.mockResolvedValue(false); + electronApi.dbAddFavorite.mockResolvedValue({ + success: false, + error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT', + }); + const collection = TestBed.inject(UnifiedCollectionDataService); + const request = { mode: 'recent', scope: 'all' } as const; + await collection.load(request); + expect(collection.favoriteUidSet().has(item.uid)).toBe(false); + expect(databaseService.isFavorite).toHaveBeenCalledWith( + 42, + 'xtream-1' + ); + await expect(collection.toggleFavorite(item)).rejects.toThrow( + 'XTREAM_FAVORITE_IDENTITY_CONFLICT' + ); + expect(electronApi.dbRemoveFavorite).not.toHaveBeenCalled(); + await collection.load(request); + expect(collection.favoriteUidSet().has(item.uid)).toBe(false); + databaseService.isFavorite.mockResolvedValue(true); + await collection.load(request); + expect(collection.favoriteUidSet().has(item.uid)).toBe(true); + } + ); + it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => { Object.defineProperty(window, 'electron', { value: undefined, 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 426e53751..8a764602a 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 @@ -80,6 +80,20 @@ export class UnifiedFavoritesDataService { } } + /** A reused provider ID alone cannot determine a recent row's favorite status. */ + async isXtreamFavorite(item: UnifiedCollectionItem): Promise { + const contentId = + item.contentId ?? (await this.resolveXtreamContentId(item)); + if (contentId == null) return false; + if (this.electronActivityBridge) { + return this.dbService.isFavorite(contentId, item.playlistId); + } + return this.xtreamDataSource.isFavorite( + { id: contentId, type: item.contentType }, + item.playlistId + ); + } + async removeFavorite(item: UnifiedCollectionItem): Promise { switch (item.sourceType) { case 'm3u': { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index c4ffcb9e4..6d4e534ae 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -145,6 +145,7 @@ describe('UnifiedCollectionPageComponent', () => { const playlistsLoaded = signal(false); const playlists = signal([]); const favoritesData = { + isXtreamFavorite: jest.fn().mockResolvedValue(true), addFavorite: jest.fn().mockResolvedValue(undefined), getFavorites: jest.fn().mockResolvedValue([]), clearFavorites: jest.fn().mockResolvedValue(undefined), diff --git a/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts index 7a1599452..1f7e30986 100644 --- a/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.pwa-xtream.spec.ts @@ -159,8 +159,20 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => { expect(xtreamEntry(manifest).userState).toEqual({ hiddenCategories: [], favorites: [ - { contentType: 'movie', xtreamId: 501 }, - { contentType: 'series', xtreamId: 77 }, + { + contentType: 'movie', + xtreamId: 501, + snapshot: { + title: heat.title, + posterUrl: heat.poster_url, + backdropUrl: heat.backdrop_url, + }, + }, + { + contentType: 'series', + xtreamId: 77, + snapshot: { title: legacyShow.title }, + }, ], // One row per title, its newest view. recentlyViewed: [ @@ -272,6 +284,60 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => { }); describe('import', () => { + it('preserves desktop favorite snapshots offline and includes them in browser re-exports', async () => { + const manifest = await exportSeeded(); + const entry = xtreamEntry(manifest); + const favorites = [ + { + contentType: 'movie' as const, + xtreamId: 501, + addedAt: '2026-01-02', + position: 3, + snapshot: { + title: 'Offline desktop movie', + posterUrl: 'https://img.example.com/saved.jpg', + backdropUrl: 'https://img.example.com/saved-wide.jpg', + tmdbId: 949, + releaseYear: 1995, + originalTitle: 'Original movie title', + }, + }, + ]; + entry.userState = { + hiddenCategories: [], + favorites, + recentlyViewed: [], + playbackPositions: [], + }; + localStorage.clear(); + const service = createPlaylistBackupService({ + playlistsService: playlistsService([existingXtreamPlaylist]), + }); + expect( + await service.importBackup(xtreamOnly(manifest)) + ).toMatchObject({ merged: 1, failed: 0 }); + expect( + read(KEYS.collectionItems)?.['xtream-1']?.['movie:501'] + ).toMatchObject({ + id: 501, + xtream_id: 501, + type: 'movie', + title: favorites[0].snapshot.title, + poster_url: favorites[0].snapshot.posterUrl, + backdrop_url: favorites[0].snapshot.backdropUrl, + tmdb_id: 949, + release_year: 1995, + original_title: 'Original movie title', + }); + const { manifest: reexport } = await service.exportBackup(); + expect(xtreamEntry(reexport).userState.favorites).toEqual( + favorites + ); + expect( + xtreamEntry(reexport).userState.collectionItems + ).toHaveLength(1); + }); + it('restores every list under the id the playlist is imported as', async () => { const manifest = await exportSeeded(); localStorage.clear(); @@ -361,6 +427,9 @@ describe('PlaylistBackupService Xtream user state in the PWA', () => { it('leaves snapshots alone for an archive without any, and skips damaged ones', async () => { const manifest = await exportSeeded(); const entry = xtreamEntry(manifest); + entry.userState.favorites = entry.userState.favorites.map( + ({ contentType, xtreamId }) => ({ contentType, xtreamId }) + ); const before = read(KEYS.collectionItems); const service = createPlaylistBackupService({ playlistsService: playlistsService([existingXtreamPlaylist]), diff --git a/libs/services/src/lib/pwa-xtream-backup-state.service.ts b/libs/services/src/lib/pwa-xtream-backup-state.service.ts index 36287dbbd..f8dbf7163 100644 --- a/libs/services/src/lib/pwa-xtream-backup-state.service.ts +++ b/libs/services/src/lib/pwa-xtream-backup-state.service.ts @@ -1,6 +1,7 @@ import { inject, Injectable } from '@angular/core'; import { PlaybackPositionData, + normalizeXtreamPendingRestoreState, XtreamBackupCollectionItem, XtreamBackupContentType, XtreamBackupFavoriteItem, @@ -42,11 +43,16 @@ export class PwaXtreamBackupStateService { const playlistId = entry.exportedId; const stored = this.readMap('collectionItems')[playlistId]; const snapshots = isRecord(stored) ? stored : {}; + const byKey = snapshotsByKey(Object.values(snapshots)); const favorites = new Map(); for (const key of this.listFor('favorites', playlistId)) { const ref = resolveKey(key, snapshots); - if (ref) favorites.set(ref.key, ref.item); + if (ref) + favorites.set( + ref.key, + favoriteWithSnapshot(ref, byKey[ref.key]) + ); } // Newest view wins, artwork included, as in the data source. @@ -66,7 +72,6 @@ export class PwaXtreamBackupStateService { } } - const byKey = snapshotsByKey(Object.values(snapshots)); const collectionItems = [ ...new Set([...favorites.keys(), ...recent.keys()]), ] @@ -94,9 +99,10 @@ export class PwaXtreamBackupStateService { * * Lists first, so the optional cache never takes space they need. A * failed write throws with earlier ones applied, and the caller keeps the - * pending snapshot for the Xtream store to replay on the next open. The - * replay carries no titles (they come from `entry`, not `state`), so they - * are still merged then, unless storage is full and they would crowd it. + * pending snapshot for the Xtream store to replay on the next open. + * Browser-only cache rows come from `entry`; desktop favorite snapshots + * also travel in `state`. Merge both unless a full store would crowd out + * the lists that still need to be restored. */ async restore( playlistId: string, @@ -108,6 +114,39 @@ export class PwaXtreamBackupStateService { const snapshots = snapshotsByKey( Array.isArray(archived) ? archived : [] ); + for (const favorite of state.favorites) { + const saved = favorite.snapshot; + const key = typedKey(favorite.contentType, favorite.xtreamId); + if (!saved || key === null) continue; + snapshots[key] = { + ...snapshots[key], + id: favorite.xtreamId, + xtream_id: favorite.xtreamId, + type: favorite.contentType, + title: saved.title, + ...(saved.posterUrl !== undefined + ? { poster_url: saved.posterUrl } + : {}), + ...(saved.backdropUrl !== undefined + ? { backdrop_url: saved.backdropUrl } + : {}), + ...(saved.tmdbId !== undefined + ? { tmdb_id: saved.tmdbId } + : {}), + ...(saved.releaseYear !== undefined + ? { release_year: saved.releaseYear } + : {}), + ...(saved.originalTitle !== undefined + ? { original_title: saved.originalTitle } + : {}), + ...(favorite.addedAt !== undefined + ? { added_at: favorite.addedAt } + : {}), + ...(favorite.position !== undefined + ? { position: favorite.position } + : {}), + }; + } try { this.replaceLists(playlistId, state); } catch (error) { @@ -222,6 +261,31 @@ export class PwaXtreamBackupStateService { } } +/** Share desktop's allowlisted snapshot fields while retaining browser cache rows. */ +function favoriteWithSnapshot( + ref: ResolvedKey, + saved?: XtreamBackupCollectionItem +): XtreamBackupFavoriteItem { + if (!saved) return ref.item; + return normalizeXtreamPendingRestoreState({ + favorites: [ + { + ...ref.item, + addedAt: saved['added_at'], + position: saved['position'], + snapshot: { + title: saved['title'], + posterUrl: saved['poster_url'], + backdropUrl: saved['backdrop_url'], + tmdbId: saved['tmdb_id'], + releaseYear: saved['release_year'], + originalTitle: saved['original_title'], + }, + }, + ], + }).favorites[0]; +} + /** * A typed key carries its own identity. A bare provider id resolves only * through the snapshot stored under it, as the data source does without a