fix(xtream): resolve unlocked favorite category copies

This commit is contained in:
4gray committed 2026-10-14 00:40:27 +02:00
1 parent c0edfef616
commit 844aaa5329
6 files changed
+79 -4

No files matched your search

@@ -1,14 +1,16 @@
import { and, eq, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { isParentalLockActive } from '../parental-lock-state';
/** A covering provider-id lookup per favorite, never a scan of the catalog. */
export function resolvedFavoriteContentId() {
export function resolvedFavoriteContentId(includeLocked: boolean) {
return sql<number | null>`(SELECT candidate.id FROM content candidate
INNER JOIN categories owner ON owner.id = candidate.category_id
WHERE candidate.xtream_id = ${schema.favorites.xtreamId}
AND candidate.type = ${schema.favorites.type}
AND owner.playlist_id = ${schema.favorites.playlistId}
${!includeLocked && isParentalLockActive() ? sql`AND owner.locked = 0` : sql``}
AND (
(${schema.favorites.tmdbId} IS NULL OR candidate.tmdb_id IS NULL
OR ${schema.favorites.tmdbId} = candidate.tmdb_id)
@@ -76,7 +78,7 @@ export async function reconcileCompletedFavoriteCatalog(
if (!match) return;
db.transaction((tx) => {
tx.update(schema.favorites)
.set({ contentId: resolvedFavoriteContentId() })
.set({ contentId: resolvedFavoriteContentId(true) })
.where(
and(
eq(schema.favorites.playlistId, match[1]),
@@ -52,7 +52,7 @@ export function selectFavoriteRows(
.from(schema.favorites)
.leftJoin(
schema.content,
eq(schema.content.id, resolvedFavoriteContentId())
eq(schema.content.id, resolvedFavoriteContentId(includeLocked))
)
.leftJoin(
schema.categories,
@@ -15,6 +15,7 @@ describe('durable Xtream favorites against initialized SQLite', () => {
'restore-snapshot',
'scope',
'lock',
'lock-duplicate',
'receipt',
])(
'%s preserves the saved favorite independently of catalog rows',
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { verifyFavoriteSnapshot } from './favorites-snapshot';
import { verifyUnlockedFavoriteDuplicate } from './favorites-locked-duplicate';
import { verifyRestoredFavoriteSnapshot } from './favorites-restore-snapshot';
import { createFavoriteTestCatalog } from './favorites-test-catalog';
import { eq, sql } from 'drizzle-orm';
@@ -275,6 +276,8 @@ async function main() {
await verifyFavoriteSnapshot(db);
} else if (scenario === 'restore-snapshot') {
await verifyRestoredFavoriteSnapshot(db);
} else if (scenario === 'lock-duplicate') {
await verifyUnlockedFavoriteDuplicate(db);
} else if (scenario === 'scope') {
await catalog(2, 7, 'Other', 'movie', 'other');
await addFavorite(db, 2, 'other');
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import { eq } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../../database.types';
import { setParentalLockActive } from '../../parental-lock-state';
import { createFavoriteTestCatalog } from './favorites-test-catalog';
import {
addFavorite,
getFavorites,
getAllGlobalFavorites,
getGlobalFavorites,
getFavoritesForBackup,
} from '../favorites.operations';
export async function verifyUnlockedFavoriteDuplicate(db: AppDatabase) {
const catalog = createFavoriteTestCatalog(db);
for (const [index, type] of (
['live', 'movie', 'series'] as const
).entries()) {
const first = 10 + index * 10;
const second = first + 1;
const xtreamId = 100 + index;
await catalog(first, xtreamId, 'Locked copy', type);
await catalog(second, xtreamId, 'Unlocked copy', type);
await db
.update(schema.categories)
.set({ locked: true })
.where(eq(schema.categories.id, first));
await addFavorite(db, second, 'p');
setParentalLockActive(true);
const reads = [getFavorites(db, 'p'), getAllGlobalFavorites(db)];
if (type === 'live') reads.push(getGlobalFavorites(db));
for (const rows of await Promise.all(reads)) {
assert.equal(
rows.find((row) => row.xtream_id === xtreamId)?.id,
second,
'an unlocked duplicate must remain visible'
);
}
await db
.update(schema.categories)
.set({ locked: true })
.where(eq(schema.categories.id, second));
assert.equal(
(await getFavorites(db, 'p')).some(
(row) => row.xtream_id === xtreamId
),
false,
'all locked copies must still be withheld'
);
assert.equal(
(await getFavoritesForBackup(db, 'p')).find(
(row) => row.xtream_id === xtreamId
)?.id,
first,
'backup remains unfiltered'
);
setParentalLockActive(false);
assert.equal(
(await getFavorites(db, 'p')).find(
(row) => row.xtream_id === xtreamId
)?.id,
first
);
}
}
@@ -72,7 +72,10 @@ unavailable closes its active detail and invalidates pending selection work,
including a retained player during a channel switch. Unresolved records are not
promoted to dashboard hero slides. Parental locks still gate display: an
unresolved category cannot prove that it is unlocked, so those records remain
stored but are withheld while locks apply.
stored but are withheld while locks apply. For duplicate category copies, ordinary
reads choose an unlocked compatible row before resolving the catalog link; a
locked earlier copy cannot hide a later unlocked copy. Backup reads remain
unfiltered.
Legacy pending favorites are converted before destructive refresh, import
requests and ordinary collection reads, even if portal-status preflight is