mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 14:16:16 -08:00
fix(xtream): protect favorite snapshots and collection navigation
This commit is contained in:
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
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user