From fcbf57db3efc17575ce1634f9d1378e05c36df48 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 01:12:34 +0200 Subject: [PATCH] fix(xtream): retain favorites through storage and metadata updates --- .../src/favorites-conflict.e2e.ts | 109 ++++++++++++++++++ .../operations/content-metadata.operations.ts | 13 +-- .../database/operations/favorite-catalog.ts | 23 ++-- .../operations/testing/favorites-snapshot.ts | 40 ++++++- .../xtream-portal-compatibility.md | 10 +- .../src/lib/database-electron.service.spec.ts | 87 ++++++++++++++ .../src/lib/xtream-pending-favorites.ts | 50 ++++---- 7 files changed, 284 insertions(+), 48 deletions(-) diff --git a/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts b/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts index ed2ec4808..4677163fc 100644 --- a/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts @@ -9,6 +9,7 @@ import { openSources, openWorkspaceSection, resetMockServers, + restartElectronApp, sourceRowByTitle, test, waitForXtreamWorkspaceReady, @@ -148,3 +149,111 @@ test('@critical @persistence @xtream @electron rejects a reused favorite identit if (app) await closeElectronApp(app); } }); + +test('@persistence @xtream @electron retains a late-confirmed snapshot through catalog loss and unavailable legacy storage', async ({ + dataDir, + request, +}) => { + await resetMockServers(request, ['xtream']); + const credentials = { username: 'minimal', password: 'minimal' }; + const fixture = await fetchXtreamVodFixture(request, credentials); + const item = fixture.items[0]; + const title = getXtreamTitle(item); + const oldTitle = 'Snapshot before identity confirmation'; + let app: LaunchedElectronApp | undefined; + try { + app = await launchElectronApp(dataDir); + await addXtreamPortal(app.mainWindow, { + ...credentials, + name: 'Late confirmed favorite', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + const playlistId = /\/xtreams\/([^/]+)/.exec( + new URL(app.mainWindow.url()).pathname + )?.[1]; + if (!playlistId) throw new Error('Expected a playlist route'); + await app.mainWindow + .getByRole('link', { name: 'Movies', exact: true }) + .click(); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + const returned = await app.mainWindow.evaluate( + async ({ playlistId, xtreamId, oldTitle }) => { + const row = await window.electron.dbGetContentByXtreamId( + xtreamId, + playlistId, + 'movie' + ); + if (!row) throw new Error('Expected imported content'); + const identity = { tmdbId: 99999999, releaseYear: 2000 }; + const restored = await window.electron.dbRestoreXtreamFavorites( + playlistId, + [ + { + xtreamId, + contentType: 'movie', + snapshot: { + title: oldTitle, + posterUrl: 'https://art.invalid/old.jpg', + ...identity, + }, + }, + ] + ); + if (!restored.success) + throw new Error('Could not seed favorite'); + await window.electron.dbSetAppState( + `xtream-import-status:${playlistId}:movie`, + 'completed' + ); + const [unconfirmed] = + await window.electron.dbGetFavoritesForBackup(playlistId); + if (unconfirmed.title !== oldTitle) + throw new Error('Unconfirmed snapshot overwritten'); + const enriched = + await window.electron.dbSetContentMetadataIfMissing( + row.id, + identity + ); + if (!enriched.success) + throw new Error('Could not confirm identity'); + // No second import completion is allowed to repair the snapshot. + await window.electron.dbDeleteXtreamContent(playlistId); + return { + poster: row.poster_url, + backdrop: row.backdrop_url ?? null, + }; + }, + { playlistId, xtreamId: Number(item.stream_id), oldTitle } + ); + const [saved] = await readSavedFavorites(app.mainWindow, playlistId); + expect(saved).toMatchObject({ + id: null, + title, + poster_url: returned.poster, + backdrop_url: returned.backdrop, + }); + await app.mainWindow.evaluate((playlistId) => { + const key = `xtream-restore-${playlistId}`; + localStorage.setItem(key, JSON.stringify({ favorites: [] })); + const getItem = Storage.prototype.getItem; + Storage.prototype.getItem = function (requestedKey) { + if (requestedKey === key) + throw new Error('Storage unavailable'); + return getItem.call(this, requestedKey); + }; + }, playlistId); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + await expect(contentCardByTitle(app.mainWindow, title)).toBeVisible(); + await expect(contentCardByTitle(app.mainWindow, oldTitle)).toHaveCount( + 0 + ); + app = await restartElectronApp(app, dataDir); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + await expect(contentCardByTitle(app.mainWindow, title)).toBeVisible(); + expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual([ + saved, + ]); + } finally { + if (app) await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts b/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts index 76c34c30b..4a5c46198 100644 --- a/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts @@ -7,6 +7,7 @@ import { } from '@iptvnator/shared/interfaces'; import { favoriteMatchesContentId, + favoriteSnapshotFromContent, favoriteSnapshotMatchesContent, } from './favorite-catalog'; import type { AppDatabase } from '../database.types'; @@ -110,17 +111,11 @@ export async function persistContentMetadataIfMissing( .where(and(eq(schema.content.id, contentId), anyAbsent)); // Favorites own their metadata beyond the lifetime of this catalog row. - // Capture enrichment when learned, before a later refresh can delete it. + // Once enrichment confirms identity, capture the complete returned snapshot, + // including title/artwork that arrived before the confirming metadata. await db .update(schema.favorites) - .set( - Object.fromEntries( - columns.map(({ key, column }) => [ - key, - sql`COALESCE(NULLIF(${schema.favorites[key]}, ''), (SELECT ${column} FROM content WHERE id = ${contentId}))`, - ]) - ) - ) + .set(favoriteSnapshotFromContent(contentId)) .where( and( favoriteMatchesContentId(contentId), diff --git a/apps/electron-backend/src/app/database/operations/favorite-catalog.ts b/apps/electron-backend/src/app/database/operations/favorite-catalog.ts index 307fa8af0..7a10e6e5c 100644 --- a/apps/electron-backend/src/app/database/operations/favorite-catalog.ts +++ b/apps/electron-backend/src/app/database/operations/favorite-catalog.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from 'drizzle-orm'; +import { and, eq, sql, type SQL } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import type { AppDatabase } from '../database.types'; import { isParentalLockActive } from '../parental-lock-state'; @@ -25,6 +25,18 @@ export function favoriteSnapshotMatchesContent() { AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = ${schema.content.releaseYear})`; } +/** Use only after confirming identity; empty catalog fields retain the saved value. */ +export function favoriteSnapshotFromContent(contentId: number | SQL) { + return { + title: sql`COALESCE((SELECT NULLIF(TRIM(title), '') FROM content WHERE id = ${contentId}), favorites.title)`, + posterUrl: sql`COALESCE((SELECT NULLIF(TRIM(poster_url), '') FROM content WHERE id = ${contentId}), favorites.poster_url)`, + backdropUrl: sql`COALESCE((SELECT NULLIF(TRIM(backdrop_url), '') FROM content WHERE id = ${contentId}), favorites.backdrop_url)`, + tmdbId: sql`COALESCE((SELECT tmdb_id FROM content WHERE id = ${contentId}), favorites.tmdb_id)`, + releaseYear: sql`COALESCE((SELECT release_year FROM content WHERE id = ${contentId}), favorites.release_year)`, + originalTitle: sql`COALESCE((SELECT NULLIF(TRIM(original_title), '') FROM content WHERE id = ${contentId}), favorites.original_title)`, + }; +} + export function favoriteCatalogStatus() { return sql<'present' | 'missing' | 'unknown'>`CASE WHEN ${schema.appState.value} IN ('importing', 'failed', 'cancelled') THEN 'unknown' @@ -90,14 +102,7 @@ export async function reconcileCompletedFavoriteCatalog( ) .run(); tx.update(schema.favorites) - .set({ - title: sql`COALESCE((SELECT NULLIF(TRIM(title), '') FROM content WHERE id = favorites.content_id), favorites.title)`, - posterUrl: sql`COALESCE((SELECT NULLIF(TRIM(poster_url), '') FROM content WHERE id = favorites.content_id), favorites.poster_url)`, - backdropUrl: sql`COALESCE((SELECT NULLIF(TRIM(backdrop_url), '') FROM content WHERE id = favorites.content_id), favorites.backdrop_url)`, - tmdbId: sql`COALESCE((SELECT tmdb_id FROM content WHERE id = favorites.content_id), favorites.tmdb_id)`, - releaseYear: sql`COALESCE((SELECT release_year FROM content WHERE id = favorites.content_id), favorites.release_year)`, - originalTitle: sql`COALESCE((SELECT NULLIF(TRIM(original_title), '') FROM content WHERE id = favorites.content_id), favorites.original_title)`, - }) + .set(favoriteSnapshotFromContent(sql`favorites.content_id`)) .where( and( sql`EXISTS (SELECT 1 FROM content WHERE id = favorites.content_id AND ${favoriteSnapshotMatchesContent()})`, diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts index 5bac10c7b..66e594ef5 100644 --- a/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts @@ -54,7 +54,12 @@ export async function verifyFavoriteSnapshot(db: AppDatabase) { assert.equal((await getFavorites(db, 'p'))[0].catalog_status, 'missing'); assert.equal((await getFavorites(db, 'p'))[0].title, original.title); await deleteXtreamContent(db, 'p'); - await catalog(3, 7, 'ABC renamed', 'movie', 'p', { tmdbId: 10 }); + await catalog(3, 7, 'ABC renamed', 'movie', 'p', { + tmdbId: 10, + posterUrl: 'https://art.invalid/returned.jpg', + backdropUrl: 'https://art.invalid/returned-wide.jpg', + originalTitle: 'ABC original renamed', + }); await setAppState(db, 'xtream-import-status:p:movie', 'completed'); assert.deepEqual( snapshot((await db.select().from(schema.favorites))[0]), @@ -62,11 +67,34 @@ export async function verifyFavoriteSnapshot(db: AppDatabase) { 'partial identity is insufficient to replace the saved fields' ); await persistContentMetadataIfMissing(db, 3, { releaseYear: 2000 }); - await setAppState(db, 'xtream-import-status:p:movie', 'completed'); - assert.equal( - (await db.select().from(schema.favorites))[0].title, - 'ABC renamed', - 'confirmed compatible identity may refresh the snapshot' + const confirmed = { + ...snapshot(original), + title: 'ABC renamed', + poster: 'https://art.invalid/returned.jpg', + backdrop: 'https://art.invalid/returned-wide.jpg', + original: 'ABC original renamed', + }; + assert.deepEqual( + snapshot((await db.select().from(schema.favorites))[0]), + confirmed, + 'detail enrichment must refresh the snapshot as soon as identity is confirmed' + ); + await deleteXtreamContent(db, 'p'); + const [missing] = await getFavoritesForBackup(db, 'p'); + assert.equal(missing.title, confirmed.title); + assert.equal(missing.poster_url, confirmed.poster); + assert.equal(missing.backdrop_url, confirmed.backdrop); + await catalog(3, 7, ' ', 'movie', 'p', { + tmdbId: 10, + posterUrl: ' ', + backdropUrl: '', + originalTitle: '', + }); + await persistContentMetadataIfMissing(db, 3, { releaseYear: 2000 }); + assert.deepEqual( + snapshot((await db.select().from(schema.favorites))[0]), + confirmed, + 'blank fields on a confirmed return must preserve the saved snapshot' ); // A replacement's year/artwork must not fill gaps before its TMDB identity is known. await removeFavorite(db, 3, 'p'); diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 88cfb2d0c..2146743bf 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -54,8 +54,9 @@ imports reconnect valid identities. Display snapshots are replaced only after the returning row confirms every saved TMDB ID/year; incomplete identity keeps the saved title/artwork in collections and backups. Restore applies the same confirmation guard before filling gaps in saved metadata from catalog rows. -Metadata learned in detail -views is copied into the saved snapshot before catalog loss. +When detail enrichment confirms the remaining identity fields, it immediately +copies the returning title, artwork and metadata into the saved snapshot without +waiting for another import. Blank catalog fields retain their saved values. Conflicting known TMDB IDs or release years prevent automatic reconnection; changed provider IDs never reconnect by title. Adding a conflicting replacement returns an explicit failure and retains the @@ -80,8 +81,9 @@ unfiltered. Legacy pending favorites are converted before destructive refresh, import requests and ordinary collection reads, even if portal-status preflight is offline. Ordinary collection reads isolate failed conversions per playlist and -still read healthy database favorites, retaining pending state for retry. Backup -reads remain strict and fail if conversion fails. Backup merges convert any +still read healthy database favorites, retaining pending state for retry. This +also applies when localStorage access or key enumeration fails. Backup reads +remain strict and fail if storage access or conversion fails. Backup merges convert any older pending favorites before parking the incoming archive. Favorite inserts and a replay receipt commit together; retries cannot resurrect a subsequently removed favorite. Deliberate backup import creates a fresh receipt ID, which stays internal and is never exported. Identity-only old diff --git a/libs/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts index e04fcf02d..87dcc875b 100644 --- a/libs/services/src/lib/database-electron.service.spec.ts +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -1,4 +1,5 @@ import { DatabaseService } from './database-electron.service'; +import { materializePendingXtreamFavorites } from './xtream-pending-favorites'; describe('DatabaseService browser guards', () => { const originalElectron = window.electron; @@ -20,6 +21,7 @@ describe('DatabaseService browser guards', () => { }); afterEach(() => { + jest.restoreAllMocks(); localStorage.clear(); consoleErrorSpy.mockRestore(); Object.defineProperty(window, 'electron', { @@ -144,6 +146,91 @@ describe('DatabaseService browser guards', () => { } ); + it.each(['storage', 'length', 'key', 'getItem'] as const)( + 'reads SQLite despite a failing localStorage %s, but rejects strict conversion', + async (failure) => { + localStorage.setItem( + 'xtream-restore-broken', + JSON.stringify({ + favorites: [{ contentType: 'movie', xtreamId: 7 }], + }) + ); + localStorage.setItem( + 'xtream-restore-healthy', + JSON.stringify({ + favorites: [{ contentType: 'movie', xtreamId: 9 }], + }) + ); + const rows = [{ favorite_id: 1, title: 'Saved favorite' }]; + const read = jest.fn().mockResolvedValue(rows); + const bridge = { + dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }), + dbRestoreXtreamFavorites: jest + .fn() + .mockResolvedValue({ success: true }), + dbGetFavorites: read, + dbGetGlobalFavorites: read, + dbGetAllGlobalFavorites: read, + dbGetFavoritesForBackup: jest.fn(), + }; + Object.defineProperty(window, 'electron', { + configurable: true, + value: bridge, + }); + const unavailable = () => { + throw new Error('Storage unavailable'); + }; + if (failure === 'storage') { + jest.spyOn(window, 'localStorage', 'get').mockImplementation( + unavailable + ); + } else if (failure === 'length') { + jest.spyOn( + Storage.prototype, + 'length', + 'get' + ).mockImplementation(unavailable); + } else if (failure === 'key') { + jest.spyOn(Storage.prototype, 'key').mockImplementation( + unavailable + ); + } else { + const getItem = Storage.prototype.getItem; + jest.spyOn(Storage.prototype, 'getItem').mockImplementation( + function (key) { + if (key === 'xtream-restore-broken') + return unavailable(); + return getItem.call(this, key); + } + ); + } + for (const method of [ + 'getGlobalFavorites', + 'getAllGlobalFavorites', + 'getFavorites', + ] as const) { + await expect(service[method]('broken')).resolves.toEqual(rows); + } + expect(read).toHaveBeenCalledTimes(3); + if (failure === 'getItem') { + expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledWith( + 'healthy', + [{ contentType: 'movie', xtreamId: 9 }], + undefined + ); + } + await expect( + materializePendingXtreamFavorites(bridge) + ).rejects.toThrow('Storage unavailable'); + if (failure === 'storage' || failure === 'getItem') { + await expect( + service.getFavoritesOrThrow('broken') + ).rejects.toThrow('Storage unavailable'); + expect(bridge.dbGetFavoritesForBackup).not.toHaveBeenCalled(); + } + } + ); + it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => { localStorage.setItem( 'xtream-restore-orphan', diff --git a/libs/services/src/lib/xtream-pending-favorites.ts b/libs/services/src/lib/xtream-pending-favorites.ts index 2418c0a86..89ec7dc73 100644 --- a/libs/services/src/lib/xtream-pending-favorites.ts +++ b/libs/services/src/lib/xtream-pending-favorites.ts @@ -14,29 +14,39 @@ export async function materializePendingXtreamFavorites( failureMode: 'throw' | 'continue' = 'throw' ): Promise { const prefix = getXtreamPendingRestoreStorageKey(''); - const ids = playlistId - ? [playlistId] - : Array.from({ length: localStorage.length }, (_, index) => - localStorage.key(index) - ) - .filter( - (key): key is string => key !== null && key.startsWith(prefix) + let ids: string[]; + try { + ids = playlistId + ? [playlistId] + : Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index) ) - .map((key) => key.slice(prefix.length)); + .filter( + (key): key is string => + key !== null && key.startsWith(prefix) + ) + .map((key) => key.slice(prefix.length)); + } catch (error) { + if (failureMode === 'throw') throw error; + console.warn('Pending favorites storage could not be read.'); + return; + } for (const id of ids) { - const raw = localStorage.getItem(getXtreamPendingRestoreStorageKey(id)); - if (!raw) continue; - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - continue; - } - const state = normalizeXtreamPendingRestoreState(parsed); - if (!state.favorites.length) continue; - // Old localStorage can outlive deletion of its owning SQLite playlist. - // Never recreate a playlist or let an orphan block other saved entries. try { + const raw = localStorage.getItem( + getXtreamPendingRestoreStorageKey(id) + ); + if (!raw) continue; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + continue; + } + const state = normalizeXtreamPendingRestoreState(parsed); + if (!state.favorites.length) continue; + // Old localStorage can outlive deletion of its owning SQLite playlist. + // Never recreate a playlist or let an orphan block other saved entries. const playlist = await bridge.dbGetPlaylist(id); if (!playlist || playlist.type !== 'xtream') continue; const result = await bridge.dbRestoreXtreamFavorites(