diff --git a/apps/electron-backend/src/app/database/operations/favorite-catalog.ts b/apps/electron-backend/src/app/database/operations/favorite-catalog.ts index 81c0c0996..307fa8af0 100644 --- a/apps/electron-backend/src/app/database/operations/favorite-catalog.ts +++ b/apps/electron-backend/src/app/database/operations/favorite-catalog.ts @@ -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`(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]), diff --git a/apps/electron-backend/src/app/database/operations/favorite-queries.ts b/apps/electron-backend/src/app/database/operations/favorite-queries.ts index 09f6ac7b0..f70864f05 100644 --- a/apps/electron-backend/src/app/database/operations/favorite-queries.ts +++ b/apps/electron-backend/src/app/database/operations/favorite-queries.ts @@ -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, 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 f34261136..c55d4c4b3 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 @@ -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', 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 43f876a95..8aa6aa763 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 { 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'); diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-locked-duplicate.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-locked-duplicate.ts new file mode 100644 index 000000000..755be0b9e --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-locked-duplicate.ts @@ -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 + ); + } +} diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 5739eec75..88cfb2d0c 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -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