fix(xtream): protect favorite snapshots and collection navigation

This commit is contained in:
4gray committed 2026-10-13 23:57:30 +02:00
1 parent 8ccfcb68cc
commit e5a5d0581d
16 files changed
+427 -50

No files matched your search

@@ -55,16 +55,6 @@ test('@critical @persistence @xtream @electron rejects a reused favorite identit
'movie'
);
if (!row) throw new Error('Expected imported content');
const metadata =
await window.electron.dbSetContentMetadataIfMissing(
row.id,
{
tmdbId: 12345,
releaseYear: 2000,
}
);
if (!metadata.success)
throw new Error('Could not set current movie identity');
const restored = await window.electron.dbRestoreXtreamFavorites(
playlistId,
[
@@ -82,6 +72,28 @@ test('@critical @persistence @xtream @electron rejects a reused favorite identit
);
if (!restored.success)
throw new Error('Could not seed the older favorite');
const completed = await window.electron.dbSetAppState(
`xtream-import-status:${playlistId}:movie`,
'completed'
);
if (!completed.success)
throw new Error('Could not complete the catalog');
const [saved] =
await window.electron.dbGetFavoritesForBackup(playlistId);
if (saved?.title !== oldTitle)
throw new Error(
'Unconfirmed catalog identity replaced the saved title'
);
const metadata =
await window.electron.dbSetContentMetadataIfMissing(
row.id,
{
tmdbId: 12345,
releaseYear: 2000,
}
);
if (!metadata.success)
throw new Error('Could not set current movie identity');
},
{ playlistId, xtreamId: Number(item.stream_id), oldTitle }
);
@@ -5,7 +5,10 @@ import {
ContentMetadataPatch,
normalizeContentMetadataPatch,
} from '@iptvnator/shared/interfaces';
import { favoriteMatchesContentId } from './favorite-catalog';
import {
favoriteMatchesContentId,
favoriteSnapshotMatchesContent,
} from './favorite-catalog';
import type { AppDatabase } from '../database.types';
/**
@@ -118,7 +121,12 @@ export async function persistContentMetadataIfMissing(
])
)
)
.where(favoriteMatchesContentId(contentId));
.where(
and(
favoriteMatchesContentId(contentId),
sql`EXISTS (SELECT 1 FROM content WHERE id = ${contentId} AND ${favoriteSnapshotMatchesContent()})`
)
);
}
export async function setContentMetadataIfMissing(
@@ -17,6 +17,12 @@ export function resolvedFavoriteContentId() {
ORDER BY candidate.id LIMIT 1)`;
}
/** Missing candidate metadata cannot authorize replacing an already known snapshot. */
export function favoriteSnapshotMatchesContent() {
return sql`(${schema.favorites.tmdbId} IS NULL OR ${schema.favorites.tmdbId} = ${schema.content.tmdbId})
AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = ${schema.content.releaseYear})`;
}
export function favoriteCatalogStatus() {
return sql<'present' | 'missing' | 'unknown'>`CASE
WHEN ${schema.appState.value} IN ('importing', 'failed', 'cancelled') THEN 'unknown'
@@ -92,6 +98,7 @@ export async function reconcileCompletedFavoriteCatalog(
})
.where(
and(
sql`EXISTS (SELECT 1 FROM content WHERE id = favorites.content_id AND ${favoriteSnapshotMatchesContent()})`,
eq(schema.favorites.playlistId, match[1]),
eq(
schema.favorites.type,
@@ -4,6 +4,7 @@ import type { AppDatabase } from '../database.types';
import { unlockedCategoryCondition } from '../parental-lock-state';
import {
favoriteCatalogStatus,
favoriteSnapshotMatchesContent,
favoriteImportStateKey,
resolvedFavoriteContentId,
} from './favorite-catalog';
@@ -20,24 +21,24 @@ export function selectFavoriteRows(
favorite_id: schema.favorites.id,
id: schema.content.id,
category_id: schema.content.categoryId,
title: sql<string>`COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title})`,
title: sql<string>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title}) ELSE ${schema.favorites.title} END`,
rating: schema.content.rating,
added: schema.content.added,
poster_url: sql<
string | null
>`COALESCE(NULLIF(TRIM(${schema.content.posterUrl}), ''), ${schema.favorites.posterUrl})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.posterUrl}), ''), ${schema.favorites.posterUrl}) ELSE ${schema.favorites.posterUrl} END`,
backdrop_url: sql<
string | null
>`COALESCE(NULLIF(TRIM(${schema.content.backdropUrl}), ''), ${schema.favorites.backdropUrl})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.backdropUrl}), ''), ${schema.favorites.backdropUrl}) ELSE ${schema.favorites.backdropUrl} END`,
tmdb_id: sql<
number | null
>`COALESCE(${schema.content.tmdbId}, ${schema.favorites.tmdbId})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(${schema.content.tmdbId}, ${schema.favorites.tmdbId}) ELSE ${schema.favorites.tmdbId} END`,
release_year: sql<
number | null
>`COALESCE(${schema.content.releaseYear}, ${schema.favorites.releaseYear})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(${schema.content.releaseYear}, ${schema.favorites.releaseYear}) ELSE ${schema.favorites.releaseYear} END`,
original_title: sql<
string | null
>`COALESCE(NULLIF(TRIM(${schema.content.originalTitle}), ''), ${schema.favorites.originalTitle})`,
>`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.originalTitle}), ''), ${schema.favorites.originalTitle}) ELSE ${schema.favorites.originalTitle} END`,
xtream_id: schema.favorites.xtreamId,
type: schema.favorites.type,
tv_archive: schema.content.tvArchive,
@@ -11,6 +11,7 @@ describe('durable Xtream favorites against initialized SQLite', () => {
'restore',
'identity',
'collision',
'snapshot',
'scope',
'lock',
'receipt',
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { verifyFavoriteSnapshot } from './favorites-snapshot';
import { createFavoriteTestCatalog } from './favorites-test-catalog';
import { eq, sql } from 'drizzle-orm';
import { closeDatabase, initDatabase } from '@iptvnator/shared/database';
@@ -269,6 +270,8 @@ async function main() {
});
await status('completed');
assert.equal((await favorites())[0]?.id, 4);
} else if (scenario === 'snapshot') {
await verifyFavoriteSnapshot(db);
} else if (scenario === 'scope') {
await catalog(2, 7, 'Other', 'movie', 'other');
await addFavorite(db, 2, 'other');
@@ -0,0 +1,95 @@
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 { persistContentMetadataIfMissing } from '../content-metadata.operations';
import {
addFavorite,
removeFavorite,
getFavorites,
getFavoritesForBackup,
} from '../favorites.operations';
import { deleteXtreamContent } from '../xtream.operations';
import { setAppState } from '../playlist.operations';
export async function verifyFavoriteSnapshot(db: AppDatabase) {
const catalog = createFavoriteTestCatalog(db);
await persistContentMetadataIfMissing(db, 1, {
tmdbId: 10,
releaseYear: 2000,
originalTitle: 'Original',
});
const [original] = await db.select().from(schema.favorites);
const snapshot = (row: typeof original) => ({
title: row.title,
poster: row.posterUrl,
backdrop: row.backdropUrl,
original: row.originalTitle,
tmdb: row.tmdbId,
year: row.releaseYear,
});
await deleteXtreamContent(db, 'p');
await catalog(2, 7, 'Replacement', 'movie', 'p', {
posterUrl: 'https://art.invalid/replacement.jpg',
backdropUrl: 'https://art.invalid/replacement-wide.jpg',
originalTitle: 'Replacement original',
});
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
assert.deepEqual(
snapshot((await db.select().from(schema.favorites))[0]),
snapshot(original),
'unknown catalog identity must not overwrite the durable snapshot'
);
for (const read of [getFavorites, getFavoritesForBackup]) {
const [row] = await read(db, 'p');
assert.equal(row.title, original.title);
assert.equal(row.poster_url, original.posterUrl);
assert.equal(row.backdrop_url, original.backdropUrl);
assert.equal(row.original_title, original.originalTitle);
}
await persistContentMetadataIfMissing(db, 2, {
tmdbId: 20,
releaseYear: 2020,
});
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 setAppState(db, 'xtream-import-status:p:movie', 'completed');
assert.deepEqual(
snapshot((await db.select().from(schema.favorites))[0]),
snapshot(original),
'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'
);
// A replacement's year/artwork must not fill gaps before its TMDB identity is known.
await removeFavorite(db, 3, 'p');
await deleteXtreamContent(db, 'p');
await catalog(4, 7, 'Known TMDB only', 'movie', 'p', {
tmdbId: 10,
backdropUrl: null,
});
await addFavorite(db, 4, 'p');
await deleteXtreamContent(db, 'p');
await catalog(5, 7, 'Year without identity', 'movie', 'p', {
releaseYear: 2020,
backdropUrl: null,
});
await setAppState(db, 'xtream-import-status:p:movie', 'completed');
await persistContentMetadataIfMissing(db, 5, {
backdropUrl: 'https://art.invalid/unverified.jpg',
});
const [partial] = await getFavoritesForBackup(db, 'p');
assert.equal(partial.release_year, null);
assert.equal(partial.backdrop_url, null);
assert.equal(
(await db.select().from(schema.favorites))[0].backdropUrl,
null
);
}