diff --git a/.changes/xtream-preserve-unavailable-favorites.md b/.changes/xtream-preserve-unavailable-favorites.md new file mode 100644 index 000000000..8333d014a --- /dev/null +++ b/.changes/xtream-preserve-unavailable-favorites.md @@ -0,0 +1,6 @@ +--- +type: fix +area: xtream +--- + +Xtream favorites now keep their titles and artwork when a server is offline or removes items from its catalog. Unavailable items stay saved through refreshes, restarts and backups, and reconnect when the same items return. You can still remove them yourself. diff --git a/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts b/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts new file mode 100644 index 000000000..db23e316d --- /dev/null +++ b/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts @@ -0,0 +1,138 @@ +import { + addXtreamPortal, + clickCategoryByNameExact, + clickGridListCardByTitle, + closeElectronApp, + contentCardByTitle, + expect, + launchElectronApp, + openSources, + openWorkspaceSection, + resetMockServers, + sourceRowByTitle, + test, + waitForXtreamWorkspaceReady, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import { fetchXtreamVodFixture, getXtreamTitle } from './portal-mock-fixtures'; +import { readSavedFavorites } from './support/xtream-favorites-catalog'; + +test('@critical @persistence @xtream @electron rejects a reused favorite identity until the older saved entry is explicitly removed', async ({ + dataDir, + request, +}) => { + test.setTimeout(90_000); + await resetMockServers(request, ['xtream']); + const credentials = { username: 'minimal', password: 'minimal' }; + const fixture = await fetchXtreamVodFixture(request, credentials); + const item = fixture.items[0]; + const title = getXtreamTitle(item); + const sourceTitle = 'Favorite identity conflict'; + const oldTitle = 'Older saved movie'; + let app: LaunchedElectronApp | undefined; + try { + app = await launchElectronApp(dataDir); + await addXtreamPortal(app.mainWindow, { + ...credentials, + name: sourceTitle, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + const playlistId = /\/xtreams\/([^/]+)/.exec( + new URL(app.mainWindow.url()).pathname + )?.[1]; + if (!playlistId) throw new Error('Expected a playlist route'); + await app.mainWindow + .getByRole('link', { name: 'Movies', exact: true }) + .click(); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + // Seed an old snapshot and conflicting current metadata via the real + // worker bridge. The subsequent save/removal runs through the UI. + await app.mainWindow.evaluate( + async ({ playlistId, xtreamId, oldTitle }) => { + const row = await window.electron.dbGetContentByXtreamId( + xtreamId, + playlistId, + '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, + [ + { + xtreamId, + contentType: 'movie', + addedAt: '2024-01-01', + position: 3, + snapshot: { + title: oldTitle, + tmdbId: 99999999, + }, + }, + ] + ); + if (!restored.success) + throw new Error('Could not seed the older favorite'); + }, + { playlistId, xtreamId: Number(item.stream_id), oldTitle } + ); + const [before] = await readSavedFavorites(app.mainWindow, playlistId); + expect(before).toMatchObject({ id: null, title: oldTitle }); + + await clickGridListCardByTitle(app.mainWindow, title); + const addButton = app.mainWindow + .getByRole('button', { name: 'Add to favorites', exact: true }) + .first(); + await expect(addButton).toBeVisible(); + await addButton.click(); + await expect( + app.mainWindow.getByText( + 'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.', + { exact: true } + ) + ).toBeVisible(); + await expect(addButton).toBeVisible(); + expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual([ + before, + ]); + + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + const oldCard = contentCardByTitle(app.mainWindow, oldTitle); + await oldCard + .getByRole('button', { name: /remove from favorites/i }) + .click(); + await expect(oldCard).toHaveCount(0); + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, sourceTitle).first().click(); + await waitForXtreamWorkspaceReady(app.mainWindow); + await app.mainWindow + .getByRole('link', { name: 'Movies', exact: true }) + .click(); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + await clickGridListCardByTitle(app.mainWindow, title); + await addButton.click(); + await expect( + app.mainWindow + .getByRole('button', { + name: 'Remove from favorites', + exact: true, + }) + .first() + ).toBeVisible(); + const [saved] = await readSavedFavorites(app.mainWindow, playlistId); + expect(saved.title).toBe(title); + expect(saved.id).not.toBeNull(); + expect(saved.favorite_id).not.toBe(before.favorite_id); + } finally { + if (app) await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend-e2e/src/favorites-legacy.e2e.ts b/apps/electron-backend-e2e/src/favorites-legacy.e2e.ts new file mode 100644 index 000000000..8602722bb --- /dev/null +++ b/apps/electron-backend-e2e/src/favorites-legacy.e2e.ts @@ -0,0 +1,322 @@ +import type { XtreamPendingRestoreState } from '@iptvnator/shared/interfaces'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + addXtreamPortal, + closeElectronApp, + contentCardByTitle, + electronMainPath, + expect, + launchElectronApp, + openSources, + openWorkspaceSection, + resetMockServers, + restartElectronApp, + sourceRowByTitle, + switchUnifiedCollectionScope, + test, + waitForXtreamWorkspaceReady, + workspaceRoot, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import { + createFavoritesCatalogProxy, + readSavedFavorites, + savedFavoriteIdentity, +} from './support/xtream-favorites-catalog'; + +test('@critical @persistence @xtream @electron recovers legacy pending favorites without catalog initialization while the portal is unreachable', async ({ + dataDir, + request, +}) => { + test.setTimeout(90_000); + await resetMockServers(request, ['xtream']); + const proxy = await createFavoritesCatalogProxy(); + const sourceTitle = 'Offline Legacy Favorites'; + const savedTitle = 'Saved offline movie'; + const fallbackTitle = 'Saved movie #910002'; + const legacyState: XtreamPendingRestoreState = { + favorites: [ + { + contentType: 'movie', + xtreamId: 910001, + addedAt: '2024-06-01T10:00:00.000Z', + position: 4, + snapshot: { title: savedTitle }, + }, + { + contentType: 'movie', + xtreamId: 910002, + addedAt: '2024-06-02T10:00:00.000Z', + position: 5, + }, + ], + hiddenCategories: [], + recentlyViewed: [], + playbackPositions: [], + }; + const serializedLegacyState = JSON.stringify(legacyState); + let app: LaunchedElectronApp | undefined; + try { + app = await launchElectronApp(dataDir); + await addXtreamPortal(app.mainWindow, { + name: sourceTitle, + serverUrl: proxy.origin, + username: 'minimal', + password: 'minimal', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + const playlistId = /\/xtreams\/([^/]+)/.exec( + new URL(app.mainWindow.url()).pathname + )?.[1]; + if (!playlistId) + throw new Error('The imported Xtream route has no playlist ID'); + await openSources(app.mainWindow); + proxy.setMode({ kind: 'offline' }); + const catalogActions = [ + 'get_live_categories', + 'get_vod_categories', + 'get_series_categories', + 'get_live_streams', + 'get_vod_streams', + 'get_series', + ]; + const catalogRequestsBefore = catalogActions.map((action) => + proxy.responseCount(action) + ); + const accountRequestsBefore = proxy.responseCount('get_account_info'); + + // Recreate an older profile: only pending localStorage owns the favorites. + // Calling the raw bridge here must not accidentally perform conversion. + await app.mainWindow.evaluate( + async ({ id, legacy }) => { + const deleted = await window.electron.dbDeleteXtreamContent(id); + if (!deleted.success) + throw new Error('Could not clear the test catalog'); + localStorage.setItem(`xtream-restore-${id}`, legacy); + }, + { id: playlistId, legacy: serializedLegacyState } + ); + expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual( + [] + ); + + // Discard the already-loaded renderer store so the next visit must probe + // the unavailable account before it can consider initializeContent(). + app = await restartElectronApp(app, dataDir); + await openSources(app.mainWindow); + await expect( + sourceRowByTitle(app.mainWindow, sourceTitle).first() + ).toBeVisible(); + await sourceRowByTitle(app.mainWindow, sourceTitle).first().click(); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Portal unavailable', + exact: true, + }) + ).toBeVisible(); + expect(proxy.responseCount('get_account_info')).toBeGreaterThan( + accountRequestsBefore + ); + expect( + catalogActions.map((action) => proxy.responseCount(action)) + ).toEqual(catalogRequestsBefore); + + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); + for (const title of [savedTitle, fallbackTitle]) { + const card = contentCardByTitle(app.mainWindow, title); + await expect(card).toHaveCount(1); + await expect(card).toContainText('Catalog status unknown'); + await expect( + card.locator('.content-card__activation') + ).toHaveAttribute('aria-disabled', 'true'); + } + const converted = await readSavedFavorites(app.mainWindow, playlistId); + expect(converted).toHaveLength(2); + expect( + converted.every( + (row) => row.id === null && row.catalog_status === 'unknown' + ) + ).toBe(true); + expect(savedFavoriteIdentity(converted)).toEqual([ + { + favoriteId: expect.any(Number), + xtreamId: 910001, + title: savedTitle, + addedAt: legacyState.favorites[0].addedAt, + position: 4, + }, + { + favoriteId: expect.any(Number), + xtreamId: 910002, + title: fallbackTitle, + addedAt: legacyState.favorites[1].addedAt, + position: 5, + }, + ]); + expect( + catalogActions.map((action) => proxy.responseCount(action)) + ).toEqual(catalogRequestsBefore); + + const removedCard = contentCardByTitle(app.mainWindow, savedTitle); + await removedCard + .getByRole('button', { name: /remove from favorites/i }) + .click(); + await expect(removedCard).toHaveCount(0); + const retained = savedFavoriteIdentity( + converted.filter((row) => row.xtream_id === 910002) + ); + expect( + savedFavoriteIdentity( + await readSavedFavorites(app.mainWindow, playlistId) + ) + ).toEqual(retained); + expect( + await app.mainWindow.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + playlistId + ) + ).toBe(serializedLegacyState); + + // The unchanged legacy payload remains eligible for reads after restart; + // the worker's durable conversion receipt must prevent resurrection. + app = await restartElectronApp(app, dataDir); + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, sourceTitle).first().click(); + await expect( + app.mainWindow.getByRole('heading', { + name: 'Portal unavailable', + exact: true, + }) + ).toBeVisible(); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + await switchUnifiedCollectionScope(app.mainWindow, 'All playlists'); + await expect( + contentCardByTitle(app.mainWindow, fallbackTitle) + ).toContainText('Catalog status unknown'); + await expect( + contentCardByTitle(app.mainWindow, savedTitle) + ).toHaveCount(0); + expect( + savedFavoriteIdentity( + await readSavedFavorites(app.mainWindow, playlistId) + ) + ).toEqual(retained); + expect( + await app.mainWindow.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + playlistId + ) + ).toBe(serializedLegacyState); + expect( + catalogActions.map((action) => proxy.responseCount(action)) + ).toEqual(catalogRequestsBefore); + } finally { + try { + if (app) await closeElectronApp(app); + } finally { + await proxy.close(); + } + } +}); + +test('@critical @persistence @xtream @electron upgrades orphaned legacy favorites without blocking startup or losing saved rows', async ({ + dataDir, +}) => { + test.setTimeout(90_000); + const schema = await readFile( + join( + workspaceRoot, + 'libs/shared/database/src/lib/testing/fixtures/v0.24.0-schema.sql' + ), + 'utf8' + ); + const databaseFile = join(dataDir, 'databases/iptvnator.db'); + const dependency = join(workspaceRoot, 'node_modules/better-sqlite3'); + const wrapper = join(dataDir, 'legacy-favorites-startup.cjs'); + const seed = ` + INSERT INTO playlists (id, name, type) VALUES ('legacy', 'Legacy favorites', 'xtream'); + INSERT INTO categories (id, playlist_id, name, type, xtream_id) + VALUES (1, 'legacy', 'Movies', 'movies', 1); + INSERT INTO content (id, category_id, title, xtream_id, type) + VALUES (1, 1, 'Legacy saved movie', 7, 'movie'); + INSERT INTO favorites (id, content_id, playlist_id, added_at, position) + VALUES (42, 1, 'legacy', '2024-01-01', 3), + (43, 1, 'deleted-playlist', '2024-01-02', 4);`; + await mkdir(join(dataDir, 'databases'), { recursive: true }); + await writeFile( + wrapper, + `const Database = require(${JSON.stringify(dependency)}); +const db = new Database(${JSON.stringify(databaseFile)}); +db.pragma('foreign_keys = OFF'); +db.exec(${JSON.stringify(schema)}); +db.exec(${JSON.stringify(seed)}); +db.close(); +require(${JSON.stringify(electronMainPath)});` + ); + let app: LaunchedElectronApp | undefined; + try { + app = await launchElectronApp(dataDir, { entryPoint: wrapper }); + for (let startup = 0; startup < 2; startup++) { + if (startup) app = await restartElectronApp(app, dataDir); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + await expect( + contentCardByTitle(app.mainWindow, 'Legacy saved movie') + ).toHaveCount(1); + expect( + savedFavoriteIdentity( + await readSavedFavorites(app.mainWindow, 'legacy') + ) + ).toEqual([ + { + favoriteId: 42, + xtreamId: 7, + title: 'Legacy saved movie', + addedAt: '2024-01-01', + position: 3, + }, + ]); + // Orphaned favorites have no source to display, but must remain in + // storage alongside healthy favorites after both startup paths. + const rows = await app.electronApp.evaluate( + (_electron, { databaseFile, dependency }) => { + const Database = process + .getBuiltinModule('module') + .createRequire(dependency)(dependency); + const db = new Database(databaseFile, { readonly: true }); + try { + return db + .prepare( + 'SELECT id, playlist_id, content_id, title, added_at, position FROM favorites ORDER BY id' + ) + .all(); + } finally { + db.close(); + } + }, + { databaseFile, dependency } + ); + expect(rows).toEqual([ + { + id: 42, + playlist_id: 'legacy', + content_id: 1, + title: 'Legacy saved movie', + added_at: '2024-01-01', + position: 3, + }, + { + id: 43, + playlist_id: 'deleted-playlist', + content_id: 1, + title: 'Legacy saved movie', + added_at: '2024-01-02', + position: 4, + }, + ]); + } + } finally { + if (app) await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index b7b466a49..2d2ce175b 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -33,6 +33,7 @@ import { waitForStalkerCatalog, waitForXtreamWorkspaceReady, writeTemporaryM3uFile, + type LaunchedElectronApp, } from './electron-test-fixtures'; import { fetchStalkerCategoryFixture, @@ -48,6 +49,12 @@ import { routePlayableStreams, startAndConfirmPlayback, } from './playable-stream-fixture'; +import { + createFavoritesCatalogProxy, + readSavedFavorites, + refreshFavoriteCatalog, + savedFavoriteIdentity, +} from './support/xtream-favorites-catalog'; test.describe('Electron Favorites', () => { for (const origin of ['favorites', 'recent', 'dashboard'] as const) { @@ -430,6 +437,262 @@ test.describe('Electron Favorites', () => { } }); + test('@critical @persistence @xtream @electron keeps durable favorites through partial refresh, repeated refresh, restart and catalog recovery', async ({ + dataDir, + request, + }) => { + test.setTimeout(150_000); + await resetMockServers(request, ['xtream']); + const credentials = { username: 'minimal', password: 'minimal' }; + const fixture = await fetchXtreamVodFixture(request, credentials); + const proxy = await createFavoritesCatalogProxy(); + const portalTitle = 'Durable Partial Favorites'; + let app: LaunchedElectronApp | undefined; + try { + app = await launchElectronApp(dataDir); + await addXtreamPortal(app.mainWindow, { + ...credentials, + name: portalTitle, + serverUrl: proxy.origin, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + const { playlistId, titles } = await saveTwoMovieFavorites( + app.mainWindow, + fixture + ); + const original = savedFavoriteIdentity( + await readSavedFavorites(app.mainWindow, playlistId) + ); + const missing = original.find((row) => row.title === titles[0]); + if (!missing) throw new Error('The newly saved movie is missing'); + expect(missing.favoriteId).toBeGreaterThan(0); + proxy.setMode({ kind: 'omit', xtreamId: missing.xtreamId }); + + // Both real refresh entry points must retain the same durable records. + for (const entry of ['sources', 'header'] as const) { + await refreshFavoriteCatalog( + app.mainWindow, + proxy, + playlistId, + portalTitle, + entry + ); + const saved = await readSavedFavorites( + app.mainWindow, + playlistId + ); + expect(savedFavoriteIdentity(saved)).toEqual(original); + expect( + saved.find((row) => row.favorite_id === missing.favoriteId) + ).toMatchObject({ + id: null, + title: titles[0], + catalog_status: 'missing', + }); + expect( + saved.find((row) => row.favorite_id !== missing.favoriteId) + ).toMatchObject({ + id: expect.any(Number), + catalog_status: 'present', + }); + await expectSavedMovieCards(app.mainWindow, titles); + const card = contentCardByTitle(app.mainWindow, titles[0]); + await expect(card).toContainText('Not in the current catalog'); + const activation = card.locator('.content-card__activation'); + await expect(activation).toHaveAttribute( + 'aria-disabled', + 'true' + ); + const detailRequests = proxy.responseCount('get_vod_info'); + await activation.focus(); + await app.mainWindow.keyboard.press('Enter'); + await expect( + app.mainWindow.locator('app-content-hero') + ).toHaveCount(0); + expect(proxy.responseCount('get_vod_info')).toBe( + detailRequests + ); + await switchUnifiedCollectionScope( + app.mainWindow, + 'All playlists' + ); + await expect( + contentCardByTitle(app.mainWindow, titles[0]) + ).toContainText('Not in the current catalog'); + } + + // The provider still omits the movie while a new Electron process opens. + app = await restartElectronApp(app, dataDir); + await openSources(app.mainWindow); + await sourceRowByTitle(app.mainWindow, portalTitle).first().click(); + await waitForXtreamWorkspaceReady(app.mainWindow); + await expectSavedMovieCards(app.mainWindow, titles); + expect( + savedFavoriteIdentity( + await readSavedFavorites(app.mainWindow, playlistId) + ) + ).toEqual(original); + await expect( + contentCardByTitle(app.mainWindow, titles[0]) + ).toContainText('Not in the current catalog'); + + proxy.setMode({ kind: 'normal' }); + await refreshFavoriteCatalog( + app.mainWindow, + proxy, + playlistId, + portalTitle, + 'sources' + ); + const recovered = await readSavedFavorites( + app.mainWindow, + playlistId + ); + expect(savedFavoriteIdentity(recovered)).toEqual(original); + expect(recovered).toHaveLength(2); + expect( + recovered.every( + (row) => row.id != null && row.catalog_status === 'present' + ) + ).toBe(true); + await expectSavedMovieCards(app.mainWindow, titles); + const recoveredCard = contentCardByTitle(app.mainWindow, titles[0]); + await expect( + recoveredCard.locator('.content-card__activation') + ).not.toHaveAttribute('aria-disabled', 'true'); + await recoveredCard.click(); + await expect( + app.mainWindow.locator('app-content-hero') + ).toContainText(titles[0]); + await expect( + app.mainWindow + .getByRole('button', { name: /remove from favorites/i }) + .first() + ).toBeVisible(); + } finally { + try { + if (app) await closeElectronApp(app); + } finally { + await proxy.close(); + } + } + }); + + test('@persistence @xtream @electron distinguishes an empty catalog from HTTP failure and keeps explicit favorite removal after recovery', async ({ + dataDir, + request, + }) => { + test.setTimeout(120_000); + await resetMockServers(request, ['xtream']); + const credentials = { username: 'minimal', password: 'minimal' }; + const fixture = await fetchXtreamVodFixture(request, credentials); + const proxy = await createFavoritesCatalogProxy(); + const portalTitle = 'Durable Empty Favorites'; + let app: LaunchedElectronApp | undefined; + try { + app = await launchElectronApp(dataDir); + await addXtreamPortal(app.mainWindow, { + ...credentials, + name: portalTitle, + serverUrl: proxy.origin, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + const { playlistId, titles } = await saveTwoMovieFavorites( + app.mainWindow, + fixture + ); + const original = savedFavoriteIdentity( + await readSavedFavorites(app.mainWindow, playlistId) + ); + + for (const kind of ['empty', 'failure'] as const) { + proxy.setMode({ kind }); + await refreshFavoriteCatalog( + app.mainWindow, + proxy, + playlistId, + portalTitle, + 'sources', + kind === 'empty' ? 'completed' : 'failed' + ); + const saved = await readSavedFavorites( + app.mainWindow, + playlistId + ); + expect(savedFavoriteIdentity(saved)).toEqual(original); + expect( + saved.every( + (row) => + row.id === null && + row.catalog_status === + (kind === 'empty' ? 'missing' : 'unknown') + ) + ).toBe(true); + await expectSavedMovieCards(app.mainWindow, titles); + for (const title of titles) { + await expect( + contentCardByTitle(app.mainWindow, title) + ).toContainText( + kind === 'empty' + ? 'Not in the current catalog' + : 'Catalog status unknown' + ); + } + } + + const removedCard = contentCardByTitle(app.mainWindow, titles[0]); + await removedCard + .getByRole('button', { name: /remove from favorites/i }) + .click(); + await expect(removedCard).toHaveCount(0); + const retained = original.filter((row) => row.title !== titles[0]); + expect( + savedFavoriteIdentity( + await readSavedFavorites(app.mainWindow, playlistId) + ) + ).toEqual(retained); + + proxy.setMode({ kind: 'normal' }); + // Replaying a stale refresh snapshot must never resurrect an explicit removal. + for (let refresh = 0; refresh < 2; refresh++) { + await refreshFavoriteCatalog( + app.mainWindow, + proxy, + playlistId, + portalTitle, + 'sources' + ); + const saved = await readSavedFavorites( + app.mainWindow, + playlistId + ); + expect(savedFavoriteIdentity(saved)).toEqual(retained); + expect(saved[0]).toMatchObject({ + id: expect.any(Number), + catalog_status: 'present', + }); + await expectSavedMovieCards(app.mainWindow, [titles[1]]); + await expect( + contentCardByTitle(app.mainWindow, titles[0]) + ).toHaveCount(0); + await switchUnifiedCollectionScope( + app.mainWindow, + 'All playlists' + ); + await expect( + contentCardByTitle(app.mainWindow, titles[0]) + ).toHaveCount(0); + await expectVisibleContentCardTitle(app.mainWindow, titles[1]); + } + } finally { + try { + if (app) await closeElectronApp(app); + } finally { + await proxy.close(); + } + } + }); + test('opens Xtream favorite movies and series inline from favorites without switching the playlist or showing the sidebar', async ({ dataDir, request, @@ -740,6 +1003,44 @@ const xtreamCredentials = { password: defaultXtreamPassword, }; +async function saveTwoMovieFavorites( + page: Page, + fixture: Awaited> +): Promise<{ playlistId: string; titles: [string, string] }> { + const playlistId = /\/xtreams\/([^/]+)/.exec( + new URL(page.url()).pathname + )?.[1]; + if (!playlistId) + throw new Error('The imported Xtream route has no playlist ID'); + await page.getByRole('link', { name: 'Movies', exact: true }).click(); + await clickCategoryByNameExact(page, fixture.categoryName); + const first = await clickFirstGridListCard(page); + await addCurrentDetailToFavorites(page); + await goBackFromDetail(page); + const second = fixture.items + .map(getXtreamTitle) + .find((title) => title && title !== first); + if (!second) throw new Error('The movie fixture needs two distinct titles'); + await clickGridListCardByTitle(page, second); + await addCurrentDetailToFavorites(page); + await goBackFromDetail(page); + await expect + .poll(async () => (await readSavedFavorites(page, playlistId)).length) + .toBe(2); + return { playlistId, titles: [first, second] }; +} + +async function expectSavedMovieCards( + page: Page, + titles: readonly string[] +): Promise { + await openPlaylistFavorites(page); + for (const title of titles) { + await expectVisibleContentCardTitle(page, title); + await expect(contentCardByTitle(page, title)).toHaveCount(1); + } +} + // By accessible name, not class: the Xtream movie detail's favorite control is // an icon-only button that carries its label in aria-label, while series and // Stalker details still use the labeled variant. This matches both. diff --git a/apps/electron-backend-e2e/src/support/xtream-favorites-catalog.ts b/apps/electron-backend-e2e/src/support/xtream-favorites-catalog.ts new file mode 100644 index 000000000..ce7501373 --- /dev/null +++ b/apps/electron-backend-e2e/src/support/xtream-favorites-catalog.ts @@ -0,0 +1,178 @@ +import { createServer } from 'node:http'; +import type { Page } from '@playwright/test'; +import type { ElectronBridgeXtreamFavorite } from '@iptvnator/shared/interfaces'; +import { + expect, + openSources, + refreshSource, + sourceRowByTitle, + waitForXtreamWorkspaceReady, + xtreamMockServer, +} from '../electron-test-fixtures'; + +export type FavoritesCatalogMode = + | { kind: 'normal' | 'empty' | 'failure' | 'offline' } + | { kind: 'omit'; xtreamId: number }; + +export interface FavoritesCatalogProxy { + origin: string; + setMode(mode: FavoritesCatalogMode): void; + responseCount(action: string): number; + close(): Promise; +} + +/** Main-process HTTP cannot be intercepted by Playwright's page.route(). */ +export async function createFavoritesCatalogProxy(): Promise { + let mode: FavoritesCatalogMode = { kind: 'normal' }; + const responses = new Map(); + const pending = new Set(); + const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', xtreamMockServer); + const action = url.searchParams.get('action') ?? 'get_account_info'; + const requestMode = mode; + response.once('finish', () => { + responses.set(action, (responses.get(action) ?? 0) + 1); + }); + response.setHeader('Content-Type', 'application/json'); + response.setHeader('Cache-Control', 'no-store'); + + if ( + requestMode.kind === 'offline' || + (action === 'get_vod_streams' && requestMode.kind === 'failure') + ) { + response.writeHead(503).end('{"error":"fixture-unavailable"}'); + return; + } + if (action === 'get_vod_streams' && requestMode.kind === 'empty') { + response.end('[]'); + return; + } + + const controller = new AbortController(); + pending.add(controller); + const timeout = setTimeout(() => controller.abort(), 10_000); + void (async () => { + try { + const upstream = await fetch(url, { + redirect: 'manual', + signal: controller.signal, + }); + let body = await upstream.text(); + if ( + action === 'get_vod_streams' && + requestMode.kind === 'omit' + ) { + const movies = JSON.parse(body) as { + stream_id: number | string; + }[]; + body = JSON.stringify( + movies.filter( + (movie) => + Number(movie.stream_id) !== requestMode.xtreamId + ) + ); + } + response.writeHead(upstream.status).end(body); + } catch { + if (!response.destroyed) { + response + .writeHead(502) + .end('{"error":"fixture-upstream-failed"}'); + } + } finally { + clearTimeout(timeout); + pending.delete(controller); + } + })(); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Favorites fixture did not bind a TCP port'); + } + return { + origin: `http://127.0.0.1:${address.port}`, + setMode: (next) => { + mode = next; + }, + responseCount: (action) => responses.get(action) ?? 0, + close: () => + new Promise((resolve, reject) => { + pending.forEach((controller) => controller.abort()); + server.close((error) => (error ? reject(error) : resolve())); + server.closeAllConnections(); + }), + }; +} + +export function readSavedFavorites(page: Page, playlistId: string) { + return page.evaluate( + (id) => window.electron.dbGetFavorites(id), + playlistId + ); +} + +/** Exclude the replaceable catalog link while retaining every user-owned field. */ +export function savedFavoriteIdentity(rows: ElectronBridgeXtreamFavorite[]) { + return rows.map((row) => ({ + favoriteId: row.favorite_id, + xtreamId: row.xtream_id, + title: row.title, + addedAt: row.added_at, + position: row.position, + })); +} + +export async function refreshFavoriteCatalog( + page: Page, + proxy: FavoritesCatalogProxy, + playlistId: string, + title: string, + entry: 'sources' | 'header', + expectedImportState: 'completed' | 'failed' = 'completed' +): Promise { + await openSources(page); + if (entry === 'header') { + await sourceRowByTitle(page, title).first().click(); + await waitForXtreamWorkspaceReady(page); + } + const requestsBefore = proxy.responseCount('get_vod_streams'); + if (entry === 'sources') { + await refreshSource(page, title, { confirm: true }); + } else { + await page + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .click(); + const dialog = page.locator('mat-dialog-container'); + await dialog + .getByRole('button', { name: 'Refresh playlist', exact: true }) + .click(); + await expect(dialog).toHaveCount(0); + } + // A completed status from the previous generation cannot satisfy this refresh. + await expect + .poll(() => proxy.responseCount('get_vod_streams'), { + timeout: 30_000, + }) + .toBeGreaterThan(requestsBefore); + await expect + .poll( + () => + page.evaluate( + (id) => + window.electron.dbGetAppState( + `xtream-import-status:${id}:movie` + ), + playlistId + ), + { timeout: 30_000 } + ) + .toBe(expectedImportState); + await expect(page.locator('.workspace-loading-overlay')).toHaveCount(0, { + timeout: 30_000, + }); +} diff --git a/apps/electron-backend/src/app/api/main.preload.spec-data.ts b/apps/electron-backend/src/app/api/main.preload.spec-data.ts index 0465345fd..a580f6290 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec-data.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec-data.ts @@ -156,6 +156,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_DELETE_XTREAM_CONTENT', forwardedArgs: [playlistId, operationId], }, + { + method: 'dbRestoreXtreamFavorites', + args: [playlistId, favorites, 'restore-1'], + channel: 'DB_RESTORE_XTREAM_FAVORITES', + forwardedArgs: [playlistId, favorites, 'restore-1'], + }, { method: 'dbRestoreXtreamUserData', args: [playlistId, favorites, recentlyViewed, operationId], @@ -276,6 +282,12 @@ export const dbPreloadCases: PreloadInvokeCase[] = [ channel: 'DB_IS_FAVORITE', forwardedArgs: [12, playlistId], }, + { + method: 'dbGetFavoritesForBackup', + args: [playlistId], + channel: 'DB_GET_FAVORITES_FOR_BACKUP', + forwardedArgs: [playlistId], + }, { method: 'dbGetFavorites', args: [playlistId], diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 3f907ede8..46d7f211d 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -1,3 +1,8 @@ +import type { + ElectronBridgeFavoriteReorderUpdate, + XtreamFavoriteRemovalRef, + XtreamBackupFavoriteItem, +} from '@iptvnator/shared/interfaces'; import type { SourceProbeContext } from '@iptvnator/shared/interfaces'; import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron'; import { @@ -804,6 +809,17 @@ const electronApi: ElectronBridgeApi = { ipcRenderer.invoke('DB_DELETE_PLAYLIST', playlistId, operationId), dbDeleteXtreamContent: (playlistId: string, operationId?: string) => ipcRenderer.invoke('DB_DELETE_XTREAM_CONTENT', playlistId, operationId), + dbRestoreXtreamFavorites: ( + playlistId: string, + favorites: XtreamBackupFavoriteItem[], + restoreId?: string + ) => + ipcRenderer.invoke( + 'DB_RESTORE_XTREAM_FAVORITES', + playlistId, + favorites, + restoreId + ), dbRestoreXtreamUserData: ( playlistId: string, favorites: unknown[], @@ -940,17 +956,21 @@ const electronApi: ElectronBridgeApi = { playlistId, backdropUrl ), - dbRemoveFavorite: (contentId: number, playlistId: string) => - ipcRenderer.invoke('DB_REMOVE_FAVORITE', contentId, playlistId), + dbRemoveFavorite: ( + contentId: XtreamFavoriteRemovalRef, + playlistId: string + ) => ipcRenderer.invoke('DB_REMOVE_FAVORITE', contentId, playlistId), dbIsFavorite: (contentId: number, playlistId: string) => ipcRenderer.invoke('DB_IS_FAVORITE', contentId, playlistId), + dbGetFavoritesForBackup: (playlistId: string) => + ipcRenderer.invoke('DB_GET_FAVORITES_FOR_BACKUP', playlistId), dbGetFavorites: (playlistId: string) => ipcRenderer.invoke('DB_GET_FAVORITES', playlistId), dbGetGlobalFavorites: () => ipcRenderer.invoke('DB_GET_GLOBAL_FAVORITES'), dbGetAllGlobalFavorites: () => ipcRenderer.invoke('DB_GET_ALL_GLOBAL_FAVORITES'), dbReorderGlobalFavorites: ( - updates: { content_id: number; playlist_id: string; position: number }[] + updates: ElectronBridgeFavoriteReorderUpdate[] ) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates), // Recently viewed (playlist-specific) dbGetRecentItems: (playlistId: string) => @@ -1081,7 +1101,12 @@ const electronApi: ElectronBridgeApi = { dbReplaceAllPlaybackPositions: ( playlistId: string, items: ElectronBridgePlaybackPositionInput[] - ) => ipcRenderer.invoke('DB_REPLACE_ALL_PLAYBACK_POSITIONS', playlistId, items), + ) => + ipcRenderer.invoke( + 'DB_REPLACE_ALL_PLAYBACK_POSITIONS', + playlistId, + items + ), dbClearAllPlaybackPositions: (playlistId: string) => ipcRenderer.invoke('DB_CLEAR_ALL_PLAYBACK_POSITIONS', playlistId), dbClearPlaybackPosition: ( @@ -1186,11 +1211,14 @@ const electronApi: ElectronBridgeApi = { // the next resize. Applied at DOMContentLoaded, never earlier — see // preload-zoom-level.ts for the Linux/Windows ready-to-show trap. applyPersistedZoomLevel({ - requestPersistedZoomLevel: () => ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL), + requestPersistedZoomLevel: () => + ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL), ...frameZoomPorts, whenDocumentParsed: (apply) => { if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', apply, { once: true }); + document.addEventListener('DOMContentLoaded', apply, { + once: true, + }); } else { apply(); } diff --git a/apps/electron-backend/src/app/database/operations/content-metadata.operations.spec.ts b/apps/electron-backend/src/app/database/operations/content-metadata.operations.spec.ts index d7b2fbbc3..7fa32965d 100644 --- a/apps/electron-backend/src/app/database/operations/content-metadata.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content-metadata.operations.spec.ts @@ -63,7 +63,7 @@ describe('content-metadata.operations', () => { sqlMock.mockClear(); }); - it('writes the whole patch in one statement', async () => { + it('writes the whole content patch then preserves it in saved favorites', async () => { const { db, set, update } = createDbMock(); await expect( @@ -75,7 +75,8 @@ describe('content-metadata.operations', () => { }) ).resolves.toEqual({ success: true }); - expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(2); + expect(update).toHaveBeenNthCalledWith(2, schema.favorites); expect(update).toHaveBeenCalledWith(schema.content); const written = set.mock.calls[0][0]; @@ -138,9 +139,9 @@ describe('content-metadata.operations', () => { const guards = orMock.mock.calls[0].map(sqlText); expect(guards.some((guard) => guard.includes("= ''"))).toBe(true); - expect( - guards.filter((guard) => guard.includes("= ''")) - ).toHaveLength(1); + expect(guards.filter((guard) => guard.includes("= ''"))).toHaveLength( + 1 + ); }); it.each([ @@ -166,7 +167,8 @@ describe('content-metadata.operations', () => { backdropUrl: 'https://example.com/backdrop.jpg', }); - expect(update).toHaveBeenCalledTimes(1); + expect(update).toHaveBeenCalledTimes(2); + expect(update).toHaveBeenNthCalledWith(2, schema.favorites); expect(update).toHaveBeenCalledWith(schema.content); expect(update).not.toHaveBeenCalledWith( schema.recentlyViewed as unknown as never diff --git a/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts b/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts index 3d17011cf..684c4c7d7 100644 --- a/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content-metadata.operations.ts @@ -5,6 +5,7 @@ import { ContentMetadataPatch, normalizeContentMetadataPatch, } from '@iptvnator/shared/interfaces'; +import { favoriteMatchesContentId } from './favorite-catalog'; import type { AppDatabase } from '../database.types'; /** @@ -104,6 +105,20 @@ export async function persistContentMetadataIfMissing( ) ) .where(and(eq(schema.content.id, contentId), anyAbsent)); + + // Favorites own their metadata beyond the lifetime of this catalog row. + // Capture enrichment when learned, before a later refresh can delete it. + await db + .update(schema.favorites) + .set( + Object.fromEntries( + columns.map(({ key, column }) => [ + key, + sql`COALESCE(NULLIF(${schema.favorites[key]}, ''), (SELECT ${column} FROM content WHERE id = ${contentId}))`, + ]) + ) + ) + .where(favoriteMatchesContentId(contentId)); } export async function setContentMetadataIfMissing( diff --git a/apps/electron-backend/src/app/database/operations/favorite-catalog.ts b/apps/electron-backend/src/app/database/operations/favorite-catalog.ts new file mode 100644 index 000000000..a78decce4 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/favorite-catalog.ts @@ -0,0 +1,118 @@ +import { and, eq, sql } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; + +/** A covering provider-id lookup per favorite, never a scan of the catalog. */ +export function resolvedFavoriteContentId() { + 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} + AND ( + (${schema.favorites.tmdbId} IS NULL OR candidate.tmdb_id IS NULL + OR ${schema.favorites.tmdbId} = candidate.tmdb_id) + AND (${schema.favorites.releaseYear} IS NULL OR candidate.release_year IS NULL + OR ${schema.favorites.releaseYear} = candidate.release_year)) + ORDER BY candidate.id LIMIT 1)`; +} + +export function favoriteCatalogStatus() { + return sql<'present' | 'missing' | 'unknown'>`CASE + WHEN ${schema.appState.value} IN ('importing', 'failed', 'cancelled') THEN 'unknown' + WHEN ${schema.content.id} IS NOT NULL THEN 'present' + WHEN ${schema.appState.value} = 'completed' THEN 'missing' + ELSE 'unknown' END`; +} + +export function favoriteImportStateKey() { + return sql`'xtream-import-status:' || ${schema.favorites.playlistId} || ':' || ${schema.favorites.type}`; +} + +export async function findFavoriteContent( + db: AppDatabase, + playlistId: string, + identity: + | { contentId: number } + | { xtreamId: number; type: 'live' | 'movie' | 'series' } +) { + const rows = await db + .select({ content: schema.content }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where( + and( + eq(schema.categories.playlistId, playlistId), + 'contentId' in identity + ? eq(schema.content.id, identity.contentId) + : and( + eq(schema.content.xtreamId, identity.xtreamId), + eq(schema.content.type, identity.type) + ) + ) + ) + .orderBy(schema.content.id) + .limit(1); + return rows[0]?.content; +} + +/** The import lifecycle already persists these keys; completion relinks cache pointers. */ +export async function reconcileCompletedFavoriteCatalog( + db: AppDatabase, + key: string, + value: string +) { + if (value !== 'completed') return; + const match = /^xtream-import-status:(.+):(live|movie|series)$/.exec(key); + if (!match) return; + db.transaction((tx) => { + tx.update(schema.favorites) + .set({ contentId: resolvedFavoriteContentId() }) + .where( + and( + eq(schema.favorites.playlistId, match[1]), + eq( + schema.favorites.type, + match[2] as 'live' | 'movie' | 'series' + ) + ) + ) + .run(); + tx.update(schema.favorites) + .set({ + title: sql`COALESCE((SELECT NULLIF(TRIM(title), '') FROM content WHERE id = favorites.content_id), favorites.title)`, + posterUrl: sql`COALESCE((SELECT NULLIF(TRIM(poster_url), '') FROM content WHERE id = favorites.content_id), favorites.poster_url)`, + backdropUrl: sql`COALESCE((SELECT NULLIF(TRIM(backdrop_url), '') FROM content WHERE id = favorites.content_id), favorites.backdrop_url)`, + tmdbId: sql`COALESCE((SELECT tmdb_id FROM content WHERE id = favorites.content_id), favorites.tmdb_id)`, + releaseYear: sql`COALESCE((SELECT release_year FROM content WHERE id = favorites.content_id), favorites.release_year)`, + originalTitle: sql`COALESCE((SELECT NULLIF(TRIM(original_title), '') FROM content WHERE id = favorites.content_id), favorites.original_title)`, + }) + .where( + and( + eq(schema.favorites.playlistId, match[1]), + eq( + schema.favorites.type, + match[2] as 'live' | 'movie' | 'series' + ) + ) + ) + .run(); + }); +} + +/** Every duplicate category row represents the same typed provider identity. */ +export function favoriteMatchesContentId( + contentId: number | ReturnType +) { + return sql`EXISTS (SELECT 1 FROM content candidate + INNER JOIN categories owner ON owner.id = candidate.category_id + WHERE candidate.id = ${contentId} + AND owner.playlist_id = ${schema.favorites.playlistId} + AND candidate.type = ${schema.favorites.type} + AND candidate.xtream_id = ${schema.favorites.xtreamId} + AND (${schema.favorites.tmdbId} IS NULL OR candidate.tmdb_id IS NULL OR ${schema.favorites.tmdbId} = candidate.tmdb_id) + AND (${schema.favorites.releaseYear} IS NULL OR candidate.release_year IS NULL OR ${schema.favorites.releaseYear} = candidate.release_year))`; +} diff --git a/apps/electron-backend/src/app/database/operations/favorite-queries.ts b/apps/electron-backend/src/app/database/operations/favorite-queries.ts new file mode 100644 index 000000000..b73851f63 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/favorite-queries.ts @@ -0,0 +1,82 @@ +import { and, asc, desc, eq, sql } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; +import { unlockedCategoryCondition } from '../parental-lock-state'; +import { + favoriteCatalogStatus, + favoriteImportStateKey, + resolvedFavoriteContentId, +} from './favorite-catalog'; + +/** Nullable catalog joins preserve the saved title and artwork when an item disappears. */ +export function selectFavoriteRows( + db: AppDatabase, + playlistId?: string, + includeLocked = false +) { + return ( + db + .select({ + favorite_id: schema.favorites.id, + id: schema.content.id, + category_id: schema.content.categoryId, + title: sql`COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title})`, + rating: schema.content.rating, + added: schema.content.added, + poster_url: sql< + string | null + >`COALESCE(NULLIF(TRIM(${schema.content.posterUrl}), ''), ${schema.favorites.posterUrl})`, + backdrop_url: sql< + string | null + >`COALESCE(NULLIF(TRIM(${schema.content.backdropUrl}), ''), ${schema.favorites.backdropUrl})`, + tmdb_id: sql< + number | null + >`COALESCE(${schema.content.tmdbId}, ${schema.favorites.tmdbId})`, + release_year: sql< + number | null + >`COALESCE(${schema.content.releaseYear}, ${schema.favorites.releaseYear})`, + original_title: sql< + string | null + >`COALESCE(NULLIF(TRIM(${schema.content.originalTitle}), ''), ${schema.favorites.originalTitle})`, + xtream_id: schema.favorites.xtreamId, + type: schema.favorites.type, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + playlist_id: schema.playlists.id, + playlist_name: schema.playlists.name, + added_at: schema.favorites.addedAt, + position: schema.favorites.position, + catalog_status: favoriteCatalogStatus(), + }) + .from(schema.favorites) + .leftJoin( + schema.content, + eq(schema.content.id, resolvedFavoriteContentId()) + ) + .leftJoin( + schema.categories, + eq(schema.categories.id, schema.content.categoryId) + ) + .innerJoin( + schema.playlists, + eq(schema.playlists.id, schema.favorites.playlistId) + ) + .leftJoin( + schema.appState, + eq(schema.appState.key, favoriteImportStateKey()) + ) + // While locked, unresolved category ownership must fail closed too. + .where( + and( + playlistId === undefined + ? undefined + : eq(schema.favorites.playlistId, playlistId), + includeLocked ? undefined : unlockedCategoryCondition() + ) + ) + .orderBy( + asc(schema.favorites.position), + desc(schema.favorites.addedAt) + ) + ); +} diff --git a/apps/electron-backend/src/app/database/operations/favorite-restore.ts b/apps/electron-backend/src/app/database/operations/favorite-restore.ts new file mode 100644 index 000000000..591fbe4c2 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/favorite-restore.ts @@ -0,0 +1,148 @@ +import { createHash } from 'node:crypto'; +import { and, eq } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import { + normalizeContentMetadataPatch, + type XtreamBackupFavoriteItem, +} from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { + checkpointOperation, + reportOperationProgress, + type OperationControl, +} from './operation-control'; + +function restoreReceiptKey( + playlistId: string, + items: XtreamBackupFavoriteItem[], + restoreId?: string +) { + // Canonical field order prevents a JSON reserialization from inventing a + // second legacy conversion and resurrecting a favorite removed meanwhile. + const payload = items.map((item) => ({ + contentType: item.contentType, + xtreamId: item.xtreamId, + addedAt: item.addedAt ?? null, + position: item.position ?? null, + snapshot: item.snapshot + ? { + title: item.snapshot.title, + posterUrl: item.snapshot.posterUrl ?? null, + backdropUrl: item.snapshot.backdropUrl ?? null, + tmdbId: item.snapshot.tmdbId ?? null, + releaseYear: item.snapshot.releaseYear ?? null, + originalTitle: item.snapshot.originalTitle ?? null, + } + : null, + })); + const digest = createHash('sha256') + .update(restoreId === undefined ? JSON.stringify(payload) : restoreId) + .digest('hex'); + return `xtream-favorites-restore:${[...playlistId].length}:${playlistId}:${restoreId === undefined ? 'legacy' : 'backup'}:${digest}`; +} + +function favoriteValue( + db: AppDatabase, + playlistId: string, + item: XtreamBackupFavoriteItem, + index: number +) { + if ( + !Number.isInteger(item.xtreamId) || + !['movie', 'series', 'live'].includes(item.contentType) + ) { + throw new Error('Invalid saved favorite identity'); + } + const candidate = db + .select({ content: schema.content }) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where( + and( + eq(schema.categories.playlistId, playlistId), + eq(schema.content.xtreamId, item.xtreamId), + eq(schema.content.type, item.contentType) + ) + ) + .orderBy(schema.content.id) + .limit(1) + .get()?.content; + const metadata = normalizeContentMetadataPatch(item.snapshot); + const conflicts = + candidate && + ((metadata?.tmdbId != null && + candidate.tmdbId != null && + metadata.tmdbId !== candidate.tmdbId) || + (metadata?.releaseYear != null && + candidate.releaseYear != null && + metadata.releaseYear !== candidate.releaseYear)); + const content = conflicts ? undefined : candidate; + return { + playlistId, + xtreamId: item.xtreamId, + type: item.contentType, + contentId: content?.id ?? null, + title: + item.snapshot?.title?.trim() || + content?.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, + addedAt: item.addedAt ?? new Date().toISOString(), + position: item.position ?? index, + }; +} + +/** + * One atomic conversion: pending legacy/backup state and its receipt commit + * together. Reads and writes are synchronous inside the transaction; a failed + * insert leaves neither partial favorites nor a receipt that would suppress retry. + * Existing canonical favorites win over replayed metadata and display order. + */ +export async function restoreXtreamFavorites( + db: AppDatabase, + playlistId: string, + favorites: XtreamBackupFavoriteItem[], + control?: OperationControl, + restoreId?: string +): Promise<{ success: boolean }> { + if (favorites.length === 0) return { success: true }; + await checkpointOperation(control); + const key = restoreReceiptKey(playlistId, favorites, restoreId); + db.transaction((tx) => { + if ( + tx + .select({ key: schema.appState.key }) + .from(schema.appState) + .where(eq(schema.appState.key, key)) + .get() + ) + return; + for (const [index, item] of favorites.entries()) { + tx.insert(schema.favorites) + .values(favoriteValue(tx, playlistId, item, index)) + .onConflictDoNothing({ + target: [ + schema.favorites.playlistId, + schema.favorites.type, + schema.favorites.xtreamId, + ], + }) + .run(); + } + tx.insert(schema.appState).values({ key, value: 'completed' }).run(); + }); + await reportOperationProgress(control, { + phase: 'restoring-favorites', + current: favorites.length, + total: favorites.length, + increment: favorites.length, + }); + return { success: true }; +} 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 new file mode 100644 index 000000000..ed82d193a --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts @@ -0,0 +1,53 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; + +describe('durable Xtream favorites against initialized SQLite', () => { + it.each([ + 'refresh', + 'empty', + 'restore', + 'identity', + 'collision', + 'scope', + 'lock', + 'receipt', + ])( + '%s preserves the saved favorite independently of catalog rows', + (scenario) => { + const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-favorites-')); + try { + expect( + execFileSync( + createRequire(__filename)('electron'), + [ + '--import', + 'tsx', + resolve(__dirname, 'testing/favorites-durable.ts'), + scenario, + ], + { + cwd: process.cwd(), + encoding: 'utf8', + timeout: 30_000, + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + IPTVNATOR_E2E_DATA_DIR: dataDir, + TSX_TSCONFIG_PATH: resolve( + process.cwd(), + 'tsconfig.base.json' + ), + }, + } + ) + ).toBe('favorites verified'); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }, + 35_000 + ); +}); diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts index 81a413a59..e40af42e2 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.spec.ts @@ -39,6 +39,7 @@ function createGlobalFavoritesDbMock(rows: unknown[]) { const query = { from: jest.fn(), innerJoin: jest.fn(), + leftJoin: jest.fn(), limit: jest.fn(), orderBy: jest.fn(), then: jest.fn((resolve, reject) => @@ -48,6 +49,7 @@ function createGlobalFavoritesDbMock(rows: unknown[]) { }; query.from.mockReturnValue(query); query.innerJoin.mockReturnValue(query); + query.leftJoin.mockReturnValue(query); query.where.mockReturnValue(query); query.orderBy.mockReturnValue(query); query.limit.mockResolvedValue(rows); @@ -85,7 +87,9 @@ describe('favorites.operations', () => { const result = await getGlobalFavorites(db); - expect(whereMock).not.toHaveBeenCalled(); + expect(whereMock).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'and' }) + ); expect(query.limit).not.toHaveBeenCalled(); expect(result).toEqual([ expect.objectContaining({ @@ -152,7 +156,7 @@ describe('favorites.operations', () => { ]) ).resolves.toEqual({ success: true }); - expect(updatePrepare).toHaveBeenCalledTimes(1); + expect(updatePrepare).toHaveBeenCalledTimes(2); expect(placeholderMock).toHaveBeenCalledWith('position'); expect(placeholderMock).toHaveBeenCalledWith('contentId'); // Regression: favorites are playlist-scoped, so the UPDATE must diff --git a/apps/electron-backend/src/app/database/operations/favorites.operations.ts b/apps/electron-backend/src/app/database/operations/favorites.operations.ts index 6deaf27bf..962d4b46b 100644 --- a/apps/electron-backend/src/app/database/operations/favorites.operations.ts +++ b/apps/electron-backend/src/app/database/operations/favorites.operations.ts @@ -1,5 +1,6 @@ -import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'; +import { and, eq, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; +import { XTREAM_FAVORITE_IDENTITY_CONFLICT } from '@iptvnator/shared/interfaces'; import type { AppDatabase } from '../database.types'; import { chunkValues, @@ -9,6 +10,12 @@ import { } from './operation-control'; import { persistContentMetadataIfMissing } from './content-metadata.operations'; +import { + findFavoriteContent, + favoriteMatchesContentId, +} from './favorite-catalog'; +import { selectFavoriteRows } from './favorite-queries'; + const DEFAULT_BATCH_SIZE = 100; export async function addFavorite( @@ -16,29 +23,56 @@ export async function addFavorite( contentId: number, playlistId: string, options?: { backdropUrl?: string } -): Promise<{ success: boolean }> { - await db.insert(schema.favorites).values({ - contentId, - playlistId, - }); - +): Promise<{ success: boolean; error?: string }> { + let content = await findFavoriteContent(db, playlistId, { contentId }); + if (!content) + throw new Error('Favorite content does not belong to the playlist'); await persistContentMetadataIfMissing(db, contentId, { backdropUrl: options?.backdropUrl, }); + content = + (await findFavoriteContent(db, playlistId, { contentId })) ?? content; + await db + .insert(schema.favorites) + .values({ + contentId, + playlistId, + xtreamId: content.xtreamId, + type: content.type, + title: content.title, + posterUrl: content.posterUrl, + backdropUrl: content.backdropUrl, + tmdbId: content.tmdbId, + releaseYear: content.releaseYear, + originalTitle: content.originalTitle, + }) + .onConflictDoNothing({ + target: [ + schema.favorites.playlistId, + schema.favorites.type, + schema.favorites.xtreamId, + ], + }); - return { success: true }; + // A reused provider ID may already belong to a different saved title. + // Keep that favorite and acknowledge only a compatible persisted record. + return (await isFavorite(db, contentId, playlistId)) + ? { success: true } + : { success: false, error: XTREAM_FAVORITE_IDENTITY_CONFLICT }; } export async function removeFavorite( db: AppDatabase, - contentId: number, + contentId: number | { favoriteId: number }, playlistId: string ): Promise<{ success: boolean }> { await db .delete(schema.favorites) .where( and( - eq(schema.favorites.contentId, contentId), + typeof contentId === 'number' + ? favoriteMatchesContentId(contentId) + : eq(schema.favorites.id, contentId.favoriteId), eq(schema.favorites.playlistId, playlistId) ) ); @@ -56,7 +90,7 @@ export async function isFavorite( .from(schema.favorites) .where( and( - eq(schema.favorites.contentId, contentId), + favoriteMatchesContentId(contentId), eq(schema.favorites.playlistId, playlistId) ) ); @@ -65,93 +99,36 @@ export async function isFavorite( } export async function getFavorites(db: AppDatabase, playlistId: string) { - return db - .select({ - id: schema.content.id, - category_id: schema.content.categoryId, - title: schema.content.title, - rating: schema.content.rating, - added: schema.content.added, - poster_url: schema.content.posterUrl, - xtream_id: schema.content.xtreamId, - type: schema.content.type, - tv_archive: schema.content.tvArchive, - tv_archive_duration: schema.content.tvArchiveDuration, - added_at: schema.favorites.addedAt, - position: schema.favorites.position, - }) - .from(schema.favorites) - .innerJoin( - schema.content, - eq(schema.favorites.contentId, schema.content.id) - ) - .where(eq(schema.favorites.playlistId, playlistId)) - .orderBy( - asc(schema.favorites.position), - desc(schema.favorites.addedAt) - ); + return selectFavoriteRows(db, playlistId); +} + +/** Archive export preserves all user-owned favorites, including locked/missing items. */ +export async function getFavoritesForBackup( + db: AppDatabase, + playlistId: string +) { + return selectFavoriteRows(db, playlistId, true); } export async function getGlobalFavorites(db: AppDatabase) { - const favorites = await selectGlobalFavoriteRows(db, { - includeBackdrop: false, - }); - - return favorites.filter((favorite) => favorite.type === 'live').slice(0, 300); + const favorites = await selectFavoriteRows(db); + return favorites + .filter((favorite) => favorite.type === 'live') + .slice(0, 300); } export async function getAllGlobalFavorites(db: AppDatabase) { - return selectGlobalFavoriteRows(db, { includeBackdrop: true }).limit(500); -} - -function selectGlobalFavoriteRows( - db: AppDatabase, - options: { includeBackdrop: boolean } -) { - return db - .select({ - id: schema.content.id, - category_id: schema.content.categoryId, - title: schema.content.title, - rating: schema.content.rating, - added: schema.content.added, - poster_url: schema.content.posterUrl, - ...(options.includeBackdrop - ? { - backdrop_url: schema.content.backdropUrl, - tmdb_id: schema.content.tmdbId, - release_year: schema.content.releaseYear, - original_title: schema.content.originalTitle, - } - : {}), - xtream_id: schema.content.xtreamId, - type: schema.content.type, - tv_archive: schema.content.tvArchive, - tv_archive_duration: schema.content.tvArchiveDuration, - playlist_id: schema.playlists.id, - playlist_name: schema.playlists.name, - added_at: schema.favorites.addedAt, - position: schema.favorites.position, - }) - .from(schema.favorites) - .innerJoin( - schema.content, - eq(schema.favorites.contentId, schema.content.id) - ) - .innerJoin( - schema.categories, - eq(schema.content.categoryId, schema.categories.id) - ) - .innerJoin( - schema.playlists, - eq(schema.categories.playlistId, schema.playlists.id) - ) - .orderBy(asc(schema.favorites.position), desc(schema.favorites.addedAt)); + return selectFavoriteRows(db).limit(500); } export async function reorderGlobalFavorites( db: AppDatabase, - updates: { content_id: number; playlist_id: string; position: number }[], + updates: { + content_id?: number; + favorite_id?: number; + playlist_id: string; + position: number; + }[], control?: OperationControl ): Promise<{ success: boolean }> { if (!Array.isArray(updates) || updates.length === 0) { @@ -171,7 +148,18 @@ export async function reorderGlobalFavorites( .set({ position: sql`${sql.placeholder('position')}` }) .where( and( - eq(schema.favorites.contentId, sql.placeholder('contentId')), + favoriteMatchesContentId(sql.placeholder('contentId')), + eq(schema.favorites.playlistId, sql.placeholder('playlistId')) + ) + ) + .prepare(); + + const updateByFavoriteId = db + .update(schema.favorites) + .set({ position: sql`${sql.placeholder('position')}` }) + .where( + and( + eq(schema.favorites.id, sql.placeholder('favoriteId')), eq(schema.favorites.playlistId, sql.placeholder('playlistId')) ) ) @@ -181,7 +169,21 @@ export async function reorderGlobalFavorites( await checkpointOperation(control); await db.transaction(() => { - for (const { content_id, playlist_id, position } of chunk) { + for (const { + content_id, + favorite_id, + playlist_id, + position, + } of chunk) { + if (favorite_id !== undefined) { + updateByFavoriteId.run({ + position, + favoriteId: favorite_id, + playlistId: playlist_id, + }); + continue; + } + if (content_id === undefined) continue; // Must be .run() (synchronous), NOT .execute(): on the // better-sqlite3 driver .execute() defers the write to a // resolved promise, which never settles inside this diff --git a/apps/electron-backend/src/app/database/operations/playlist.operations.ts b/apps/electron-backend/src/app/database/operations/playlist.operations.ts index 7196601a7..9e297efdd 100644 --- a/apps/electron-backend/src/app/database/operations/playlist.operations.ts +++ b/apps/electron-backend/src/app/database/operations/playlist.operations.ts @@ -1,3 +1,4 @@ +import { reconcileCompletedFavoriteCatalog } from './favorite-catalog'; import { eq, sql } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import type { Channel, M3uFavoriteChannel } from '@iptvnator/shared/interfaces'; @@ -613,6 +614,7 @@ export async function setAppState( set: { value, updatedAt }, }); + await reconcileCompletedFavoriteCatalog(db, key, value); return { success: true }; } 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 new file mode 100644 index 000000000..254490928 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts @@ -0,0 +1,392 @@ +import assert from 'node:assert/strict'; +import { createFavoriteTestCatalog } from './favorites-test-catalog'; +import { eq, sql } from 'drizzle-orm'; +import { closeDatabase, initDatabase } from '@iptvnator/shared/database'; +import * as schema from '@iptvnator/shared/database/schema'; +import { setParentalLockActive } from '../../parental-lock-state'; +import { + addFavorite, + getFavorites, + getFavoritesForBackup, + getAllGlobalFavorites, + getGlobalFavorites, + removeFavorite, + reorderGlobalFavorites, + isFavorite, +} from '../favorites.operations'; +import { + deleteXtreamContent, + restoreXtreamUserData, + restoreXtreamFavorites, +} from '../xtream.operations'; +import { persistContentMetadataIfMissing } from '../content-metadata.operations'; +import { setAppState } from '../playlist.operations'; + +async function main() { + assert(process.env['IPTVNATOR_E2E_DATA_DIR']); + console.log = () => undefined; + const db = await initDatabase(); + await db.insert(schema.playlists).values([ + { id: 'p', name: 'Source', type: 'xtream' }, + { id: 'other', name: 'Other source', type: 'xtream' }, + ]); + const catalog = createFavoriteTestCatalog(db); + const status = (value: string, type = 'movie') => + setAppState(db, `xtream-import-status:p:${type}`, value); + const favorites = () => getFavorites(db, 'p'); + const backup = [ + { + xtreamId: 7, + contentType: 'movie' as const, + addedAt: '2025-01-01', + position: 4, + }, + ]; + await catalog(1, 7); + await addFavorite(db, 1, 'p'); + const [original] = await favorites(); + const scenario = process.argv[2]; + if (scenario === 'refresh') { + await persistContentMetadataIfMissing(db, 1, { + tmdbId: 123, + releaseYear: 2001, + }); + const removed = await deleteXtreamContent(db, 'p'); + await catalog(2, 8, 'DEF'); + await restoreXtreamUserData(db, 'p', removed.favorites, []); + await status('completed'); + const [saved] = await favorites(); + assert(saved, 'ABC must remain after only DEF is imported'); + assert.equal(saved.title, 'ABC'); + assert.equal(saved.tmdb_id, 123); + assert.equal(saved.release_year, 2001); + assert.equal(saved.id, null); + assert.equal(saved.favorite_id, original.favorite_id); + assert.equal(saved.catalog_status, 'missing'); + assert.deepEqual( + removed.favorites, + [], + 'refresh must not replay canonical favorites' + ); + assert.equal((await getAllGlobalFavorites(db))[0].title, 'ABC'); + await deleteXtreamContent(db, 'p'); + await catalog(3, 7, 'ABC renamed', 'movie', 'p', { + posterUrl: '', + backdropUrl: '', + }); + await status('completed'); + const [returned] = await favorites(); + assert.equal(returned.id, 3); + assert.equal(returned.favorite_id, original.favorite_id); + assert.equal(returned.catalog_status, 'present'); + assert.equal(returned.poster_url, original.poster_url); + assert.equal( + (await db.select().from(schema.favorites))[0].posterUrl, + original.poster_url + ); + assert.equal( + (await db.select().from(schema.favorites))[0].contentId, + 3 + ); + assert(await isFavorite(db, 3, 'p')); + await addFavorite(db, 3, 'p'); + assert.equal((await favorites()).length, 1); + await removeFavorite(db, 3, 'p'); + assert.deepEqual(await favorites(), []); + } else if (scenario === 'empty') { + await deleteXtreamContent(db, 'p'); + for (const value of ['idle', 'importing', 'failed', 'cancelled']) { + await status(value); + assert.equal( + (await favorites())[0]?.catalog_status, + 'unknown', + value + ); + } + await status('completed'); + assert.equal((await favorites())[0]?.catalog_status, 'missing'); + await deleteXtreamContent(db, 'p'); + assert.equal((await favorites())[0]?.catalog_status, 'unknown'); + await reorderGlobalFavorites(db, [ + { + favorite_id: original.favorite_id, + content_id: 999, + playlist_id: 'p', + position: 9, + }, + ]); + assert.equal((await favorites())[0]?.position, 9); + await removeFavorite(db, { favoriteId: original.favorite_id }, 'other'); + assert.equal((await favorites()).length, 1); + await removeFavorite(db, { favoriteId: original.favorite_id }, 'p'); + assert.deepEqual(await favorites(), []); + await catalog(2, 7); + await status('completed'); + assert.deepEqual( + await favorites(), + [], + 'removed missing favorites must not reappear' + ); + } else if (scenario === 'restore') { + await restoreXtreamUserData( + db, + 'p', + [ + { + ...backup[0], + xtreamId: 99, + snapshot: { + title: 'Saved offline', + tmdbId: 31, + releaseYear: 2001, + }, + }, + ], + [] + ); + assert.equal( + (await favorites()).length, + 2, + 'restore must not wipe existing favorite' + ); + await restoreXtreamUserData( + db, + 'p', + [{ ...backup[0], xtreamId: 99 }], + [] + ); + const rows = await favorites(); + assert.equal(rows.length, 2); + assert.equal( + rows.find((row) => row.xtream_id === 99)?.title, + 'Saved offline' + ); + await restoreXtreamUserData( + db, + 'p', + [{ ...backup[0], xtreamId: 100 }], + [] + ); + assert((await favorites()).find((row) => row.xtream_id === 100)?.title); + await catalog(3, 100, 'Recovered title'); + await status('completed'); + await deleteXtreamContent(db, 'p'); + assert.equal( + (await favorites()).find((row) => row.xtream_id === 100)?.title, + 'Recovered title', + 'legacy fallback must acquire a durable snapshot when content returns' + ); + await status('completed'); + assert.equal( + (await favorites()).find((row) => row.xtream_id === 99) + ?.catalog_status, + 'missing' + ); + } else if (scenario === 'identity') { + await catalog(10, 7, 'ABC in second category'); + assert( + await isFavorite(db, 10, 'p'), + 'every category row of one provider identity must be favorite' + ); + await reorderGlobalFavorites(db, [ + { content_id: 10, playlist_id: 'p', position: 12 }, + ]); + assert.equal((await favorites())[0].position, 12); + await removeFavorite(db, 10, 'p'); + assert.deepEqual(await favorites(), []); + await addFavorite(db, 1, 'p'); + await catalog(2, 7, 'Other movie', 'movie', 'other'); + await catalog(3, 7, 'Live', 'live', 'p', { + tvArchive: 1, + tvArchiveDuration: 7, + }); + await catalog(4, 7, 'Series', 'series'); + await addFavorite(db, 2, 'other'); + await addFavorite(db, 3, 'p'); + await addFavorite(db, 4, 'p'); + await persistContentMetadataIfMissing(db, 4, { tmdbId: 345 }); + await assert.rejects( + addFavorite(db, 2, 'p'), + 'cross-playlist content must be rejected' + ); + assert.equal((await favorites()).length, 3); + const [live] = await getGlobalFavorites(db); + assert.equal(live.tv_archive, 1); + assert.equal(live.tv_archive_duration, 7); + await deleteXtreamContent(db, 'p'); + await catalog(5, 7, 'Returned live', 'live'); + await status('completed', 'live'); + await status('completed'); + const rows = await favorites(); + assert.equal(rows.find((row) => row.type === 'movie')?.id, null); + assert.equal(rows.find((row) => row.type === 'live')?.id, 5); + await catalog(11, 7, 'Unrelated series', 'series', 'p', { + tmdbId: 456, + }); + await status('completed', 'series'); + assert.equal( + (await favorites()).find((row) => row.type === 'series')?.id, + null + ); + assert.equal((await getAllGlobalFavorites(db)).length, 4); + } else if (scenario === 'collision') { + await removeFavorite(db, 1, 'p'); + await db + .update(schema.content) + .set({ tmdbId: 10, releaseYear: 2000 }) + .where(eq(schema.content.id, 1)); + await addFavorite(db, 1, 'p'); + for (const [id, tmdbId, releaseYear] of [ + [2, 20, 2000], + [3, 10, 2020], + ]) { + await deleteXtreamContent(db, 'p'); + await catalog(id, 7, 'Unrelated', 'movie', 'p', { + tmdbId, + releaseYear, + }); + await status('completed'); + const [saved] = await favorites(); + assert.equal(saved?.id, null); + assert.equal(saved?.title, 'ABC'); + assert.equal(saved?.catalog_status, 'missing'); + const beforeAdd = await db.select().from(schema.favorites); + assert.equal(await isFavorite(db, id, 'p'), false); + assert.deepEqual(await addFavorite(db, id, 'p'), { + success: false, + error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT', + }); + assert.equal(await isFavorite(db, id, 'p'), false); + assert.deepEqual( + await db.select().from(schema.favorites), + beforeAdd + ); + } + await deleteXtreamContent(db, 'p'); + await catalog(4, 7, 'ABC renamed', 'movie', 'p', { + tmdbId: 10, + releaseYear: 2000, + }); + await status('completed'); + assert.equal((await favorites())[0]?.id, 4); + } else if (scenario === 'scope') { + await catalog(2, 7, 'Other', 'movie', 'other'); + await addFavorite(db, 2, 'other'); + await deleteXtreamContent(db, 'p'); + await db.delete(schema.playlists).where(eq(schema.playlists.id, 'p')); + assert.deepEqual(await favorites(), []); + assert.equal((await getAllGlobalFavorites(db)).length, 1); + } else if (scenario === 'receipt') { + const items = [ + { ...backup[0], xtreamId: 99, snapshot: { title: 'Offline' } }, + ]; + await restoreXtreamFavorites(db, 'p', items); + const saved = (await favorites()).find((row) => row.xtream_id === 99); + assert(saved); + await removeFavorite(db, { favoriteId: saved.favorite_id }, 'p'); + await restoreXtreamFavorites(db, 'p', items); + assert.equal( + (await favorites()).length, + 1, + 'legacy receipt prevents stale replay' + ); + await restoreXtreamFavorites(db, 'p', [ + { + snapshot: { title: 'Offline' }, + position: 4, + addedAt: '2025-01-01', + contentType: 'movie', + xtreamId: 99, + }, + ]); + assert.equal( + (await favorites()).length, + 1, + 'receipt ignores JSON property order' + ); + await restoreXtreamFavorites( + db, + 'p', + items, + undefined, + 'explicit-backup' + ); + assert.equal( + (await favorites()).length, + 2, + 'new backup can intentionally restore' + ); + await db.run( + sql`CREATE TRIGGER reject_test_favorite BEFORE INSERT ON favorites WHEN NEW.xtream_id = 100 BEGIN SELECT RAISE(ABORT, 'injected failure'); END` + ); + await assert.rejects( + restoreXtreamFavorites( + db, + 'p', + [ + { ...items[0], xtreamId: 101 }, + { ...items[0], xtreamId: 100 }, + ], + undefined, + 'retry-failed' + ) + ); + assert.equal( + (await favorites()).length, + 2, + 'favorite writes roll back together with receipt' + ); + await db.run(sql`DROP TRIGGER reject_test_favorite`); + await restoreXtreamFavorites( + db, + 'p', + [ + { ...items[0], xtreamId: 101 }, + { ...items[0], xtreamId: 100 }, + ], + undefined, + 'retry-failed' + ); + assert.equal((await favorites()).length, 4); + await db.delete(schema.playlists).where(eq(schema.playlists.id, 'p')); + assert.deepEqual( + (await db.select().from(schema.appState)).filter((row) => + row.key.startsWith('xtream-') + ), + [], + 'playlist deletion clears restore receipts' + ); + } else if (scenario === 'lock') { + setParentalLockActive(true); + await db.update(schema.categories).set({ locked: true }); + assert.deepEqual(await favorites(), []); + assert.equal( + (await getFavoritesForBackup(db, 'p')).length, + 1, + 'backup includes locked favorites' + ); + setParentalLockActive(false); + assert.equal((await favorites()).length, 1); + await deleteXtreamContent(db, 'p'); + setParentalLockActive(true); + assert.deepEqual( + await favorites(), + [], + 'unknown category ownership must be withheld' + ); + assert.equal( + (await getFavoritesForBackup(db, 'p'))[0]?.title, + 'ABC', + 'backup includes missing favorites while locked' + ); + setParentalLockActive(false); + assert.equal((await favorites()).length, 1); + } + closeDatabase(); + process.stdout.write('favorites verified'); +} +main().catch((error) => { + console.error(error); + closeDatabase(); + process.exitCode = 1; +}); diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-test-catalog.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-test-catalog.ts new file mode 100644 index 000000000..b7d1df1bb --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-test-catalog.ts @@ -0,0 +1,31 @@ +import type { AppDatabase } from '../../database.types'; +import * as schema from '@iptvnator/shared/database/schema'; + +export function createFavoriteTestCatalog(db: AppDatabase) { + return async function catalog( + id: number, + xtreamId: number, + title = 'ABC', + type: 'movie' | 'live' | 'series' = 'movie', + playlistId = 'p', + metadata = {} + ) { + await db.insert(schema.categories).values({ + id, + playlistId, + name: 'Category', + type: type === 'movie' ? 'movies' : type, + xtreamId: id, + }); + await db.insert(schema.content).values({ + id, + categoryId: id, + title, + xtreamId, + type, + posterUrl: 'https://art.invalid/poster.jpg', + backdropUrl: 'https://art.invalid/backdrop.jpg', + ...metadata, + }); + }; +} diff --git a/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts b/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts index 8a7effa77..3ca8fcfb3 100644 --- a/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts +++ b/apps/electron-backend/src/app/database/operations/xtream-delete-performance.spec.ts @@ -52,14 +52,6 @@ function createDeleteHarness() { { hidden: true, id: 11, type: 'live', xtreamId: 101 }, { hidden: false, id: 12, type: 'movies', xtreamId: 102 }, ]; - const favorites = [ - { - addedAt: '2026-01-01T00:00:00.000Z', - contentType: 'live', - position: 3, - xtreamId: 201, - }, - ]; const recentlyViewed = [ { contentType: 'movie', @@ -79,9 +71,6 @@ function createDeleteHarness() { .mockReturnValueOnce( createSelectStep(categories, 'select:categories', timeline) ) - .mockReturnValueOnce( - createSelectStep(favorites, 'select:favorites', timeline) - ) .mockReturnValueOnce( createSelectStep(recentlyViewed, 'select:recently-viewed', timeline) ) @@ -110,7 +99,15 @@ function createDeleteHarness() { return { countFilters, - db: { select, transaction } as unknown as AppDatabase, + db: { + select, + transaction, + update: jest.fn(() => ({ + set: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })), + })), + } as unknown as AppDatabase, deleteFilters, timeline, transaction, @@ -145,14 +142,7 @@ describe('Xtream delete performance phases', () => { recording.capture ) ).resolves.toEqual({ - favorites: [ - { - addedAt: '2026-01-01T00:00:00.000Z', - contentType: 'live', - position: 3, - xtreamId: 201, - }, - ], + favorites: [], hiddenCategories: [{ categoryType: 'live', xtreamId: 101 }], recentlyViewed: [ { @@ -169,7 +159,7 @@ describe('Xtream delete performance phases', () => { expect(harness.transaction).toHaveBeenCalledTimes(2); // Progress reporting performs a second cooperative checkpoint after // each committed batch. - expect(checkpoint).toHaveBeenCalledTimes(4); + expect(checkpoint).toHaveBeenCalledTimes(5); expect(onProgress.mock.calls.map(([value]) => value)).toEqual([ { phase: 'deleting-content', @@ -184,9 +174,8 @@ describe('Xtream delete performance phases', () => { increment: 2, }, ]); - expect(harness.timeline.slice(1, 5)).toEqual([ + expect(harness.timeline.slice(2, 5)).toEqual([ 'select:categories', - 'select:favorites', 'select:recently-viewed', 'select:content', ]); @@ -264,7 +253,7 @@ describe('Xtream delete performance phases', () => { { checkpoint: () => { checkpointCount += 1; - if (checkpointCount === 2) { + if (checkpointCount === 3) { throw cancellation; } }, @@ -286,6 +275,11 @@ describe('Xtream delete performance phases', () => { const from = jest.fn(() => ({ where })); const db = { select: jest.fn(() => ({ from })), + update: jest.fn(() => ({ + set: jest.fn(() => ({ + where: jest.fn().mockResolvedValue(undefined), + })), + })), } as unknown as AppDatabase; const recording = createRecordingOperationPhaseCapture(); diff --git a/apps/electron-backend/src/app/database/operations/xtream.operations.ts b/apps/electron-backend/src/app/database/operations/xtream.operations.ts index bc385bd61..a8e7c3c58 100644 --- a/apps/electron-backend/src/app/database/operations/xtream.operations.ts +++ b/apps/electron-backend/src/app/database/operations/xtream.operations.ts @@ -1,3 +1,5 @@ +import { restoreXtreamFavorites } from './favorite-restore'; +export { restoreXtreamFavorites } from './favorite-restore'; import { and, eq, inArray } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import { @@ -69,37 +71,11 @@ async function collectXtreamDeletionRows( categoryType: category.type, })); - let favorites: XtreamBackupFavoriteItem[] = []; + const favorites: XtreamBackupFavoriteItem[] = []; let recentlyViewed: XtreamBackupRecentlyViewedItem[] = []; let contentRowCounts: CategoryRowCount[] = []; if (categoryIds.length > 0) { - const favoritedContent = await db - .select({ - xtreamId: schema.content.xtreamId, - contentType: schema.content.type, - addedAt: schema.favorites.addedAt, - position: schema.favorites.position, - }) - .from(schema.favorites) - .innerJoin( - schema.content, - eq(schema.favorites.contentId, schema.content.id) - ) - .where( - and( - eq(schema.favorites.playlistId, playlistId), - inArray(schema.content.categoryId, categoryIds) - ) - ); - - favorites = favoritedContent.map((favorite) => ({ - xtreamId: favorite.xtreamId, - contentType: favorite.contentType, - addedAt: favorite.addedAt ?? undefined, - position: favorite.position, - })); - const recentlyViewedContent = await db .select({ xtreamId: schema.content.xtreamId, @@ -186,6 +162,18 @@ export async function deleteXtreamContent( recentlyViewed: XtreamBackupRecentlyViewedItem[]; hiddenCategories: XtreamBackupHiddenCategory[]; }> { + await checkpointOperation(control); + await db + .update(schema.appState) + .set({ value: 'idle' }) + .where( + inArray( + schema.appState.key, + ['live', 'movie', 'series'].map( + (type) => `xtream-import-status:${playlistId}:${type}` + ) + ) + ); const collection = capturePhase ? await capturePhase.captureAsync( XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA, @@ -265,10 +253,7 @@ export async function restoreXtreamUserData( recentlyViewed: XtreamBackupRecentlyViewedItem[], control?: OperationControl ): Promise<{ success: boolean }> { - await checkpointOperation(control); - await db - .delete(schema.favorites) - .where(eq(schema.favorites.playlistId, playlistId)); + await restoreXtreamFavorites(db, playlistId, favorites, control); await checkpointOperation(control); await db @@ -276,61 +261,12 @@ export async function restoreXtreamUserData( .where(eq(schema.recentlyViewed.playlistId, playlistId)); const contentByIdentity = await getContentIdentityMap(db, playlistId, [ - ...favorites.map((item) => ({ - contentType: item.contentType, - xtreamId: item.xtreamId, - })), ...recentlyViewed.map((item) => ({ contentType: item.contentType, xtreamId: item.xtreamId, })), ]); - const favoriteValues = favorites - .map((item, index) => { - const contentId = contentByIdentity.get( - toContentIdentityKey(item.contentType, item.xtreamId) - ); - - if (!contentId) { - return null; - } - - return { - contentId, - playlistId, - addedAt: item.addedAt ?? new Date().toISOString(), - position: item.position ?? index, - }; - }) - .filter( - ( - value - ): value is { - contentId: number; - playlistId: string; - addedAt: string; - position: number | null; - } => value !== null - ); - - let restoredFavorites = 0; - const totalFavorites = favoriteValues.length; - - for (const chunk of chunkValues(favoriteValues, DEFAULT_BATCH_SIZE)) { - await checkpointOperation(control); - await db.transaction((tx) => { - tx.insert(schema.favorites).values(chunk).run(); - }); - restoredFavorites += chunk.length; - await reportOperationProgress(control, { - phase: 'restoring-favorites', - current: restoredFavorites, - total: totalFavorites, - increment: chunk.length, - }); - } - const recentlyViewedValues = recentlyViewed .map((item) => { const contentId = contentByIdentity.get( diff --git a/apps/electron-backend/src/app/events/database/favorites.events.ts b/apps/electron-backend/src/app/events/database/favorites.events.ts index 2daef247b..db0c0fe0a 100644 --- a/apps/electron-backend/src/app/events/database/favorites.events.ts +++ b/apps/electron-backend/src/app/events/database/favorites.events.ts @@ -1,8 +1,13 @@ +import type { + ElectronBridgeFavoriteReorderUpdate, + XtreamFavoriteRemovalRef, +} from '@iptvnator/shared/interfaces'; /** * Favorites IPC event handlers * Operations for managing user's favorite content */ +import type { XtreamBackupFavoriteItem } from '@iptvnator/shared/interfaces'; import { handleWorkerRequest } from './worker-events.utils'; handleWorkerRequest( @@ -16,7 +21,7 @@ handleWorkerRequest( handleWorkerRequest( 'DB_REMOVE_FAVORITE', - (contentId: number, playlistId: string) => ({ + (contentId: XtreamFavoriteRemovalRef, playlistId: string) => ({ contentId, playlistId, }) @@ -39,7 +44,20 @@ handleWorkerRequest('DB_GET_ALL_GLOBAL_FAVORITES', () => ({})); handleWorkerRequest( 'DB_REORDER_GLOBAL_FAVORITES', - (updates: { content_id: number; playlist_id: string; position: number }[]) => ({ + (updates: ElectronBridgeFavoriteReorderUpdate[]) => ({ updates, }) ); + +handleWorkerRequest( + 'DB_RESTORE_XTREAM_FAVORITES', + ( + playlistId: string, + favorites: XtreamBackupFavoriteItem[], + restoreId?: string + ) => ({ playlistId, favorites, restoreId }) +); + +handleWorkerRequest('DB_GET_FAVORITES_FOR_BACKUP', (playlistId: string) => ({ + playlistId, +})); diff --git a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts index 51b4b49ea..64d66cc27 100644 --- a/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts +++ b/apps/electron-backend/src/app/events/database/worker-ipc-contract.spec-data.ts @@ -259,6 +259,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ payload: { playlistId, favorites, recentlyViewed, operationId }, forwardsEvents: true, }, + { + operation: 'DB_RESTORE_XTREAM_FAVORITES', + args: [playlistId, favorites, 'restore-1'], + payload: { playlistId, favorites, restoreId: 'restore-1' }, + }, { operation: 'DB_ADD_FAVORITE', args: [12, playlistId, 'https://image.example/backdrop.jpg'], @@ -278,6 +283,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [12, playlistId], payload: { contentId: 12, playlistId }, }, + { + operation: 'DB_GET_FAVORITES_FOR_BACKUP', + args: [playlistId], + payload: { playlistId }, + }, { operation: 'DB_GET_FAVORITES', args: [playlistId], diff --git a/apps/electron-backend/src/app/workers/database-worker.types.ts b/apps/electron-backend/src/app/workers/database-worker.types.ts index 31ca8687f..ed20975b9 100644 --- a/apps/electron-backend/src/app/workers/database-worker.types.ts +++ b/apps/electron-backend/src/app/workers/database-worker.types.ts @@ -34,10 +34,12 @@ export const DB_WORKER_OPERATIONS = [ 'DB_DELETE_ALL_PLAYLISTS', 'DB_DELETE_XTREAM_CONTENT', 'DB_RESTORE_XTREAM_USER_DATA', + 'DB_RESTORE_XTREAM_FAVORITES', 'DB_ADD_FAVORITE', 'DB_REMOVE_FAVORITE', 'DB_IS_FAVORITE', 'DB_GET_FAVORITES', + 'DB_GET_FAVORITES_FOR_BACKUP', 'DB_GET_GLOBAL_FAVORITES', 'DB_GET_ALL_GLOBAL_FAVORITES', 'DB_REORDER_GLOBAL_FAVORITES', diff --git a/apps/electron-backend/src/app/workers/database.worker.ts b/apps/electron-backend/src/app/workers/database.worker.ts index fa36f4aa8..48a80de30 100644 --- a/apps/electron-backend/src/app/workers/database.worker.ts +++ b/apps/electron-backend/src/app/workers/database.worker.ts @@ -1,3 +1,7 @@ +import type { + ElectronBridgeFavoriteReorderUpdate, + XtreamFavoriteRemovalRef, +} from '@iptvnator/shared/interfaces'; import { migrateAppPlaylists } from '../database/operations/playlist-migration.operations'; import { closeWorkerDatabase, @@ -34,6 +38,7 @@ import { addFavorite, getAllGlobalFavorites, getFavorites, + getFavoritesForBackup, getGlobalFavorites, isFavorite, removeFavorite, @@ -111,6 +116,7 @@ import { import { deleteXtreamContent, restoreXtreamUserData, + restoreXtreamFavorites, } from '../database/operations/xtream.operations'; import { armWorkerPerformanceCapture, @@ -1046,6 +1052,20 @@ async function executeRequest( ); } + case 'DB_RESTORE_XTREAM_FAVORITES': { + const { playlistId, favorites, restoreId } = message.payload as { + playlistId: string; + favorites: XtreamBackupFavoriteItem[]; + restoreId?: string; + }; + return restoreXtreamFavorites( + db, + playlistId, + favorites, + undefined, + restoreId + ); + } case 'DB_ADD_FAVORITE': { const payload = message.payload as { contentId: number; @@ -1059,7 +1079,7 @@ async function executeRequest( case 'DB_REMOVE_FAVORITE': { const payload = message.payload as { - contentId: number; + contentId: XtreamFavoriteRemovalRef; playlistId: string; }; return removeFavorite(db, payload.contentId, payload.playlistId); @@ -1073,6 +1093,10 @@ async function executeRequest( return isFavorite(db, payload.contentId, payload.playlistId); } + case 'DB_GET_FAVORITES_FOR_BACKUP': { + const { playlistId } = message.payload as { playlistId: string }; + return getFavoritesForBackup(db, playlistId); + } case 'DB_GET_FAVORITES': { const payload = message.payload as { playlistId: string }; return getFavorites(db, payload.playlistId); @@ -1086,11 +1110,7 @@ async function executeRequest( case 'DB_REORDER_GLOBAL_FAVORITES': { const payload = message.payload as { - updates: { - content_id: number; - playlist_id: string; - position: number; - }[]; + updates: ElectronBridgeFavoriteReorderUpdate[]; }; return reorderGlobalFavorites(db, payload.updates); } @@ -1207,7 +1227,11 @@ async function executeRequest( playlistId: string; items: Parameters[2]; }; - return replaceAllPlaybackPositions(db, payload.playlistId, payload.items); + return replaceAllPlaybackPositions( + db, + payload.playlistId, + payload.items + ); } case 'DB_CLEAR_ALL_PLAYBACK_POSITIONS': { diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 3b9e77d05..66926a6d2 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "تمت الإزالة من المفضلة", "ADDED_TO_FAVORITES": "تمت الإضافة للمفضلة", "FAVORITES": "المفضلة", + "FAVORITE_IDENTITY_CONFLICT": "استبدل هذا المصدر عنصراً حفظته. احذف الإدخال القديم من المفضلة قبل إضافة هذا العنصر.", + "FAVORITE_SAVE_FAILED": "تعذر حفظ هذا العنصر في المفضلة. يرجى المحاولة مرة أخرى.", "RECENTLY_VIEWED": "تمت مشاهدتها مؤخرًا", "THIS_PLAYLIST": "قائمة التشغيل هذه", "ALL_PLAYLISTS": "جميع قوائم التشغيل", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "غير موجود في الكتالوج الحالي", + "CATALOG_UNKNOWN": "حالة الكتالوج غير معروفة", "TITLE": "المفضلة العامة", "SUBTITLE": "البث المباشر من جميع قوائم التشغيل", "SEARCH_PLACEHOLDER": "البحث في المفضلة...", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 6d9d83852..d7e361c3b 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "تحيد من المفضلة", "ADDED_TO_FAVORITES": "تزاد للمفضلة", "FAVORITES": "المفضلة", + "FAVORITE_IDENTITY_CONFLICT": "هاد المصدر بدّل واحد العنصر اللي حفظتي. حيد القديم من المفضلة قبل ما تزيد هادا.", + "FAVORITE_SAVE_FAILED": "ما قدرناش نحفظو هاد العنصر فالمفضلة. عاود حاول.", "RECENTLY_VIEWED": "متشافين مؤخراً", "THIS_PLAYLIST": "هاد قائمة التشغيل", "ALL_PLAYLISTS": "جميع قوائم التشغيل", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "ما كاينش فالفهرس الحالي", + "CATALOG_UNKNOWN": "حالة الفهرس ما معروفةش", "TITLE": "المفضلة العامة", "SUBTITLE": "البث المباشر من جميع قوائم التشغيل", "SEARCH_PLACEHOLDER": "قلب على المفضلة...", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 606bcd6bd..3c3d4f426 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Выдалена з выбранага", "ADDED_TO_FAVORITES": "Дададзена ў выбранае", "FAVORITES": "Выбранае", + "FAVORITE_IDENTITY_CONFLICT": "Крыніца замяніла захаваны вамі элемент. Выдаліце стары запіс з абранага, перш чым дадаць новы.", + "FAVORITE_SAVE_FAILED": "Не ўдалося захаваць абранае. Паспрабуйце яшчэ раз.", "RECENTLY_VIEWED": "Нядаўна праглянутае", "THIS_PLAYLIST": "Гэты плэйліст", "ALL_PLAYLISTS": "Усе плэйлісты", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Няма ў бягучым каталогу", + "CATALOG_UNKNOWN": "Стан каталога невядомы", "TITLE": "Усе фаварыты", "SUBTITLE": "Прамы эфір з усіх плэйлістоў", "SEARCH_PLACEHOLDER": "Пошук у выбраным...", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 11962d897..b23be84fb 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Aus Favoriten entfernt", "ADDED_TO_FAVORITES": "Zu den Favoriten hinzugefügt", "FAVORITES": "Favoriten", + "FAVORITE_IDENTITY_CONFLICT": "Diese Quelle hat einen gespeicherten Eintrag ersetzt. Entferne den alten Eintrag aus den Favoriten, bevor du diesen hinzufügst.", + "FAVORITE_SAVE_FAILED": "Der Favorit konnte nicht gespeichert werden. Bitte versuche es erneut.", "RECENTLY_VIEWED": "Zuletzt angesehen", "THIS_PLAYLIST": "Diese Playlist", "ALL_PLAYLISTS": "Alle Playlists", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Nicht im aktuellen Katalog", + "CATALOG_UNKNOWN": "Katalogstatus unbekannt", "TITLE": "Globale Favoriten", "SUBTITLE": "Live-TV aus allen Playlists", "SEARCH_PLACEHOLDER": "Favoriten durchsuchen...", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 7f2238a7b..5b9b4c002 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Αφαιρέθηκε από τα αγαπημένα", "ADDED_TO_FAVORITES": "Προστέθηκε στα αγαπημένα", "FAVORITES": "Αγαπημένα", + "FAVORITE_IDENTITY_CONFLICT": "Αυτή η πηγή αντικατέστησε ένα αποθηκευμένο στοιχείο. Αφαιρέστε την παλιά καταχώριση από τα αγαπημένα πριν προσθέσετε τη νέα.", + "FAVORITE_SAVE_FAILED": "Δεν ήταν δυνατή η αποθήκευση του αγαπημένου. Δοκιμάστε ξανά.", "RECENTLY_VIEWED": "Πρόσφατα προβληθέντα", "THIS_PLAYLIST": "Αυτή η λίστα αναπαραγωγής", "ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Δεν υπάρχει στον τρέχοντα κατάλογο", + "CATALOG_UNKNOWN": "Άγνωστη κατάσταση καταλόγου", "TITLE": "Βασικά Αγαπημένα", "SUBTITLE": "Ζωντανή TV από όλες τις λίστες αναπαραγωγής", "SEARCH_PLACEHOLDER": "Αναζήτηση αγαπημένων...", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 084229dc6..e740e8ebe 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Removed from favorites", "ADDED_TO_FAVORITES": "Added to favorites", "FAVORITES": "Favorites", + "FAVORITE_IDENTITY_CONFLICT": "This source replaced an item you saved. Remove the older entry from Favorites before adding this one.", + "FAVORITE_SAVE_FAILED": "Could not save this favorite. Please try again.", "RECENTLY_VIEWED": "Recently viewed", "THIS_PLAYLIST": "This playlist", "ALL_PLAYLISTS": "All playlists", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Not in the current catalog", + "CATALOG_UNKNOWN": "Catalog status unknown", "TITLE": "Global Favorites", "SUBTITLE": "Live TV from all playlists", "SEARCH_PLACEHOLDER": "Search favorites...", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 19874b2cf..1bf2a2f40 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Eliminado de favoritos", "ADDED_TO_FAVORITES": "Añadido a favoritos", "FAVORITES": "Favoritos", + "FAVORITE_IDENTITY_CONFLICT": "Esta fuente ha sustituido un elemento guardado. Elimina la entrada anterior de Favoritos antes de añadir esta.", + "FAVORITE_SAVE_FAILED": "No se pudo guardar este favorito. Inténtalo de nuevo.", "RECENTLY_VIEWED": "Vistos recientemente", "THIS_PLAYLIST": "Esta lista de reproducción", "ALL_PLAYLISTS": "Todas las listas", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "No está en el catálogo actual", + "CATALOG_UNKNOWN": "Estado del catálogo desconocido", "TITLE": "Favoritos globales", "SUBTITLE": "TV en vivo de todas las listas", "SEARCH_PLACEHOLDER": "Buscar favoritos…", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 5b01c03a0..02fd8135a 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Retiré des favoris", "ADDED_TO_FAVORITES": "Ajouté aux favoris", "FAVORITES": "Favoris", + "FAVORITE_IDENTITY_CONFLICT": "Cette source a remplacé un élément enregistré. Supprimez l’ancienne entrée des favoris avant d’ajouter celle-ci.", + "FAVORITE_SAVE_FAILED": "Impossible d’enregistrer ce favori. Veuillez réessayer.", "RECENTLY_VIEWED": "Récemment consultés", "THIS_PLAYLIST": "Cette liste de lecture", "ALL_PLAYLISTS": "Toutes les listes de lecture", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Absent du catalogue actuel", + "CATALOG_UNKNOWN": "État du catalogue inconnu", "TITLE": "Favoris globaux", "SUBTITLE": "TV en direct de toutes les listes de lecture", "SEARCH_PLACEHOLDER": "Rechercher dans les favoris…", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 673e40489..21beabd86 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Eltávolítva a kedvencek közül", "ADDED_TO_FAVORITES": "Hozzáadva a kedvencekhez", "FAVORITES": "Kedvencek", + "FAVORITE_IDENTITY_CONFLICT": "Ez a forrás lecserélt egy mentett elemet. Az új hozzáadása előtt távolítsd el a régit a kedvencekből.", + "FAVORITE_SAVE_FAILED": "Nem sikerült menteni a kedvencet. Próbáld újra.", "RECENTLY_VIEWED": "Legutóbb megtekintett", "THIS_PLAYLIST": "Ez a lejátszási lista", "ALL_PLAYLISTS": "Összes lejátszási lista", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Nincs a jelenlegi katalógusban", + "CATALOG_UNKNOWN": "A katalógus állapota ismeretlen", "TITLE": "Összesített kedvencek", "SUBTITLE": "Élő csatornák az összes lejátszási listából", "SEARCH_PLACEHOLDER": "Keresés a kedvencek között…", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 57414c463..42a6daf20 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Rimosso dai preferiti", "ADDED_TO_FAVORITES": "Aggiunto ai preferiti", "FAVORITES": "Preferiti", + "FAVORITE_IDENTITY_CONFLICT": "Questa fonte ha sostituito un elemento salvato. Rimuovi la vecchia voce dai preferiti prima di aggiungere questa.", + "FAVORITE_SAVE_FAILED": "Impossibile salvare questo preferito. Riprova.", "RECENTLY_VIEWED": "Visti di recente", "THIS_PLAYLIST": "Questa playlist", "ALL_PLAYLISTS": "Tutte le playlist", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Non presente nel catalogo attuale", + "CATALOG_UNKNOWN": "Stato del catalogo sconosciuto", "TITLE": "Preferiti globali", "SUBTITLE": "TV in diretta da tutte le playlist", "SEARCH_PLACEHOLDER": "Cerca tra i preferiti...", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 7147bb1a2..c47536ea3 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "お気に入りから削除されました", "ADDED_TO_FAVORITES": "お気に入りに追加されました", "FAVORITES": "お気に入り", + "FAVORITE_IDENTITY_CONFLICT": "この配信元は保存済みの項目を別の項目に置き換えました。新しい項目を追加する前に、お気に入りから古い項目を削除してください。", + "FAVORITE_SAVE_FAILED": "お気に入りを保存できませんでした。もう一度お試しください。", "RECENTLY_VIEWED": "最近表示した項目", "THIS_PLAYLIST": "このプレイリスト", "ALL_PLAYLISTS": "すべてのプレイリスト", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "現在のカタログにありません", + "CATALOG_UNKNOWN": "カタログの状態は不明です", "TITLE": "グローバルお気に入り", "SUBTITLE": "すべてのプレイリストのライブTV", "SEARCH_PLACEHOLDER": "お気に入りを検索...", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 59c240111..8d34c9371 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "즐겨찾기에서 삭제됨", "ADDED_TO_FAVORITES": "즐겨찾기에 추가됨", "FAVORITES": "즐겨찾기", + "FAVORITE_IDENTITY_CONFLICT": "이 소스가 저장된 항목을 교체했습니다. 새 항목을 추가하려면 먼저 즐겨찾기에서 이전 항목을 삭제하세요.", + "FAVORITE_SAVE_FAILED": "즐겨찾기를 저장하지 못했습니다. 다시 시도해 주세요.", "RECENTLY_VIEWED": "최근 본 항목", "THIS_PLAYLIST": "이 재생목록", "ALL_PLAYLISTS": "모든 재생목록", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "현재 카탈로그에 없음", + "CATALOG_UNKNOWN": "카탈로그 상태 알 수 없음", "TITLE": "전체 즐겨찾기", "SUBTITLE": "모든 재생목록의 라이브 TV", "SEARCH_PLACEHOLDER": "즐겨찾기 검색...", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index ccb881c59..102931f6d 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Uit favorieten verwijderd", "ADDED_TO_FAVORITES": "Toegevoegd aan favorieten", "FAVORITES": "Favorieten", + "FAVORITE_IDENTITY_CONFLICT": "Deze bron heeft een opgeslagen item vervangen. Verwijder het oude item uit Favorieten voordat je dit toevoegt.", + "FAVORITE_SAVE_FAILED": "Deze favoriet kon niet worden opgeslagen. Probeer het opnieuw.", "RECENTLY_VIEWED": "Recent bekeken", "THIS_PLAYLIST": "Deze afspeellijst", "ALL_PLAYLISTS": "Alle afspeellijsten", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Niet in de huidige catalogus", + "CATALOG_UNKNOWN": "Catalogusstatus onbekend", "TITLE": "Algemene favorieten", "SUBTITLE": "Live tv uit alle afspeellijsten", "SEARCH_PLACEHOLDER": "Zoek favorieten...", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 85c5714d0..d525dcc79 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Usunięto z ulubionych", "ADDED_TO_FAVORITES": "Dodano do ulubionych", "FAVORITES": "Ulubione", + "FAVORITE_IDENTITY_CONFLICT": "To źródło zastąpiło zapisany element. Usuń starszy wpis z ulubionych przed dodaniem nowego.", + "FAVORITE_SAVE_FAILED": "Nie udało się zapisać ulubionego. Spróbuj ponownie.", "RECENTLY_VIEWED": "Ostatnio oglądane", "THIS_PLAYLIST": "Ta lista odtwarzania", "ALL_PLAYLISTS": "Wszystkie listy odtwarzania", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Brak w bieżącym katalogu", + "CATALOG_UNKNOWN": "Nieznany stan katalogu", "TITLE": "Globalne ulubione", "SUBTITLE": "Telewizja na żywo ze wszystkich list odtwarzania", "SEARCH_PLACEHOLDER": "Szukaj ulubionych...", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 0a32d81c9..2237ae79a 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Removido dos favoritos", "ADDED_TO_FAVORITES": "Adicionado aos favoritos", "FAVORITES": "Favoritos", + "FAVORITE_IDENTITY_CONFLICT": "Esta fonte substituiu um item guardado. Remova a entrada antiga dos favoritos antes de adicionar esta.", + "FAVORITE_SAVE_FAILED": "Não foi possível guardar este favorito. Tente novamente.", "RECENTLY_VIEWED": "Visualizados recentemente", "THIS_PLAYLIST": "Esta playlist", "ALL_PLAYLISTS": "Todas as playlists", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Não está no catálogo atual", + "CATALOG_UNKNOWN": "Estado do catálogo desconhecido", "TITLE": "Favoritos globais", "SUBTITLE": "TV ao vivo de todas as playlists", "SEARCH_PLACEHOLDER": "Pesquisar favoritos...", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 9da7e722b..8e2159a80 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Удалено из избранного", "ADDED_TO_FAVORITES": "Добавлено в избранное", "FAVORITES": "Избранное", + "FAVORITE_IDENTITY_CONFLICT": "Источник заменил сохранённый вами элемент. Удалите старую запись из избранного, прежде чем добавить новую.", + "FAVORITE_SAVE_FAILED": "Не удалось сохранить избранное. Попробуйте ещё раз.", "RECENTLY_VIEWED": "Недавно просмотренные", "THIS_PLAYLIST": "Этот плейлист", "ALL_PLAYLISTS": "Все плейлисты", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Нет в текущем каталоге", + "CATALOG_UNKNOWN": "Статус каталога неизвестен", "TITLE": "Глобальные избранные", "SUBTITLE": "Live TV из всех плейлистов", "SEARCH_PLACEHOLDER": "Поиск в избранном...", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index d5632df26..78b89f7aa 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "Favorilerden kaldırıldı", "ADDED_TO_FAVORITES": "Favorilere eklendi", "FAVORITES": "Favoriler", + "FAVORITE_IDENTITY_CONFLICT": "Bu kaynak kaydettiğiniz bir öğeyi değiştirdi. Bunu eklemeden önce eski kaydı Favorilerden kaldırın.", + "FAVORITE_SAVE_FAILED": "Bu favori kaydedilemedi. Lütfen tekrar deneyin.", "RECENTLY_VIEWED": "Son izlenenler", "THIS_PLAYLIST": "Bu oynatma listesi", "ALL_PLAYLISTS": "Tüm oynatma listeleri", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "Mevcut katalogda bulunmuyor", + "CATALOG_UNKNOWN": "Katalog durumu bilinmiyor", "TITLE": "Tüm Favoriler", "SUBTITLE": "Tüm oynatma listelerinden canlı TV", "SEARCH_PLACEHOLDER": "Favorilerde ara...", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 3c1e77759..075cc6bf6 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "已从收藏中移除", "ADDED_TO_FAVORITES": "已添加至收藏夹", "FAVORITES": "收藏", + "FAVORITE_IDENTITY_CONFLICT": "此来源替换了您保存的项目。请先从收藏夹中移除旧项目,再添加此项目。", + "FAVORITE_SAVE_FAILED": "无法保存此收藏。请重试。", "RECENTLY_VIEWED": "最近观看", "THIS_PLAYLIST": "本播放列表", "ALL_PLAYLISTS": "所有播放列表", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "当前目录中不存在", + "CATALOG_UNKNOWN": "目录状态未知", "TITLE": "全局收藏", "SUBTITLE": "来自所有播放列表的直播电视", "SEARCH_PLACEHOLDER": "搜索收藏…", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index a88e94426..566f6db46 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1271,6 +1271,8 @@ "REMOVED_FROM_FAVORITES": "已從最愛中移除", "ADDED_TO_FAVORITES": "已新增至最愛", "FAVORITES": "我的最愛", + "FAVORITE_IDENTITY_CONFLICT": "此來源替換了您儲存的項目。請先從收藏中移除舊項目,再新增此項目。", + "FAVORITE_SAVE_FAILED": "無法儲存此收藏。請重試。", "RECENTLY_VIEWED": "最近觀看", "THIS_PLAYLIST": "此播放清單", "ALL_PLAYLISTS": "所有播放清單", @@ -1977,6 +1979,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "目前目錄中不存在", + "CATALOG_UNKNOWN": "目錄狀態未知", "TITLE": "全域我的最愛", "SUBTITLE": "來自所有播放清單的直播電視", "SEARCH_PLACEHOLDER": "搜尋我的最愛...", diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index e0d213da2..db5819d30 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -656,6 +656,26 @@ is unavailable or the SQLite playlist migration has not completed. 5. `DB_GET_GLOBAL_FAVORITES` 6. `DB_GET_ALL_GLOBAL_FAVORITES` 7. `DB_REORDER_GLOBAL_FAVORITES` +8. `DB_RESTORE_XTREAM_FAVORITES` +9. `DB_GET_FAVORITES_FOR_BACKUP` + +Xtream favorites survive catalog/category deletion through nullable `content_id` +links and independent typed provider identities plus display snapshots. Reads +return the durable `favorite_id`, nullable catalog IDs and `catalog_status`; +remove accepts `{ favoriteId }` and reorder accepts `favorite_id` without a +catalog row. Legacy content-ID callers resolve playlist/type/provider identity, +including duplicate category rows. Import completion reconciles links and saved +metadata; detail enrichment also updates the snapshot. Known identity conflicts +remain unresolved. See [Xtream compatibility](./xtream-portal-compatibility.md#durable-electron-favorites). + +`DB_RESTORE_XTREAM_FAVORITES` materializes pending/backup snapshots without an +online catalog. Inserts and their replay receipt share one synchronous worker +transaction, so retry after removal does not resurrect an item. Explicit backup +imports supply a fresh restore ID; legacy pending payloads use a canonical +hash. `DB_DELETE_XTREAM_CONTENT` returns no canonical favorites to replay. +`DB_GET_FAVORITES_FOR_BACKUP` deliberately includes locked/unresolved rows; +ordinary UI reads preserve parental-lock filtering. A failed export read must +propagate to the caller. ### Recently viewed diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index f24018603..9f102efb6 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -37,6 +37,60 @@ See [Nx Workspace Boundaries](./nx-workspace-boundaries.md), [Portal Detail Navigation](./portal-detail-navigation.md), and [VOD Multi-Source](./vod-multi-source.md). +## Durable Electron Favorites + +SQLite favorites are user-owned records keyed by playlist, normalized content +kind (`live`, `movie`, `series`), and provider ID. The nullable catalog link uses +`ON DELETE SET NULL`; only explicit favorite removal/clear or playlist deletion +removes a saved record. Refresh and import-cache cleanup never replay canonical +favorites from a temporary snapshot. The two Refresh entry points continue to +share `XtreamRefreshFlowService` and its per-playlist single-flight guard. + +Favorite rows retain a durable `favorite_id`, saved title/artwork, added date +and order. Their `id`/`category_id` may be null; consumers must not invent a +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 and refresh display snapshots. 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. +Adding a conflicting replacement returns an explicit failure and retains the +older favorite. The detail toggle stays inactive and explains that the older +entry must be removed from Favorites before the replacement can be saved. + +`catalog_status` is `present`, `missing`, or `unknown`. Only a completed import +of the relevant type can prove absence. Failed, cancelled and in-progress +imports remain unknown. Collections and dashboard rails retain unresolved rows +with saved titles/status and removal/order controls; they suppress playback, +details, downloads and EPG requests that depend on catalog resolution. History +navigation rechecks current collection state. Removing a live row or making it +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. + +Legacy pending favorites are converted before destructive refresh, import +requests and ordinary collection reads, even if portal-status preflight is +offline. Backup merges convert any older pending favorites before parking the +incoming archive. Favorite inserts and a replay receipt commit together; +retries cannot resurrect a subsequently removed favorite. Deliberate backup import creates a +fresh receipt ID, which stays internal and is never exported. Identity-only old +backups use a labelled saved-item fallback until catalog metadata returns. +Backups include unresolved snapshots and locked records through a dedicated +unfiltered read; a failed favorites read fails export rather than producing an +empty collection. New backup imports materialize favorites before waiting for +an online catalog. Existing history, hidden-category and playback-position +restore contracts remain separate. + +Both Xtream data-source and portal-activity capabilities require the pending +favorite conversion bridge; activity reads also require playlist lookup. + +Records already deleted by an older build cannot be recovered without a +surviving pending state or backup. Opening an Xtream section may initialize +content when its cache is not ready; startup imports and explicit refresh use +the same durable persistence invariant. This does not add a refresh scheduler. + ## PWA Collection Persistence Collection operations carry `{ id, type }`: `id` remains the SQLite row ID diff --git a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts index 13c75c41c..f0bcc5f74 100644 --- a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts +++ b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.spec.ts @@ -77,6 +77,7 @@ describe('XtreamRefreshFlowService', () => { let confirmPromise: Promise | undefined; let order: string[]; let databaseService: { + restoreXtreamFavoriteSnapshots: jest.Mock; createOperationId: jest.Mock; deleteXtreamPlaylistContent: jest.Mock; updateXtreamPlaylistDetails: jest.Mock; @@ -94,6 +95,10 @@ describe('XtreamRefreshFlowService', () => { order = []; databaseService = { + restoreXtreamFavoriteSnapshots: jest.fn(() => { + order.push('save-favorites'); + return Promise.resolve(); + }), createOperationId: jest.fn((prefix: string) => `${prefix}-op`), deleteXtreamPlaylistContent: jest.fn(() => { order.push('delete'); @@ -154,6 +159,53 @@ describe('XtreamRefreshFlowService', () => { localStorage.clear(); }); + it('converts legacy pending favorites before deleting or replacing the old catalog', async () => { + localStorage.setItem( + 'xtream-restore-playlist-1', + JSON.stringify({ + favorites: [{ contentType: 'movie', xtreamId: 7 }], + hiddenCategories: [], + recentlyViewed: [], + playbackPositions: [], + }) + ); + service.confirmAndRefresh( + createPlaylistMeta(), + createReporter().reporter + ); + await confirmPromise; + expect( + databaseService.restoreXtreamFavoriteSnapshots + ).toHaveBeenCalledWith( + 'playlist-1', + [{ contentType: 'movie', xtreamId: 7 }], + undefined + ); + expect(order.indexOf('save-favorites')).toBeLessThan( + order.indexOf('delete') + ); + }); + + it('preserves legacy pending state and the catalog when durable conversion fails', async () => { + const pending = JSON.stringify({ + favorites: [{ contentType: 'movie', xtreamId: 7 }], + }); + localStorage.setItem('xtream-restore-playlist-1', pending); + databaseService.restoreXtreamFavoriteSnapshots.mockRejectedValue( + new Error('SQLite unavailable') + ); + jest.spyOn(console, 'error').mockImplementation(() => undefined); + service.confirmAndRefresh( + createPlaylistMeta(), + createReporter().reporter + ); + await confirmPromise; + expect( + databaseService.deleteXtreamPlaylistContent + ).not.toHaveBeenCalled(); + expect(localStorage.getItem('xtream-restore-playlist-1')).toBe(pending); + }); + it('ignores a confirmation that arrives while the playlist is already busy', async () => { const { reporter, calls } = createReporter({ isBusy: () => true }); diff --git a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts index c872a6263..cd4bea0ac 100644 --- a/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts +++ b/libs/playlist/shared/ui/src/lib/xtream-refresh-flow.service.ts @@ -152,6 +152,17 @@ export class XtreamRefreshFlowService { ); await reporter.waitForVisibleProgress?.(); + // Migrate an older parked favorite list before replacing its state. + // A durable receipt prevents replay after an explicit user removal. + const pending = this.pendingRestoreService.getOrThrow(item._id); + if (pending) { + await this.databaseService.restoreXtreamFavoriteSnapshots( + item._id, + pending.favorites, + pending.favoritesRestoreId + ); + } + // Delete content/categories and update the timestamp in parallel — // all three operations are fully independent. const updateDate = Date.now(); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver-favorites.test-helpers.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver-favorites.test-helpers.ts new file mode 100644 index 000000000..39fb20eb0 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver-favorites.test-helpers.ts @@ -0,0 +1,30 @@ +import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; +import { StreamResolverService } from './stream-resolver.service'; + +export function registerUnavailableFavoriteEpgTests( + getContext: () => { + service: StreamResolverService; + xtreamApi: { getShortEpg: jest.Mock }; + dataService: { sendIpcEvent: jest.Mock }; + } +) { + it.each(['missing', 'unknown'] as const)( + 'skips EPG requests for an unavailable saved channel (%s)', + async (catalogStatus) => { + const { service, xtreamApi, dataService } = getContext(); + const item: UnifiedCollectionItem = { + uid: 'saved-1', + name: 'ABC', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + xtreamId: 1, + catalogStatus, + }; + expect((await service.loadEpgForItems([item])).size).toBe(0); + expect(xtreamApi.getShortEpg).not.toHaveBeenCalled(); + expect(dataService.sendIpcEvent).not.toHaveBeenCalled(); + } + ); +} diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts index ca634b2c0..e9c6451d6 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.spec.ts @@ -1,3 +1,4 @@ +import { registerUnavailableFavoriteEpgTests } from './stream-resolver-favorites.test-helpers'; import { TestBed } from '@angular/core/testing'; import { of } from 'rxjs'; import { @@ -299,6 +300,12 @@ describe('StreamResolverService', () => { ); }); + registerUnavailableFavoriteEpgTests(() => ({ + service, + xtreamApi, + dataService, + })); + it('skips portal EPG lookups in browser/PWA mode', async () => { window.electron = undefined as unknown as typeof window.electron; epgBridge.supportsProgramLookup = false; @@ -830,9 +837,7 @@ describe('StreamResolverService', () => { } satisfies UnifiedCollectionItem); expect(stalkerSession.ensureToken).toHaveBeenCalled(); - expect(playback.headers?.['Cookie']).toContain( - 'mac=00:11:22:33:44:55' - ); + expect(playback.headers?.['Cookie']).toContain('mac=00:11:22:33:44:55'); expect(playback.headers?.['Authorization']).toBe('Bearer TOKEN77'); expect(playback.userAgent).toBe(playback.headers?.['User-Agent']); expect(playback.referer).toBe('https://stalker.example.com'); diff --git a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts index 0fa311c04..701ec70a3 100644 --- a/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/stream-resolver.service.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { inject, Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { extractDrmFromRaw } from '@iptvnator/shared/m3u-utils'; @@ -213,7 +214,7 @@ export class StreamResolverService { const stalkerByPlaylist = new Map(); for (const item of items) { - if (item.contentType !== 'live') { + if (item.contentType !== 'live' || isCatalogUnavailable(item)) { continue; } diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts index 4c1dd412f..b1e8dfa00 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.spec.ts @@ -226,6 +226,55 @@ describe('UnifiedFavoritesDataService', () => { service = TestBed.inject(UnifiedFavoritesDataService); }); + it('keeps an unavailable Xtream favorite readable and removable without a catalog row', async () => { + databaseService.getAllGlobalFavorites.mockResolvedValue([ + { + id: null, + category_id: null, + favorite_id: 77, + catalog_status: 'missing', + playlist_id: 'xtream-1', + playlist_name: 'Xtream One', + xtream_id: 101, + title: 'ABC', + type: 'movie', + poster_url: 'abc.png', + position: 3, + }, + ]); + + const [item] = await service.getFavorites('all'); + expect(item).toEqual( + expect.objectContaining({ + name: 'ABC', + favoriteId: 77, + catalogStatus: 'missing', + contentId: undefined, + posterUrl: 'abc.png', + position: 3, + }) + ); + await service.removeFavorite(item); + expect(electronApi.dbRemoveFavorite).toHaveBeenCalledWith( + { favoriteId: 77 }, + 'xtream-1' + ); + await service.reorder([item], { + scope: 'playlist', + playlistId: 'xtream-1', + portalType: 'xtream', + }); + expect(electronApi.dbReorderGlobalFavorites).toHaveBeenCalledWith([ + { favorite_id: 77, playlist_id: 'xtream-1', position: 0 }, + ]); + electronApi.dbRemoveFavorite.mockClear(); + await service.clearFavorites([item]); + expect(electronApi.dbRemoveFavorite).toHaveBeenCalledWith( + { favoriteId: 77 }, + 'xtream-1' + ); + }); + it('maps global Xtream favorites across live, movie, and series content types', async () => { databaseService.getAllGlobalFavorites.mockResolvedValue([ { diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts index 6f50e98da..7c0ef2825 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-favorites-data.service.ts @@ -1,3 +1,4 @@ +import type { ElectronBridgeFavoriteReorderUpdate } from '@iptvnator/shared/interfaces'; import { inject, Injectable } from '@angular/core'; import { Store } from '@ngrx/store'; import { TranslateService } from '@ngx-translate/core'; @@ -31,7 +32,7 @@ import { } from '@iptvnator/portal/shared/util'; import { XTREAM_DATA_SOURCE, - XtreamContentItem, + XtreamFavoriteItem, } from '@iptvnator/portal/xtream/data-access'; const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1'; @@ -92,10 +93,15 @@ export class UnifiedFavoritesDataService { break; } case 'xtream': { - if (item.contentId == null) { + const electron = this.electronActivityBridge; + if (electron && item.favoriteId != null) { + await electron.dbRemoveFavorite( + { favoriteId: item.favoriteId }, + item.playlistId + ); return; } - const electron = this.electronActivityBridge; + if (item.contentId == null) return; if (electron) { await electron.dbRemoveFavorite( item.contentId, @@ -290,6 +296,7 @@ export class UnifiedFavoritesDataService { } const xtreamUpdates = this.buildXtreamPositionUpdates(items); + const electron = this.electronActivityBridge; if ( @@ -373,19 +380,7 @@ export class UnifiedFavoritesDataService { return; } - await Promise.all( - items - .filter( - ( - item - ): item is UnifiedCollectionItem & { - readonly contentId: number; - } => item.contentId != null - ) - .map((item) => - electron.dbRemoveFavorite(item.contentId, item.playlistId) - ) - ); + await Promise.all(items.map((item) => this.removeFavorite(item))); } private async clearStalkerFavorites( @@ -593,7 +588,7 @@ export class UnifiedFavoritesDataService { logo: ct === 'live' ? (row.poster_url ?? null) : null, posterUrl: ct !== 'live' ? (row.poster_url ?? null) : null, xtreamId: row.xtream_id, - categoryId: row.category_id, + categoryId: row.category_id ?? undefined, tvgId: ct === 'live' ? String(row.xtream_id) : undefined, rating: row.rating ?? undefined, tvArchive: row.tv_archive ?? null, @@ -601,12 +596,14 @@ export class UnifiedFavoritesDataService { addedAt: normalizeStalkerDate(row.added_at) || new Date(0).toISOString(), position: row.position ?? 0, - contentId: row.id, + contentId: row.id ?? undefined, + favoriteId: row.favorite_id, + catalogStatus: row.catalog_status, }; } private mapXtreamContentItem( - item: XtreamContentItem, + item: XtreamFavoriteItem, playlistId: string, playlistName?: string ): UnifiedCollectionItem { @@ -621,7 +618,7 @@ export class UnifiedFavoritesDataService { logo: ct === 'live' ? (item.poster_url ?? null) : null, posterUrl: ct !== 'live' ? (item.poster_url ?? null) : null, xtreamId: item.xtream_id, - categoryId: item.category_id, + categoryId: item.category_id ?? undefined, tvgId: ct === 'live' ? String(item.xtream_id) : undefined, rating: item.rating ?? undefined, tvArchive: item.tv_archive ?? null, @@ -630,7 +627,9 @@ export class UnifiedFavoritesDataService { normalizeStalkerDate(item.added_at ?? item.added) || new Date(0).toISOString(), position: item.position ?? 0, - contentId: item.id, + contentId: item.id ?? undefined, + favoriteId: item.favorite_id, + catalogStatus: item.catalog_status, }; } @@ -807,16 +806,16 @@ export class UnifiedFavoritesDataService { } private buildXtreamPositionUpdates(items: UnifiedCollectionItem[]) { - const updates: { - content_id: number; - playlist_id: string; - position: number; - }[] = []; - + const updates: ElectronBridgeFavoriteReorderUpdate[] = []; for (const item of items) { - if (item.sourceType === 'xtream' && item.contentId != null) { + if ( + item.sourceType === 'xtream' && + (item.favoriteId != null || item.contentId != null) + ) { updates.push({ - content_id: item.contentId, + ...(item.favoriteId != null + ? { favorite_id: item.favoriteId } + : { content_id: item.contentId }), playlist_id: item.playlistId, position: updates.length, }); diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html index a071cbde8..0586d9a56 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.html @@ -7,6 +7,7 @@ role="button" tabindex="0" [attr.aria-label]="title()" + [attr.aria-disabled]="disabled() || null" (click)="onCardClick()" (keydown.enter)="onCardKey($event)" (keydown.space)="onCardKey($event)" @@ -57,11 +58,18 @@ @if (!postersOnly()) {

{{ title() }}

- @if (date()) { + @if (statusLabel()) { +

+ {{ statusLabel() }} +

+ } @else if (date()) {

{{ date() - | date: 'd MMM, HH:mm' : undefined : currentLocale() + | date + : 'd MMM, HH:mm' + : undefined + : currentLocale() }}

} diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss index cb3c1134c..c32c89575 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.scss @@ -11,6 +11,10 @@ .content-card__activation { border-radius: inherit; outline: none; + + &[aria-disabled='true'] { + cursor: default; + } } &:has(> .content-card__activation:focus-visible) { diff --git a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts index ed43e9ec9..a504c8ad2 100644 --- a/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts +++ b/libs/portal/shared/ui/src/lib/components/content-card/content-card.component.ts @@ -38,6 +38,8 @@ export class ContentCardComponent { /** Title to display */ readonly title = input.required(); + readonly disabled = input(false); + readonly statusLabel = input(''); /** Content type (live, movie, series) */ readonly type = input(); @@ -106,7 +108,7 @@ export class ContentCardComponent { } onCardClick(): void { - this.cardClick.emit(); + if (!this.disabled()) this.cardClick.emit(); } /** @@ -116,7 +118,7 @@ export class ContentCardComponent { */ onCardKey(event: Event): void { event.preventDefault(); - this.cardClick.emit(); + this.onCardClick(); } onRemoveClick(event: Event): void { diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html index a96126687..dccc2295a 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.html @@ -34,8 +34,17 @@ [isDraggable]="canDragDrop()" [logo]="ch.logo" [name]="ch.name" + [disabled]="isUnavailable(ch)" + [statusLabel]=" + isUnavailable(ch) + ? ((ch.catalogStatus === 'missing' + ? 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING' + : 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN' + ) | translate) + : '' + " [selected]="activeUid() === ch.uid" - [showEpg]="showEpg() && ch.radio !== 'true'" + [showEpg]="!isUnavailable(ch) && showEpg() && ch.radio !== 'true'" [isRadio]="ch.radio === 'true'" [epgProgram]="ch.currentEpgProgram" [progressPercentage]="ch.progressPercentage" @@ -47,7 +56,7 @@ [showDetailsContextMenu]="hasChannelContextMenu(ch)" (clicked)="onChannelClick(ch)" (activated)="onChannelActivate(ch)" - (keyboardActivated)="playbackRequested.emit(ch)" + (keyboardActivated)="onPlaybackRequested(ch)" (favoriteToggled)="onFavoriteToggled(ch)" (contextMenuRequested)="onChannelContextMenu(ch, $event)" /> diff --git a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts index 9f803157d..cf506b85c 100644 --- a/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts +++ b/libs/portal/shared/ui/src/lib/components/global-favorites-list/global-favorites-list.component.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { ChannelScrollFocusDirective } from '@iptvnator/ui/components'; import { CdkDragDrop, @@ -165,12 +166,20 @@ export class GlobalFavoritesListComponent { }); } + readonly isUnavailable = isCatalogUnavailable; + + onPlaybackRequested(channel: UnifiedFavoriteChannel): void { + if (!isCatalogUnavailable(channel)) + this.playbackRequested.emit(channel); + } + onChannelClick(channel: UnifiedFavoriteChannel): void { + if (isCatalogUnavailable(channel)) return; this.channelSelected.emit(channel); } onChannelActivate(channel: UnifiedFavoriteChannel): void { - if (this.openStreamOnDoubleClick()) { + if (this.openStreamOnDoubleClick() && !isCatalogUnavailable(channel)) { this.playbackRequested.emit(channel); } } @@ -200,6 +209,7 @@ export class GlobalFavoritesListComponent { } hasChannelContextMenu(channel: UnifiedFavoriteChannel): boolean { + if (isCatalogUnavailable(channel)) return false; return ( Boolean(channel.m3uChannel) || this.mode() === 'recent' || @@ -211,6 +221,7 @@ export class GlobalFavoritesListComponent { } canOpenInPlaylist(channel: UnifiedFavoriteChannel): boolean { + if (isCatalogUnavailable(channel)) return false; return getLiveCollectionPlaylistNavigation(channel) !== null; } @@ -226,7 +237,7 @@ export class GlobalFavoritesListComponent { openEpgMapping(): void { const item = this.contextMenuChannel(); - if (!item) { + if (!item || isCatalogUnavailable(item)) { return; } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts index 51d4659dc..2e917d5a2 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-detail-state.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { computed, effect, @@ -50,6 +51,8 @@ export interface CollectionDetailState { */ export function createCollectionDetailState(options: { mode: Signal; + items: Signal; + isLoading: Signal; portalType: Signal; /** Absent while the host projects no detail template. */ detailTemplate: Signal; @@ -72,6 +75,7 @@ export function createCollectionDetailState(options: { ); const canOpen = (candidate: UnifiedCollectionItem): boolean => + !isCatalogUnavailable(candidate) && Boolean(options.detailTemplate()) && candidate.contentType !== 'live' && (candidate.sourceType === 'xtream' || @@ -82,6 +86,7 @@ export function createCollectionDetailState(options: { next: UnifiedCollectionItem, nextSeriesResume?: SeriesResumeTarget | null ): void => { + if (isCatalogUnavailable(next)) return; options.onOpen(next.contentType); item.set(next); seriesResume.set(nextSeriesResume ?? null); @@ -128,6 +133,38 @@ export function createCollectionDetailState(options: { }); }); + const syncFromHistory = (): void => { + if (options.isLoading()) return; + const detailState = getOpenCollectionDetailItemState( + window.history.state + ); + const savedItem = detailState?.item; + const currentItem = + savedItem && + options.items().find((row) => row.uid === savedItem.uid); + // The catalog may have changed since this history entry was written. + // Unrelated dashboard discoveries need not be a saved favorite. + const detailItem = + currentItem ?? + (savedItem?.favoriteId != null && options.mode() === 'favorites' + ? null + : savedItem); + + if (detailItem && canOpen(detailItem)) { + open(detailItem, detailState?.seriesResume); + return; + } + + if (item()) { + clear(); + } + }; + + effect(() => { + options.items(); + if (!options.isLoading()) untracked(syncFromHistory); + }); + return { item, seriesResume, @@ -147,20 +184,6 @@ export function createCollectionDetailState(options: { canOpen, open, clear, - syncFromHistory(): void { - const detailState = getOpenCollectionDetailItemState( - window.history.state - ); - const detailItem = detailState?.item; - - if (detailItem && canOpen(detailItem)) { - open(detailItem, detailState.seriesResume); - return; - } - - if (item()) { - clear(); - } - }, + syncFromHistory, }; } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index efdd4c2a8..8712b2e13 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -762,6 +762,39 @@ describe('UnifiedCollectionPageComponent', () => { expect(fixture.componentInstance.allItems()).toEqual(liveItems); }); + it('does not open a stale history detail when the favorite is now missing', async () => { + const item: UnifiedCollectionItem = { + uid: 'xtream::xtream-1::movie:77', + name: 'ABC', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream', + xtreamId: 77, + favoriteId: 11, + catalogStatus: 'present', + }; + favoritesData.getFavorites.mockResolvedValue([ + { ...item, catalogStatus: 'missing' }, + ]); + window.history.replaceState( + { [OPEN_COLLECTION_DETAIL_STATE_KEY]: { item } }, + document.title + ); + const hostFixture = TestBed.createComponent( + HostUnifiedCollectionPageComponent + ); + hostFixture.detectChanges(); + await hostFixture.whenStable(); + hostFixture.detectChanges(); + expect( + hostFixture.nativeElement.querySelector('.detail-probe') + ).toBeNull(); + expect( + hostFixture.componentInstance.pageComponent?.selectedDetailItem() + ).toBeNull(); + }); + it('opens inline detail from history state when a detail template is projected', async () => { const item: UnifiedCollectionItem = { uid: 'xtream::xtream-1::77', diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index cbbbdc259..e877881a8 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { NgTemplateOutlet } from '@angular/common'; import { AfterContentInit, @@ -228,6 +229,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { }), }); private readonly detailState = createCollectionDetailState({ + items: this.allItems, + isLoading: this.isLoading, mode: this.mode, portalType: this.portalType, detailTemplate: this.detailTemplate, @@ -309,6 +312,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { } onGridItemSelected(item: UnifiedCollectionItem): void { + if (isCatalogUnavailable(item)) return; this.viewStateSync.commitCurrent(); if (this.detailState.canOpen(item)) { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html index 445e8f5de..15430fa4f 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.html @@ -20,7 +20,16 @@
@for (item of filteredItems(); track trackByUid($index, item)) { { fixture.componentRef.setInput('items', ITEMS); }); + it('retains the title and removal action while blocking an unavailable favorite', () => { + const item = { + ...ITEMS[0], + catalogStatus: 'missing', + } as UnifiedCollectionItem; + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + const selected = jest.fn(); + const removed = jest.fn(); + fixture.componentInstance.itemSelected.subscribe(selected); + fixture.componentInstance.removeItem.subscribe(removed); + + fixture.componentInstance.onCardClick(item); + fixture.componentInstance.onRemove(item); + + expect(selected).not.toHaveBeenCalled(); + expect(removed).toHaveBeenCalledWith(item); + expect(fixture.nativeElement.textContent).toContain( + 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING' + ); + expect(fixture.debugElement.query(By.css('.card-info'))).not.toBeNull(); + }); + const cardInfos = () => fixture.debugElement.queryAll(By.css('.card-info')); const overlays = () => fixture.debugElement.queryAll(By.css('.cover-title-overlay')); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts index 075978c42..38d18e4d0 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-grid-tab.component.ts @@ -12,7 +12,10 @@ import { UnifiedCollectionItem, } from '@iptvnator/portal/shared/util'; import { ContentCardComponent } from '../content-card/content-card.component'; -import { foldSearchText } from '@iptvnator/shared/interfaces'; +import { + foldSearchText, + isCatalogUnavailable, +} from '@iptvnator/shared/interfaces'; @Component({ selector: 'app-unified-grid-tab', @@ -49,7 +52,10 @@ export class UnifiedGridTabComponent { : all; }); + readonly isUnavailable = isCatalogUnavailable; + onCardClick(item: UnifiedCollectionItem): void { + if (isCatalogUnavailable(item)) return; this.itemSelected.emit(item); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts index 2bf3c87fb..1b1d92155 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-channel-rows.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { computed, Signal } from '@angular/core'; import { deriveVisibleFavoriteChannels, @@ -49,7 +50,10 @@ export function createUnifiedLiveChannelRows(options: { }) ), fullscreenPanel: computed(() => - all().filter((channel) => channel.radio !== 'true') + all().filter( + (channel) => + channel.radio !== 'true' && !isCatalogUnavailable(channel) + ) ), }; } @@ -92,5 +96,7 @@ export function toUnifiedFavoriteChannel( addedAt: item.addedAt ?? new Date(0).toISOString(), position: item.position ?? 0, contentId: item.contentId, + favoriteId: item.favoriteId, + catalogStatus: item.catalogStatus, }; } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts index f3fce3fd1..c7a1b8d66 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-selection.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { inject, WritableSignal } from '@angular/core'; import { ResolvedLiveCollectionDetail, @@ -161,6 +162,7 @@ export function createUnifiedLiveSelection(options: { isAutoOpen = false, startPlayback = false ): Promise { + if (isCatalogUnavailable(item)) return; const activeDetail = options.activeDetail(); // The row is on screen only when it is both the highlight and the // resolved item: while a replacement resolves, `activeUid` already diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts index 0a8e8429a..fb8e4ca7b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { ChangeDetectionStrategy, Component, @@ -258,7 +259,15 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } const activeUid = this.activeUid(); - if (activeUid && !items.some((item) => item.uid === activeUid)) { + const selected = items.find((item) => item.uid === activeUid); + // During a switch the highlight belongs to the pending selection, + // while the mounted detail still belongs to the previous item. + const retainedUid = this.activeItem()?.uid; + const retained = items.find((item) => item.uid === retainedUid); + if ( + (activeUid && (!selected || isCatalogUnavailable(selected))) || + (retainedUid && (!retained || isCatalogUnavailable(retained))) + ) { this.onClose(); } }); @@ -319,7 +328,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { async onChannelSelected(channel: UnifiedFavoriteChannel): Promise { const item = this.itemFor(channel); - if (item) { + if (item && !isCatalogUnavailable(item)) { await this.selection.activate(item); } } @@ -328,7 +337,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { channel: UnifiedFavoriteChannel ): Promise { const item = this.itemFor(channel); - if (item) { + if (item && !isCatalogUnavailable(item)) { await this.selection.activate(item, false, true); } } @@ -419,7 +428,9 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost { } private openInPlaylist(item: UnifiedCollectionItem): void { - const target = getLiveCollectionPlaylistNavigation(item); + const current = this.items().find((row) => row.uid === item.uid); + if (!current || isCatalogUnavailable(current)) return; + const target = getLiveCollectionPlaylistNavigation(current); if (target) { void this.router.navigate(target.link, { state: target.state }); } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts index f2a1fb9e2..1a224cf2f 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.open-in-playlist.spec.ts @@ -22,6 +22,7 @@ import { OpenInPlaylistChipComponent } from '../open-in-playlist-chip/open-in-pl import { buildCurrentEpgItem, buildLiveItem, + createDeferred, } from './unified-live-tab.spec-data'; import { StubAudioPlayerComponent, @@ -212,6 +213,92 @@ describe('UnifiedLiveTabComponent open in playlist', () => { ); }); + it.each(['missing', 'unknown'] as const)( + 'closes an active favorite when its catalog status becomes %s', + async (catalogStatus) => { + const item = await activate('xtream'); + fixture.componentRef.setInput('items', [ + { ...item, catalogStatus }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(component.activeItem()).toBeNull(); + expect(component.activeDetail()).toBeNull(); + expect(component.activeUid()).toBeNull(); + expect(chip()).toBeNull(); + component.onOpenInPlaylistRequested(component.channelsForList()[0]); + expect(navigate).not.toHaveBeenCalled(); + } + ); + + it.each(['missing', 'unknown'] as const)( + 'discards a pending favorite selection when its catalog status becomes %s', + async (catalogStatus) => { + const item = buildLiveItem('xtream'); + const detail = { + epgMode: 'portal', + playback: { + streamUrl: 'https://example.com/xtream.m3u8', + title: item.name, + }, + epgItems: [], + }; + const pending = createDeferred(); + streamResolver.resolveLiveDetail.mockReturnValue(pending.promise); + fixture.componentRef.setInput('items', [item]); + fixture.detectChanges(); + const activation = component.onChannelPlaybackRequested( + component.channelsForList()[0] + ); + expect(component.isSelecting()).toBe(true); + + fixture.componentRef.setInput('items', [ + { ...item, catalogStatus }, + ]); + fixture.detectChanges(); + pending.resolve(detail); + await activation; + fixture.detectChanges(); + + expect(component.activeItem()).toBeNull(); + expect(component.activeDetail()).toBeNull(); + expect(component.isSelecting()).toBe(false); + expect( + TestBed.inject(PORTAL_PLAYER).openResolvedPlayback + ).not.toHaveBeenCalled(); + } + ); + + it.each(['missing', 'removed'] as const)( + 'revokes a retained %s favorite while another selection is pending', + async (status) => { + const first = await activate('xtream'); + const second = { ...first, uid: 'xtream::pl-2::21', xtreamId: 21 }; + const pending = createDeferred(); + streamResolver.resolveLiveDetail.mockReturnValue(pending.promise); + fixture.componentRef.setInput('items', [first, second]); + fixture.detectChanges(); + const activation = component.onChannelSelected( + component.channelsForList()[1] + ); + expect(component.isSelecting()).toBe(true); + + fixture.componentRef.setInput( + 'items', + status === 'removed' + ? [second] + : [{ ...first, catalogStatus: 'missing' }, second] + ); + fixture.detectChanges(); + expect(component.activeDetail()).toBeNull(); + expect(component.activeItem()).toBeNull(); + pending.resolve(null); + await activation; + expect(component.activeDetail()).toBeNull(); + } + ); + it('keeps the chip out of the panel for a Stalker radio station', async () => { const item = { ...buildLiveItem('stalker'), radio: 'true' }; fixture.componentRef.setInput('items', [item]); diff --git a/libs/portal/shared/util/src/lib/collection/collection-helpers.ts b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts index 60cf6da0e..2dbf79638 100644 --- a/libs/portal/shared/util/src/lib/collection/collection-helpers.ts +++ b/libs/portal/shared/util/src/lib/collection/collection-helpers.ts @@ -1,9 +1,12 @@ +import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces'; import { StalkerPortalItem } from '@iptvnator/shared/interfaces'; import { CollectionContentType } from './unified-collection-item.interface'; export interface XtreamFavoriteRow { - readonly id: number; - readonly category_id: number; + readonly id: number | null; + readonly category_id: number | null; + readonly favorite_id?: number; + readonly catalog_status?: XtreamFavoriteCatalogStatus; readonly playlist_id: string; readonly playlist_name: string; readonly xtream_id: number; diff --git a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts index a9656c395..7fd8667fb 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-collection-item.interface.ts @@ -1,3 +1,4 @@ +import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces'; /** * Unified interface for displaying favorites or recently-viewed items * from any source (M3U, Xtream, Stalker) in one list. @@ -55,6 +56,9 @@ export interface UnifiedCollectionItem { /** Xtream DB content id — used for reorder / remove IPC */ contentId?: number; + /** Durable favorite identity, usable even after its catalog row is gone. */ + favoriteId?: number; + catalogStatus?: XtreamFavoriteCatalogStatus; /** Whether the Xtream provider has timeshift / archive enabled for this stream */ tvArchive?: number | null; diff --git a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts index 057662a3d..07359959f 100644 --- a/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts +++ b/libs/portal/shared/util/src/lib/collection/unified-favorite-channel.interface.ts @@ -1,3 +1,4 @@ +import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces'; /** * Unified interface for displaying live TV favorites from any source * (M3U playlists, Xtream Codes portals, Stalker portals) in one list. @@ -69,6 +70,8 @@ export interface UnifiedFavoriteChannel { /** Xtream DB content id — used for REORDER IPC call */ contentId?: number; + favoriteId?: number; + catalogStatus?: XtreamFavoriteCatalogStatus; /** Stalker portal credentials needed to resolve the live stream */ stalkerPortalUrl?: string; diff --git a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts index 0b79f1df5..69aa7135e 100644 --- a/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts +++ b/libs/portal/shared/util/src/lib/navigation/workspace-portal-navigation.ts @@ -671,6 +671,8 @@ function buildDashboardCollectionDetailItem( posterUrl: item.poster_url ?? null, xtreamId: toOptionalNumber(item.xtream_id), contentId: toOptionalNumber(item.id), + favoriteId: item.favorite_id, + catalogStatus: item.catalogStatus, categoryId: item.category_id, addedAt: 'added_at' in item ? item.added_at : undefined, viewedAt: 'viewed_at' in item ? item.viewed_at : undefined, @@ -840,6 +842,13 @@ function normalizeCollectionDetailItem( logo: toOptionalPathSegment(record['logo']) ?? null, xtreamId: toOptionalNumber(record['xtreamId']), contentId: toOptionalNumber(record['contentId']), + favoriteId: toOptionalNumber(record['favoriteId']), + catalogStatus: + record['catalogStatus'] === 'missing' || + record['catalogStatus'] === 'unknown' || + record['catalogStatus'] === 'present' + ? record['catalogStatus'] + : undefined, stalkerId: toOptionalPathSegment(record['stalkerId']), stalkerCmd: toOptionalPathSegment(record['stalkerCmd']), stalkerPortalUrl: toOptionalPathSegment(record['stalkerPortalUrl']), diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts index c52766a51..f4ad22ab1 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.test-helpers.ts @@ -39,7 +39,8 @@ export function createDbServiceMock() { saveXtreamContent: jest.fn().mockResolvedValue(0), searchXtreamContent: jest.fn().mockResolvedValue([]), getFavorites: jest.fn().mockResolvedValue([]), - addToFavorites: jest.fn().mockResolvedValue(undefined), + addToFavorites: jest.fn().mockResolvedValue(true), + addToFavoritesOrThrow: jest.fn().mockResolvedValue(undefined), removeFromFavorites: jest.fn().mockResolvedValue(undefined), isFavorite: jest.fn().mockResolvedValue(false), getRecentItems: jest.fn().mockResolvedValue([]), @@ -54,6 +55,7 @@ export function createDbServiceMock() { favorites: [], recentlyViewed: [], }), + restoreXtreamFavoriteSnapshots: jest.fn().mockResolvedValue(undefined), restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), }; } diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts index e6a8f5270..459207633 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.ts @@ -468,7 +468,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { // Favorites Operations // ========================================================================= - async getFavorites(playlistId: string): Promise { + async getFavorites( + playlistId: string + ): Promise { return this.dbService.getFavorites(playlistId); } @@ -477,7 +479,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { playlistId: string, backdropUrl?: string ): Promise { - await this.dbService.addToFavorites( + await this.dbService.addToFavoritesOrThrow( xtreamCollectionId(contentId), playlistId, backdropUrl @@ -683,6 +685,17 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { }; } + async restoreFavoriteSnapshots( + playlistId: string, + state: XtreamPendingRestoreState + ): Promise { + await this.dbService.restoreXtreamFavoriteSnapshots( + playlistId, + state.favorites, + state.favoritesRestoreId + ); + } + async restoreUserData( playlistId: string, restoreState: XtreamPendingRestoreState, @@ -731,9 +744,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource { } } + await this.restoreFavoriteSnapshots(playlistId, restoreState); await this.dbService.restoreXtreamUserData( playlistId, - restoreState.favorites, + [], restoreState.recentlyViewed, options ); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts index c48bd45c4..2125ba027 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/electron-xtream-data-source.user-data.spec.ts @@ -43,11 +43,9 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { playlistId, { backdropUrl: 'backdrop.jpg' } ); - expect(harness.dbService.addToFavorites).toHaveBeenCalledWith( - 202, - playlistId, - 'backdrop.jpg' - ); + expect( + harness.dbService.addToFavoritesOrThrow + ).toHaveBeenCalledWith(202, playlistId, 'backdrop.jpg'); expect( harness.dbService.removeFromFavorites ).toHaveBeenCalledWith(202, playlistId); @@ -70,6 +68,15 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { } ); + it('propagates a failed favorite write instead of acknowledging it', async () => { + harness.dbService.addToFavoritesOrThrow.mockRejectedValue( + new Error('XTREAM_FAVORITE_IDENTITY_CONFLICT') + ); + await expect( + harness.dataSource.addFavorite(202, playlistId) + ).rejects.toThrow(); + }); + it('delegates favorites operations to the DB', async () => { harness.dbService.isFavorite.mockResolvedValue(true); @@ -78,7 +85,9 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { playlistId, 'https://example.com/backdrop.png' ); - expect(harness.dbService.addToFavorites).toHaveBeenCalledWith( + expect( + harness.dbService.addToFavoritesOrThrow + ).toHaveBeenCalledWith( 202, playlistId, 'https://example.com/backdrop.png' @@ -442,7 +451,7 @@ describe('ElectronXtreamDataSource (user data delegation)', () => { harness.dbService.restoreXtreamUserData ).toHaveBeenCalledWith( playlistId, - [{ xtreamId: 202, type: 'movie' }], + [], [{ xtreamId: 101, type: 'live' }], options ); diff --git a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts index 38c1eddd5..5d770a305 100644 --- a/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/data-sources/xtream-data-source.interface.ts @@ -1,3 +1,4 @@ +import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces'; import { InjectionToken } from '@angular/core'; import { ContentMetadataPatch, @@ -92,6 +93,16 @@ export interface XtreamContentItem { playlist_name?: string; } +export interface XtreamFavoriteItem extends Omit< + XtreamContentItem, + 'id' | 'category_id' +> { + id: number | null; + category_id: number | string | null; + favorite_id?: number; + catalog_status?: XtreamFavoriteCatalogStatus; +} + /** * Category from the database with additional fields */ @@ -332,7 +343,7 @@ export interface IXtreamDataSource { /** * Get all favorites for a playlist */ - getFavorites(playlistId: string): Promise; + getFavorites(playlistId: string): Promise; /** * Add content to favorites. @@ -512,6 +523,11 @@ export interface IXtreamDataSource { /** * Restore user data after refresh */ + restoreFavoriteSnapshots?( + playlistId: string, + restoreState: XtreamPendingRestoreState + ): Promise; + restoreUserData( playlistId: string, restoreState: XtreamPendingRestoreState, diff --git a/libs/portal/xtream/data-access/src/lib/services/favorite-item.interface.ts b/libs/portal/xtream/data-access/src/lib/services/favorite-item.interface.ts index 6913a0d50..62824f149 100644 --- a/libs/portal/xtream/data-access/src/lib/services/favorite-item.interface.ts +++ b/libs/portal/xtream/data-access/src/lib/services/favorite-item.interface.ts @@ -1,5 +1,8 @@ +import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces'; export interface FavoriteItem { - content_id: number; + content_id: number | null; + favorite_id?: number; + catalog_status?: XtreamFavoriteCatalogStatus; playlist_id: string; type: 'live' | 'movie' | 'vod' | 'series'; title: string; diff --git a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts index ce3a53b11..096282e24 100644 --- a/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts +++ b/libs/portal/xtream/data-access/src/lib/services/favorites.service.ts @@ -6,7 +6,7 @@ import { } from '../data-sources/xtream-data-source.interface'; import { FavoriteItem } from './favorite-item.interface'; -function normalizeCategoryId(categoryId: string | number): number { +function normalizeCategoryId(categoryId: string | number | null): number { const numericCategoryId = Number(categoryId); return Number.isFinite(numericCategoryId) ? numericCategoryId : 0; } @@ -48,6 +48,8 @@ export class FavoritesService { map((items) => items.map((item) => ({ content_id: item.id, + favorite_id: item.favorite_id, + catalog_status: item.catalog_status, playlist_id: playlistId, type: item.type as FavoriteItem['type'], title: item.title, diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.favorites.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.favorites.spec.ts new file mode 100644 index 000000000..50a067b6b --- /dev/null +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.favorites.spec.ts @@ -0,0 +1,81 @@ +import { TestBed } from '@angular/core/testing'; +import { + createContentTestStore, + createContentTestProviders, + createPendingRestoreServiceMock, + PENDING_RESTORE_STATE, + TEST_PLAYLIST as PLAYLIST, +} from './with-content.feature.spec-helpers'; + +jest.mock('@iptvnator/portal/shared/util', () => ({ + createLogger: () => ({ + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }), +})); +const TestStore = createContentTestStore(async () => 'active'); +describe('withContent durable favorite conversion', () => { + let store: InstanceType; + let dataSource: { + hasCategories: jest.Mock; + getCategories: jest.Mock; + getContent: jest.Mock; + restoreFavoriteSnapshots: jest.Mock; + }; + let pendingRestoreService: ReturnType< + typeof createPendingRestoreServiceMock + >; + beforeEach(() => { + localStorage.clear(); + dataSource = { + hasCategories: jest.fn().mockResolvedValue(false), + getCategories: jest.fn().mockResolvedValue([]), + getContent: jest.fn().mockResolvedValue([]), + restoreFavoriteSnapshots: jest.fn().mockResolvedValue(undefined), + }; + pendingRestoreService = createPendingRestoreServiceMock(); + TestBed.configureTestingModule({ + providers: createContentTestProviders(TestStore, { + dataSource, + pendingRestoreService, + dataService: { sendIpcEvent: jest.fn() }, + xtreamApiService: { cancelSession: jest.fn() }, + databaseService: { + createOperationId: jest.fn(() => 'import-1'), + getXtreamImportStatus: jest.fn().mockResolvedValue('idle'), + setXtreamImportStatus: jest.fn().mockResolvedValue(true), + clearXtreamImportCache: jest.fn().mockResolvedValue(true), + }, + }), + }); + store = TestBed.inject(TestStore); + }); + afterEach(() => localStorage.clear()); + it('converts legacy favorites before fetching a catalog even when the network fails', async () => { + pendingRestoreService.getOrThrow.mockReturnValue(PENDING_RESTORE_STATE); + dataSource.hasCategories.mockResolvedValue(false); + dataSource.getCategories.mockRejectedValue(new Error('Server offline')); + await store.initializeContent(); + expect(dataSource.restoreFavoriteSnapshots).toHaveBeenCalledWith( + PLAYLIST.id, + PENDING_RESTORE_STATE + ); + expect( + dataSource.restoreFavoriteSnapshots.mock.invocationCallOrder[0] + ).toBeLessThan(dataSource.getCategories.mock.invocationCallOrder[0]); + expect(pendingRestoreService.clear).not.toHaveBeenCalled(); + }); + + it('stops before catalog requests when saved favorites cannot be persisted', async () => { + pendingRestoreService.getOrThrow.mockReturnValue(PENDING_RESTORE_STATE); + dataSource.restoreFavoriteSnapshots.mockRejectedValue( + new Error('SQLite unavailable') + ); + await store.initializeContent(); + expect(dataSource.getCategories).not.toHaveBeenCalled(); + expect(dataSource.getContent).not.toHaveBeenCalled(); + expect(pendingRestoreService.clear).not.toHaveBeenCalled(); + }); +}); diff --git a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts index 0b8c0874a..21a782f09 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/features/with-content.feature.ts @@ -959,6 +959,16 @@ export function withContent() { initialRestoreSnapshot !== null, }); + // Legacy favorites become durable before any network request, + // even if the provider stays offline through this import. + if (initialRestoreSnapshot) { + await dataSource.restoreFavoriteSnapshots?.( + ctx.playlistId, + initialRestoreSnapshot.state + ); + throwIfImportCancelled(importSessionId); + } + // Electron content persistence maps remote category IDs // to internal DB category rows, so categories must exist // before content import starts. diff --git a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts index d4f50ad65..dee463ef6 100644 --- a/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/stores/xtream.store.spec.ts @@ -1,4 +1,6 @@ import { TestBed } from '@angular/core/testing'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; import { DataService, DatabaseService, @@ -75,6 +77,11 @@ function configureStore( TestBed.configureTestingModule({ providers: [ XtreamStore, + { provide: MatSnackBar, useValue: { open: jest.fn() } }, + { + provide: TranslateService, + useValue: { instant: (key: string) => key }, + }, { provide: XTREAM_DATA_SOURCE, useValue: dataSource }, { provide: XtreamApiService, useValue: xtreamApiService }, { provide: DatabaseService, useValue: {} }, diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts index f70fc357d..71898e120 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.spec.ts @@ -1,4 +1,6 @@ import { RuntimeCapabilitiesService } from '@iptvnator/services'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; import { TestBed } from '@angular/core/testing'; import { patchState, signalStore } from '@ngrx/signals'; import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface'; @@ -20,6 +22,8 @@ const TestFavoritesStore = signalStore( describe('withFavorites', () => { const originalElectron = window.electron; + const snackBar = { open: jest.fn() }; + const translate = { instant: jest.fn((key: string) => key) }; const runtime = { supportsXtreamSqliteDataSource: false }; let store: InstanceType; let dataSource: { @@ -30,6 +34,7 @@ describe('withFavorites', () => { }; beforeEach(() => { + snackBar.open.mockClear(); runtime.supportsXtreamSqliteDataSource = false; dataSource = { addFavorite: jest.fn().mockResolvedValue(undefined), @@ -41,6 +46,8 @@ describe('withFavorites', () => { TestBed.configureTestingModule({ providers: [ TestFavoritesStore, + { provide: MatSnackBar, useValue: snackBar }, + { provide: TranslateService, useValue: translate }, { provide: RuntimeCapabilitiesService, useValue: runtime }, { provide: XTREAM_DATA_SOURCE, @@ -60,6 +67,27 @@ describe('withFavorites', () => { }); }); + it.each([ + [ + 'XTREAM_FAVORITE_IDENTITY_CONFLICT', + 'PORTALS.FAVORITE_IDENTITY_CONFLICT', + ], + ['write failed', 'PORTALS.FAVORITE_SAVE_FAILED'], + ])( + 'keeps the heart inactive and explains rejected saves: %s', + async (message, key) => { + dataSource.getContentByXtreamId.mockResolvedValue({ id: 42 }); + dataSource.addFavorite.mockRejectedValue(new Error(message)); + await expect( + store.toggleFavorite(7, 'playlist-1', 'movie') + ).resolves.toBe(false); + expect(store.isFavorite()).toBe(false); + expect(snackBar.open).toHaveBeenCalledWith(key, undefined, { + duration: 6000, + }); + } + ); + it.each([false, true])( 'toggles the typed target using its persisted status %s rather than the previous detail state', async (persistedStatus) => { diff --git a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts index 35eec0b72..9e05a3be9 100644 --- a/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts +++ b/libs/portal/xtream/data-access/src/lib/with-favorites.feature.ts @@ -1,5 +1,8 @@ import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { inject } from '@angular/core'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { TranslateService } from '@ngx-translate/core'; +import { XTREAM_FAVORITE_IDENTITY_CONFLICT } from '@iptvnator/shared/interfaces'; import { patchState, signalStoreFeature, @@ -19,7 +22,9 @@ export const withFavorites = function () { ( store, dataSource = inject(XTREAM_DATA_SOURCE), - runtime = inject(RuntimeCapabilitiesService) + runtime = inject(RuntimeCapabilitiesService), + snackBar = inject(MatSnackBar), + translate = inject(TranslateService) ) => ({ async toggleFavorite( xtreamId: number | string, @@ -70,11 +75,25 @@ export const withFavorites = function () { return false; } else { // Add to favorites - await dataSource.addFavorite( - { id: contentId, type: contentType }, - playlistId, - backdropUrl - ); + try { + await dataSource.addFavorite( + { id: contentId, type: contentType }, + playlistId, + backdropUrl + ); + } catch (error) { + patchState(store, { isFavorite: false }); + const key = + error instanceof Error && + error.message === + XTREAM_FAVORITE_IDENTITY_CONFLICT + ? 'PORTALS.FAVORITE_IDENTITY_CONFLICT' + : 'PORTALS.FAVORITE_SAVE_FAILED'; + snackBar.open(translate.instant(key), undefined, { + duration: 6000, + }); + return false; + } patchState(store, { isFavorite: true }); return true; } diff --git a/libs/services/src/lib/database-electron.service.spec.ts b/libs/services/src/lib/database-electron.service.spec.ts index 3c04e76d4..28b1940e5 100644 --- a/libs/services/src/lib/database-electron.service.spec.ts +++ b/libs/services/src/lib/database-electron.service.spec.ts @@ -6,6 +6,7 @@ describe('DatabaseService browser guards', () => { let consoleErrorSpy: jest.SpyInstance; beforeEach(() => { + localStorage.clear(); Object.defineProperty(window, 'electron', { configurable: true, writable: true, @@ -19,6 +20,7 @@ describe('DatabaseService browser guards', () => { }); afterEach(() => { + localStorage.clear(); consoleErrorSpy.mockRestore(); Object.defineProperty(window, 'electron', { configurable: true, @@ -27,6 +29,92 @@ describe('DatabaseService browser guards', () => { }); }); + it('does not report success when the favorites worker rejects a conflicting identity', async () => { + Object.defineProperty(window, 'electron', { + configurable: true, + value: { + dbAddFavorite: jest.fn().mockResolvedValue({ + success: false, + error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT', + }), + }, + }); + await expect(service.addToFavorites(42, 'playlist-1')).resolves.toBe( + false + ); + await expect( + service.addToFavoritesOrThrow(42, 'playlist-1') + ).rejects.toThrow('XTREAM_FAVORITE_IDENTITY_CONFLICT'); + }); + + it('recovers legacy favorites on collection reads without a portal status or catalog request', async () => { + const pending = { + favorites: [{ contentType: 'movie', xtreamId: 7 }], + hiddenCategories: [], + recentlyViewed: [], + playbackPositions: [], + }; + localStorage.setItem( + 'xtream-restore-playlist-1', + JSON.stringify(pending) + ); + const bridge = { + dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }), + dbRestoreXtreamFavorites: jest + .fn() + .mockResolvedValue({ success: true }), + dbGetFavorites: jest.fn().mockResolvedValue([]), + dbGetAllGlobalFavorites: jest.fn().mockResolvedValue([]), + }; + Object.defineProperty(window, 'electron', { + configurable: true, + value: bridge, + }); + await service.getFavorites('playlist-1'); + expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledWith( + 'playlist-1', + pending.favorites, + undefined + ); + expect( + bridge.dbRestoreXtreamFavorites.mock.invocationCallOrder[0] + ).toBeLessThan(bridge.dbGetFavorites.mock.invocationCallOrder[0]); + await service.getAllGlobalFavorites(); + expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledTimes(2); + expect(localStorage.getItem('xtream-restore-playlist-1')).toBe( + JSON.stringify(pending) + ); + }); + + it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => { + localStorage.setItem( + 'xtream-restore-orphan', + JSON.stringify({ + favorites: [{ contentType: 'movie', xtreamId: 7 }], + }) + ); + const bridge = { + dbGetPlaylist: jest.fn().mockResolvedValue(null), + dbRestoreXtreamFavorites: jest + .fn() + .mockRejectedValue(new Error('SQLite unavailable')), + dbGetFavoritesForBackup: jest.fn().mockResolvedValue([]), + }; + Object.defineProperty(window, 'electron', { + configurable: true, + value: bridge, + }); + await expect(service.getFavoritesOrThrow('orphan')).resolves.toEqual( + [] + ); + expect(bridge.dbRestoreXtreamFavorites).not.toHaveBeenCalled(); + bridge.dbGetPlaylist.mockResolvedValue({ type: 'xtream' }); + await expect(service.getFavoritesOrThrow('orphan')).rejects.toThrow( + 'SQLite unavailable' + ); + expect(bridge.dbGetFavoritesForBackup).toHaveBeenCalledTimes(1); + }); + it('treats app state persistence as unavailable without the Electron bridge', async () => { await expect(service.getAppState('xtream-key')).resolves.toBeNull(); await expect( diff --git a/libs/services/src/lib/database-electron.service.ts b/libs/services/src/lib/database-electron.service.ts index 5ae6a77fa..ce45cc728 100644 --- a/libs/services/src/lib/database-electron.service.ts +++ b/libs/services/src/lib/database-electron.service.ts @@ -1,3 +1,8 @@ +import { materializePendingXtreamFavorites } from './xtream-pending-favorites'; +import type { + ElectronBridgeXtreamFavorite, + XtreamFavoriteRemovalRef, +} from '@iptvnator/shared/interfaces'; /** * Database service for Electron renderer process * Communicates with the main process database via IPC @@ -100,7 +105,9 @@ export interface GlobalRecentItem extends XtreamContent { viewed_at: string; } -export interface GlobalFavoriteItem extends XtreamContent { +export type XtreamFavorite = ElectronBridgeXtreamFavorite; + +export interface GlobalFavoriteItem extends XtreamFavorite { playlist_id: string; playlist_name: string; added_at: string; @@ -288,8 +295,23 @@ export class DatabaseService { } /** - * Restore favorites and recently viewed items after Xtream refresh + * Materialize saved favorites without requiring the provider catalog. */ + async restoreXtreamFavoriteSnapshots( + playlistId: string, + favorites: XtreamBackupFavoriteItem[], + restoreId?: string + ): Promise { + if (favorites.length === 0) return; + const result = await window.electron.dbRestoreXtreamFavorites( + playlistId, + favorites, + restoreId + ); + if (!result.success) + throw new Error('Restoring saved favorites failed.'); + } + async restoreXtreamUserData( playlistId: string, favorites: XtreamBackupFavoriteItem[], @@ -719,6 +741,7 @@ export class DatabaseService { } try { + await materializePendingXtreamFavorites(window.electron); const items = await window.electron.dbGetGlobalFavorites(); return items || []; } catch (error) { @@ -736,6 +759,7 @@ export class DatabaseService { } try { + await materializePendingXtreamFavorites(window.electron); const items = await window.electron.dbGetAllGlobalFavorites(); return items || []; } catch (error) { @@ -795,9 +819,8 @@ export class DatabaseService { if (typeof window.electron?.dbAddFavorite !== 'function') { return false; } - try { - await window.electron.dbAddFavorite( + await this.addToFavoritesOrThrow( contentId, playlistId, backdropUrl @@ -809,11 +832,30 @@ export class DatabaseService { } } + /** User-triggered saves must expose a rejected write to the detail toggle. */ + async addToFavoritesOrThrow( + contentId: number, + playlistId: string, + backdropUrl?: string + ): Promise { + if (typeof window.electron?.dbAddFavorite !== 'function') { + throw new Error('Favorite storage is unavailable'); + } + const result = await window.electron.dbAddFavorite( + contentId, + playlistId, + backdropUrl + ); + if (!result.success) { + throw new Error(result.error ?? 'Favorite could not be saved'); + } + } + /** * Remove content from favorites */ async removeFromFavorites( - contentId: number, + contentId: XtreamFavoriteRemovalRef, playlistId: string ): Promise { if (typeof window.electron?.dbRemoveFavorite !== 'function') { @@ -845,15 +887,22 @@ export class DatabaseService { } } - /** - * Get all favorites for a playlist - */ - async getFavorites(playlistId: string): Promise { + /** Backup reads must fail rather than export an empty list after an IPC error. */ + async getFavoritesOrThrow(playlistId: string): Promise { + await materializePendingXtreamFavorites(window.electron, playlistId); + return window.electron.dbGetFavoritesForBackup(playlistId); + } + + async getFavorites(playlistId: string): Promise { if (typeof window.electron?.dbGetFavorites !== 'function') { return []; } try { + await materializePendingXtreamFavorites( + window.electron, + playlistId + ); return await window.electron.dbGetFavorites(playlistId); } catch (error) { console.error('Error getting favorites:', error); diff --git a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts index 53a5194e7..96015bc5d 100644 --- a/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts +++ b/libs/services/src/lib/playlist-backup.service.roundtrip.spec.ts @@ -209,12 +209,8 @@ describe('PlaylistBackupService export → import round-trip', () => { const snapshot = collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1'); expect(snapshot).not.toBeNull(); - // Pending state must remain exportable even while catalog/storage - // reads are unavailable; it is already the complete restore input. - jest.spyOn( - collaborators.databaseService, - 'getFavorites' - ).mockRejectedValue(new Error('Catalog unavailable')); + // Durable favorites remain readable independently of the catalog; + // other pending state does not require a completed import. jest.spyOn( collaborators.playbackPositionService, 'getAllPlaybackPositionsOrThrow' @@ -230,6 +226,7 @@ describe('PlaylistBackupService export → import round-trip', () => { 'honors empty pending collections and optional source pins (present: %s)', async (pinsPresent) => { const state = seedState(); + state.xtreamFavorites = []; const collaborators = createStatefulBackupCollaborators(state); const pending: XtreamPendingRestoreState = { hiddenCategories: [], @@ -259,6 +256,7 @@ describe('PlaylistBackupService export → import round-trip', () => { it('keeps absent pending pins absent when the pin store is unavailable', async () => { const state = seedState(); + state.xtreamFavorites = []; const collaborators = createStatefulBackupCollaborators(state); collaborators.vodSourcePinService.isAvailable = false; const pending: XtreamPendingRestoreState = { @@ -273,6 +271,91 @@ describe('PlaylistBackupService export → import round-trip', () => { expect(xtreamEntry(exported.manifest).userState).toEqual(pending); }); + it('preserves older pending favorites before merging another archive', async () => { + const state = seedState(); + const collaborators = createStatefulBackupCollaborators(state); + const service = createPlaylistBackupService(collaborators); + const archive = await service.exportBackup(); + state.xtreamFavorites = []; + collaborators.pendingRestoreService.set('xtream-1', { + favorites: [ + { + contentType: 'movie', + xtreamId: 999, + snapshot: { title: 'Older saved ABC' }, + }, + ], + hiddenCategories: [], + recentlyViewed: [], + playbackPositions: [], + }); + expect((await service.importBackup(archive.json)).failed).toBe(0); + expect( + state.xtreamFavorites.map((row) => row.xtream_id).sort() + ).toEqual([501, 999]); + expect( + state.xtreamFavorites.find((row) => row.xtream_id === 999)?.title + ).toBe('Older saved ABC'); + }); + + it('restores and re-exports saved titles while the catalog is offline', async () => { + const state = seedState(); + state.xtreamFavorites = [ + { + xtream_id: 501, + type: 'movie', + title: 'Saved ABC', + poster_url: 'https://example.test/abc.jpg', + position: 3, + }, + ]; + const collaborators = createStatefulBackupCollaborators(state); + const service = createPlaylistBackupService(collaborators); + const backup = await service.exportBackup(); + state.xtreamFavorites = []; + state.xtreamCategories = []; + collaborators.databaseService.getXtreamImportStatus = async () => + 'idle'; + expect((await service.importBackup(backup.json)).failed).toBe(0); + expect(state.xtreamFavorites).toEqual([ + { + xtream_id: 501, + type: 'movie', + title: 'Saved ABC', + poster_url: 'https://example.test/abc.jpg', + position: 3, + }, + ]); + const exported = await service.exportBackup(); + expect(xtreamEntry(exported.manifest).userState.favorites).toEqual( + xtreamEntry(backup.manifest).userState.favorites + ); + expect(xtreamEntry(exported.manifest).userState).not.toHaveProperty( + 'favoritesRestoreId' + ); + }); + + it('refuses to export an empty favorites list after a storage read failure, including during pending import', async () => { + const collaborators = createStatefulBackupCollaborators(seedState()); + const service = createPlaylistBackupService(collaborators); + jest.spyOn( + collaborators.databaseService, + 'getFavoritesOrThrow' + ).mockRejectedValue(new Error('Favorites read failed')); + await expect(service.exportBackup()).rejects.toThrow( + 'Favorites read failed' + ); + collaborators.pendingRestoreService.set('xtream-1', { + favorites: [], + hiddenCategories: [], + recentlyViewed: [], + playbackPositions: [], + }); + await expect(service.exportBackup()).rejects.toThrow( + 'Favorites read failed' + ); + }); + it('refuses to export if pending storage cannot be read', async () => { const collaborators = createStatefulBackupCollaborators(seedState()); jest.spyOn( @@ -347,7 +430,7 @@ describe('PlaylistBackupService export → import round-trip', () => { expect(pins).not.toHaveBeenCalled(); expect( collaborators.pendingRestoreService.getOrThrow('xtream-1') - ).toEqual(userState); + ).toEqual({ ...userState, favoritesRestoreId: expect.any(String) }); const parkedExport = await service.exportBackup(); expect(xtreamEntry(parkedExport.manifest).userState).toEqual( userState diff --git a/libs/services/src/lib/playlist-backup.service.test-helpers.ts b/libs/services/src/lib/playlist-backup.service.test-helpers.ts index 4db4ec481..b563664c5 100644 --- a/libs/services/src/lib/playlist-backup.service.test-helpers.ts +++ b/libs/services/src/lib/playlist-backup.service.test-helpers.ts @@ -119,11 +119,14 @@ export function createPlaylistBackupService( }, databaseService: { getAllXtreamCategories: jest.fn().mockResolvedValue([]), - getFavorites: jest.fn().mockResolvedValue([]), + getFavoritesOrThrow: jest.fn().mockResolvedValue([]), getRecentItems: jest.fn().mockResolvedValue([]), getXtreamImportStatus: jest.fn().mockResolvedValue('idle'), hasXtreamCategories: jest.fn().mockResolvedValue(false), hasXtreamContent: jest.fn().mockResolvedValue(false), + restoreXtreamFavoriteSnapshots: jest + .fn() + .mockResolvedValue(undefined), restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), updateCategoryVisibility: jest.fn().mockResolvedValue(true), }, @@ -178,6 +181,8 @@ export interface FakeXtreamContentRow { added_at?: string; position?: number | null; viewed_at?: string; + title?: string; + poster_url?: string; } /** @@ -206,6 +211,7 @@ export function createStatefulBackupCollaborators( state: FakeBackupBackendState ) { let lastParsedRawM3u: string | null = null; + const favoriteReceipts = new Set(); return { playlistsService: { @@ -270,7 +276,7 @@ export function createStatefulBackupCollaborators( row.playlist_id === playlistId && row.type === type ) .map((row) => ({ ...row })), - getFavorites: async () => + getFavoritesOrThrow: async () => state.xtreamFavorites.map((row) => ({ ...row })), getRecentItems: async () => state.xtreamRecent.map((row) => ({ ...row })), @@ -289,21 +295,47 @@ export function createStatefulBackupCollaborators( return true; }, - restoreXtreamUserData: async ( + restoreXtreamFavoriteSnapshots: async ( _playlistId: string, favorites: XtreamBackupFavoriteItem[], + restoreId?: string + ) => { + const receipt = restoreId ?? JSON.stringify(favorites); + if (!favorites.length || favoriteReceipts.has(receipt)) return; + favoriteReceipts.add(receipt); + const additions = favorites.filter( + (item) => + !state.xtreamFavorites.some( + (row) => + row.xtream_id === item.xtreamId && + row.type === item.contentType + ) + ); + state.xtreamFavorites.push( + ...additions.map((item) => ({ + xtream_id: item.xtreamId, + type: item.contentType, + ...(item.snapshot + ? { + title: item.snapshot.title, + poster_url: + item.snapshot.posterUrl ?? undefined, + } + : {}), + ...(item.addedAt !== undefined + ? { added_at: item.addedAt } + : {}), + ...(item.position !== undefined + ? { position: item.position } + : {}), + })) + ); + }, + restoreXtreamUserData: async ( + _playlistId: string, + _favorites: XtreamBackupFavoriteItem[], recentlyViewed: XtreamBackupRecentlyViewedItem[] ) => { - state.xtreamFavorites = favorites.map((item) => ({ - xtream_id: item.xtreamId, - type: item.contentType, - ...(item.addedAt !== undefined - ? { added_at: item.addedAt } - : {}), - ...(item.position !== undefined - ? { position: item.position } - : {}), - })); state.xtreamRecent = recentlyViewed.map((item) => ({ xtream_id: item.xtreamId, type: item.contentType, diff --git a/libs/services/src/lib/playlist-backup.service.ts b/libs/services/src/lib/playlist-backup.service.ts index 85498b79b..2ac06762a 100644 --- a/libs/services/src/lib/playlist-backup.service.ts +++ b/libs/services/src/lib/playlist-backup.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core'; import { firstValueFrom } from 'rxjs'; import { PlaylistsService } from './playlists.service'; import { SettingsStore } from './settings-store.service'; +import { toXtreamBackupFavorite } from './xtream-favorite-backup'; import { DatabaseService } from './database-electron.service'; import { VodSourcePinService } from './vod-source-pin.service'; import { RuntimeCapabilitiesService } from './runtime-capabilities.service'; @@ -318,15 +319,30 @@ export class PlaylistBackupService { }; const pending = this.pendingRestoreService.getOrThrow(playlist._id); if (pending) { - // A parked import is authoritative, including empty collections. - // Reading the incomplete catalog instead would silently lose it. + // Other parked user state is authoritative during an import. Favorites + // already have a durable owner: materialize old parked identities once, + // then export the current saved records (including explicit removals). + let favorites = pending.favorites; + if (this.runtime.supportsXtreamSqliteDataSource) { + await this.databaseService.restoreXtreamFavoriteSnapshots( + playlist._id, + pending.favorites, + pending.favoritesRestoreId + ); + favorites = ( + await this.databaseService.getFavoritesOrThrow(playlist._id) + ).map(toXtreamBackupFavorite); + } const sourcePins = pending.sourcePins ?? (await this.readSourcePinsForExport(playlist._id)); return { ...entry, userState: { - ...pending, + hiddenCategories: pending.hiddenCategories, + favorites, + recentlyViewed: pending.recentlyViewed, + playbackPositions: pending.playbackPositions, ...this.optionalLockedXtreamCategories(playlist._id), ...(sourcePins ? { sourcePins } : {}), }, @@ -349,7 +365,7 @@ export class PlaylistBackupService { this.databaseService.getAllXtreamCategories(playlist._id, 'live'), this.databaseService.getAllXtreamCategories(playlist._id, 'movies'), this.databaseService.getAllXtreamCategories(playlist._id, 'series'), - this.databaseService.getFavorites(playlist._id), + this.databaseService.getFavoritesOrThrow(playlist._id), this.databaseService.getRecentItems(playlist._id), this.playbackPositionService.getAllPlaybackPositionsOrThrow( playlist._id @@ -371,14 +387,7 @@ export class PlaylistBackupService { ...this.mapHiddenCategories(seriesCategories, 'series'), ], ...this.optionalLockedXtreamCategories(playlist._id), - favorites: favorites.map((item) => ({ - xtreamId: item.xtream_id, - contentType: item.type as XtreamBackupContentType, - ...(item.added_at ? { addedAt: item.added_at } : {}), - ...(item.position !== undefined - ? { position: item.position } - : {}), - })), + favorites: favorites.map(toXtreamBackupFavorite), recentlyViewed: recent.map((item) => ({ xtreamId: item.xtream_id, contentType: item.type as XtreamBackupContentType, @@ -925,7 +934,20 @@ export class PlaylistBackupService { // arbitrary categories during restore. const restoreState: XtreamPendingRestoreState = normalizeXtreamPendingRestoreState(entry.userState); + // A deliberate re-import is a fresh user action. Do not accept an archive's + // internal receipt or confuse it with a retry of an older parked import. + restoreState.favoritesRestoreId = createRandomId(); + // Preserve an older unfinished import before parking the incoming backup. + // Canonical favorites are additive; legacy-only ones need the same protection. + const previous = this.pendingRestoreService.getOrThrow(playlistId); + if (previous && this.runtime.supportsXtreamSqliteDataSource) { + await this.databaseService.restoreXtreamFavoriteSnapshots( + playlistId, + previous.favorites, + previous.favoritesRestoreId + ); + } const restoreSnapshot = this.pendingRestoreService.set( playlistId, restoreState @@ -939,6 +961,11 @@ export class PlaylistBackupService { // Browser storage has no catalog cache to wait for: apply at once. const sqlite = this.runtime.supportsXtreamSqliteDataSource; if (sqlite) { + await this.databaseService.restoreXtreamFavoriteSnapshots( + playlistId, + restoreState.favorites, + restoreState.favoritesRestoreId + ); this.playbackPositionService.assertSupportsAtomicReplacement(); if (!(await this.hasCompletedOfflineCache(playlistId))) return; } @@ -1007,9 +1034,14 @@ export class PlaylistBackupService { ): Promise { this.playbackPositionService.assertSupportsAtomicReplacement(); await this.restoreXtreamCategoryVisibility(playlistId, state); - await this.databaseService.restoreXtreamUserData( + await this.databaseService.restoreXtreamFavoriteSnapshots( playlistId, state.favorites, + state.favoritesRestoreId + ); + await this.databaseService.restoreXtreamUserData( + playlistId, + [], state.recentlyViewed ); diff --git a/libs/services/src/lib/playlist-backup.xtream-fixtures.ts b/libs/services/src/lib/playlist-backup.xtream-fixtures.ts index 17de2a304..6b553c57f 100644 --- a/libs/services/src/lib/playlist-backup.xtream-fixtures.ts +++ b/libs/services/src/lib/playlist-backup.xtream-fixtures.ts @@ -97,6 +97,7 @@ export function createRestoreCollaborators() { let pendingSnapshot: XtreamPendingRestoreSnapshot | null = null; let lastConsumedRevision: number | null = null; const pendingRestoreService = { + getOrThrow: jest.fn(() => pendingSnapshot?.state ?? null), set: jest.fn( ( playlistId: string, @@ -125,18 +126,13 @@ export function createRestoreCollaborators() { ? 'consumed' : 'superseded'; } - if ( - pendingSnapshot.revision !== expectedSnapshot.revision - ) { + if (pendingSnapshot.revision !== expectedSnapshot.revision) { return 'superseded'; } await apply(pendingSnapshot.state); if ( - !pendingRestoreService.clear( - playlistId, - pendingSnapshot.state - ) + !pendingRestoreService.clear(playlistId, pendingSnapshot.state) ) { return 'consume-failed'; } @@ -158,11 +154,14 @@ export function createRestoreCollaborators() { (_playlistId: string, type: string) => Promise.resolve(categoryRowsByType[type] ?? []) ), - getFavorites: jest.fn().mockResolvedValue([]), + getFavoritesOrThrow: jest.fn().mockResolvedValue([]), getRecentItems: jest.fn().mockResolvedValue([]), getXtreamImportStatus: jest.fn().mockResolvedValue('completed'), hasXtreamCategories: jest.fn().mockResolvedValue(true), hasXtreamContent: jest.fn().mockResolvedValue(true), + restoreXtreamFavoriteSnapshots: jest + .fn() + .mockResolvedValue(undefined), restoreXtreamUserData: jest.fn().mockResolvedValue(undefined), updateCategoryVisibility: jest.fn().mockResolvedValue(true), }, diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts index 5b1307bea..e0f27d358 100644 --- a/libs/services/src/lib/runtime-capabilities.service.spec.ts +++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts @@ -118,6 +118,7 @@ describe('RuntimeCapabilitiesService', () => { onPlaybackPositionUpdate: jest.fn(), dbDeleteXtreamContent: jest.fn(), dbRestoreXtreamUserData: jest.fn(), + dbRestoreXtreamFavorites: jest.fn(), dbReplaceAllPlaybackPositions: jest.fn(), downloadsStart: jest.fn(), downloadsCancel: jest.fn(), @@ -196,6 +197,13 @@ describe('RuntimeCapabilitiesService', () => { expect(service.supportsEpgDataManagement).toBe(true); expect(service.supportsEpgGuide).toBe(true); expect(service.supportsEpgProgramSearch).toBe(true); + for (const method of ['dbGetPlaylist', 'dbRestoreXtreamFavorites']) { + const implementation = testWindow.electron[method]; + delete testWindow.electron[method]; + expect(service.supportsPortalActivityStorage).toBe(false); + testWindow.electron[method] = implementation; + expect(service.supportsPortalActivityStorage).toBe(true); + } }); it('keeps feature-specific capabilities false when an Electron bridge is partial', () => { @@ -661,6 +669,7 @@ function createXtreamSqliteBridge(): Record { dbRemoveFavorite: jest.fn(), dbRemoveRecentItem: jest.fn(), dbRestoreXtreamUserData: jest.fn(), + dbRestoreXtreamFavorites: jest.fn(), dbReplaceAllPlaybackPositions: jest.fn(), dbSaveCategories: jest.fn(), dbSaveContent: jest.fn(), diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts index 30bafb0d7..8cc0ec5a1 100644 --- a/libs/services/src/lib/runtime-capabilities.service.ts +++ b/libs/services/src/lib/runtime-capabilities.service.ts @@ -223,6 +223,7 @@ export class RuntimeCapabilitiesService { ...playbackPositionStorageMethods, 'dbDeleteXtreamContent', 'dbRestoreXtreamUserData', + 'dbRestoreXtreamFavorites', ].every((methodName) => this.hasElectronMethod(methodName)); } @@ -274,6 +275,8 @@ export class RuntimeCapabilitiesService { get supportsPortalActivityStorage(): boolean { return [ + 'dbGetPlaylist', + 'dbRestoreXtreamFavorites', 'dbGetRecentlyViewed', 'dbClearRecentlyViewed', 'dbGetAllGlobalFavorites', diff --git a/libs/services/src/lib/xtream-favorite-backup.ts b/libs/services/src/lib/xtream-favorite-backup.ts new file mode 100644 index 000000000..d4d10fafb --- /dev/null +++ b/libs/services/src/lib/xtream-favorite-backup.ts @@ -0,0 +1,36 @@ +import { + ElectronBridgeXtreamFavorite, + XtreamBackupFavoriteItem, +} from '@iptvnator/shared/interfaces'; + +/** Export the saved display snapshot, including records without catalog rows. */ +export function toXtreamBackupFavorite( + item: ElectronBridgeXtreamFavorite +): XtreamBackupFavoriteItem { + return { + xtreamId: item.xtream_id, + contentType: item.type as XtreamBackupFavoriteItem['contentType'], + ...(item.added_at ? { addedAt: item.added_at } : {}), + ...(item.position !== undefined ? { position: item.position } : {}), + ...(item.title + ? { + snapshot: { + title: item.title, + ...(item.poster_url != null + ? { posterUrl: item.poster_url } + : {}), + ...(item.backdrop_url != null + ? { backdropUrl: item.backdrop_url } + : {}), + ...(item.tmdb_id != null ? { tmdbId: item.tmdb_id } : {}), + ...(item.release_year != null + ? { releaseYear: item.release_year } + : {}), + ...(item.original_title != null + ? { originalTitle: item.original_title } + : {}), + }, + } + : {}), + }; +} diff --git a/libs/services/src/lib/xtream-pending-favorites.ts b/libs/services/src/lib/xtream-pending-favorites.ts new file mode 100644 index 000000000..061e3af3f --- /dev/null +++ b/libs/services/src/lib/xtream-pending-favorites.ts @@ -0,0 +1,48 @@ +import { + ElectronBridgeApi, + getXtreamPendingRestoreStorageKey, + normalizeXtreamPendingRestoreState, +} from '@iptvnator/shared/interfaces'; + +/** Reads must recover older parked favorites without entering a network bootstrap. */ +export async function materializePendingXtreamFavorites( + bridge: Pick< + ElectronBridgeApi, + 'dbGetPlaylist' | 'dbRestoreXtreamFavorites' + >, + playlistId?: string +): Promise { + const prefix = getXtreamPendingRestoreStorageKey(''); + const ids = playlistId + ? [playlistId] + : Array.from({ length: localStorage.length }, (_, index) => + localStorage.key(index) + ) + .filter( + (key): key is string => key !== null && key.startsWith(prefix) + ) + .map((key) => key.slice(prefix.length)); + for (const id of ids) { + const raw = localStorage.getItem(getXtreamPendingRestoreStorageKey(id)); + if (!raw) continue; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + continue; + } + const state = normalizeXtreamPendingRestoreState(parsed); + if (!state.favorites.length) continue; + // Old localStorage can outlive deletion of its owning SQLite playlist. + // Never recreate a playlist or let an orphan block other saved entries. + const playlist = await bridge.dbGetPlaylist(id); + if (!playlist || playlist.type !== 'xtream') continue; + const result = await bridge.dbRestoreXtreamFavorites( + id, + state.favorites, + state.favoritesRestoreId + ); + if (!result.success) + throw new Error('Recovering saved favorites failed.'); + } +} diff --git a/libs/shared/database/README.md b/libs/shared/database/README.md index 98b45868f..18baad9a6 100644 --- a/libs/shared/database/README.md +++ b/libs/shared/database/README.md @@ -46,13 +46,30 @@ root is resolved in this order: Users may skip releases. The application must apply every required migration in dependency order when opening an older database, preserving user data without requiring intermediate application installations or a database reset. This is -the repository policy mirrored in `AGENTS.md` and `CLAUDE.md`. +the repository policy in `AGENTS.md`, which `CLAUDE.md` imports. `src/lib/connection.ts` owns initialization: `createTables()` creates missing schema objects, then `runMigrations()` applies column/index migrations and dedicated schema/data upgrades. One-off data migrations can record completion in `app_state`. Keep existing migration paths when adding new ones. +Xtream favorites are migrated by `src/lib/favorites-table.ts` before legacy +content/category deduplication. The transactional table rebuild backfills typed +provider identity and saved display metadata while retaining favorite IDs, +dates and positions. Duplicate favorites of the same playlist/type/provider ID +collapse to the earliest favorite record. `content_id` becomes nullable with +`ON DELETE SET NULL`; owning playlist deletion still cascades. The legacy +content deduplicator updates references without replacing favorite IDs. +Favorites whose owning playlist was already missing remain stored. The rebuild +temporarily disables foreign-key enforcement outside its transaction, permits +only those pre-existing playlist violations by favorite ID, and rolls back if +it introduces another violation. The original enforcement setting is restored +on both success and failure. +Historical upgrade tests include tagged 0.19–0.24 schemas, duplicate categories, +orphaned favorites, repeated startup and forced rollbacks. Keep snapshot +migration before content deduplication so the originally favorited row's +metadata survives. + When changing initialization: - Create required tables before migrating them. `CREATE TABLE IF NOT EXISTS` @@ -102,7 +119,7 @@ playlists) are guarded in `runMigrations()`: The #1580 index-ordering fix is included in 0.24 through PR #1550. `src/lib/connection-upgrades.spec.ts` exercises `initDatabase()` with real SQLite under the Electron runtime, using fresh-install schema snapshots from -tags 0.19–0.23 and a fresh current database. The `epg_channel_id` column is absent +tags 0.19–0.24 and a fresh current database. The `epg_channel_id` column is absent in 0.19, present from 0.20, and indexed from 0.23. Each case checks current Drizzle tables, columns/types, and named indexes/uniqueness, as well as preserved user rows, foreign keys, database integrity, index availability, and repeated startup; @@ -115,3 +132,6 @@ the index ahead of its column migration makes the 0.19 case fail again. Run this coverage with `pnpm nx test database --runInBand`. The Electron UI and worker upgrade flow is also covered by `pnpm nx run electron-backend-e2e:e2e-ci--src/legacy-playlist-migration.e2e.ts`. +For an orphaned 0.24 favorites table, initial startup and restart through the +real Electron UI and worker are covered by +`pnpm nx run electron-backend-e2e:e2e-ci--src/favorites-legacy.e2e.ts`. diff --git a/libs/shared/database/src/lib/connection-migrations.spec.ts b/libs/shared/database/src/lib/connection-migrations.spec.ts index 28d81c1f7..e8d1f9cae 100644 --- a/libs/shared/database/src/lib/connection-migrations.spec.ts +++ b/libs/shared/database/src/lib/connection-migrations.spec.ts @@ -40,7 +40,11 @@ function createSqliteMock( const rule = rules.find(([pattern]) => compact.includes(pattern)); return { - all: jest.fn(() => []), + all: jest.fn(() => + compact === "PRAGMA table_info('favorites')" + ? [{ name: 'xtream_id' }] + : [] + ), get: jest.fn(), run: jest.fn(), ...(rule?.[1] ?? {}), @@ -299,7 +303,7 @@ describe('runMigrations Xtream cache deduplication', () => { 'SELECT id FROM content WHERE category_id = ?', { all: () => [{ id: 10 }, { id: 11 }] }, ], - ['INSERT INTO favorites', { run: moveFavoritesRun }], + ['UPDATE OR IGNORE favorites', { run: moveFavoritesRun }], [ 'DELETE FROM favorites WHERE content_id = ?', { run: deleteFavoritesRun }, diff --git a/libs/shared/database/src/lib/connection-upgrades.spec.ts b/libs/shared/database/src/lib/connection-upgrades.spec.ts index bce379e57..da6607536 100644 --- a/libs/shared/database/src/lib/connection-upgrades.spec.ts +++ b/libs/shared/database/src/lib/connection-upgrades.spec.ts @@ -5,7 +5,19 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; describe('database initialization across skipped releases (#1580)', () => { - it.each(['0.19.0', '0.20.0', '0.21.0', '0.22.0', '0.23.0', 'fresh'])( + it.each([ + '0.19.0', + '0.20.0', + '0.21.0', + '0.22.0', + '0.23.0', + '0.24.0', + '0.19.0-duplicates', + '0.19.0-orphans', + '0.24.0-orphans', + 'fresh', + 'rollback', + ])( 'preserves %s data and indexes across upgrade and repeated startup', (version) => { const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-upgrade-')); @@ -13,12 +25,12 @@ describe('database initialization across skipped releases (#1580)', () => { 'electron' ) as string; const fixture = - version === 'fresh' - ? 'fresh' + version === 'fresh' || version === 'rollback' + ? version : resolve( __dirname, 'testing/fixtures', - `v${version}-schema.sql` + `v${version.replace(/-(duplicates|orphans)$/, '')}-schema.sql` ); try { const output = execFileSync( @@ -28,6 +40,11 @@ describe('database initialization across skipped releases (#1580)', () => { 'tsx', resolve(__dirname, 'testing/connection-upgrade.ts'), fixture, + version.endsWith('-duplicates') + ? 'deduplicate' + : version.endsWith('-orphans') + ? 'orphans' + : '', ], { cwd: process.cwd(), diff --git a/libs/shared/database/src/lib/connection.spec.ts b/libs/shared/database/src/lib/connection.spec.ts index ccba0fd02..6457677e4 100644 --- a/libs/shared/database/src/lib/connection.spec.ts +++ b/libs/shared/database/src/lib/connection.spec.ts @@ -486,6 +486,9 @@ describe('database schema statements', () => { ).runMigrations; const sqlite = { exec: jest.fn(), + pragma: jest.fn((statement: string) => + statement === 'foreign_keys' ? 1 : [] + ), prepare: jest.fn((statement: string) => { if (statement.includes('SELECT value FROM app_state')) { return { @@ -556,6 +559,9 @@ describe('database schema statements', () => { } ).runMigrations; const sqlite = { + pragma: jest.fn((statement: string) => + statement === 'foreign_keys' ? 1 : [] + ), exec: jest.fn((statement: string) => { if (statement.includes('idx_epg_programs_source')) { callOrder.push('epg-source-index'); diff --git a/libs/shared/database/src/lib/connection.ts b/libs/shared/database/src/lib/connection.ts index 5f026114b..07cdbde59 100644 --- a/libs/shared/database/src/lib/connection.ts +++ b/libs/shared/database/src/lib/connection.ts @@ -7,6 +7,11 @@ import { ensurePlaylistsPayloadLast, playlistsTableSql, } from './playlists-table'; +import { + ensureDurableFavorites, + favoritesTableSql, + FAVORITES_RECEIPT_CLEANUP_TRIGGER, +} from './favorites-table'; import { xtreamCacheUniqueIndexesExist } from './xtream-cache-unique-indexes'; /** * Database connection and initialization for IPTVnator @@ -183,15 +188,8 @@ const CREATE_TABLE_STATEMENTS = [ FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE, FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE )`, - `CREATE TABLE IF NOT EXISTS favorites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - content_id INTEGER NOT NULL, - playlist_id TEXT NOT NULL, - added_at TEXT DEFAULT (datetime('now')), - position INTEGER DEFAULT 0, - FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE, - FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE - )`, + favoritesTableSql(), + FAVORITES_RECEIPT_CLEANUP_TRIGGER, `CREATE INDEX IF NOT EXISTS idx_content_type ON content(type)`, `CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id)`, `CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`, @@ -407,6 +405,7 @@ const INDEX_MIGRATION_STATEMENTS = [ // v1.3.0 -> v1.4.0: Prevent duplicate Xtream categories/content rows `CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)`, `CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS favorites_playlist_type_xtream_unique ON favorites(playlist_id, type, xtream_id)`, // v1.6.0 -> v1.7.0: Query global favorites in stable display order `CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`, // v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time @@ -550,11 +549,7 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void { ORDER BY id ASC` ); const moveFavorites = sqliteDb.prepare( - `INSERT INTO favorites (content_id, playlist_id, added_at, position) - SELECT ?, playlist_id, added_at, COALESCE(position, 0) - FROM favorites - WHERE content_id = ? - ON CONFLICT(content_id, playlist_id) DO NOTHING` + `UPDATE OR IGNORE favorites SET content_id = ? WHERE content_id = ?` ); const deleteFavorites = sqliteDb.prepare( `DELETE FROM favorites WHERE content_id = ?` @@ -1183,6 +1178,8 @@ function runMigrations(sqliteDb: Database.Database): void { if (!upgradeContentTitleFtsTokenizer(sqliteDb)) { ensureContentTitleFts(sqliteDb); } + // Capture the favorited row's metadata before duplicate cache rows disappear. + ensureDurableFavorites(sqliteDb); deduplicateXtreamCache(sqliteDb); normalizeXtreamContentAddedEpochs(sqliteDb); runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS); diff --git a/libs/shared/database/src/lib/favorites-table.ts b/libs/shared/database/src/lib/favorites-table.ts new file mode 100644 index 000000000..c635a14e1 --- /dev/null +++ b/libs/shared/database/src/lib/favorites-table.ts @@ -0,0 +1,106 @@ +import type Database from 'better-sqlite3'; + +export function favoritesTableSql(name = 'favorites'): string { + return `CREATE TABLE IF NOT EXISTS ${name} ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_id INTEGER, + playlist_id TEXT NOT NULL, + xtream_id INTEGER NOT NULL, + type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')), + title TEXT NOT NULL, + poster_url TEXT, + backdrop_url TEXT, + tmdb_id INTEGER, + release_year INTEGER, + original_title TEXT, + added_at TEXT DEFAULT (datetime('now')), + position INTEGER DEFAULT 0, + FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE SET NULL, + FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE + )`; +} + +/** Runs after column upgrades, before cache deduplication and dependent indexes. */ +export function ensureDurableFavorites(sqlite: Database.Database): void { + const columns = sqlite.prepare("PRAGMA table_info('favorites')").all() as { + name: string; + }[]; + if (columns.some((column) => column.name === 'xtream_id')) return; + // Historical profiles may already contain favorites with a missing owner. + // Retain those exact records, but never let this rebuild introduce new ones. + const orphanIds = new Set( + ( + sqlite + .prepare( + `SELECT f.id FROM favorites f + LEFT JOIN playlists p ON p.id = f.playlist_id + WHERE p.id IS NULL` + ) + .all() as { id: number }[] + ).map(({ id }) => id) + ); + const foreignKeys = sqlite.pragma('foreign_keys', { simple: true }); + // This must happen outside the transaction; SQLite ignores it inside one. + sqlite.pragma('foreign_keys = OFF'); + try { + sqlite.transaction(() => { + sqlite.exec(favoritesTableSql('favorites_durable')); + // Multiple categories may contain the same provider item. Keep the first + // saved favorite's primary key, timestamp and position deterministically. + sqlite.exec(`INSERT INTO favorites_durable + (id, content_id, playlist_id, xtream_id, type, title, poster_url, + backdrop_url, tmdb_id, release_year, original_title, added_at, position) + SELECT id, content_id, playlist_id, xtream_id, type, title, poster_url, + backdrop_url, tmdb_id, release_year, original_title, added_at, position + FROM ( + SELECT f.id, c.id AS content_id, f.playlist_id, + COALESCE(c.xtream_id, -f.id) AS xtream_id, + COALESCE(c.type, 'movie') AS type, + COALESCE(c.title, 'Saved favorite') AS title, + c.poster_url, c.backdrop_url, c.tmdb_id, c.release_year, c.original_title, + f.added_at, f.position, + ROW_NUMBER() OVER (PARTITION BY f.playlist_id, c.type, + COALESCE(c.xtream_id, -f.id) ORDER BY f.id) AS identity_rank + FROM favorites f LEFT JOIN content c ON c.id = f.content_id + ) WHERE identity_rank = 1`); + sqlite.exec('DROP TABLE favorites'); + sqlite.exec('ALTER TABLE favorites_durable RENAME TO favorites'); + sqlite.exec( + 'CREATE UNIQUE INDEX favorites_content_playlist_unique ON favorites(content_id, playlist_id)' + ); + sqlite.exec( + 'CREATE INDEX favorites_playlist_idx ON favorites(playlist_id)' + ); + sqlite.exec( + 'CREATE INDEX favorites_content_idx ON favorites(content_id)' + ); + const violations = sqlite.pragma( + 'foreign_key_check(favorites)' + ) as { + rowid: number; + parent: string; + }[]; + if ( + violations.some( + ({ rowid, parent }) => + parent !== 'playlists' || !orphanIds.has(rowid) + ) + ) { + throw new Error( + 'Favorites migration introduced a foreign key violation' + ); + } + })(); + } finally { + sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`); + } +} + +/** Receipts are playlist-owned even though app_state is a generic key/value table. */ +export const FAVORITES_RECEIPT_CLEANUP_TRIGGER = `CREATE TRIGGER IF NOT EXISTS favorites_receipts_playlist_delete + AFTER DELETE ON playlists BEGIN + DELETE FROM app_state WHERE substr(key, 1, length('xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':')) = + 'xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':' + OR key IN ('xtream-import-status:' || OLD.id || ':live', + 'xtream-import-status:' || OLD.id || ':movie', 'xtream-import-status:' || OLD.id || ':series'); + END`; diff --git a/libs/shared/database/src/lib/schema.ts b/libs/shared/database/src/lib/schema.ts index 045338c04..c4295d9d0 100644 --- a/libs/shared/database/src/lib/schema.ts +++ b/libs/shared/database/src/lib/schema.ts @@ -170,17 +170,30 @@ export const favorites = sqliteTable( 'favorites', { id: integer('id').primaryKey({ autoIncrement: true }), - contentId: integer('content_id') - .notNull() - .references(() => content.id, { onDelete: 'cascade' }), + contentId: integer('content_id').references(() => content.id, { + onDelete: 'set null', + }), playlistId: text('playlist_id') .notNull() .references(() => playlists.id, { onDelete: 'cascade' }), + xtreamId: integer('xtream_id').notNull(), + type: text('type', { enum: ['live', 'movie', 'series'] }).notNull(), + title: text('title').notNull(), + posterUrl: text('poster_url'), + backdropUrl: text('backdrop_url'), + tmdbId: integer('tmdb_id'), + releaseYear: integer('release_year'), + originalTitle: text('original_title'), addedAt: text('added_at').default(sql`CURRENT_TIMESTAMP`), /** Display order position in the global favorites list (lower = first) */ position: integer('position').default(0), }, (table) => ({ + identityUnique: uniqueIndex('favorites_playlist_type_xtream_unique').on( + table.playlistId, + table.type, + table.xtreamId + ), contentPlaylistUnique: uniqueIndex( 'favorites_content_playlist_unique' ).on(table.contentId, table.playlistId), diff --git a/libs/shared/database/src/lib/testing/connection-upgrade.ts b/libs/shared/database/src/lib/testing/connection-upgrade.ts index 2a6b071ff..51d6b25bd 100644 --- a/libs/shared/database/src/lib/testing/connection-upgrade.ts +++ b/libs/shared/database/src/lib/testing/connection-upgrade.ts @@ -1,3 +1,4 @@ +import { verifyFavoriteMigrationRollback } from './favorites-migration-rollback'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import Database from 'better-sqlite3'; @@ -25,7 +26,6 @@ function seed(sqlite: Database.Database) { VALUES (1, 'saved', 'Movies', 'movies', 42, 1); INSERT INTO content (id, category_id, title, xtream_id, type, added) VALUES (1, 1, 'Saved movie', 7, 'movie', '1700000000'); - INSERT INTO favorites (content_id, playlist_id) VALUES (1, 'saved'); INSERT INTO recently_viewed (content_id, playlist_id) VALUES (1, 'saved'); INSERT INTO playback_positions (playlist_id, content_xtream_id, content_type, position_seconds, duration_seconds) @@ -33,6 +33,18 @@ function seed(sqlite: Database.Database) { INSERT INTO downloads (playlist_id, xtream_id, content_type, title, url, status) VALUES ('saved', 7, 'vod', 'Saved movie', 'https://source.invalid/movie', 'completed'); `); + const favoriteColumns = sqlite.pragma('table_info(favorites)') as { + name: string; + }[]; + const durable = favoriteColumns.some(({ name }) => name === 'xtream_id'); + sqlite.exec( + durable + ? `INSERT INTO favorites (id, content_id, playlist_id, added_at, position, xtream_id, type, title) VALUES (42, 1, 'saved', '2025-01-01', 9, 7, 'movie', 'Saved movie')` + : `INSERT INTO favorites (id, content_id, playlist_id, added_at) VALUES (42, 1, 'saved', '2025-01-01')` + ); + if (favoriteColumns.some(({ name }) => name === 'position')) { + sqlite.exec('UPDATE favorites SET position = 9'); + } const columns = sqlite.pragma('table_info(content)') as { name: string }[]; if (columns.some(({ name }) => name === 'epg_channel_id')) { sqlite.exec("UPDATE content SET epg_channel_id = 'retained-epg-id'"); @@ -139,6 +151,18 @@ function epgIndex(sqlite: Database.Database) { .get(); } +function foreignKeyViolations(sqlite: Database.Database) { + // Rebuilding a table can reorder its FK definitions without changing the + // affected row or parent, so compare identities rather than FK ordinals. + return ( + sqlite.pragma('foreign_key_check') as { + table: string; + rowid: number; + parent: string; + }[] + ).map(({ table, rowid, parent }) => ({ table, rowid, parent })); +} + function verifyCurrentSchema(sqlite: Database.Database) { for (const table of Object.values(currentSchema)) { const { name, columns, indexes } = getTableConfig(table); @@ -173,6 +197,11 @@ function verifyCurrentSchema(sqlite: Database.Database) { async function main() { const fixture = process.argv[2]; + if (fixture === 'rollback') { + verifyFavoriteMigrationRollback(); + process.stdout.write('upgrade verified'); + return; + } assert( process.env['IPTVNATOR_E2E_DATA_DIR'], 'Never open the user database' @@ -191,7 +220,21 @@ async function main() { legacy.pragma('foreign_keys = ON'); if (fixture !== 'fresh') legacy.exec(readFileSync(fixture, 'utf8')); seed(legacy); + if (process.argv[3] === 'orphans') { + legacy.pragma('foreign_keys = OFF'); + legacy.exec(`INSERT INTO favorites (id, content_id, playlist_id, added_at) + VALUES (43, 1, 'deleted-playlist', '2024-01-01')`); + legacy.pragma('foreign_keys = ON'); + } const before = snapshot(legacy); + const originalViolations = foreignKeyViolations(legacy); + if (process.argv[3] === 'deduplicate') { + legacy.exec(`INSERT INTO categories (id, playlist_id, name, type, xtream_id, hidden) + VALUES (2, 'saved', 'Movies', 'movies', 42, 1); + INSERT INTO content (id, category_id, title, xtream_id, type, added) + VALUES (2, 2, 'Original favorite title', 7, 'movie', '1700000000'); + UPDATE favorites SET content_id = 2;`); + } const originalIndex = epgIndex(legacy); legacy.close(); @@ -214,7 +257,37 @@ async function main() { for (const { query, rows } of before) { assert.deepEqual(sqlite.prepare(query).all(), rows, query); } - assert.deepEqual(sqlite.pragma('foreign_key_check'), []); + assert.deepEqual( + sqlite + .prepare( + 'SELECT id, content_id, playlist_id, xtream_id, type, title, added_at FROM favorites' + ) + .get(), + { + id: 42, + content_id: 1, + playlist_id: 'saved', + xtream_id: 7, + type: 'movie', + title: + process.argv[3] === 'deduplicate' + ? 'Original favorite title' + : 'Saved movie', + added_at: '2025-01-01', + } + ); + const favoriteForeignKeys = sqlite.pragma( + 'foreign_key_list(favorites)' + ) as { from: string; on_delete: string }[]; + assert.equal( + favoriteForeignKeys.find((key) => key.from === 'content_id') + ?.on_delete, + 'SET NULL' + ); + assert.deepEqual( + foreignKeyViolations(sqlite), + originalViolations + ); assert.deepEqual(sqlite.pragma('integrity_check'), [ { integrity_check: 'ok' }, ]); diff --git a/libs/shared/database/src/lib/testing/favorites-migration-rollback.ts b/libs/shared/database/src/lib/testing/favorites-migration-rollback.ts new file mode 100644 index 000000000..6e6e6c8c7 --- /dev/null +++ b/libs/shared/database/src/lib/testing/favorites-migration-rollback.ts @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict'; +import Database from 'better-sqlite3'; +import { ensureDurableFavorites } from '../favorites-table'; + +export function verifyFavoriteMigrationRollback() { + const sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + try { + sqlite.exec(`CREATE TABLE playlists (id TEXT PRIMARY KEY); + CREATE TABLE content (id INTEGER PRIMARY KEY, xtream_id INTEGER, type TEXT, + title TEXT, poster_url TEXT, backdrop_url TEXT, tmdb_id INTEGER, + release_year INTEGER, original_title TEXT); + CREATE TABLE favorites (id INTEGER PRIMARY KEY, content_id INTEGER NOT NULL + REFERENCES content(id) ON DELETE CASCADE, playlist_id TEXT REFERENCES playlists(id), + added_at TEXT, position INTEGER); + INSERT INTO playlists VALUES ('p'); + INSERT INTO content (id, xtream_id, type, title) VALUES (1, 7, 'movie', 'ABC'); + INSERT INTO favorites VALUES (42, 1, 'p', '2025-01-01', 9); + CREATE VIEW favorites_dependency AS SELECT id FROM favorites;`); + const before = sqlite.prepare('SELECT * FROM favorites').all(); + assert.throws( + () => ensureDurableFavorites(sqlite), + /favorites_dependency/ + ); + assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1); + assert.deepEqual( + sqlite.prepare('SELECT * FROM favorites').all(), + before + ); + assert.equal( + sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE name = 'favorites_durable'" + ) + .get(), + undefined + ); + sqlite.exec('DROP VIEW favorites_dependency'); + sqlite.pragma('foreign_keys = OFF'); + sqlite.exec(`INSERT INTO favorites VALUES (43, 1, 'deleted', '2024-01-01', 10); + INSERT INTO favorites VALUES (44, 999, 'p', '2024-01-02', 11);`); + sqlite.pragma('foreign_keys = ON'); + const withOrphans = sqlite.prepare('SELECT * FROM favorites').all(); + const exec = sqlite.exec; + sqlite.exec = function injectInvalidOwner(statement) { + const result = exec.call(this, statement); + if ( + statement === + 'ALTER TABLE favorites_durable RENAME TO favorites' + ) { + exec.call( + this, + "UPDATE favorites SET playlist_id = 'new-orphan' WHERE id = 42" + ); + } + return result; + }; + // The missing content link is repaired during the rebuild. A mere + // before/after violation count would conceal a new orphan in its place. + try { + assert.throws( + () => ensureDurableFavorites(sqlite), + /introduced a foreign key violation/ + ); + } finally { + sqlite.exec = exec; + } + assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1); + assert.deepEqual( + sqlite.prepare('SELECT * FROM favorites').all(), + withOrphans + ); + assert.equal( + sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE name = 'favorites_durable'" + ) + .get(), + undefined + ); + ensureDurableFavorites(sqlite); + assert.equal(sqlite.pragma('foreign_keys', { simple: true }), 1); + ensureDurableFavorites(sqlite); + sqlite.exec('DELETE FROM content'); + assert.deepEqual( + sqlite + .prepare( + 'SELECT id, content_id, title, added_at, position FROM favorites' + ) + .get(), + { + id: 42, + content_id: null, + title: 'ABC', + added_at: '2025-01-01', + position: 9, + } + ); + assert.deepEqual( + ( + sqlite.pragma('foreign_key_check') as { + rowid: number; + parent: string; + }[] + ).map(({ rowid, parent }) => ({ rowid, parent })), + [{ rowid: 43, parent: 'playlists' }] + ); + assert.equal( + sqlite.prepare('SELECT id FROM favorites').all().length, + 3 + ); + } finally { + sqlite.close(); + } +} diff --git a/libs/shared/database/src/lib/testing/fixtures/v0.24.0-schema.sql b/libs/shared/database/src/lib/testing/fixtures/v0.24.0-schema.sql new file mode 100644 index 000000000..d84123092 --- /dev/null +++ b/libs/shared/database/src/lib/testing/fixtures/v0.24.0-schema.sql @@ -0,0 +1,61 @@ +-- Fresh-install schema from tag v0.24.0, libs/shared/database/src/lib/connection.ts. +-- Whitespace compacted; statements and referenced constants retain their original order. +CREATE TABLE IF NOT EXISTS playlists ( id TEXT PRIMARY KEY, name TEXT NOT NULL, serverUrl TEXT, username TEXT, password TEXT, date_created TEXT DEFAULT (datetime('now')), last_updated TEXT, type TEXT NOT NULL CHECK (type IN ('xtream', 'stalker', 'm3u-file', 'm3u-text', 'm3u-url')), userAgent TEXT, origin TEXT, referrer TEXT, filePath TEXT, epg_urls TEXT, detected_epg_urls TEXT, manual_epg_urls TEXT, disabled_epg_urls TEXT, autoRefresh INTEGER DEFAULT 0, macAddress TEXT, url TEXT, portal_url TEXT, count INTEGER, import_date TEXT, update_date INTEGER, position INTEGER, favorites TEXT, recently_viewed TEXT, payload TEXT, last_usage TEXT ); +CREATE TABLE IF NOT EXISTS app_state ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL CHECK (type IN ('live', 'movies', 'series')), xtream_id INTEGER NOT NULL, hidden INTEGER DEFAULT 0, UNIQUE(playlist_id, type, xtream_id), FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS content ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, title TEXT NOT NULL, rating TEXT, added TEXT, poster_url TEXT, backdrop_url TEXT, tmdb_id INTEGER, release_year INTEGER, original_title TEXT, epg_channel_id TEXT, tv_archive INTEGER, tv_archive_duration INTEGER, direct_source TEXT, xtream_id INTEGER NOT NULL, type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')), UNIQUE(category_id, type, xtream_id), FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS recently_viewed ( id INTEGER PRIMARY KEY AUTOINCREMENT, content_id INTEGER NOT NULL, playlist_id TEXT NOT NULL, viewed_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE, FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS favorites ( id INTEGER PRIMARY KEY AUTOINCREMENT, content_id INTEGER NOT NULL, playlist_id TEXT NOT NULL, added_at TEXT DEFAULT (datetime('now')), position INTEGER DEFAULT 0, FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE, FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE ); +CREATE INDEX IF NOT EXISTS idx_content_type ON content(type); +CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id); +CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id); +CREATE INDEX IF NOT EXISTS idx_content_title ON content(title); +CREATE INDEX IF NOT EXISTS idx_content_xtream ON content(xtream_id); +CREATE INDEX IF NOT EXISTS idx_content_type_added ON content(type, added); +CREATE INDEX IF NOT EXISTS idx_categories_type ON categories(type); +CREATE INDEX IF NOT EXISTS idx_categories_visible ON categories(id, playlist_id, type) WHERE hidden = 0; +CREATE VIRTUAL TABLE IF NOT EXISTS content_title_fts USING fts5( title, content='content', content_rowid='id', tokenize='trigram' ); +CREATE TRIGGER IF NOT EXISTS content_title_fts_ai AFTER INSERT ON content BEGIN INSERT INTO content_title_fts(rowid, title) VALUES (new.id, new.title); END; +CREATE TRIGGER IF NOT EXISTS content_title_fts_ad AFTER DELETE ON content BEGIN INSERT INTO content_title_fts(content_title_fts, rowid, title) VALUES ('delete', old.id, old.title); END; +CREATE TRIGGER IF NOT EXISTS content_title_fts_au AFTER UPDATE ON content BEGIN INSERT INTO content_title_fts(content_title_fts, rowid, title) VALUES ('delete', old.id, old.title); INSERT INTO content_title_fts(rowid, title) VALUES (new.id, new.title); END; +CREATE UNIQUE INDEX IF NOT EXISTS favorites_content_playlist_unique ON favorites(content_id, playlist_id); +CREATE INDEX IF NOT EXISTS favorites_playlist_idx ON favorites(playlist_id); +CREATE INDEX IF NOT EXISTS favorites_content_idx ON favorites(content_id); +CREATE UNIQUE INDEX IF NOT EXISTS recently_viewed_content_playlist_unique ON recently_viewed(content_id, playlist_id); +CREATE INDEX IF NOT EXISTS recently_viewed_playlist_idx ON recently_viewed(playlist_id); +CREATE INDEX IF NOT EXISTS recently_viewed_viewed_at_idx ON recently_viewed(viewed_at); +CREATE INDEX IF NOT EXISTS recently_viewed_playlist_viewed_idx ON recently_viewed(playlist_id, viewed_at DESC); +CREATE TABLE IF NOT EXISTS epg_channels ( id TEXT PRIMARY KEY, display_name TEXT NOT NULL, icon_url TEXT, url TEXT, source_url TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS epg_channel_sources ( channel_id TEXT NOT NULL, source_url TEXT NOT NULL, display_name TEXT NOT NULL, icon_url TEXT, url TEXT, updated_at TEXT DEFAULT (datetime('now')), write_order INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (channel_id, source_url), FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE ); +CREATE INDEX IF NOT EXISTS idx_epg_channel_sources_source ON epg_channel_sources(source_url); +CREATE TABLE IF NOT EXISTS epg_programs ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_id TEXT NOT NULL, start TEXT NOT NULL, stop TEXT NOT NULL, title TEXT NOT NULL, description TEXT, category TEXT, icon_url TEXT, rating TEXT, episode_num TEXT, source_url TEXT, FOREIGN KEY (channel_id) REFERENCES epg_channels(id) ON DELETE CASCADE ); +CREATE INDEX IF NOT EXISTS idx_epg_channels_source ON epg_channels(source_url); +CREATE INDEX IF NOT EXISTS idx_epg_channels_name ON epg_channels(display_name); +CREATE INDEX IF NOT EXISTS idx_epg_programs_channel ON epg_programs(channel_id); +CREATE INDEX IF NOT EXISTS idx_epg_programs_start ON epg_programs(start); +CREATE INDEX IF NOT EXISTS idx_epg_programs_stop ON epg_programs(stop); +CREATE INDEX IF NOT EXISTS idx_epg_programs_time_range ON epg_programs(channel_id, start, stop); +CREATE VIRTUAL TABLE IF NOT EXISTS epg_programs_fts USING fts5( title, description, category, content='epg_programs', content_rowid='id' ); +CREATE TRIGGER IF NOT EXISTS epg_programs_ai AFTER INSERT ON epg_programs BEGIN INSERT INTO epg_programs_fts(rowid, title, description, category) VALUES (new.id, new.title, new.description, new.category); END; +CREATE TRIGGER IF NOT EXISTS epg_programs_ad AFTER DELETE ON epg_programs BEGIN INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) VALUES ('delete', old.id, old.title, old.description, old.category); END; +CREATE TRIGGER IF NOT EXISTS epg_programs_au AFTER UPDATE ON epg_programs BEGIN INSERT INTO epg_programs_fts(epg_programs_fts, rowid, title, description, category) VALUES ('delete', old.id, old.title, old.description, old.category); INSERT INTO epg_programs_fts(rowid, title, description, category) VALUES (new.id, new.title, new.description, new.category); END; +CREATE TABLE IF NOT EXISTS epg_channel_mappings ( id INTEGER PRIMARY KEY AUTOINCREMENT, channel_key TEXT NOT NULL UNIQUE, epg_channel_id TEXT NOT NULL, playlist_id TEXT, updated_at TEXT DEFAULT (datetime('now')) ); +CREATE INDEX IF NOT EXISTS idx_epg_channel_mappings_playlist ON epg_channel_mappings(playlist_id); +CREATE TABLE IF NOT EXISTS vod_source_pins ( id INTEGER PRIMARY KEY AUTOINCREMENT, match_key TEXT NOT NULL UNIQUE, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, content_id INTEGER NOT NULL, portal_type TEXT NOT NULL CHECK (portal_type IN ('xtream', 'stalker', 'm3u')), updated_at TEXT DEFAULT (datetime('now')) ); +CREATE INDEX IF NOT EXISTS idx_vod_source_pins_playlist ON vod_source_pins(playlist_id); +CREATE TABLE IF NOT EXISTS playback_positions ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, content_xtream_id INTEGER NOT NULL, content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode')), series_xtream_id INTEGER, season_number INTEGER, episode_number INTEGER, position_seconds INTEGER NOT NULL DEFAULT 0, duration_seconds INTEGER, updated_at TEXT DEFAULT (datetime('now')), FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE ); +CREATE UNIQUE INDEX IF NOT EXISTS playback_positions_content_playlist_unique ON playback_positions(content_xtream_id, playlist_id, content_type); +CREATE INDEX IF NOT EXISTS playback_positions_playlist_idx ON playback_positions(playlist_id); +CREATE INDEX IF NOT EXISTS playback_positions_series_idx ON playback_positions(series_xtream_id); +CREATE INDEX IF NOT EXISTS playback_positions_updated_idx ON playback_positions(updated_at); +CREATE INDEX IF NOT EXISTS playback_positions_playlist_updated_idx ON playback_positions(playlist_id, updated_at DESC); +CREATE TABLE IF NOT EXISTS downloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL, xtream_id INTEGER NOT NULL, content_type TEXT NOT NULL CHECK (content_type IN ('vod', 'episode', 'catchup')), programme_start INTEGER NOT NULL DEFAULT 0, catchup TEXT, series_xtream_id INTEGER, season_number INTEGER, episode_number INTEGER, episode_identity_scope TEXT, title TEXT NOT NULL, url TEXT NOT NULL, file_name TEXT, file_path TEXT, poster_url TEXT, request_headers TEXT, resume_validator TEXT, metadata_snapshot TEXT, status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'downloading', 'paused', 'completed', 'failed', 'canceled')), bytes_downloaded INTEGER DEFAULT 0, total_bytes INTEGER, error_message TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); +CREATE UNIQUE INDEX IF NOT EXISTS downloads_xtream_playlist_unique ON downloads(xtream_id, playlist_id, content_type) WHERE content_type != 'catchup'; +CREATE INDEX IF NOT EXISTS downloads_playlist_idx ON downloads(playlist_id); +CREATE INDEX IF NOT EXISTS downloads_status_idx ON downloads(status); +CREATE TABLE IF NOT EXISTS recordings ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, owner_pid INTEGER, status TEXT NOT NULL DEFAULT 'recording' CHECK (status IN ('recording', 'completed', 'interrupted', 'failed')), file_path TEXT NOT NULL, file_size_bytes INTEGER, channel_name TEXT NOT NULL, channel_logo_url TEXT, playlist_id TEXT, playlist_name TEXT, source_type TEXT CHECK (source_type IN ('m3u', 'xtream', 'stalker')), epg_channel_id TEXT, program_title TEXT, program_description TEXT, program_start TEXT, program_stop TEXT, programs_json TEXT, error_message TEXT, started_at TEXT NOT NULL, ended_at TEXT, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ); +CREATE INDEX IF NOT EXISTS recordings_status_idx ON recordings(status); +CREATE INDEX IF NOT EXISTS recordings_file_path_idx ON recordings(file_path); +CREATE INDEX IF NOT EXISTS recordings_playlist_idx ON recordings(playlist_id); +CREATE TABLE IF NOT EXISTS tmdb_metadata ( id INTEGER PRIMARY KEY AUTOINCREMENT, media_type TEXT NOT NULL CHECK (media_type IN ('movie', 'tv', 'person')), lookup_key TEXT NOT NULL, language TEXT NOT NULL, tmdb_id INTEGER, payload TEXT, fetched_at TEXT DEFAULT (datetime('now')) ); +CREATE UNIQUE INDEX IF NOT EXISTS tmdb_metadata_lookup_unique ON tmdb_metadata(media_type, lookup_key, language); diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index e01ae1763..a82a61662 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -81,6 +81,7 @@ export * from './lib/xtream-live-stream.interface'; export * from './lib/xtream-response.interface'; export * from './lib/xtream-portal.utils'; export * from './lib/xtream-restore-state.util'; +export * from './lib/xtream-favorite.interface'; export * from './lib/xtream-server-timezone.util'; export * from './lib/xtream-recently-added.utils'; export * from './lib/xtream-performance-marker.interface'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 7452189cb..3986a937d 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -564,7 +564,20 @@ export interface ElectronBridgeGlobalRecentItem extends ElectronBridgeXtreamCont viewed_at: string; } -export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamContent { +export interface ElectronBridgeXtreamFavorite extends Omit< + ElectronBridgeXtreamContent, + 'id' | 'category_id' +> { + id: number | null; + category_id: number | null; + favorite_id?: number; + catalog_status?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus; + tmdb_id?: number | null; + release_year?: number | null; + original_title?: string | null; +} + +export interface ElectronBridgeGlobalFavoriteItem extends ElectronBridgeXtreamFavorite { playlist_id: string; playlist_name: string; added_at: string; @@ -577,7 +590,8 @@ export interface ElectronBridgeGlobalRecentlyAddedItem extends ElectronBridgeXtr } export interface ElectronBridgeFavoriteReorderUpdate { - content_id: number; + content_id?: number; + favorite_id?: number; /** Favorites are playlist-scoped — scope the position write per playlist */ playlist_id: string; position: number; @@ -1033,6 +1047,11 @@ export interface ElectronBridgeApi { recentlyViewed: XtreamBackupRecentlyViewedItem[]; hiddenCategories: XtreamBackupHiddenCategory[]; }>; + dbRestoreXtreamFavorites: ( + playlistId: string, + favorites: XtreamBackupFavoriteItem[], + restoreId?: string + ) => Promise; dbRestoreXtreamUserData: ( playlistId: string, favorites: XtreamBackupFavoriteItem[], @@ -1109,15 +1128,18 @@ export interface ElectronBridgeApi { contentId: number, playlistId: string, backdropUrl?: string - ) => Promise; + ) => Promise; dbRemoveFavorite: ( - contentId: number, + contentId: import('./xtream-favorite.interface').XtreamFavoriteRemovalRef, playlistId: string ) => Promise; dbIsFavorite: (contentId: number, playlistId: string) => Promise; + dbGetFavoritesForBackup: ( + playlistId: string + ) => Promise; dbGetFavorites: ( playlistId: string - ) => Promise; + ) => Promise; dbGetGlobalFavorites: () => Promise; dbGetAllGlobalFavorites: () => Promise; dbReorderGlobalFavorites: ( diff --git a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts index 8fcb6ee96..ba889d257 100644 --- a/libs/shared/interfaces/src/lib/playlist-backup.interface.ts +++ b/libs/shared/interfaces/src/lib/playlist-backup.interface.ts @@ -1,3 +1,4 @@ +import type { XtreamFavoriteSnapshot } from './xtream-favorite.interface'; import { PlaybackPositionData } from './playback-position.interface'; import { M3uRecentlyViewedItem } from './playlist-recently-viewed.interface'; import { StalkerPortalItem } from './stalker-portal-item.interface'; @@ -56,6 +57,7 @@ export interface XtreamBackupFavoriteItem { xtreamId: number; addedAt?: string; position?: number | null; + snapshot?: XtreamFavoriteSnapshot; } export interface XtreamBackupRecentlyViewedItem { diff --git a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts index d66c6c907..6cf83feb2 100644 --- a/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts +++ b/libs/shared/interfaces/src/lib/portal-activity-item.interface.ts @@ -44,6 +44,8 @@ export interface PortalActivityItem { /** The year the PROVIDER stated, never one read out of the title */ release_year?: number; original_title?: string; + favorite_id?: number; + catalogStatus?: import('./xtream-favorite.interface').XtreamFavoriteCatalogStatus; source?: 'xtream' | 'stalker' | 'm3u'; /** Original stalker item for navigation state; undefined for xtream. */ stalker_item?: StalkerPortalItem; diff --git a/libs/shared/interfaces/src/lib/xtream-favorite.interface.ts b/libs/shared/interfaces/src/lib/xtream-favorite.interface.ts new file mode 100644 index 000000000..a858a696d --- /dev/null +++ b/libs/shared/interfaces/src/lib/xtream-favorite.interface.ts @@ -0,0 +1,30 @@ +/** Catalog membership is separate from whether a stream can currently play. */ +export const XTREAM_FAVORITE_CATALOG_STATUS = { + PRESENT: 'present', + MISSING: 'missing', + UNKNOWN: 'unknown', +} as const; + +export type XtreamFavoriteCatalogStatus = + (typeof XTREAM_FAVORITE_CATALOG_STATUS)[keyof typeof XTREAM_FAVORITE_CATALOG_STATUS]; + +export interface XtreamFavoriteSnapshot { + title: string; + posterUrl?: string | null; + backdropUrl?: string | null; + tmdbId?: number | null; + releaseYear?: number | null; + originalTitle?: string | null; +} + +/** Numeric references are legacy catalog row IDs, never provider IDs. */ +export type XtreamFavoriteRemovalRef = number | { favoriteId: number }; + +export const XTREAM_FAVORITE_IDENTITY_CONFLICT = + 'XTREAM_FAVORITE_IDENTITY_CONFLICT'; + +export function isCatalogUnavailable(item: { + catalogStatus?: XtreamFavoriteCatalogStatus; +}): boolean { + return item.catalogStatus === 'missing' || item.catalogStatus === 'unknown'; +} diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts index e7e13040a..e7b028f71 100644 --- a/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.spec.ts @@ -1,6 +1,37 @@ import { normalizeXtreamPendingRestoreState } from './xtream-restore-state.util'; describe('normalizeXtreamPendingRestoreState', () => { + it('retains only display metadata in a durable favorite snapshot', () => { + const state = normalizeXtreamPendingRestoreState({ + favorites: [ + { + contentType: 'movie', + xtreamId: 7, + position: 2, + snapshot: { + title: 'ABC', + posterUrl: 'https://example.test/poster.jpg', + tmdbId: 42, + password: 'private', + streamUrl: 'https://example.test/secret', + }, + }, + ], + }); + expect(state.favorites).toEqual([ + { + contentType: 'movie', + xtreamId: 7, + position: 2, + snapshot: { + title: 'ABC', + posterUrl: 'https://example.test/poster.jpg', + tmdbId: 42, + }, + }, + ]); + }); + // No `sourcePins`: absent has to stay absent, or an older archive would // read as "there are no pins" and restore would clear the user's. const emptyState = { diff --git a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts index 99cbc5af2..4c7025207 100644 --- a/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts +++ b/libs/shared/interfaces/src/lib/xtream-restore-state.util.ts @@ -5,6 +5,7 @@ import { XtreamBackupRecentlyViewedItem, XtreamBackupSourcePin, } from './playlist-backup.interface'; +import { XtreamFavoriteSnapshot } from './xtream-favorite.interface'; import { PlaybackPositionData } from './playback-position.interface'; export interface XtreamPendingRestoreState { @@ -18,6 +19,8 @@ export interface XtreamPendingRestoreState { * can distinguish "no opinion" from an authoritative empty collection. */ sourcePins?: XtreamBackupSourcePin[]; + /** Internal replay receipt; never exported in an archive. */ + favoritesRestoreId?: string; } export function getXtreamPendingRestoreStorageKey(playlistId: string): string { @@ -36,6 +39,7 @@ interface RestoreStateCandidate { recentlyViewed?: unknown; playbackPositions?: unknown; sourcePins?: unknown; + favoritesRestoreId?: unknown; } interface RestoreEntryCandidate { @@ -135,9 +139,12 @@ export function normalizeXtreamPendingRestoreState( return { hiddenCategories, - favorites: withNumericXtreamId( - toArray(candidate.favorites) - ), + favorites: normalizeFavorites(candidate.favorites), + ...(typeof candidate.favoritesRestoreId === 'string' && + candidate.favoritesRestoreId.length > 0 && + candidate.favoritesRestoreId.length <= 200 + ? { favoritesRestoreId: candidate.favoritesRestoreId } + : {}), recentlyViewed: withNumericXtreamId( toArray(candidate.recentlyViewed) ), @@ -184,3 +191,51 @@ function normalizeSourcePins(value: unknown): XtreamBackupSourcePin[] { return pins; } + +/** Snapshot fields are display metadata, never a raw provider response. */ +function normalizeFavorites(value: unknown): XtreamBackupFavoriteItem[] { + return withNumericXtreamId(toArray(value)) + .filter((item) => + ['live', 'movie', 'series'].includes(item.contentType) + ) + .map((item) => { + const snapshot = normalizeFavoriteSnapshot(item.snapshot); + return { + contentType: item.contentType, + xtreamId: item.xtreamId, + ...(typeof item.addedAt === 'string' + ? { addedAt: item.addedAt } + : {}), + ...(item.position === null || + (typeof item.position === 'number' && + Number.isFinite(item.position)) + ? { position: item.position } + : {}), + ...(snapshot ? { snapshot } : {}), + }; + }); +} + +function normalizeFavoriteSnapshot( + value: unknown +): XtreamFavoriteSnapshot | undefined { + if ( + !isRecord(value) || + typeof value['title'] !== 'string' || + !value['title'].trim() + ) + return undefined; + const snapshot: XtreamFavoriteSnapshot = { title: value['title'].trim() }; + for (const key of ['posterUrl', 'backdropUrl', 'originalTitle'] as const) { + const field = value[key]; + if (typeof field === 'string') snapshot[key] = field; + else if (field === null) snapshot[key] = null; + } + for (const key of ['tmdbId', 'releaseYear'] as const) { + const field = value[key]; + if (typeof field === 'number' && Number.isInteger(field) && field > 0) + snapshot[key] = field; + else if (field === null) snapshot[key] = null; + } + return snapshot; +} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html index 3a77c63c7..bda0dac09 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.html @@ -17,6 +17,7 @@ type="button" class="channel-content" [attr.aria-label]="displayName()" + [attr.aria-disabled]="disabled() || null" > - @if (showEpg()) { + @if (statusLabel()) { +
{{ statusLabel() }}
+ } @else if (showEpg()) { @if (epgProgram(); as program) {
{{ program.title }} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts index f36174ae1..16c0d1e8e 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item/channel-list-item.component.ts @@ -43,6 +43,8 @@ export class ChannelListItemComponent { readonly isDraggable = input(false); readonly logo = input(''); readonly name = input(''); + readonly disabled = input(false); + readonly statusLabel = input(''); readonly displayName = computed(() => applyChannelNameStrip( this.name(), @@ -125,6 +127,7 @@ export class ChannelListItemComponent { } onClick(event?: MouseEvent): void { + if (this.disabled()) return; if ((event?.detail ?? 1) > 1) { return; } @@ -137,6 +140,7 @@ export class ChannelListItemComponent { } onDoubleClick(): void { + if (this.disabled()) return; this.activated.emit(); } diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts index 8e9dbc518..d2f31116f 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.spec.ts @@ -632,6 +632,44 @@ describe('DashboardDataService', () => { ).toHaveLength(1); }); + it('keeps an unresolved favorite readable and removable without opening its details', async () => { + dbServiceMock.getAllGlobalFavorites.mockResolvedValue([ + { + id: null, + category_id: null, + favorite_id: 71, + catalog_status: 'missing', + title: 'Saved ABC', + xtream_id: 501, + type: 'movie', + playlist_id: 'xtream-1', + playlist_name: 'Xtream Playlist', + added_at: '2026-02-02T10:00:00.000Z', + poster_url: 'https://example.test/abc.jpg', + }, + ]); + await service.reloadGlobalFavorites(); + const item = service + .globalFavoriteItems() + .find((row) => row.source === 'xtream'); + if (!item) throw new Error('Expected the saved Xtream favorite'); + expect(item).toMatchObject({ + id: 'favorite:71', + title: 'Saved ABC', + catalogStatus: 'missing', + favorite_id: 71, + }); + expect(service.getGlobalFavoriteLink(item)).toEqual([ + '/workspace/global-favorites', + ]); + expect(service.getGlobalFavoriteNavigationState(item)).toBeUndefined(); + await service.removeGlobalFavorite(item); + expect(dbServiceMock.removeFromFavorites).toHaveBeenCalledWith( + { favoriteId: 71 }, + 'xtream-1' + ); + }); + it('includes PWA Xtream favorites from the active data source', async () => { Object.defineProperty(window, 'electron', { value: undefined, @@ -1139,7 +1177,10 @@ describe('DashboardDataService', () => { // Hold every answer: asked one after another, the second playlist // would not be asked until the first had answered, and a DB worker // busy with a long request would delay the set once per playlist. - const pending = new Map void>(); + const pending = new Map< + string, + (rows: PlaybackPositionData[]) => void + >(); playbackPositionsMock.getAllPlaybackPositions.mockImplementation( (playlistId: string) => new Promise((resolve) => { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts index 80f52340d..656323b00 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-data.service.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { Injectable, NgZone, @@ -699,17 +700,19 @@ export class DashboardDataService { } private mapPwaXtreamFavoriteItem( - item: XtreamContentItem, + item: import('@iptvnator/portal/xtream/data-access').XtreamFavoriteItem, playlist: PlaylistMeta ): DashboardFavoriteItem { return { - id: item.id, + id: item.id ?? `favorite:${item.favorite_id}`, + favorite_id: item.favorite_id, + catalogStatus: item.catalog_status, title: item.title, type: this.normalizeXtreamActivityType(item.type), playlist_id: playlist._id, playlist_name: playlist.title || 'Xtream', added_at: item.added_at || item.added || new Date(0).toISOString(), - category_id: item.category_id, + category_id: item.category_id ?? '', xtream_id: item.xtream_id, poster_url: item.poster_url, backdrop_url: item.backdrop_url ?? undefined, @@ -996,12 +999,14 @@ export class DashboardDataService { } getGlobalFavoriteLink(item: DashboardFavoriteItem): string[] { + if (isCatalogUnavailable(item)) return ['/workspace/global-favorites']; return getGlobalFavoriteLinkUtil(item); } getGlobalFavoriteNavigationState( item: DashboardFavoriteItem ): WorkspaceNavigationTarget['state'] { + if (isCatalogUnavailable(item)) return undefined; return getGlobalFavoriteNavigationStateUtil(item); } @@ -1019,7 +1024,9 @@ export class DashboardDataService { if (item.source === 'xtream') { if (this.hasPortalActivityStorage) { await this.dbService.removeFromFavorites( - item.id as number, + item.favorite_id != null + ? { favoriteId: item.favorite_id } + : (item.id as number), item.playlist_id ); } else { diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts index be0cc61d6..465dbb62a 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-mappers.ts @@ -60,13 +60,15 @@ export function mapDbFavoriteToItem( item: DbGlobalFavoriteItem ): PortalFavoriteItem { return { - id: item.id, + id: item.id ?? `favorite:${item.favorite_id}`, + favorite_id: item.favorite_id, + catalogStatus: item.catalog_status, title: item.title, type: normalizeActivityType(item.type), playlist_id: item.playlist_id, playlist_name: item.playlist_name, added_at: normalizeStalkerDate(item.added_at), - category_id: item.category_id, + category_id: item.category_id ?? '', xtream_id: item.xtream_id, poster_url: item.poster_url, backdrop_url: item.backdrop_url ?? undefined, diff --git a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.ts b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.ts index f38a7c44a..1bf1f59f8 100644 --- a/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.ts +++ b/libs/workspace/dashboard/data-access/src/lib/dashboard-portal-live-epg.util.ts @@ -1,5 +1,6 @@ import { isStalkerRadioItem, + isCatalogUnavailable, type EpgProgram, type PortalActivityItem, } from '@iptvnator/shared/interfaces'; @@ -35,7 +36,7 @@ export function buildDashboardPortalLiveEpgKey( export function buildDashboardPortalLiveEpgEntry( item: PortalActivityItem ): DashboardPortalLiveEpgEntry | null { - if (item.type !== 'live') { + if (item.type !== 'live' || isCatalogUnavailable(item)) { return null; } if (item.source === 'xtream') { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts index ee7bcac7c..a2cee2bef 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.spec.ts @@ -231,6 +231,15 @@ describe('DashboardHeroSlidesPresenter', () => { jest.useRealTimers(); }); + it('leaves unavailable saved films in the collection rather than promoting them to a playable hero', () => { + favorites.set([{ ...favoriteFilm, catalogStatus: 'missing' }]); + expect( + create() + .slides() + .some((slide) => slide.kind === 'favorite') + ).toBe(false); + }); + it('builds the rotation from resume, live, favourite and import slides', () => { const slides = create().slides(); diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts index b58748195..64b5ba41d 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-hero-slides.presenter.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { computed, DestroyRef, @@ -128,7 +129,9 @@ export class DashboardHeroSlidesPresenter { favorites: this.data .globalFavoriteItems() .filter( - (item) => item.type === 'movie' || item.type === 'series' + (item) => + !isCatalogUnavailable(item) && + (item.type === 'movie' || item.type === 'series') ), recentlyAdded: this.data.xtreamRecentlyAddedItems(), // Until the resume candidates are known the newest row may be one diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts index d7c7ade5d..864ef9a59 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.spec.ts @@ -1,6 +1,9 @@ import type { EpgProgram } from '@iptvnator/shared/interfaces'; import { buildDashboardLiveEpgDetails, + buildLiveEpgLookupGroups, + getLiveEpgProgramForCard, + liveEpgProgramKey, calcEpgProgress, formatEpgTimeRange, sameLiveEpgAnswers, @@ -25,6 +28,23 @@ function program(overrides: Partial = {}): EpgProgram { } describe('dashboard-live-epg.utils', () => { + it.each(['missing', 'unknown'] as const)( + 'does not query or enrich unavailable favorites (%s)', + (catalogStatus) => { + const card = { + id: 'saved', + title: 'ABC', + icon: 'live_tv', + link: [], + catalogStatus, + }; + expect(buildLiveEpgLookupGroups([card], () => [])).toEqual([]); + const answers = new Map([ + [liveEpgProgramKey('scope', 'ABC'), program()], + ]); + expect(getLiveEpgProgramForCard(card, answers, 'scope')).toBeNull(); + } + ); describe('timestamps in unix seconds', () => { // `EpgProgram.startTimestamp`/`stopTimestamp` are unix SECONDS // everywhere else in the app; treating them as milliseconds put diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts index bff7d1390..865d01a10 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-live-epg.utils.ts @@ -1,3 +1,4 @@ +import { isCatalogUnavailable } from '@iptvnator/shared/interfaces'; import { epgDisplayTimeMs, epgProviderClockMs, @@ -120,7 +121,9 @@ export function buildDashboardLiveEpgDetails( } function liveEpgLookupKeyForCard(card: DashboardRailCard): string { - return card.epgLookupKey?.trim() || card.title.trim(); + return isCatalogUnavailable(card) + ? '' + : card.epgLookupKey?.trim() || card.title.trim(); } /** One XMLTV lookup: the scope it is answered in and the keys asked for. */ @@ -244,6 +247,7 @@ export function getLiveEpgProgramForCard( epgMap: ReadonlyMap, scopeKey: string ): EpgProgram | null { + if (isCatalogUnavailable(card)) return null; const key = liveEpgLookupKeyForCard(card); const program = epgMap.get(liveEpgProgramKey(scopeKey, key)); if (program) { diff --git a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts index 4bae6e6b7..d2edb14b8 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/dashboard-rail.component.ts @@ -1,3 +1,4 @@ +import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces'; import { AfterViewInit, ChangeDetectionStrategy, @@ -36,6 +37,7 @@ export interface DashboardRailCard { id: string; title: string; subtitle?: string; + catalogStatus?: XtreamFavoriteCatalogStatus; imageUrl?: string; icon: string; contentType?: 'live' | 'movie' | 'series'; diff --git a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts index 1eebb8e7d..6c6e365d0 100644 --- a/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts +++ b/libs/workspace/dashboard/feature/src/lib/rails/workspace-dashboard-rails.component.ts @@ -513,6 +513,15 @@ export class WorkspaceDashboardRailsComponent { private toFavoriteCard(item: DashboardFavoriteItem): DashboardRailCard { return { id: `fav-${item.id}-${item.playlist_id}-${item.added_at}`, + catalogStatus: item.catalogStatus, + subtitle: + item.catalogStatus && item.catalogStatus !== 'present' + ? this.translate.instant( + item.catalogStatus === 'missing' + ? 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING' + : 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN' + ) + : undefined, title: item.title, imageUrl: item.poster_url, icon: this.typeIcon(item.type),