fix(xtream): confirm saved identity before restoring metadata

This commit is contained in:
4gray committed 2026-10-14 00:12:38 +02:00
1 parent e5a5d0581d
commit 2a8009fe6c
5 files changed
+88 -7

No files matched your search

@@ -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,
};
@@ -12,6 +12,7 @@ describe('durable Xtream favorites against initialized SQLite', () => {
'identity',
'collision',
'snapshot',
'restore-snapshot',
'scope',
'lock',
'receipt',
@@ -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');
@@ -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');
}
}
@@ -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.