From 2a8009fe6cdd7094ee48527375309cc566ec53f5 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 14 Oct 2026 00:12:38 +0200 Subject: [PATCH] fix(xtream): confirm saved identity before restoring metadata --- .../database/operations/favorite-restore.ts | 22 +++++-- .../favorites-durable.sqlite.spec.ts | 1 + .../operations/testing/favorites-durable.ts | 3 + .../testing/favorites-restore-snapshot.ts | 65 +++++++++++++++++++ .../xtream-portal-compatibility.md | 4 +- 5 files changed, 88 insertions(+), 7 deletions(-) create mode 100644 apps/electron-backend/src/app/database/operations/testing/favorites-restore-snapshot.ts diff --git a/apps/electron-backend/src/app/database/operations/favorite-restore.ts b/apps/electron-backend/src/app/database/operations/favorite-restore.ts index 591fbe4c2..c9d8d5783 100644 --- a/apps/electron-backend/src/app/database/operations/favorite-restore.ts +++ b/apps/electron-backend/src/app/database/operations/favorite-restore.ts @@ -80,6 +80,15 @@ function favoriteValue( candidate.releaseYear != null && metadata.releaseYear !== candidate.releaseYear)); const content = conflicts ? undefined : candidate; + // Keep a compatible provider link, but do not mix a saved identity with + // metadata from a returning row that has not confirmed it yet. + const snapshotContent = + content && + (metadata?.tmdbId == null || metadata.tmdbId === content.tmdbId) && + (metadata?.releaseYear == null || + metadata.releaseYear === content.releaseYear) + ? content + : undefined; return { playlistId, xtreamId: item.xtreamId, @@ -87,13 +96,14 @@ function favoriteValue( contentId: content?.id ?? null, title: item.snapshot?.title?.trim() || - content?.title || + snapshotContent?.title || `Saved ${item.contentType} #${item.xtreamId}`, - posterUrl: item.snapshot?.posterUrl ?? content?.posterUrl, - backdropUrl: metadata?.backdropUrl ?? content?.backdropUrl, - tmdbId: metadata?.tmdbId ?? content?.tmdbId, - releaseYear: metadata?.releaseYear ?? content?.releaseYear, - originalTitle: metadata?.originalTitle ?? content?.originalTitle, + posterUrl: item.snapshot?.posterUrl ?? snapshotContent?.posterUrl, + backdropUrl: metadata?.backdropUrl ?? snapshotContent?.backdropUrl, + tmdbId: metadata?.tmdbId ?? snapshotContent?.tmdbId, + releaseYear: metadata?.releaseYear ?? snapshotContent?.releaseYear, + originalTitle: + metadata?.originalTitle ?? snapshotContent?.originalTitle, addedAt: item.addedAt ?? new Date().toISOString(), position: item.position ?? index, }; diff --git a/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts b/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts index 8fc788a59..f34261136 100644 --- a/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts +++ b/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts @@ -12,6 +12,7 @@ describe('durable Xtream favorites against initialized SQLite', () => { 'identity', 'collision', 'snapshot', + 'restore-snapshot', 'scope', 'lock', 'receipt', diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts index ab8beb638..43f876a95 100644 --- a/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { verifyFavoriteSnapshot } from './favorites-snapshot'; +import { verifyRestoredFavoriteSnapshot } from './favorites-restore-snapshot'; import { createFavoriteTestCatalog } from './favorites-test-catalog'; import { eq, sql } from 'drizzle-orm'; import { closeDatabase, initDatabase } from '@iptvnator/shared/database'; @@ -272,6 +273,8 @@ async function main() { assert.equal((await favorites())[0]?.id, 4); } else if (scenario === 'snapshot') { await verifyFavoriteSnapshot(db); + } else if (scenario === 'restore-snapshot') { + await verifyRestoredFavoriteSnapshot(db); } else if (scenario === 'scope') { await catalog(2, 7, 'Other', 'movie', 'other'); await addFavorite(db, 2, 'other'); diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-restore-snapshot.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-restore-snapshot.ts new file mode 100644 index 000000000..f38c935fe --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-restore-snapshot.ts @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../../database.types'; +import { createFavoriteTestCatalog } from './favorites-test-catalog'; +import { getFavoritesForBackup } from '../favorites.operations'; +import { + deleteXtreamContent, + restoreXtreamFavorites, +} from '../xtream.operations'; +import { setAppState } from '../playlist.operations'; + +export async function verifyRestoredFavoriteSnapshot(db: AppDatabase) { + const catalog = createFavoriteTestCatalog(db); + const cases = [ + { saved: { tmdbId: 10 }, candidate: { releaseYear: 2020 } }, + { saved: { releaseYear: 2000 }, candidate: { tmdbId: 20 } }, + ]; + for (const [index, { saved, candidate }] of cases.entries()) { + const id = 20 + index * 2; + const xtreamId = 100 + index; + await catalog(id, xtreamId, 'Replacement', 'movie', 'p', candidate); + await restoreXtreamFavorites(db, 'p', [ + { + contentType: 'movie', + xtreamId, + snapshot: { title: 'Saved original', ...saved }, + }, + ]); + const restored = (await db.select().from(schema.favorites)).find( + (row) => row.xtreamId === xtreamId + ); + assert(restored); + assert.equal( + restored.tmdbId, + saved.tmdbId ?? null, + 'restore must not adopt an unconfirmed TMDB ID' + ); + assert.equal( + restored.releaseYear, + saved.releaseYear ?? null, + 'restore must not adopt an unconfirmed year' + ); + assert.equal( + restored.posterUrl, + null, + 'restore must not copy replacement artwork' + ); + await deleteXtreamContent(db, 'p'); + await catalog(id + 1, xtreamId, 'Original returns', 'movie', 'p', { + tmdbId: 10, + releaseYear: 2000, + }); + await setAppState(db, 'xtream-import-status:p:movie', 'completed'); + const returned = (await getFavoritesForBackup(db, 'p')).find( + (row) => row.xtream_id === xtreamId + ); + assert.equal( + returned?.catalog_status, + 'present', + 'incomplete restored metadata must not block original content reconnecting' + ); + assert.equal(returned?.favorite_id, restored.id); + assert.equal(returned?.title, 'Original returns'); + } +} diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index df292fa31..42b2d62ec 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -52,7 +52,9 @@ catalog ID. Remove/reorder can use the stable favorite ID while detail toggles resolve any category row through the same typed provider identity. Completed 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. Metadata learned in detail +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. Conflicting known TMDB IDs or release years prevent automatic reconnection; changed provider IDs never reconnect by title.