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..542dc1255 --- /dev/null +++ b/apps/electron-backend-e2e/src/favorites-conflict.e2e.ts @@ -0,0 +1,617 @@ +import { createServer, get as httpGet } from 'node:http'; +import { + addXtreamPortal, + clickCategoryByNameExact, + clickGridListCardByTitle, + closeElectronApp, + contentCardByTitle, + expect, + launchElectronApp, + openSources, + openWorkspaceSection, + resetMockServers, + restartElectronApp, + sourceRowByTitle, + test, + waitForXtreamWorkspaceReady, + xtreamMockServer, + type LaunchedElectronApp, +} from './electron-test-fixtures'; +import { fetchXtreamVodFixture, getXtreamTitle } from './portal-mock-fixtures'; +import { + createFavoritesCatalogProxy, + refreshFavoriteCatalog, + readSavedFavorites, + savedFavoriteIdentity, +} from './support/xtream-favorites-catalog'; + +test('@xtream catalog proxy keeps absolute request targets on the mock upstream', async () => { + let redirectedRequests = 0; + const trap = createServer((_request, response) => { + redirectedRequests++; + response.end('{"unexpected":"upstream"}'); + }); + await new Promise((resolve) => trap.listen(0, '127.0.0.1', resolve)); + const address = trap.address(); + if (!address || typeof address === 'string') + throw new Error('Expected TCP address'); + const proxy = await createFavoritesCatalogProxy(); + try { + const target = new URL(proxy.origin); + const response = await new Promise<{ status: number; body: string }>( + (resolve, reject) => { + const request = httpGet( + { + hostname: target.hostname, + port: target.port, + path: `http://127.0.0.1:${address.port}/player_api.php?username=minimal&password=minimal`, + }, + (incoming) => { + let body = ''; + incoming.setEncoding('utf8'); + incoming.on('data', (chunk) => { + body += chunk; + }); + incoming.on('end', () => + resolve({ status: incoming.statusCode ?? 0, body }) + ); + incoming.on('error', reject); + } + ); + request.on('error', reject); + } + ); + expect(response.status).toBe(200); + expect(redirectedRequests).toBe(0); + expect(JSON.parse(response.body)).toHaveProperty('user_info'); + } finally { + await proxy.close(); + await new Promise((resolve, reject) => + trap.close((error) => (error ? reject(error) : resolve())) + ); + } +}); + +for (const identityKnown of [true, false]) { + test(`@critical @persistence @xtream @electron rejects a reused favorite identity discovered in ${identityKnown ? 'catalog' : 'details'} until explicit removal`, 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]; + // Catalog evidence and the actual detail response describe the same current replacement. + const response = await request.get( + `${xtreamMockServer}/player_api.php`, + { + params: { + ...credentials, + action: 'get_vod_info', + vod_id: String(item.stream_id), + }, + } + ); + expect(response.ok()).toBe(true); + const detail = await response.json(); + const currentIdentity = { + tmdbId: Number(detail.info.tmdb_id), + releaseYear: Number(String(detail.info.releasedate).slice(0, 4)), + }; + expect(currentIdentity.tmdbId).toBeGreaterThan(0); + expect(currentIdentity.releaseYear).toBeGreaterThan(1900); + 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, + identityKnown, + currentIdentity, + }) => { + const row = await window.electron.dbGetContentByXtreamId( + xtreamId, + playlistId, + 'movie' + ); + if (!row) throw new Error('Expected imported content'); + 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'); + const completed = await window.electron.dbSetAppState( + `xtream-import-status:${playlistId}:movie`, + 'completed' + ); + if (!completed.success) + throw new Error('Could not complete the catalog'); + const [saved] = + await window.electron.dbGetFavoritesForBackup( + playlistId + ); + if (saved?.title !== oldTitle) + throw new Error( + 'Unconfirmed catalog identity replaced the saved title' + ); + if (!identityKnown) return; + const metadata = + await window.electron.dbSetContentMetadataIfMissing( + row.id, + currentIdentity + ); + if (!metadata.success) + throw new Error('Could not set current movie identity'); + }, + { + playlistId, + xtreamId: Number(item.stream_id), + oldTitle, + identityKnown, + currentIdentity, + } + ); + const [before] = await readSavedFavorites( + app.mainWindow, + playlistId + ); + expect(before).toMatchObject({ + id: identityKnown ? null : expect.any(Number), + 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(); + const after = await readSavedFavorites(app.mainWindow, playlistId); + if (identityKnown) expect(after).toEqual([before]); + else { + // Detail evidence detaches the replaceable catalog fields only. + expect(savedFavoriteIdentity(after)).toEqual( + savedFavoriteIdentity([before]) + ); + expect(after[0]).toMatchObject({ + id: null, + catalog_status: 'missing', + tmdb_id: 99999999, + poster_url: before.poster_url, + backdrop_url: before.backdrop_url, + release_year: before.release_year, + }); + } + + 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).toMatchObject({ + tmdb_id: currentIdentity.tmdbId, + release_year: currentIdentity.releaseYear, + }); + expect(saved.id).not.toBeNull(); + expect(saved.favorite_id).not.toBe(before.favorite_id); + } finally { + if (app) await closeElectronApp(app); + } + }); +} + +test('@persistence @xtream @electron retains a late-confirmed snapshot through catalog loss and unavailable legacy storage', async ({ + dataDir, + request, +}) => { + 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 oldTitle = 'Snapshot before identity confirmation'; + let app: LaunchedElectronApp | undefined; + try { + app = await launchElectronApp(dataDir); + await addXtreamPortal(app.mainWindow, { + ...credentials, + name: 'Late confirmed favorite', + }); + 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); + const returned = 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 identity = { tmdbId: 99999999, releaseYear: 2000 }; + const restored = await window.electron.dbRestoreXtreamFavorites( + playlistId, + [ + { + xtreamId, + contentType: 'movie', + snapshot: { + title: oldTitle, + posterUrl: 'https://art.invalid/old.jpg', + ...identity, + }, + }, + ] + ); + if (!restored.success) + throw new Error('Could not seed favorite'); + await window.electron.dbSetAppState( + `xtream-import-status:${playlistId}:movie`, + 'completed' + ); + const [unconfirmed] = + await window.electron.dbGetFavoritesForBackup(playlistId); + if (unconfirmed.title !== oldTitle) + throw new Error('Unconfirmed snapshot overwritten'); + const enriched = + await window.electron.dbSetContentMetadataIfMissing( + row.id, + identity + ); + if (!enriched.success) + throw new Error('Could not confirm identity'); + // No second import completion is allowed to repair the snapshot. + await window.electron.dbDeleteXtreamContent(playlistId); + return { + poster: row.poster_url, + backdrop: row.backdrop_url ?? null, + }; + }, + { playlistId, xtreamId: Number(item.stream_id), oldTitle } + ); + const [saved] = await readSavedFavorites(app.mainWindow, playlistId); + expect(saved).toMatchObject({ + id: null, + title, + poster_url: returned.poster, + backdrop_url: returned.backdrop, + }); + await app.mainWindow.evaluate((playlistId) => { + const key = `xtream-restore-${playlistId}`; + localStorage.setItem(key, JSON.stringify({ favorites: [] })); + const getItem = Storage.prototype.getItem; + Storage.prototype.getItem = function (requestedKey) { + if (requestedKey === key) + throw new Error('Storage unavailable'); + return getItem.call(this, requestedKey); + }; + }, playlistId); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + await expect(contentCardByTitle(app.mainWindow, title)).toBeVisible(); + await expect(contentCardByTitle(app.mainWindow, oldTitle)).toHaveCount( + 0 + ); + app = await restartElectronApp(app, dataDir); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + await expect(contentCardByTitle(app.mainWindow, title)).toBeVisible(); + expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual([ + saved, + ]); + } finally { + if (app) await closeElectronApp(app); + } +}); + +test('@critical @persistence @xtream @electron keeps known conflicts through sparse refresh and restart', async ({ + dataDir, + request, +}) => { + test.setTimeout(90_000); + await resetMockServers(request, ['xtream']); + const credentials = { username: 'minimal', password: 'minimal' }; + const fixture = await fetchXtreamVodFixture(request, credentials); + const xtreamId = Number(fixture.items[0].stream_id); + const proxy = await createFavoritesCatalogProxy(); + const sourceTitle = 'Conflict latch source'; + let app: LaunchedElectronApp | undefined; + try { + app = await launchElectronApp(dataDir); + await addXtreamPortal(app.mainWindow, { + ...credentials, + name: sourceTitle, + serverUrl: proxy.origin, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + const playlistId = /\/xtreams\/([^/]+)/.exec( + new URL(app.mainWindow.url()).pathname + )?.[1]; + if (!playlistId) throw new Error('Expected playlist route'); + await app.mainWindow + .getByRole('link', { name: 'Movies', exact: true }) + .click(); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + await app.mainWindow.evaluate( + async ({ playlistId, xtreamId }) => { + const row = await window.electron.dbGetContentByXtreamId( + xtreamId, + playlistId, + 'movie' + ); + if (!row) throw new Error('Missing imported movie'); + await window.electron.dbRestoreXtreamFavorites(playlistId, [ + { + xtreamId, + contentType: 'movie', + addedAt: '2020-01-01', + position: 4, + snapshot: { + title: 'Saved original before reuse', + tmdbId: 100, + releaseYear: 2000, + }, + }, + ]); + await window.electron.dbSetContentMetadataIfMissing(row.id, { + tmdbId: 200, + }); + }, + { playlistId, xtreamId } + ); + const [before] = await readSavedFavorites(app.mainWindow, playlistId); + expect(before).toMatchObject({ + title: 'Saved original before reuse', + id: null, + }); + await refreshFavoriteCatalog( + app.mainWindow, + proxy, + playlistId, + sourceTitle, + 'sources' + ); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + expect( + (await readSavedFavorites(app.mainWindow, playlistId))[0] + .catalog_status + ).toBe('missing'); + const card = app.mainWindow.getByRole('button', { + name: 'Saved original before reuse', + exact: true, + }); + await expect(card).toHaveAttribute('aria-disabled', 'true'); + await card.dispatchEvent('click'); + await expect(app.mainWindow.locator('app-content-hero')).toHaveCount(0); + app = await restartElectronApp(app, dataDir); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + await expect( + app.mainWindow.getByRole('button', { + name: 'Saved original before reuse', + exact: true, + }) + ).toHaveAttribute('aria-disabled', 'true'); + const [after] = await readSavedFavorites(app.mainWindow, playlistId); + expect(savedFavoriteIdentity([after])).toEqual( + savedFavoriteIdentity([before]) + ); + expect(after.catalog_status).toBe('missing'); + await app.mainWindow.evaluate( + async ({ playlistId, xtreamId }) => { + const row = await window.electron.dbGetContentByXtreamId( + xtreamId, + playlistId, + 'movie' + ); + if (!row) throw new Error('Missing sparse movie'); + await window.electron.dbSetContentMetadataIfMissing(row.id, { + tmdbId: 100, + }); + if ( + (await window.electron.dbGetFavorites(playlistId))[0] + .catalog_status !== 'missing' + ) + throw new Error('Partial confirmation cleared conflict'); + await window.electron.dbSetContentMetadataIfMissing(row.id, { + releaseYear: 2000, + }); + }, + { playlistId, xtreamId } + ); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + const [confirmed] = await readSavedFavorites( + app.mainWindow, + playlistId + ); + expect(confirmed.catalog_status).toBe('present'); + expect(confirmed.favorite_id).toBe(before.favorite_id); + expect(confirmed.added_at).toBe(before.added_at); + } finally { + if (app) await closeElectronApp(app); + await proxy.close(); + } +}); + +test('@critical @persistence @xtream @electron detail hearts use the displayed compatible duplicate', async ({ + dataDir, + request, +}) => { + test.setTimeout(90_000); + await resetMockServers(request, ['xtream']); + const credentials = { username: 'minimal', password: 'minimal' }; + const fixture = await fetchXtreamVodFixture(request, credentials); + const xtreamId = Number(fixture.items[0].stream_id); + const proxy = await createFavoritesCatalogProxy(); + proxy.setMode({ kind: 'duplicate', xtreamId }); + let app: LaunchedElectronApp | undefined; + try { + app = await launchElectronApp(dataDir); + await addXtreamPortal(app.mainWindow, { + ...credentials, + name: 'Compatible duplicate source', + serverUrl: proxy.origin, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + const playlistId = /\/xtreams\/([^/]+)/.exec( + new URL(app.mainWindow.url()).pathname + )?.[1]; + if (!playlistId) throw new Error('Missing playlist route'); + await app.mainWindow + .getByRole('link', { name: 'Movies', exact: true }) + .click(); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + const compatibleId = await app.mainWindow.evaluate( + async ({ playlistId, xtreamId }) => { + const first = await window.electron.dbGetContentByXtreamId( + xtreamId, + playlistId, + 'movie' + ); + const rows = await window.electron.dbSearchContent( + playlistId, + 'Compatible duplicate movie', + ['movie'] + ); + const compatible = rows.find( + (row) => row.xtream_id === xtreamId + ); + if (!first || !compatible || first.id === compatible.id) + throw new Error('Missing distinct duplicate rows'); + await window.electron.dbSetContentMetadataIfMissing(first.id, { + tmdbId: 200, + releaseYear: 2000, + }); + await window.electron.dbSetContentMetadataIfMissing( + compatible.id, + { tmdbId: 100, releaseYear: 1995 } + ); + await window.electron.dbRestoreXtreamFavorites(playlistId, [ + { + xtreamId, + contentType: 'movie', + snapshot: { + title: 'Saved original', + tmdbId: 100, + releaseYear: 1995, + }, + }, + ]); + await window.electron.dbSetAppState( + `xtream-import-status:${playlistId}:movie`, + 'completed' + ); + return compatible.id; + }, + { playlistId, xtreamId } + ); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + await contentCardByTitle( + app.mainWindow, + 'Compatible duplicate movie' + ).click(); + const hero = app.mainWindow.locator('app-content-hero'); + await expect( + hero.getByRole('button', { + name: 'Remove from favorites', + exact: true, + }) + ).toBeVisible(); + await hero + .getByRole('button', { name: 'Remove from favorites', exact: true }) + .click(); + expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual( + [] + ); + await hero + .getByRole('button', { name: 'Add to favorites', exact: true }) + .click(); + const [saved] = await readSavedFavorites(app.mainWindow, playlistId); + expect(saved).toMatchObject({ + id: compatibleId, + tmdb_id: 100, + release_year: 1995, + }); + app = await restartElectronApp(app, dataDir); + await openWorkspaceSection(app.mainWindow, 'Global favorites'); + expect( + (await readSavedFavorites(app.mainWindow, playlistId))[0].id + ).toBe(compatibleId); + } finally { + if (app) await closeElectronApp(app); + await proxy.close(); + } +}); 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..59b232d72 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, @@ -457,6 +720,10 @@ test.describe('Electron Favorites', () => { name: sourcePortalTitle, }); await waitForXtreamWorkspaceReady(app.mainWindow); + const sourcePath = new URL(app.mainWindow.url()).pathname; + const sourceId = decodeURIComponent( + sourcePath.split('/xtreams/')[1].split('/')[0] + ); await app.mainWindow .getByRole('link', { name: 'Movies', exact: true }) @@ -485,6 +752,23 @@ test.describe('Electron Favorites', () => { .first() .click(); await waitForXtreamWorkspaceReady(app.mainWindow); + + // The dashboard includes Source favorites while Host stays active. + // A handoff must load all rows before checking saved availability. + for (const title of [movieTitle, seriesTitle]) { + await goToDashboard(app.mainWindow); + await app.mainWindow + .getByTestId('dashboard-favorite-vod-rail-card') + .filter({ hasText: title }) + .first() + .click(); + await expectInlineCollectionDetail(app.mainWindow, { + pathname: /\/workspace\/global-favorites$/, + title, + playlistTitle: hostPortalTitle, + }); + } + await goToDashboard(app.mainWindow); await openPlaylistFavorites(app.mainWindow); await expect(playlistSwitcherTitle(app.mainWindow)).toContainText( hostPortalTitle @@ -527,6 +811,41 @@ test.describe('Electron Favorites', () => { ); await switchUnifiedCollectionContent(app.mainWindow, 'Series'); await expectVisibleContentCardTitle(app.mainWindow, seriesTitle); + // Missing favorites must remain visible even while Host is active. + await app.mainWindow.evaluate(async (playlistId) => { + for (const type of ['movie', 'series'] as const) { + await window.electron.dbClearXtreamImportCache( + playlistId, + type + ); + await window.electron.dbSetAppState( + `xtream-import-status:${playlistId}:${type}`, + 'completed' + ); + } + }, sourceId); + for (const title of [movieTitle, seriesTitle]) { + await goToDashboard(app.mainWindow); + await app.mainWindow + .getByTestId('dashboard-favorite-vod-rail-card') + .filter({ hasText: title }) + .first() + .click(); + await expectPathname( + app.mainWindow, + /\/workspace\/global-favorites$/ + ); + await expectVisibleContentCardTitle(app.mainWindow, title); + await expect( + app.mainWindow.locator('app-content-hero') + ).toHaveCount(0); + await expect( + playlistSwitcherTitle(app.mainWindow) + ).toContainText(hostPortalTitle); + await expect( + contentCardByTitle(app.mainWindow, title).first() + ).toContainText('Not in the current catalog'); + } } finally { await closeElectronApp(app); } @@ -740,6 +1059,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..6fbb6d978 --- /dev/null +++ b/apps/electron-backend-e2e/src/support/xtream-favorites-catalog.ts @@ -0,0 +1,230 @@ +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' | 'duplicate'; 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 incoming = new URL(request.url ?? '/', xtreamMockServer); + // Only path/query may vary: an absolute request target must never select + // a different upstream host for this local test proxy. + const url = new URL(xtreamMockServer); + url.pathname = incoming.pathname; + url.search = incoming.search; + 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 + ) + ); + } + if (requestMode.kind === 'duplicate') { + if (action === 'get_vod_categories') { + body = JSON.stringify([ + ...JSON.parse(body), + { + category_id: '987654', + category_name: 'Compatible duplicate', + parent_id: 0, + }, + ]); + } else if (action === 'get_vod_streams') { + const movies = JSON.parse(body) as Record< + string, + unknown + >[]; + const original = movies.find( + (movie) => + Number(movie['stream_id']) === + requestMode.xtreamId + ); + if (original) + movies.push({ + ...original, + category_id: '987654', + name: 'Compatible duplicate movie', + }); + body = JSON.stringify(movies); + } else if ( + action === 'get_vod_info' && + Number(url.searchParams.get('vod_id')) === + requestMode.xtreamId + ) { + const detail = JSON.parse(body); + Object.assign(detail.info, { + tmdb_id: 100, + tmdb: 100, + releasedate: '1995-01-01', + }); + body = JSON.stringify(detail); + } + } + 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.getByTestId('playlist-switcher-trigger').click(); + await page + .locator( + 'app-playlist-switcher-panel [role="option"][aria-current="true"] .source-row__more' + ) + .click(); + await page + .getByRole('menuitem', { 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..db2be08c7 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) => @@ -976,13 +996,15 @@ const electronApi: ElectronBridgeApi = { dbGetContentByXtreamId: ( xtreamId: number, playlistId: string, - contentType?: 'live' | 'movie' | 'series' + contentType?: 'live' | 'movie' | 'series', + identity?: ContentMetadataPatch ) => ipcRenderer.invoke( 'DB_GET_CONTENT_BY_XTREAM_ID', xtreamId, playlistId, - contentType + contentType, + ...(identity ? [identity] : []) ), dbSetContentMetadataIfMissing: ( contentId: number, @@ -1081,7 +1103,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 +1213,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-fields.ts b/apps/electron-backend/src/app/database/operations/content-fields.ts new file mode 100644 index 000000000..f97d63909 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-fields.ts @@ -0,0 +1,18 @@ +import * as schema from '@iptvnator/shared/database/schema'; + +export function selectContentFields() { + return { + 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, + epg_channel_id: schema.content.epgChannelId, + tv_archive: schema.content.tvArchive, + tv_archive_duration: schema.content.tvArchiveDuration, + direct_source: schema.content.directSource, + xtream_id: schema.content.xtreamId, + type: schema.content.type, + }; +} diff --git a/apps/electron-backend/src/app/database/operations/content-identity-lookup.ts b/apps/electron-backend/src/app/database/operations/content-identity-lookup.ts new file mode 100644 index 000000000..0f292978a --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/content-identity-lookup.ts @@ -0,0 +1,58 @@ +import { and, asc, eq, sql } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import { + normalizeContentMetadataPatch, + type ContentMetadataPatch, +} from '@iptvnator/shared/interfaces'; +import type { AppDatabase } from '../database.types'; +import { selectContentFields } from './content-fields'; + +/** Typed lookup prefers a fully confirming duplicate; legacy calls retain their old semantics. */ +export async function getContentByXtreamId( + db: AppDatabase, + xtreamId: number, + playlistId: string, + contentType?: 'live' | 'movie' | 'series', + identity?: ContentMetadataPatch +) { + const conditions = [ + eq(schema.content.xtreamId, xtreamId), + eq(schema.categories.playlistId, playlistId), + ]; + if (contentType) conditions.push(eq(schema.content.type, contentType)); + const patch = identity ? normalizeContentMetadataPatch(identity) : null; + if (patch?.tmdbId != null) + conditions.push( + sql`(${schema.content.tmdbId} IS NULL OR ${schema.content.tmdbId} = ${patch.tmdbId})` + ); + if (patch?.releaseYear != null) + conditions.push( + sql`(${schema.content.releaseYear} IS NULL OR ${schema.content.releaseYear} = ${patch.releaseYear})` + ); + const query = db + .select(selectContentFields()) + .from(schema.content) + .innerJoin( + schema.categories, + eq(schema.content.categoryId, schema.categories.id) + ) + .where(and(...conditions)); + if (patch?.tmdbId != null || patch?.releaseYear != null) { + const confirmed = and( + patch.tmdbId != null + ? eq(schema.content.tmdbId, patch.tmdbId) + : sql`1`, + patch.releaseYear != null + ? eq(schema.content.releaseYear, patch.releaseYear) + : sql`1` + ); + return ( + ( + await query + .orderBy(sql`${confirmed} DESC`, asc(schema.content.id)) + .limit(1) + )[0] ?? null + ); + } + return (await query.limit(1))[0] ?? null; +} diff --git a/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts b/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts index 35354d398..11c7c9cf6 100644 --- a/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts +++ b/apps/electron-backend/src/app/database/operations/content-import-performance.spec.ts @@ -258,7 +258,15 @@ describe('content import performance phases', () => { const transaction = jest.fn((callback: (tx: unknown) => unknown) => callback({ delete: deleteRows }) ); - const db = { select, transaction } as unknown as AppDatabase; + const db = { + select, + transaction, + update: jest.fn(() => ({ + set: jest.fn(() => ({ + where: jest.fn(() => ({ run: jest.fn() })), + })), + })), + } as unknown as AppDatabase; const recording = createRecordingOperationPhaseCapture(); await clearXtreamImportCache( @@ -288,7 +296,15 @@ describe('content import performance phases', () => { const from = jest.fn(() => ({ where })); const select = jest.fn(() => ({ from })); const transaction = jest.fn(); - const db = { select, transaction } as unknown as AppDatabase; + const db = { + select, + transaction, + update: jest.fn(() => ({ + set: jest.fn(() => ({ + where: jest.fn(() => ({ run: jest.fn() })), + })), + })), + } as unknown as AppDatabase; const recording = createRecordingOperationPhaseCapture(); await expect( 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..d0fbf1a46 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 @@ -32,7 +32,7 @@ import type { AppDatabase } from '../database.types'; import { setContentMetadataIfMissing } from './content-metadata.operations'; function createDbMock() { - const where = jest.fn().mockResolvedValue(undefined); + const where = jest.fn().mockReturnValue({ run: jest.fn() }); const set = jest.fn().mockReturnValue({ where }); const update = jest.fn().mockReturnValue({ set }); @@ -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(3); + 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(3); + 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..ef1813e23 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,12 @@ import { ContentMetadataPatch, normalizeContentMetadataPatch, } from '@iptvnator/shared/interfaces'; +import { + favoriteMatchesContentId, + reconcileFavoriteIdentity, + favoriteSnapshotFromContent, + favoriteSnapshotMatchesContent, +} from './favorite-catalog'; import type { AppDatabase } from '../database.types'; /** @@ -104,6 +110,21 @@ export async function persistContentMetadataIfMissing( ) ) .where(and(eq(schema.content.id, contentId), anyAbsent)); + + reconcileFavoriteIdentity(db, favoriteMatchesContentId(contentId, false)); + + // Favorites own their metadata beyond the lifetime of this catalog row. + // Once enrichment confirms identity, capture the complete returned snapshot, + // including title/artwork that arrived before the confirming metadata. + await db + .update(schema.favorites) + .set(favoriteSnapshotFromContent(contentId)) + .where( + and( + favoriteMatchesContentId(contentId), + sql`EXISTS (SELECT 1 FROM content WHERE id = ${contentId} AND ${favoriteSnapshotMatchesContent()})` + ) + ); } export async function setContentMetadataIfMissing( diff --git a/apps/electron-backend/src/app/database/operations/content.operations.ts b/apps/electron-backend/src/app/database/operations/content.operations.ts index 6f6770ffd..2ce86ee16 100644 --- a/apps/electron-backend/src/app/database/operations/content.operations.ts +++ b/apps/electron-backend/src/app/database/operations/content.operations.ts @@ -1,3 +1,6 @@ +import { selectContentFields } from './content-fields'; +export { getContentByXtreamId } from './content-identity-lookup'; +import { reconcileFavoriteIdentity } from './favorite-catalog'; import { and, asc, desc, eq, inArray, or, sql, type SQL } from 'drizzle-orm'; import * as schema from '@iptvnator/shared/database/schema'; import { @@ -649,23 +652,6 @@ function getRecentlyAddedContentTypes( return ['movie', 'series']; } -function selectContentFields() { - return { - 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, - epg_channel_id: schema.content.epgChannelId, - tv_archive: schema.content.tvArchive, - tv_archive_duration: schema.content.tvArchiveDuration, - direct_source: schema.content.directSource, - xtream_id: schema.content.xtreamId, - type: schema.content.type, - }; -} - export async function hasContent( db: AppDatabase, playlistId: string, @@ -1013,6 +999,14 @@ export async function clearXtreamImportCache( : { success: true }; } + reconcileFavoriteIdentity( + db, + and( + eq(schema.favorites.playlistId, playlistId), + eq(schema.favorites.type, type) + ) + ); + // Count and delete stay scoped to the captured ids, not to the // playlist/type predicate, so a category a concurrent import creates // between the read and the delete survives. @@ -1034,34 +1028,6 @@ export async function clearXtreamImportCache( : deleteXtreamCacheRows(db, contentRowCounts, categoryIds); } -export async function getContentByXtreamId( - db: AppDatabase, - xtreamId: number, - playlistId: string, - contentType?: 'live' | 'movie' | 'series' -) { - const conditions = [ - eq(schema.content.xtreamId, xtreamId), - eq(schema.categories.playlistId, playlistId), - ]; - - if (contentType) { - conditions.push(eq(schema.content.type, contentType)); - } - - const result = await db - .select(selectContentFields()) - .from(schema.content) - .innerJoin( - schema.categories, - eq(schema.content.categoryId, schema.categories.id) - ) - .where(and(...conditions)) - .limit(1); - - return result[0] || null; -} - export async function searchContent( db: AppDatabase, playlistId: string, 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..de8259f02 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/favorite-catalog.ts @@ -0,0 +1,180 @@ +import { and, eq, sql, type SQL } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../database.types'; +import { isParentalLockActive } from '../parental-lock-state'; + +/** A covering provider-id lookup per favorite, never a scan of the catalog. */ +export function resolvedFavoriteContentId(includeLocked: boolean) { + return sql`(SELECT candidate.id FROM content candidate + INNER JOIN categories owner ON owner.id = candidate.category_id + WHERE candidate.xtream_id = ${schema.favorites.xtreamId} + AND candidate.type = ${schema.favorites.type} + AND owner.playlist_id = ${schema.favorites.playlistId} + ${!includeLocked && isParentalLockActive() ? sql`AND owner.locked = 0` : sql``} + AND (${schema.favorites.identityConflict} = 0 OR ( + (${schema.favorites.tmdbId} IS NULL OR ${schema.favorites.tmdbId} = candidate.tmdb_id) + AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = candidate.release_year))) + 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 ( + (${schema.favorites.tmdbId} IS NULL OR ${schema.favorites.tmdbId} = candidate.tmdb_id) + AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = candidate.release_year) + ) DESC, candidate.id LIMIT 1)`; +} + +/** Missing candidate metadata cannot authorize replacing an already known snapshot. */ +export function favoriteSnapshotMatchesContent() { + return sql`(${schema.favorites.tmdbId} IS NULL OR ${schema.favorites.tmdbId} = ${schema.content.tmdbId}) + AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = ${schema.content.releaseYear})`; +} + +/** Use only after confirming identity; empty catalog fields retain the saved value. */ +export function favoriteSnapshotFromContent(contentId: number | SQL) { + return { + title: sql`COALESCE((SELECT NULLIF(TRIM(title), '') FROM content WHERE id = ${contentId}), favorites.title)`, + posterUrl: sql`COALESCE((SELECT NULLIF(TRIM(poster_url), '') FROM content WHERE id = ${contentId}), favorites.poster_url)`, + backdropUrl: sql`COALESCE((SELECT NULLIF(TRIM(backdrop_url), '') FROM content WHERE id = ${contentId}), favorites.backdrop_url)`, + tmdbId: sql`COALESCE((SELECT tmdb_id FROM content WHERE id = ${contentId}), favorites.tmdb_id)`, + releaseYear: sql`COALESCE((SELECT release_year FROM content WHERE id = ${contentId}), favorites.release_year)`, + originalTitle: sql`COALESCE((SELECT NULLIF(TRIM(original_title), '') FROM content WHERE id = ${contentId}), favorites.original_title)`, + }; +} + +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) => { + reconcileFavoriteIdentity( + tx, + and( + eq(schema.favorites.playlistId, match[1]), + eq( + schema.favorites.type, + match[2] as 'live' | 'movie' | 'series' + ) + ) + ); + tx.update(schema.favorites) + .set({ contentId: resolvedFavoriteContentId(true) }) + .where( + and( + eq(schema.favorites.playlistId, match[1]), + eq( + schema.favorites.type, + match[2] as 'live' | 'movie' | 'series' + ) + ) + ) + .run(); + tx.update(schema.favorites) + .set(favoriteSnapshotFromContent(sql`favorites.content_id`)) + .where( + and( + sql`EXISTS (SELECT 1 FROM content WHERE id = favorites.content_id AND ${favoriteSnapshotMatchesContent()})`, + eq(schema.favorites.playlistId, match[1]), + eq( + schema.favorites.type, + match[2] as 'live' | 'movie' | 'series' + ) + ) + ) + .run(); + }); +} + +/** Every duplicate category row represents the same typed provider identity. */ +export function favoriteMatchesContentId( + contentId: number | ReturnType, + checkIdentity = true +) { + 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} + ${ + checkIdentity + ? sql`AND (${schema.favorites.identityConflict} = 0 OR ( + (${schema.favorites.tmdbId} IS NULL OR ${schema.favorites.tmdbId} = candidate.tmdb_id) + AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = candidate.release_year))) + 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)` + : sql`` + })`; +} + +/** Full confirmation outranks conflicting duplicates; absence/partial evidence retains the latch. */ +export function reconcileFavoriteIdentity(db: AppDatabase, scope?: SQL) { + const candidate = ( + evidence: SQL + ) => sql`EXISTS (SELECT 1 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 (${evidence}))`; + const confirmed = + candidate(sql`(${schema.favorites.tmdbId} IS NULL OR ${schema.favorites.tmdbId} = candidate.tmdb_id) + AND (${schema.favorites.releaseYear} IS NULL OR ${schema.favorites.releaseYear} = candidate.release_year)`); + const conflicting = + candidate(sql`(${schema.favorites.tmdbId} IS NOT NULL AND candidate.tmdb_id IS NOT NULL AND ${schema.favorites.tmdbId} != candidate.tmdb_id) + OR (${schema.favorites.releaseYear} IS NOT NULL AND candidate.release_year IS NOT NULL AND ${schema.favorites.releaseYear} != candidate.release_year)`); + db.update(schema.favorites) + .set({ + identityConflict: sql`CASE + WHEN ${confirmed} THEN 0 WHEN ${conflicting} THEN 1 ELSE ${schema.favorites.identityConflict} END`, + }) + .where(scope) + .run(); +} 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..bff1deb75 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/favorite-queries.ts @@ -0,0 +1,86 @@ +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, + favoriteSnapshotMatchesContent, + 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`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title}) ELSE ${schema.favorites.title} END`, + rating: schema.content.rating, + added: schema.content.added, + poster_url: sql< + string | null + >`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.posterUrl}), ''), ${schema.favorites.posterUrl}) ELSE ${schema.favorites.posterUrl} END`, + backdrop_url: sql< + string | null + >`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.backdropUrl}), ''), ${schema.favorites.backdropUrl}) ELSE ${schema.favorites.backdropUrl} END`, + tmdb_id: sql< + number | null + >`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(${schema.content.tmdbId}, ${schema.favorites.tmdbId}) ELSE ${schema.favorites.tmdbId} END`, + release_year: sql< + number | null + >`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(${schema.content.releaseYear}, ${schema.favorites.releaseYear}) ELSE ${schema.favorites.releaseYear} END`, + original_title: sql< + string | null + >`CASE WHEN ${favoriteSnapshotMatchesContent()} THEN COALESCE(NULLIF(TRIM(${schema.content.originalTitle}), ''), ${schema.favorites.originalTitle}) ELSE ${schema.favorites.originalTitle} END`, + xtream_id: schema.favorites.xtreamId, + type: schema.favorites.type, + tv_archive: schema.content.tvArchive, + 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(), + catalog_missing_reason: sql< + 'identity_conflict' | null + >`CASE WHEN ${schema.favorites.identityConflict} = 1 AND ${schema.content.id} IS NULL THEN 'identity_conflict' ELSE NULL END`, + }) + .from(schema.favorites) + .leftJoin( + schema.content, + eq(schema.content.id, resolvedFavoriteContentId(includeLocked)) + ) + .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..3162536d3 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/favorite-restore.ts @@ -0,0 +1,165 @@ +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 { reconcileFavoriteIdentity } from './favorite-catalog'; +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; + // Keep a compatible provider link, but do not mix a saved identity with + // metadata from a returning row that has not confirmed it yet. + const snapshotContent = + content && + (metadata?.tmdbId == null || metadata.tmdbId === content.tmdbId) && + (metadata?.releaseYear == null || + metadata.releaseYear === content.releaseYear) + ? content + : undefined; + return { + playlistId, + xtreamId: item.xtreamId, + type: item.contentType, + contentId: content?.id ?? null, + identityConflict: + item.snapshot?.catalogMissingReason === 'identity_conflict', + title: + item.snapshot?.title?.trim() || + snapshotContent?.title || + `Saved ${item.contentType} #${item.xtreamId}`, + posterUrl: item.snapshot?.posterUrl ?? snapshotContent?.posterUrl, + backdropUrl: metadata?.backdropUrl ?? snapshotContent?.backdropUrl, + tmdbId: metadata?.tmdbId ?? snapshotContent?.tmdbId, + releaseYear: metadata?.releaseYear ?? snapshotContent?.releaseYear, + originalTitle: + metadata?.originalTitle ?? snapshotContent?.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(); + } + reconcileFavoriteIdentity( + tx, + eq(schema.favorites.playlistId, playlistId) + ); + 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..ea32e004a --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/favorites-durable.sqlite.spec.ts @@ -0,0 +1,59 @@ +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', + 'snapshot', + 'restore-snapshot', + 'scope', + 'lock', + 'lock-duplicate', + 'receipt', + 'conflict-latch', + 'conflict-migration', + 'compatible-actions', + ])( + '%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-compatible-actions.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-compatible-actions.ts new file mode 100644 index 000000000..8c8a3abf8 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-compatible-actions.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import type { AppDatabase } from '../../database.types'; +import { createFavoriteTestCatalog } from './favorites-test-catalog'; +import { getContentByXtreamId } from '../content.operations'; +import { + addFavorite, + getFavorites, + isFavorite, + removeFavorite, +} from '../favorites.operations'; +import { setAppState } from '../playlist.operations'; + +export async function verifyCompatibleFavoriteActions(db: AppDatabase) { + const catalog = createFavoriteTestCatalog(db); + for (const [index, type] of ( + ['movie', 'series', 'live'] as const + ).entries()) { + const id = 100 + index * 10; + await catalog(id, 500, 'Conflicting copy', type, 'p', { + tmdbId: 200, + releaseYear: 2000, + }); + await catalog(id + 1, 500, 'Sparse copy', type); + await catalog(id + 2, 500, 'Displayed original', type, 'p', { + tmdbId: 100, + releaseYear: 1995, + }); + await catalog(id + 3, 500, 'Other source', type, 'other', { + tmdbId: 100, + releaseYear: 1995, + }); + await addFavorite(db, id + 2, 'p'); + await setAppState(db, `xtream-import-status:p:${type}`, 'completed'); + const row = await getContentByXtreamId(db, 500, 'p', type, { + tmdbId: 100, + releaseYear: 1995, + }); + assert.equal( + row?.id, + id + 2, + 'identity-qualified actions must resolve the compatible duplicate' + ); + assert(await isFavorite(db, row.id, 'p')); + await removeFavorite(db, row.id, 'p'); + assert.equal( + (await getFavorites(db, 'p')).filter( + (item) => item.type === type && item.xtream_id === 500 + ).length, + 0 + ); + await addFavorite(db, row.id, 'p'); + assert.equal( + (await getFavorites(db, 'p')).find( + (item) => item.type === type && item.xtream_id === 500 + )?.id, + id + 2 + ); + assert.equal( + (await getContentByXtreamId(db, 500, 'p', type))?.id, + id, + 'legacy lookup remains unchanged' + ); + assert.equal( + (await getContentByXtreamId(db, 500, 'p', type, { tmdbId: 999 })) + ?.id, + id + 1, + 'sparse fallback cannot select a contrary known identity' + ); + assert.equal( + ( + await getContentByXtreamId(db, 500, 'p', type, { + tmdbId: 0, + releaseYear: 12, + }) + )?.id, + id, + 'invalid metadata retains legacy behavior' + ); + } +} diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-conflict-latch.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-conflict-latch.ts new file mode 100644 index 000000000..1fe2b52a2 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-conflict-latch.ts @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import { closeDatabase, initDatabase } from '@iptvnator/shared/database'; +import type { AppDatabase } from '../../database.types'; +import { createFavoriteTestCatalog } from './favorites-test-catalog'; +import { + addFavorite, + getFavorites, + getFavoritesForBackup, + isFavorite, + removeFavorite, +} from '../favorites.operations'; +import { + deleteXtreamContent, + restoreXtreamFavorites, +} from '../xtream.operations'; +import { persistContentMetadataIfMissing } from '../content-metadata.operations'; +import { setAppState } from '../playlist.operations'; + +export async function verifyFavoriteConflictLatch(initial: AppDatabase) { + let db = initial; + for (const [index, type] of ( + ['movie', 'series', 'live'] as const + ).entries()) { + let catalog = createFavoriteTestCatalog(db); + const id = 100 + index * 10; + const providerId = 500; + const complete = () => + setAppState(db, `xtream-import-status:p:${type}`, 'completed'); + const saved = async () => { + const row = (await getFavorites(db, 'p')).find( + (row) => row.xtream_id === providerId && row.type === type + ); + assert(row, 'Expected retained favorite'); + return row; + }; + await catalog(id, providerId, 'Original', type, 'p', { + tmdbId: 100, + releaseYear: 2000, + }); + await addFavorite(db, id, 'p'); + const original = await saved(); + await deleteXtreamContent(db, 'p'); + await catalog(id + 1, providerId, 'Replacement', type, 'p', { + tmdbId: 200, + }); + await complete(); + assert.equal((await saved()).catalog_status, 'missing'); + await deleteXtreamContent(db, 'p'); + await catalog(id + 2, providerId, 'Sparse replacement', type); + await complete(); + assert.equal( + (await saved()).catalog_status, + 'missing', + 'known conflict must survive a later sparse catalog' + ); + assert.equal(await isFavorite(db, id + 2, 'p'), false); + await removeFavorite(db, id + 2, 'p'); + assert.equal( + (await saved()).favorite_id, + original.favorite_id, + 'replacement heart cannot remove the original' + ); + closeDatabase(); + db = await initDatabase(); + catalog = createFavoriteTestCatalog(db); + assert.equal( + (await saved()).catalog_status, + 'missing', + 'conflict survives process restart' + ); + const archive = (await getFavoritesForBackup(db, 'p')).find( + (row) => row.xtream_id === providerId && row.type === type + ); + assert(archive); + assert.equal(archive.catalog_missing_reason, 'identity_conflict'); + await restoreXtreamFavorites(db, 'other', [ + { + xtreamId: providerId, + contentType: type, + snapshot: { + title: archive.title, + tmdbId: archive.tmdb_id, + releaseYear: archive.release_year, + catalogStatus: 'missing', + catalogMissingReason: 'identity_conflict', + }, + }, + ]); + await catalog( + id + 6, + providerId, + 'Sparse restored replacement', + type, + 'other' + ); + await setAppState( + db, + `xtream-import-status:other:${type}`, + 'completed' + ); + assert.equal( + (await getFavorites(db, 'other')).find( + (row) => row.xtream_id === providerId && row.type === type + )?.catalog_status, + 'missing' + ); + await persistContentMetadataIfMissing(db, id + 2, { tmdbId: 100 }); + assert.equal( + (await saved()).catalog_status, + 'missing', + 'partial confirmation cannot clear conflict' + ); + await persistContentMetadataIfMissing(db, id + 2, { + releaseYear: 2000, + }); + assert.equal( + (await saved()).catalog_status, + 'present', + 'full detail confirmation clears conflict' + ); + assert.equal((await saved()).favorite_id, original.favorite_id); + assert.equal((await saved()).added_at, original.added_at); + assert.equal((await saved()).position, original.position); + // A conflict first discovered by detail enrichment must latch too. + await deleteXtreamContent(db, 'p'); + await catalog(id + 3, providerId, 'Unknown replacement', type); + await complete(); + await persistContentMetadataIfMissing(db, id + 3, { tmdbId: 300 }); + await deleteXtreamContent(db, 'p'); + await catalog(id + 4, providerId, 'Sparse again', type); + await complete(); + assert.equal((await saved()).catalog_status, 'missing'); + // An original duplicate wins over a sparse or conflicting category. + await catalog(id + 5, providerId, 'Confirmed original', type, 'p', { + tmdbId: 100, + releaseYear: 2000, + }); + await complete(); + assert.equal((await saved()).id, id + 5); + await deleteXtreamContent(db, 'p'); + await catalog( + id + 7, + providerId, + 'Conflict before completion', + type, + 'p', + { tmdbId: 400 } + ); + // Refresh can erase an incomplete catalog; retain its known negative evidence first. + await deleteXtreamContent(db, 'p'); + await catalog( + id + 8, + providerId, + 'Sparse after interrupted import', + type + ); + await complete(); + assert.equal((await saved()).catalog_status, 'missing'); + await removeFavorite(db, { favoriteId: original.favorite_id }, 'p'); + await addFavorite(db, id + 8, 'p'); + assert.equal( + (await saved()).id, + id + 8, + 'explicit remove/re-add starts a new ownership' + ); + } +} 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..ea688dc6b --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-durable.ts @@ -0,0 +1,381 @@ +import { verifyCompatibleFavoriteActions } from './favorites-compatible-actions'; +import { verifyFavoriteRefresh } from './favorites-refresh'; +import { verifyFavoriteConflictLatch } from './favorites-conflict-latch'; +import assert from 'node:assert/strict'; +import { verifyFavoriteSnapshot } from './favorites-snapshot'; +import { verifyUnlockedFavoriteDuplicate } from './favorites-locked-duplicate'; +import { verifyRestoredFavoriteSnapshot } from './favorites-restore-snapshot'; +import { createFavoriteTestCatalog } from './favorites-test-catalog'; +import { eq, sql } from 'drizzle-orm'; +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 === 'compatible-actions') { + await verifyCompatibleFavoriteActions(db); + } else if (scenario === 'conflict-latch') { + await verifyFavoriteConflictLatch(db); + } else if (scenario === 'conflict-migration') { + await db.run(sql`ALTER TABLE favorites DROP COLUMN identity_conflict`); + closeDatabase(); + const upgraded = await initDatabase(); + const [saved] = await getFavorites(upgraded, 'p'); + assert.equal(saved.favorite_id, original.favorite_id); + assert.equal(saved.added_at, original.added_at); + assert.equal( + (await upgraded.select().from(schema.favorites))[0] + .identityConflict, + false + ); + closeDatabase(); + const restarted = await initDatabase(); + assert.equal( + (await getFavorites(restarted, 'p'))[0].favorite_id, + original.favorite_id + ); + } else if (scenario === 'refresh') { + await verifyFavoriteRefresh(db, original); + } 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 === 'snapshot') { + await verifyFavoriteSnapshot(db); + } else if (scenario === 'restore-snapshot') { + await verifyRestoredFavoriteSnapshot(db); + } else if (scenario === 'lock-duplicate') { + await verifyUnlockedFavoriteDuplicate(db); + } else if (scenario === 'scope') { + await catalog(2, 7, 'Other', 'movie', 'other'); + await addFavorite(db, 2, 'other'); + 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-locked-duplicate.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-locked-duplicate.ts new file mode 100644 index 000000000..755be0b9e --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-locked-duplicate.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { eq } from 'drizzle-orm'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../../database.types'; +import { setParentalLockActive } from '../../parental-lock-state'; +import { createFavoriteTestCatalog } from './favorites-test-catalog'; +import { + addFavorite, + getFavorites, + getAllGlobalFavorites, + getGlobalFavorites, + getFavoritesForBackup, +} from '../favorites.operations'; + +export async function verifyUnlockedFavoriteDuplicate(db: AppDatabase) { + const catalog = createFavoriteTestCatalog(db); + for (const [index, type] of ( + ['live', 'movie', 'series'] as const + ).entries()) { + const first = 10 + index * 10; + const second = first + 1; + const xtreamId = 100 + index; + await catalog(first, xtreamId, 'Locked copy', type); + await catalog(second, xtreamId, 'Unlocked copy', type); + await db + .update(schema.categories) + .set({ locked: true }) + .where(eq(schema.categories.id, first)); + await addFavorite(db, second, 'p'); + setParentalLockActive(true); + const reads = [getFavorites(db, 'p'), getAllGlobalFavorites(db)]; + if (type === 'live') reads.push(getGlobalFavorites(db)); + for (const rows of await Promise.all(reads)) { + assert.equal( + rows.find((row) => row.xtream_id === xtreamId)?.id, + second, + 'an unlocked duplicate must remain visible' + ); + } + await db + .update(schema.categories) + .set({ locked: true }) + .where(eq(schema.categories.id, second)); + assert.equal( + (await getFavorites(db, 'p')).some( + (row) => row.xtream_id === xtreamId + ), + false, + 'all locked copies must still be withheld' + ); + assert.equal( + (await getFavoritesForBackup(db, 'p')).find( + (row) => row.xtream_id === xtreamId + )?.id, + first, + 'backup remains unfiltered' + ); + setParentalLockActive(false); + assert.equal( + (await getFavorites(db, 'p')).find( + (row) => row.xtream_id === xtreamId + )?.id, + first + ); + } +} diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-refresh.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-refresh.ts new file mode 100644 index 000000000..dbfe2eff2 --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-refresh.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../../database.types'; +import { createFavoriteTestCatalog } from './favorites-test-catalog'; +import { + addFavorite, + getFavorites, + getAllGlobalFavorites, + isFavorite, + removeFavorite, +} from '../favorites.operations'; +import { + deleteXtreamContent, + restoreXtreamUserData, +} from '../xtream.operations'; +import { persistContentMetadataIfMissing } from '../content-metadata.operations'; +import { setAppState } from '../playlist.operations'; + +export async function verifyFavoriteRefresh( + db: AppDatabase, + original: { favorite_id: number; poster_url: string | null } +) { + const catalog = createFavoriteTestCatalog(db); + const favorites = () => getFavorites(db, 'p'); + const status = (value: string) => + setAppState(db, 'xtream-import-status:p:movie', value); + 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(), []); +} diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-restore-snapshot.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-restore-snapshot.ts new file mode 100644 index 000000000..ad0823f2c --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-restore-snapshot.ts @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import { PwaXtreamBackupStorage } from '@iptvnator/services/pwa-xtream-backup-storage'; +import type { XtreamPlaylistBackupEntry } from '@iptvnator/shared/interfaces'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../../database.types'; +import { createFavoriteTestCatalog } from './favorites-test-catalog'; +import { getFavoritesForBackup } from '../favorites.operations'; +import { + deleteXtreamContent, + restoreXtreamFavorites, +} from '../xtream.operations'; +import { setAppState } from '../playlist.operations'; + +export async function verifyRestoredFavoriteSnapshot(db: AppDatabase) { + const catalog = createFavoriteTestCatalog(db); + const cases = [ + { saved: { tmdbId: 10 }, candidate: { releaseYear: 2020 } }, + { saved: { releaseYear: 2000 }, candidate: { tmdbId: 20 } }, + ]; + for (const [index, { saved, candidate }] of cases.entries()) { + const id = 20 + index * 2; + const xtreamId = 100 + index; + await catalog(id, xtreamId, 'Replacement', 'movie', 'p', candidate); + await restoreXtreamFavorites(db, 'p', [ + { + contentType: 'movie', + xtreamId, + snapshot: { title: 'Saved original', ...saved }, + }, + ]); + const restored = (await db.select().from(schema.favorites)).find( + (row) => row.xtreamId === xtreamId + ); + assert(restored); + assert.equal( + restored.tmdbId, + saved.tmdbId ?? null, + 'restore must not adopt an unconfirmed TMDB ID' + ); + assert.equal( + restored.releaseYear, + saved.releaseYear ?? null, + 'restore must not adopt an unconfirmed year' + ); + assert.equal( + restored.posterUrl, + null, + 'restore must not copy replacement artwork' + ); + await deleteXtreamContent(db, 'p'); + await catalog(id + 1, xtreamId, 'Original returns', 'movie', 'p', { + tmdbId: 10, + releaseYear: 2000, + }); + await setAppState(db, 'xtream-import-status:p:movie', 'completed'); + const returned = (await getFavoritesForBackup(db, 'p')).find( + (row) => row.xtream_id === xtreamId + ); + assert.equal( + returned?.catalog_status, + 'present', + 'incomplete restored metadata must not block original content reconnecting' + ); + assert.equal(returned?.favorite_id, restored.id); + assert.equal(returned?.title, 'Original returns'); + } + const storage = new Map([ + ['xtream-favorites', JSON.stringify({ p: ['movie:199'] })], + [ + 'xtream-collection-items', + JSON.stringify({ + p: { + 'movie:199': { + xtream_id: 199, + type: 'movie', + title: 'Browser original', + tmdb_id: '949', + release_year: '1995', + added_at: '2026-01-02', + position: 3, + }, + }, + }), + ], + ]); + const exported = new PwaXtreamBackupStorage({ + getItem: (key) => storage.get(key) ?? null, + setItem: (key, value) => { + storage.set(key, value); + }, + removeItem: (key) => { + storage.delete(key); + }, + }).exportInto({ + exportedId: 'p', + userState: {}, + } as XtreamPlaylistBackupEntry); + await catalog(99, 199, 'Different movie', 'movie', 'p', { + tmdbId: 950, + releaseYear: 2000, + }); + await restoreXtreamFavorites(db, 'p', exported.userState.favorites); + const restored = (await getFavoritesForBackup(db, 'p')).find( + (row) => row.xtream_id === 199 + ); + assert.equal( + restored?.tmdb_id, + 949, + 'browser backup must preserve numeric-string TMDB identity' + ); + assert.equal(restored?.release_year, 1995); + assert.equal(restored?.title, 'Browser original'); + assert.equal(restored?.catalog_status, 'missing'); + assert.equal( + restored?.id, + null, + 'the restored favorite must reject the reused provider ID' + ); + assert.equal(restored?.added_at, '2026-01-02'); + assert.equal(restored?.position, 3); +} diff --git a/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts b/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts new file mode 100644 index 000000000..60758c53a --- /dev/null +++ b/apps/electron-backend/src/app/database/operations/testing/favorites-snapshot.ts @@ -0,0 +1,149 @@ +import assert from 'node:assert/strict'; +import * as schema from '@iptvnator/shared/database/schema'; +import type { AppDatabase } from '../../database.types'; +import { createFavoriteTestCatalog } from './favorites-test-catalog'; +import { persistContentMetadataIfMissing } from '../content-metadata.operations'; +import { + addFavorite, + removeFavorite, + getFavorites, + getFavoritesForBackup, +} from '../favorites.operations'; +import { deleteXtreamContent } from '../xtream.operations'; +import { setAppState } from '../playlist.operations'; + +export async function verifyFavoriteSnapshot(db: AppDatabase) { + const catalog = createFavoriteTestCatalog(db); + await persistContentMetadataIfMissing(db, 1, { + tmdbId: 10, + releaseYear: 2000, + originalTitle: 'Original', + }); + const [original] = await db.select().from(schema.favorites); + const snapshot = (row: typeof original) => ({ + title: row.title, + poster: row.posterUrl, + backdrop: row.backdropUrl, + original: row.originalTitle, + tmdb: row.tmdbId, + year: row.releaseYear, + }); + await deleteXtreamContent(db, 'p'); + await catalog(2, 7, 'Replacement', 'movie', 'p', { + posterUrl: 'https://art.invalid/replacement.jpg', + backdropUrl: 'https://art.invalid/replacement-wide.jpg', + originalTitle: 'Replacement original', + }); + await setAppState(db, 'xtream-import-status:p:movie', 'completed'); + assert.deepEqual( + snapshot((await db.select().from(schema.favorites))[0]), + snapshot(original), + 'unknown catalog identity must not overwrite the durable snapshot' + ); + for (const read of [getFavorites, getFavoritesForBackup]) { + const [row] = await read(db, 'p'); + assert.equal(row.title, original.title); + assert.equal(row.poster_url, original.posterUrl); + assert.equal(row.backdrop_url, original.backdropUrl); + assert.equal(row.original_title, original.originalTitle); + } + await persistContentMetadataIfMissing(db, 2, { + tmdbId: 20, + releaseYear: 2020, + }); + assert.equal((await getFavorites(db, 'p'))[0].catalog_status, 'missing'); + assert.equal((await getFavorites(db, 'p'))[0].title, original.title); + await deleteXtreamContent(db, 'p'); + await catalog(3, 7, 'ABC renamed', 'movie', 'p', { + tmdbId: 10, + posterUrl: 'https://art.invalid/returned.jpg', + backdropUrl: 'https://art.invalid/returned-wide.jpg', + originalTitle: 'ABC original renamed', + }); + await setAppState(db, 'xtream-import-status:p:movie', 'completed'); + assert.deepEqual( + snapshot((await db.select().from(schema.favorites))[0]), + snapshot(original), + 'partial identity is insufficient to replace the saved fields' + ); + await persistContentMetadataIfMissing(db, 3, { releaseYear: 2000 }); + const confirmed = { + ...snapshot(original), + title: 'ABC renamed', + poster: 'https://art.invalid/returned.jpg', + backdrop: 'https://art.invalid/returned-wide.jpg', + original: 'ABC original renamed', + }; + assert.deepEqual( + snapshot((await db.select().from(schema.favorites))[0]), + confirmed, + 'detail enrichment must refresh the snapshot as soon as identity is confirmed' + ); + await deleteXtreamContent(db, 'p'); + const [missing] = await getFavoritesForBackup(db, 'p'); + assert.equal(missing.title, confirmed.title); + assert.equal(missing.poster_url, confirmed.poster); + assert.equal(missing.backdrop_url, confirmed.backdrop); + await catalog(3, 7, ' ', 'movie', 'p', { + tmdbId: 10, + posterUrl: ' ', + backdropUrl: '', + originalTitle: '', + }); + await persistContentMetadataIfMissing(db, 3, { releaseYear: 2000 }); + assert.deepEqual( + snapshot((await db.select().from(schema.favorites))[0]), + confirmed, + 'blank fields on a confirmed return must preserve the saved snapshot' + ); + // A fully confirming duplicate must outrank an earlier sparse category copy. + await deleteXtreamContent(db, 'p'); + await catalog(6, 7, 'Sparse copy', 'movie', 'p', { tmdbId: 10 }); + await catalog(7, 7, 'Confirmed copy', 'movie', 'p', { + tmdbId: 10, + releaseYear: 2000, + posterUrl: 'confirmed.jpg', + }); + await setAppState(db, 'xtream-import-status:p:movie', 'completed'); + const [duplicate] = await getFavoritesForBackup(db, 'p'); + assert.equal( + duplicate.id, + 7, + 'fully confirming duplicate wins over sparse lower ID' + ); + assert.equal(duplicate.title, 'Confirmed copy'); + assert.equal(duplicate.poster_url, 'confirmed.jpg'); + const [duplicateSaved] = await db.select().from(schema.favorites); + assert.equal(duplicateSaved.contentId, 7); + assert.equal(duplicateSaved.title, 'Confirmed copy'); + await deleteXtreamContent(db, 'p'); + await catalog(3, 7, 'Confirmed copy', 'movie', 'p', { + tmdbId: 10, + releaseYear: 2000, + }); + await setAppState(db, 'xtream-import-status:p:movie', 'completed'); + // A replacement's year/artwork must not fill gaps before its TMDB identity is known. + await removeFavorite(db, 3, 'p'); + await deleteXtreamContent(db, 'p'); + await catalog(4, 7, 'Known TMDB only', 'movie', 'p', { + tmdbId: 10, + backdropUrl: null, + }); + await addFavorite(db, 4, 'p'); + await deleteXtreamContent(db, 'p'); + await catalog(5, 7, 'Year without identity', 'movie', 'p', { + releaseYear: 2020, + backdropUrl: null, + }); + await setAppState(db, 'xtream-import-status:p:movie', 'completed'); + await persistContentMetadataIfMissing(db, 5, { + backdropUrl: 'https://art.invalid/unverified.jpg', + }); + const [partial] = await getFavoritesForBackup(db, 'p'); + assert.equal(partial.release_year, null); + assert.equal(partial.backdrop_url, null); + assert.equal( + (await db.select().from(schema.favorites))[0].backdropUrl, + null + ); +} 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..7bfde02aa 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().mockReturnValue({ run: jest.fn() }), + })), + })), + } 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().mockReturnValue({ run: jest.fn() }), + })), + })), } 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..1e6055b2a 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,6 @@ +import { reconcileFavoriteIdentity } from './favorite-catalog'; +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 +72,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 +163,19 @@ export async function deleteXtreamContent( recentlyViewed: XtreamBackupRecentlyViewedItem[]; hiddenCategories: XtreamBackupHiddenCategory[]; }> { + await checkpointOperation(control); + reconcileFavoriteIdentity(db, eq(schema.favorites.playlistId, playlistId)); + 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 +255,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 +263,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/content.events.ts b/apps/electron-backend/src/app/events/database/content.events.ts index b14130458..f7ad11057 100644 --- a/apps/electron-backend/src/app/events/database/content.events.ts +++ b/apps/electron-backend/src/app/events/database/content.events.ts @@ -36,11 +36,7 @@ handleWorkerRequest( kind: 'all' | 'vod' | 'series' = 'all', limit = 200, playlistType?: - | 'xtream' - | 'stalker' - | 'm3u-file' - | 'm3u-text' - | 'm3u-url' + 'xtream' | 'stalker' | 'm3u-file' | 'm3u-text' | 'm3u-url' ) => ({ kind, limit, @@ -58,16 +54,12 @@ ipcMain.handle( operationId?: string ) => { try { - return await requestWorkerWithEvents( - event, - 'DB_SAVE_CONTENT', - { - playlistId, - streams, - type, - operationId, - } - ); + return await requestWorkerWithEvents(event, 'DB_SAVE_CONTENT', { + playlistId, + streams, + type, + operationId, + }); } catch (error) { console.error('Error handling DB_SAVE_CONTENT:', error); throw error; @@ -88,11 +80,13 @@ handleWorkerRequest( ( xtreamId: number, playlistId: string, - contentType?: 'live' | 'movie' | 'series' + contentType?: 'live' | 'movie' | 'series', + identity?: ContentMetadataPatch ) => ({ xtreamId, playlistId, contentType, + ...(identity ? { identity } : {}), }) ); 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..6b2c78ba3 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 @@ -209,6 +209,16 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [ args: [42, playlistId, 'movie'], payload: { xtreamId: 42, playlistId, contentType: 'movie' }, }, + { + operation: 'DB_GET_CONTENT_BY_XTREAM_ID', + args: [42, playlistId, 'movie', { tmdbId: 100, releaseYear: 1995 }], + payload: { + xtreamId: 42, + playlistId, + contentType: 'movie', + identity: { tmdbId: 100, releaseYear: 1995 }, + }, + }, { operation: 'DB_SET_CONTENT_METADATA_IF_MISSING', // Same object on both sides: the patch must cross the boundary @@ -259,6 +269,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 +293,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..f7e22367f 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, @@ -604,12 +610,14 @@ async function executeRequest( xtreamId: number; playlistId: string; contentType?: 'live' | 'movie' | 'series'; + identity?: ContentMetadataPatch; }; return getContentByXtreamId( db, payload.xtreamId, payload.playlistId, - payload.contentType + payload.contentType, + payload.identity ); } @@ -1046,6 +1054,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 +1081,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 +1095,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 +1112,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 +1229,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-e2e/src/server-storage.e2e.ts b/apps/web-e2e/src/server-storage.e2e.ts index 6b08c873f..2aae6e36e 100644 --- a/apps/web-e2e/src/server-storage.e2e.ts +++ b/apps/web-e2e/src/server-storage.e2e.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises'; import type { Browser, Page } from '@playwright/test'; import { expect, test } from './fixtures'; import { @@ -170,4 +171,207 @@ test.describe('server storage @self-hosted @persistence', () => { await page.context().close(); }); + for (const conflictingRestore of [false, true]) + test(`durable Xtream backup snapshots survive server restore, another device and explicit removal (conflict=${conflictingRestore})`, async ({ + browser, + request, + }) => { + const first = await newServerPage(browser); + const second = await newServerPage(browser); + const playlistId = 'durable-server-favorite'; + let injected = false; + await first.route( + (url) => + url.pathname === + `/v1/user-state/xtream-favorites/${playlistId}`, + async (route) => { + if ( + conflictingRestore && + !injected && + route.request().method() === 'PUT' + ) { + injected = true; + const response = await request.put( + `${STORAGE_BACKEND_URL}/v1/user-state/xtream-favorites/${playlistId}`, + { + headers: { 'If-None-Match': '*' }, + data: { value: ['live:99'] }, + } + ); + expect(response.ok()).toBe(true); + } + await route.continue(); + } + ); + + const manifest = { + kind: 'iptvnator-playlist-backup', + version: 1, + exportedAt: new Date().toISOString(), + includeSecrets: true, + playlists: [ + { + portalType: 'xtream', + exportedId: playlistId, + title: 'Server favorites', + autoRefresh: false, + connection: { + serverUrl: conflictingRestore + ? `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}` + : 'https://offline.example.invalid', + username: conflictingRestore + ? 'user1' + : 'synthetic', + password: conflictingRestore + ? 'pass1' + : 'synthetic', + }, + userState: { + hiddenCategories: [], + favorites: [ + { + xtreamId: 987655, + contentType: 'movie', + addedAt: '2020-01-01', + position: 4, + snapshot: { + title: 'Saved server original', + tmdbId: 100, + releaseYear: 2000, + }, + }, + ], + recentlyViewed: [], + playbackPositions: [], + collectionItems: [ + { + id: 987655, + xtream_id: 987655, + type: 'movie', + title: 'Wrong cache replacement', + tmdb_id: 200, + release_year: 2026, + }, + ], + }, + }, + ], + }; + try { + if (!conflictingRestore) + await first.route( + (url) => url.pathname === '/xtream', + (route) => route.abort() + ); + if (!conflictingRestore) + await second.route( + (url) => url.pathname === '/xtream', + (route) => route.abort() + ); + await first.goto('/workspace/settings/backup'); + const chooser = first.waitForEvent('filechooser'); + await first + .getByRole('button', { name: 'Import', exact: true }) + .click(); + await ( + await chooser + ).setFiles({ + name: 'server-favorites.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(manifest)), + }); + await expect( + first.getByText( + new RegExp( + `Backup import finished: .*${conflictingRestore ? 1 : 0} failed` + ) + ) + ).toBeVisible(); + if (conflictingRestore) { + expect(injected).toBe(true); + await first.goto(`/workspace/xtreams/${playlistId}/vod`); + await expect + .poll(() => + first.evaluate( + (id) => + localStorage.getItem( + `xtream-restore-${id}` + ), + playlistId + ) + ) + .toBeNull(); + } + const entries = ( + await ( + await request.get( + `${STORAGE_BACKEND_URL}/v1/user-state` + ) + ).json() + ).entries; + expect(Object.keys(entries['xtream-favorites'])).toEqual([ + playlistId, + ]); + expect(entries['xtream-favorites'][playlistId]).toEqual([ + 'movie:987655', + ]); + expect( + entries['xtream-collection-items'][playlistId][ + 'movie:987655' + ] + ).toMatchObject({ + title: 'Saved server original', + tmdb_id: 100, + release_year: 2000, + }); + await second.goto('/workspace/global-favorites'); + await expect( + second.getByRole('button', { + name: 'Saved server original', + exact: true, + }) + ).toBeVisible(); + await second.goto('/workspace/settings/backup'); + const downloading = second.waitForEvent('download'); + await second + .getByRole('button', { name: 'Export', exact: true }) + .click(); + const path = await (await downloading).path(); + if (!path) throw new Error('Missing server backup'); + const exported = JSON.parse(await readFile(path, 'utf8')); + expect( + exported.playlists[0].userState.favorites[0] + ).toMatchObject(manifest.playlists[0].userState.favorites[0]); + await second.goto('/workspace/global-favorites'); + await second + .getByRole('button', { + name: 'Remove from favorites', + exact: true, + }) + .click(); + await expect + .poll( + async () => + ( + await ( + await request.get( + `${STORAGE_BACKEND_URL}/v1/user-state` + ) + ).json() + ).entries['xtream-favorites'][playlistId] + ) + .toEqual([]); + await first.goto('/workspace/global-favorites'); + await first.reload(); + await expect( + first.getByRole('button', { + name: 'Saved server original', + exact: true, + }) + ).toHaveCount(0); + } finally { + await first.context().close(); + await second.context().close(); + } + }); }); diff --git a/apps/web-e2e/src/xtream-favorites-availability.e2e.ts b/apps/web-e2e/src/xtream-favorites-availability.e2e.ts new file mode 100644 index 000000000..45da94bf9 --- /dev/null +++ b/apps/web-e2e/src/xtream-favorites-availability.e2e.ts @@ -0,0 +1,1058 @@ +import { readFile } from 'node:fs/promises'; +import { expect, test } from './fixtures'; +import { configureLiveFormat } from './xtream-live-format.fixture'; +import { + addXtreamPortal, + DEFAULT_USERNAME, + DEFAULT_PASSWORD, + interceptXtreamRequests, + MOCK_SERVER, +} from './xtream-series-playback.fixture'; + +test.beforeEach(async ({ page, request }) => { + await request.post(`${MOCK_SERVER}/reset`); + await page.goto('/'); + await interceptXtreamRequests(page); +}); + +test('@xtream @collections absent saved items recover on sparse return without losing legacy backup identity', async ({ + page, +}) => { + const loaded = new Set(); + let detailRequests = 0; + page.on('response', (response) => { + const action = new URL(response.url()).searchParams.get('action'); + if (action && response.ok()) loaded.add(action); + }); + page.on('request', (request) => { + if ( + new URL(request.url()).searchParams.get('action') === 'get_vod_info' + ) + detailRequests++; + }); + await addXtreamPortal(page, { name: 'Absent Favorites Portal' }); + const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, ''); + const rawId = playlistPath.split('/').pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const playlistId = decodeURIComponent(rawId); + await page.evaluate((playlistId) => { + const key = 'movie:99999'; + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ [playlistId]: [key] }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + [playlistId]: [{ id: key, viewedAt: '2026-01-03' }], + }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + [playlistId]: { + [key]: { + id: 99999, + xtream_id: 99999, + type: 'movie', + title: 'Saved absent favorite', + poster_url: '/assets/icons/icon-192x192.png', + tmdb_id: '949', + release_year: '1995', + added_at: '2026-01-02', + position: 3, + catalog_status: 'present', + }, + }, + }) + ); + }, playlistId); + await page.getByRole('link', { name: 'Series', exact: true }).click(); + await expect.poll(() => loaded.has('get_series')).toBe(true); + await page.getByRole('link', { name: 'Live TV', exact: true }).click(); + await expect.poll(() => loaded.has('get_live_streams')).toBe(true); + await expect.poll(() => loaded.has('get_vod_streams')).toBe(true); + await page + .getByRole('link', { name: 'Global favorites', exact: true }) + .click(); + const card = page.getByRole('button', { + name: 'Saved absent favorite', + exact: true, + }); + await expect(card).toHaveAttribute('aria-disabled', 'true'); + await card.dispatchEvent('click'); + await card.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('app-content-hero')).toHaveCount(0); + expect(detailRequests).toBe(0); + await page.reload(); + await expect(card).toHaveAttribute('aria-disabled', 'true'); + await page.goto('/workspace/global-recent'); + await expect(card).toHaveAttribute('aria-disabled', 'true'); + await card.dispatchEvent('click'); + await expect(page.locator('app-content-hero')).toHaveCount(0); + expect(detailRequests).toBe(0); + await page.goto('/workspace/dashboard'); + await expect( + page.locator('[data-test-id="dashboard-page-heading"]') + ).toBeVisible(); + await expect( + page + .locator('lib-workspace-dashboard-rails') + .getByText('Saved absent favorite', { exact: true }) + .first() + ).toBeVisible(); + await expect( + page.locator('[data-test-id="dashboard-hero-skeleton"]') + ).toHaveCount(0); + await expect( + page + .locator('lib-dashboard-hero .hero__content') + .filter({ hasText: 'Saved absent favorite' }) + ).toHaveCount(0); + expect(detailRequests).toBe(0); + await page.goto('/workspace/settings/backup'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const path = await (await download).path(); + if (!path) throw new Error('Backup download is missing'); + const backup = JSON.parse(await readFile(path, 'utf8')); + const entry = backup.playlists.find( + (item: { exportedId: string }) => item.exportedId === playlistId + ); + expect(entry.userState.favorites[0]).toMatchObject({ + contentType: 'movie', + xtreamId: 99999, + addedAt: '2026-01-02', + position: 3, + snapshot: { + title: 'Saved absent favorite', + tmdbId: 949, + releaseYear: 1995, + }, + }); + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + if (original.searchParams.get('action') !== 'get_vod_streams') { + await route.fallback(); + return; + } + const mockUrl = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') mockUrl.searchParams.set(key, value); + }); + const response = await route.fetch({ url: mockUrl.toString() }); + const body = await response.json(); + body.payload.push({ + stream_id: 99999, + name: 'Sparse returning movie', + category_id: body.payload[0].category_id, + stream_type: 'movie', + container_extension: 'mp4', + }); + await route.fulfill({ response, json: body }); + }); + await page.goto(`${playlistPath}/vod`); + await page + .getByRole('link', { name: 'Global favorites', exact: true }) + .click(); + await expect(card).not.toHaveAttribute('aria-disabled', 'true'); + await page.reload(); + await expect(card).not.toHaveAttribute('aria-disabled', 'true'); + await page.goto('/workspace/global-recent'); + await expect(card).not.toHaveAttribute('aria-disabled', 'true'); + await card.click(); + await expect.poll(() => detailRequests).toBeGreaterThan(0); +}); + +test('@xtream @collections PWA retries retain saved favorite metadata when snapshot storage is full', async ({ + page, +}) => { + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + if (original.searchParams.get('action') !== 'get_vod_streams') + return route.fallback(); + const url = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') url.searchParams.set(key, value); + }); + const response = await route.fetch({ url: url.toString() }); + const body = await response.json(); + body.payload.push({ + stream_id: 987654, + name: 'Sparse replacement', + category_id: body.payload[0].category_id, + stream_type: 'movie', + }); + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Pending Favorites Portal' }); + const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, ''); + const rawId = playlistPath.split('/').pop(); + if (!rawId) throw new Error('Missing playlist ID in the portal route'); + const playlistId = decodeURIComponent(rawId); + const favorite = { + contentType: 'movie', + xtreamId: 987654, + addedAt: '2026-01-02T12:00:00.000Z', + position: 3, + snapshot: { + title: 'Saved offline movie', + catalogStatus: 'missing', + catalogMissingReason: 'identity_conflict', + posterUrl: '/assets/icons/icon-192x192.png', + tmdbId: 949, + releaseYear: 1995, + }, + }; + await page.goto('/'); + await page.evaluate( + ({ playlistId, favorite }) => { + localStorage.setItem( + `xtream-restore-${playlistId}`, + JSON.stringify({ + hiddenCategories: [], + favorites: [favorite], + recentlyViewed: [], + playbackPositions: [], + }) + ); + sessionStorage.setItem('block-favorite-snapshot', '1'); + }, + { playlistId, favorite } + ); + await page.addInitScript(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function (this: Storage, key, value) { + if ( + this === localStorage && + key === 'xtream-collection-items' && + sessionStorage.getItem('block-favorite-snapshot') === '1' + ) { + sessionStorage.setItem('favorite-snapshot-attempted', '1'); + throw new DOMException('full', 'QuotaExceededError'); + } + return setItem.call(this, key, value); + }; + }); + await page.goto(`${playlistPath}/vod`); + await expect + .poll(() => + page.evaluate(() => + sessionStorage.getItem('favorite-snapshot-attempted') + ) + ) + .toBe('1'); + expect( + await page.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + playlistId + ) + ).not.toBeNull(); + await page.evaluate(() => + sessionStorage.removeItem('block-favorite-snapshot') + ); + await page.reload(); + await expect + .poll(() => + page.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + playlistId + ) + ) + .toBeNull(); + await page.goto(`${playlistPath}/favorites`); + await expect( + page.getByText(favorite.snapshot.title, { exact: true }) + ).toBeVisible(); + await page.reload(); + await expect( + page.getByText(favorite.snapshot.title, { exact: true }) + ).toBeVisible(); + expect( + await page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[id]?.['movie:987654'], + playlistId + ) + ).toMatchObject({ + title: favorite.snapshot.title, + poster_url: favorite.snapshot.posterUrl, + tmdb_id: 949, + release_year: 1995, + added_at: favorite.addedAt, + position: 3, + catalog_status: 'missing', + catalog_missing_reason: 'identity_conflict', + }); + await page.goto('/workspace/global-favorites'); + const card = page.getByRole('button', { + name: favorite.snapshot.title, + exact: true, + }); + await expect(card).toHaveAttribute('aria-disabled', 'true'); + await card.dispatchEvent('click'); + await expect(page.locator('app-content-hero')).toHaveCount(0); +}); + +test('@xtream @collections removing a readable favorite during failed restore survives retry and export', async ({ + page, +}) => { + await addXtreamPortal(page, { name: 'Removal Retry Portal' }); + const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, ''); + const rawId = playlistPath.split('/').pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const id = decodeURIComponent(rawId); + await page.goto('/'); + await page.evaluate((id) => { + const item = { + id: 987654, + xtream_id: 987654, + type: 'movie', + title: 'Readable saved movie', + tmdb_id: 949, + poster_url: '/assets/icons/icon-192x192.png', + catalog_status: 'missing', + catalog_missing_reason: 'absent', + }; + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ [id]: { 'movie:987654': item } }) + ); + localStorage.setItem( + `xtream-restore-${id}`, + JSON.stringify({ + favoritesRestoreId: 'removal-test', + hiddenCategories: [], + favorites: [ + { + contentType: 'movie', + xtreamId: 987654, + snapshot: { title: item.title, tmdbId: 949 }, + }, + ], + recentlyViewed: [], + playbackPositions: [], + }) + ); + sessionStorage.setItem('block-removal-snapshot', '1'); + }, id); + await page.addInitScript(() => { + const set = Storage.prototype.setItem; + Storage.prototype.setItem = function (this: Storage, key, value) { + if ( + this === localStorage && + key === 'xtream-collection-items' && + sessionStorage.getItem('block-removal-snapshot') + ) { + sessionStorage.setItem('removal-snapshot-attempted', '1'); + throw new DOMException('full', 'QuotaExceededError'); + } + return set.call(this, key, value); + }; + }); + await page.goto(`${playlistPath}/vod`); + await expect + .poll(() => + page.evaluate(() => + sessionStorage.getItem('removal-snapshot-attempted') + ) + ) + .toBe('1'); + await page.goto('/workspace/global-favorites'); + const card = page + .locator('app-content-card') + .filter({ hasText: 'Readable saved movie' }); + await expect(card).toBeVisible(); + await card + .getByRole('button', { name: 'Remove from favorites', exact: true }) + .click(); + await expect(card).toHaveCount(0); + await page.goto('/workspace/settings/backup'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const path = await (await download).path(); + if (!path) throw new Error('Missing backup'); + const archived = JSON.parse(await readFile(path, 'utf8')).playlists.find( + (entry: { exportedId: string }) => entry.exportedId === id + ); + expect(archived.userState.favorites).toEqual([]); + await page.evaluate(() => + sessionStorage.removeItem('block-removal-snapshot') + ); + await page.goto(`${playlistPath}/vod`); + await expect + .poll(() => + page.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + id + ) + ) + .toBeNull(); + await page.goto('/workspace/global-favorites'); + await page.reload(); + await expect(card).toHaveCount(0); + expect( + await page.evaluate( + (id) => + JSON.parse(localStorage.getItem('xtream-favorites') ?? '{}')[ + id + ], + id + ) + ).toEqual([]); +}); + +test('@xtream @collections cold shared live playback records the selected favorite identity', async ({ + page, +}) => { + test.setTimeout(60_000); + await configureLiveFormat(page, 'html5', 'ts'); + await addXtreamPortal(page, { + name: 'Shared Live Identity Portal', + username: 'live-fallback', + password: 'live-fallback', + }); + const rawId = new URL(page.url()).pathname + .replace(/\/vod.*$/, '') + .split('/') + .pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const id = decodeURIComponent(rawId); + await page.goto('/workspace/global-favorites'); + await page.evaluate((id) => { + const played = { + id: 10000, + xtream_id: 10000, + type: 'live', + title: 'Played favorite B', + tmdb_id: 200, + category_id: '1', + poster_url: '', + catalog_status: 'present', + }; + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ [id]: ['live:10000'] }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ [id]: { 'live:10000': played } }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + [id]: [ + { + id: 'live:10000', + viewedAt: '2020-01-01', + collectionItem: { + ...played, + title: 'Earlier watched A', + tmdb_id: 100, + }, + }, + ], + }) + ); + }, id); + await page.route('**/localhost:3000/xtream**', async (route) => { + if ( + new URL(route.request().url()).searchParams.get('action') === + 'get_live_streams' + ) + return route.abort(); + await route.fallback(); + }); + await page.reload(); + const row = page + .locator('app-channel-list-item') + .filter({ hasText: 'Played favorite B' }); + await expect(row).toBeVisible(); + await row.click(); + await expect + .poll( + () => + page + .locator('video') + .first() + .evaluate((video: HTMLVideoElement) => video.currentTime), + { timeout: 30_000 } + ) + .toBeGreaterThan(2); + await expect + .poll(() => + page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-recent-items') ?? '{}' + )[id]?.[0]?.collectionItem?.tmdb_id, + id + ) + ) + .toBe(200); + await page.goto('/workspace/global-recent'); + await page.reload(); + await expect( + page + .locator('app-channel-list-item') + .filter({ hasText: 'Played favorite B' }) + ).toBeVisible(); + await expect( + page + .locator('app-channel-list-item') + .filter({ hasText: 'Earlier watched A' }) + ).toHaveCount(0); + expect( + await page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[id]['live:10000'].tmdb_id, + id + ) + ).toBe(200); + await page.evaluate((id) => { + const snapshots = JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + ); + snapshots[id]['live:10000'].catalog_status = 'missing'; + snapshots[id]['live:10000'].catalog_missing_reason = 'absent'; + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify(snapshots) + ); + const recent = JSON.parse( + localStorage.getItem('xtream-recent-items') ?? '{}' + ); + recent[id][0].collectionItem.catalog_status = 'missing'; + recent[id].push({ + id: 'live:10001', + viewedAt: '2020-01-02', + collectionItem: { + id: 10001, + xtream_id: 10001, + type: 'live', + title: 'Another watched channel', + tmdb_id: 300, + catalog_status: 'present', + }, + }); + localStorage.setItem('xtream-recent-items', JSON.stringify(recent)); + }, id); + await page.reload(); + await expect( + row.getByRole('button', { name: 'Played favorite B', exact: true }) + ).toHaveAttribute('aria-disabled', 'true'); + await row.click({ button: 'right' }); + await expect(page.getByRole('menuitem')).toHaveCount(1); + await page + .getByRole('menuitem', { name: 'Remove from history', exact: true }) + .click(); + await expect(row).toHaveCount(0); + await expect( + page.getByText('Another watched channel', { exact: true }) + ).toBeVisible(); + await page.reload(); + await expect(row).toHaveCount(0); + expect( + await page.evaluate( + (id) => + JSON.parse(localStorage.getItem('xtream-favorites') ?? '{}')[ + id + ], + id + ) + ).toEqual(['live:10000']); + expect( + await page.evaluate( + (id) => + JSON.parse(localStorage.getItem('xtream-recent-items') ?? '{}')[ + id + ].map((item: { id: string }) => item.id), + id + ) + ).toEqual(['live:10001']); +}); + +test('@xtream @collections catalog hydration cannot replace pending backup identity or dates', async ({ + page, +}) => { + test.setTimeout(60_000); + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + if (original.searchParams.get('action') !== 'get_vod_streams') + return route.fallback(); + const mockUrl = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') mockUrl.searchParams.set(key, value); + }); + const response = await route.fetch({ url: mockUrl.toString() }); + const body = await response.json(); + body.payload.push({ + stream_id: 987655, + name: 'Catalog replacement', + category_id: body.payload[0].category_id, + stream_type: 'movie', + tmdb_id: 200, + }); + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Hydration Retry Portal' }); + const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, ''); + const rawId = playlistPath.split('/').pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const id = decodeURIComponent(rawId); + await page.goto('/'); + await page.evaluate((id) => { + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + [id]: { + 'movie:987656': { + xtream_id: 987656, + type: 'movie', + title: 'Older cached item', + tmdb_id: 300, + added_at: '2020-01-01', + position: 9, + }, + }, + }) + ); + localStorage.setItem( + `xtream-restore-${id}`, + JSON.stringify({ + favoritesRestoreId: 'hydration-race', + hiddenCategories: [], + favorites: [ + { + contentType: 'movie', + xtreamId: 987655, + snapshot: { title: 'Archived original', tmdbId: 100 }, + }, + { + contentType: 'movie', + xtreamId: 987656, + addedAt: '2026-01-01', + position: 2, + snapshot: { title: 'Archived dated item', tmdbId: 300 }, + }, + ], + recentlyViewed: [], + playbackPositions: [], + }) + ); + sessionStorage.setItem('block-hydration-snapshot', '1'); + }, id); + await page.addInitScript(() => { + const set = Storage.prototype.setItem; + Storage.prototype.setItem = function (this: Storage, key, value) { + if ( + this === localStorage && + key === 'xtream-collection-items' && + sessionStorage.getItem('block-hydration-snapshot') + ) { + sessionStorage.setItem('hydration-snapshot-attempted', '1'); + throw new DOMException('full', 'QuotaExceededError'); + } + return set.call(this, key, value); + }; + }); + await page.goto(`${playlistPath}/vod`); + await expect + .poll(() => + page.evaluate(() => + sessionStorage.getItem('hydration-snapshot-attempted') + ) + ) + .toBe('1'); + await page.goto('/workspace/settings/backup'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const path = await (await download).path(); + if (!path) throw new Error('Missing backup'); + const exported = JSON.parse(await readFile(path, 'utf8')).playlists.find( + (entry: { exportedId: string }) => entry.exportedId === id + ); + expect( + exported.userState.favorites.find( + (item: { xtreamId: number }) => item.xtreamId === 987656 + ) + ).toMatchObject({ + addedAt: '2026-01-01', + position: 2, + snapshot: { title: 'Archived dated item' }, + }); + await page.evaluate(() => + sessionStorage.removeItem('block-hydration-snapshot') + ); + // Client-side navigation performs a collection read without restarting the pending import. + await page + .getByRole('link', { name: 'Global favorites', exact: true }) + .click(); + await expect + .poll(() => + page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[id]?.['movie:987655']?.tmdb_id, + id + ) + ) + .toBe(200); + expect( + await page.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + id + ) + ).not.toBeNull(); + await page.goto(`${playlistPath}/vod`); + await expect + .poll(() => + page.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + id + ) + ) + .toBeNull(); + await page.goto('/workspace/global-favorites'); + await expect( + page.getByRole('button', { name: 'Archived original', exact: true }) + ).toBeVisible(); + await page.reload(); + expect( + await page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[id]?.['movie:987655']?.tmdb_id, + id + ) + ).toBe(100); +}); + +test('@xtream @collections importing partial favorite identity rejects conflicting optional cache fields', async ({ + page, +}) => { + const id = 'partial-import-source'; + const favorite = { + contentType: 'movie', + xtreamId: 987655, + addedAt: '2020-01-02', + position: 4, + snapshot: { title: 'Partial saved original', tmdbId: 100 }, + }; + const manifest = { + kind: 'iptvnator-playlist-backup', + version: 1, + exportedAt: new Date().toISOString(), + includeSecrets: true, + playlists: [ + { + portalType: 'xtream', + exportedId: id, + title: 'Partial Backup Portal', + autoRefresh: false, + connection: { + serverUrl: MOCK_SERVER, + username: DEFAULT_USERNAME, + password: DEFAULT_PASSWORD, + }, + userState: { + hiddenCategories: [], + favorites: [favorite], + recentlyViewed: [], + playbackPositions: [], + collectionItems: [ + { + id: 987655, + xtream_id: 987655, + type: 'movie', + title: 'Cached replacement', + tmdb_id: 200, + release_year: 2026, + poster_url: '/replacement.jpg', + }, + ], + }, + }, + ], + }; + await page.goto('/workspace/settings/backup'); + const choosing = page.waitForEvent('filechooser'); + await page.getByRole('button', { name: 'Import', exact: true }).click(); + await ( + await choosing + ).setFiles({ + name: 'partial-favorite.json', + mimeType: 'application/json', + buffer: Buffer.from(JSON.stringify(manifest)), + }); + await expect( + page.getByText(/Backup import finished: .*0 failed/) + ).toBeVisible(); + await page.goto('/workspace/global-favorites'); + await expect( + page.getByRole('button', { name: favorite.snapshot.title, exact: true }) + ).toBeVisible(); + await page.reload(); + await expect( + page.getByRole('button', { name: favorite.snapshot.title, exact: true }) + ).toBeVisible(); + await page.goto('/workspace/settings/backup'); + const downloading = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const path = await (await downloading).path(); + if (!path) throw new Error('Missing exported backup'); + const exported = JSON.parse(await readFile(path, 'utf8')); + const saved = exported.playlists.find( + (entry: { exportedId: string }) => entry.exportedId === id + ).userState.favorites[0]; + expect(saved).toMatchObject(favorite); + expect(saved.snapshot.releaseYear).toBeUndefined(); + expect(saved.snapshot.posterUrl).not.toBe('/replacement.jpg'); +}); + +test('@xtream @collections unavailable watched series retain history without requesting replacement episodes', async ({ + page, +}) => { + await addXtreamPortal(page, { name: 'Unavailable series discovery' }); + const rawId = /\/xtreams\/([^/]+)/.exec(new URL(page.url()).pathname)?.[1]; + if (!rawId) throw new Error('Missing playlist ID'); + const id = decodeURIComponent(rawId); + await page.goto('/workspace/global-recent'); + await page.evaluate((id) => { + const items = [ + { + id: 987651, + xtream_id: 987651, + type: 'series', + title: 'Retained missing series', + catalog_status: 'missing', + catalog_missing_reason: 'identity_conflict', + tmdb_id: 100, + }, + { + id: 987652, + xtream_id: 987652, + type: 'series', + title: 'Retained unknown series', + catalog_status: 'unknown', + tmdb_id: 200, + }, + ]; + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + [id]: items.map((item) => ({ + id: `series:${item.id}`, + viewedAt: '2026-10-15', + collectionItem: item, + })), + }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + [id]: Object.fromEntries( + items.map((item) => [`series:${item.id}`, item]) + ), + }) + ); + localStorage.setItem( + 'xtream-playback-positions', + JSON.stringify({ + [id]: items.map((item) => ({ + playlistId: id, + contentXtreamId: item.id + 10, + seriesXtreamId: item.id, + contentType: 'episode', + seasonNumber: 1, + episodeNumber: 1, + positionSeconds: 990, + durationSeconds: 1000, + updatedAt: '2026-10-15', + })), + }) + ); + }, id); + let requests = 0; + page.on('request', (request) => { + if ( + new URL(request.url()).searchParams.get('action') === + 'get_series_info' + ) + requests++; + }); + await page.reload(); + for (const title of [ + 'Retained missing series', + 'Retained unknown series', + ]) { + await expect( + page.getByRole('button', { name: title, exact: true }) + ).toHaveAttribute('aria-disabled', 'true'); + } + await page.goto('/workspace/dashboard'); + await expect( + page.locator('[data-test-id="dashboard-page-heading"]') + ).toBeVisible(); + await expect( + page.locator('[data-test-id="dashboard-hero-skeleton"]') + ).toHaveCount(0); + await expect( + page + .locator('lib-workspace-dashboard-rails') + .getByText('Retained missing series', { exact: true }) + .first() + ).toBeVisible(); + await expect(page.locator('lib-dashboard-hero')).not.toContainText( + 'Retained missing series' + ); + expect(requests).toBe(0); + expect( + await page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-playback-positions') ?? '{}' + )[id].length, + id + ) + ).toBe(2); + await page.goto('/workspace/global-recent'); + await expect( + page.getByRole('button', { + name: 'Retained missing series', + exact: true, + }) + ).toBeVisible(); + expect(requests).toBe(0); +}); + +test('@xtream @collections sparse detail hearts cannot delete a retained conflicting favorite', async ({ + page, + request, +}) => { + const response = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { + username: DEFAULT_USERNAME, + password: DEFAULT_PASSWORD, + action: 'get_vod_streams', + }, + }); + const movie = (await response.json())[0]; + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + const action = original.searchParams.get('action'); + if (action !== 'get_vod_streams' && action !== 'get_vod_info') + return route.fallback(); + const url = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') url.searchParams.set(key, value); + }); + const response = await route.fetch({ url: url.toString() }); + const body = await response.json(); + if (action === 'get_vod_streams') { + for (const row of body.payload) { + delete row.tmdb_id; + delete row.tmdb; + delete row.release_year; + delete row.release_date; + } + } else { + Object.assign(body.payload.info, { + tmdb_id: null, + tmdb: null, + release_year: null, + releasedate: null, + release_date: null, + }); + } + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Sparse detail conflict' }); + const portal = new URL(page.url()).pathname; + const rawId = /\/xtreams\/([^/]+)/.exec(portal)?.[1]; + if (!rawId) throw new Error('Missing playlist ID'); + const id = decodeURIComponent(rawId); + await page.evaluate( + ({ id, movie }) => { + const key = `movie:${movie.stream_id}`; + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ [id]: [key] }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + [id]: { + [key]: { + id: movie.stream_id, + xtream_id: movie.stream_id, + type: 'movie', + title: 'Older saved original', + tmdb_id: 100, + release_year: 1995, + catalog_status: 'missing', + catalog_missing_reason: 'identity_conflict', + }, + }, + }) + ); + }, + { id, movie } + ); + await page.reload(); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await page.getByText(movie.name, { exact: true }).first().click(); + const add = page + .getByRole('button', { name: 'Add to favorites', exact: true }) + .first(); + await expect(add).toBeVisible(); + await add.click(); + await expect( + page.getByText( + 'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.', + { exact: true } + ) + ).toBeVisible(); + expect( + await page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[id][ + Object.keys( + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? + '{}' + )[id] + )[0] + ].tmdb_id, + id + ) + ).toBe(100); + await page.goto('/workspace/global-favorites'); + await expect( + page.getByRole('button', { name: 'Older saved original', exact: true }) + ).toBeVisible(); + await page + .getByRole('button', { name: 'Remove from favorites', exact: true }) + .click(); + expect( + await page.evaluate( + (id) => + JSON.parse(localStorage.getItem('xtream-favorites') ?? '{}')[ + id + ], + id + ) + ).toEqual([]); +}); diff --git a/apps/web-e2e/src/xtream-favorites-restore.e2e.ts b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts new file mode 100644 index 000000000..e7bf63e0b --- /dev/null +++ b/apps/web-e2e/src/xtream-favorites-restore.e2e.ts @@ -0,0 +1,1158 @@ +import { readFile } from 'node:fs/promises'; +import { expect, test } from './fixtures'; +import { + addXtreamPortal, + DEFAULT_PASSWORD, + DEFAULT_USERNAME, + interceptXtreamRequests, + MOCK_SERVER, + routeMovieClip, + selectWebPlayer, +} from './xtream-series-playback.fixture'; + +test.beforeEach(async ({ page, request }) => { + await request.post(`${MOCK_SERVER}/reset`); + await page.goto('/'); + await interceptXtreamRequests(page); +}); + +for (const identity of ['missing', 'conflicting'] as const) { + test(`@xtream @collections ${identity} catalog identity preserves saved favorites through reload and export`, async ({ + page, + request, + }) => { + test.setTimeout(60_000); + if (identity === 'conflicting') { + await routeMovieClip(page); + await selectWebPlayer(page, 'HTML5 video player'); + } + const response = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { + username: DEFAULT_USERNAME, + password: DEFAULT_PASSWORD, + action: 'get_vod_streams', + }, + }); + expect(response.ok()).toBeTruthy(); + const movie = (await response.json())[0]; + let detailRequests = 0; + let catalogOffline = false; + page.on('request', (request) => { + if ( + new URL(request.url()).searchParams.get('action') === + 'get_vod_info' + ) + detailRequests++; + }); + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + const action = original.searchParams.get('action'); + if ( + action !== 'get_vod_streams' && + !(action === 'get_vod_info' && identity === 'conflicting') + ) { + await route.fallback(); + return; + } + if (catalogOffline && action === 'get_vod_streams') { + await route.abort(); + return; + } + const mockUrl = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') mockUrl.searchParams.set(key, value); + }); + const response = await route.fetch({ url: mockUrl.toString() }); + const body = await response.json(); + if (action === 'get_vod_info') { + // Give the local WebM bytes to Chromium's native media path. + body.payload.movie_data.container_extension = 'mp4'; + Object.assign(body.payload.info, { + tmdb_id: 999, + releasedate: '2026-01-01', + }); + await route.fulfill({ response, json: body }); + return; + } + for (const item of body.payload) { + delete item['tmdb_id']; + delete item['release_year']; + if (identity === 'conflicting') { + item['tmdb_id'] = 999; + item['release_year'] = 2026; + } + } + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Saved Metadata Portal' }); + const playlistPath = new URL(page.url()).pathname.replace( + /\/vod.*$/, + '' + ); + const rawId = playlistPath.split('/').pop(); + if (!rawId) throw new Error('Missing playlist ID in the portal route'); + const playlistId = decodeURIComponent(rawId); + const favorite = { + contentType: 'movie', + xtreamId: movie.stream_id, + addedAt: '2026-01-02T12:00:00.000Z', + position: 3, + snapshot: { + title: 'Saved original favorite', + posterUrl: '/assets/icons/icon-192x192.png', + tmdbId: 949, + releaseYear: 1995, + originalTitle: 'Saved original title', + }, + }; + const exportedFavorite = { + ...favorite, + snapshot: { + ...favorite.snapshot, + ...(identity === 'conflicting' + ? { + catalogStatus: 'missing', + catalogMissingReason: 'identity_conflict', + } + : {}), + }, + }; + await page.evaluate( + ({ playlistId, favorite }) => + localStorage.setItem( + `xtream-restore-${playlistId}`, + JSON.stringify({ + hiddenCategories: [], + favorites: [favorite], + recentlyViewed: [ + { + contentType: favorite.contentType, + xtreamId: favorite.xtreamId, + viewedAt: '2026-01-02T12:00:00.000Z', + }, + ], + playbackPositions: [], + }) + ), + { playlistId, favorite } + ); + await page.reload(); + await expect + .poll(() => + page.evaluate( + (id) => localStorage.getItem(`xtream-restore-${id}`), + playlistId + ) + ) + .toBeNull(); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await expect( + page.getByText(movie.name, { exact: true }).first() + ).toBeVisible(); + // Keep the catalog in memory while opening Favorites, exercising the merge. + await page + .getByRole('link', { name: 'Global favorites', exact: true }) + .click(); + await expect( + page.getByText(favorite.snapshot.title, { exact: true }).first() + ).toBeVisible(); + await page.reload(); + await expect( + page.getByText(favorite.snapshot.title, { exact: true }).first() + ).toBeVisible(); + expect( + await page.evaluate( + ({ playlistId, key }) => + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[playlistId]?.[key], + { playlistId, key: `movie:${movie.stream_id}` } + ) + ).toMatchObject({ + title: favorite.snapshot.title, + poster_url: favorite.snapshot.posterUrl, + tmdb_id: 949, + release_year: 1995, + original_title: 'Saved original title', + added_at: favorite.addedAt, + position: 3, + }); + if (identity === 'conflicting') { + const card = page.getByRole('button', { + name: favorite.snapshot.title, + exact: true, + }); + await expect(card).toHaveAttribute('aria-disabled', 'true'); + await card.dispatchEvent('click'); + await card.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('app-content-hero')).toHaveCount(0); + expect(detailRequests).toBe(0); + } + await page.goto('/workspace/settings/backup'); + const downloadPromise = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const downloaded = await (await downloadPromise).path(); + if (!downloaded) throw new Error('Backup download is missing'); + const backup = JSON.parse(await readFile(downloaded, 'utf8')); + expect( + backup.playlists.find( + (entry: { exportedId: string }) => + entry.exportedId === playlistId + ).userState.favorites + ).toEqual([exportedFavorite]); + if (identity === 'conflicting') { + await page.goto('/workspace/global-recent'); + const recent = page.getByRole('button', { + name: favorite.snapshot.title, + exact: true, + }); + await expect(recent).toHaveAttribute('aria-disabled', 'true'); + await recent.dispatchEvent('click'); + await recent.focus(); + await page.keyboard.press('Enter'); + await expect(page.locator('app-content-hero')).toHaveCount(0); + expect(detailRequests).toBe(0); + // Opening the actual replacement from its catalog must not expose a + // Remove heart that could delete the retained, incompatible favorite. + await page.goto(`${playlistPath}/vod`); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await page.getByText(movie.name, { exact: true }).first().click(); + const add = page + .getByRole('button', { name: 'Add to favorites', exact: true }) + .first(); + await expect(add).toBeVisible(); + await add.click(); + await expect( + page.getByText( + 'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.', + { exact: true } + ) + ).toBeVisible(); + await expect(add).toBeVisible(); + await page + .getByRole('button', { name: /^Play / }) + .first() + .click(); + await expect + .poll(() => + page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-recent-items') ?? + '{}' + )[id]?.[0]?.collectionItem?.title, + playlistId + ) + ) + .toBe(movie.name); + catalogOffline = true; + await page.goto('/workspace/global-recent'); + await page.reload(); + const replacement = page.getByRole('button', { + name: movie.name, + exact: true, + }); + await expect(replacement).toBeVisible(); + await expect(replacement).not.toHaveAttribute( + 'aria-disabled', + 'true' + ); + await replacement.click(); + const coldDetailAdd = page + .getByRole('button', { name: 'Add to favorites', exact: true }) + .first(); + await expect(coldDetailAdd).toBeVisible(); + await coldDetailAdd.click(); + await expect( + page.getByText( + 'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.', + { exact: true } + ) + ).toBeVisible(); + await expect(coldDetailAdd).toBeVisible(); + await page.goto('/workspace/settings/backup'); + const playedDownload = page.waitForEvent('download'); + await page + .getByRole('button', { name: 'Export', exact: true }) + .click(); + const playedPath = await (await playedDownload).path(); + if (!playedPath) throw new Error('Played backup is missing'); + const playedEntry = JSON.parse( + await readFile(playedPath, 'utf8') + ).playlists.find( + (entry: { exportedId: string }) => + entry.exportedId === playlistId + ); + expect(playedEntry.userState.favorites).toEqual([exportedFavorite]); + expect( + playedEntry.userState.recentlyViewed[0].collectionItem + ).toMatchObject({ title: movie.name, tmdb_id: 999 }); + await page.goto('/workspace/global-favorites'); + const card = page + .locator('app-content-card') + .filter({ hasText: favorite.snapshot.title }); + await card + .getByRole('button', { + name: 'Remove from favorites', + exact: true, + }) + .click(); + await expect(card).toHaveCount(0); + expect( + await page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-favorites') ?? '{}' + )[id], + playlistId + ) + ).toEqual([]); + } + }); +} + +test('@xtream @collections detail identity protects a sparse PWA favorite from a reused provider ID', async ({ + page, + request, +}) => { + const response = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { + username: DEFAULT_USERNAME, + password: DEFAULT_PASSWORD, + action: 'get_vod_streams', + }, + }); + const movie = (await response.json())[0]; + let replaced = false; + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + const action = original.searchParams.get('action'); + if (action !== 'get_vod_streams' && action !== 'get_vod_info') { + await route.fallback(); + return; + } + const url = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') url.searchParams.set(key, value); + }); + const response = await route.fetch({ url: url.toString() }); + const body = await response.json(); + if (action === 'get_vod_streams') { + for (const item of body.payload) { + delete item.tmdb_id; + delete item.release_year; + if (replaced && item.stream_id === movie.stream_id) + Object.assign(item, { + name: 'Reused provider entry', + tmdb_id: 999, + release_year: 2026, + }); + } + } else { + Object.assign(body.payload.info, { + tmdb_id: 949, + releasedate: '1995-12-15', + o_name: 'Original detail identity', + }); + } + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Detail Identity Portal' }); + const rawId = new URL(page.url()).pathname + .replace(/\/vod.*$/, '') + .split('/') + .pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const playlistId = decodeURIComponent(rawId); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await page.getByText(movie.name, { exact: true }).first().click(); + await page + .getByRole('button', { name: 'Add to favorites', exact: true }) + .first() + .click(); + await expect + .poll(() => + page.evaluate( + ({ playlistId, key }) => + JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[playlistId]?.[key]?.tmdb_id, + { playlistId, key: `movie:${movie.stream_id}` } + ) + ) + .toBe(949); + replaced = true; + await page.goto(`/workspace/xtreams/${rawId}/vod`); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await expect( + page.getByText('Reused provider entry', { exact: true }).first() + ).toBeVisible(); + await page + .getByRole('link', { name: 'Global favorites', exact: true }) + .click(); + const originalCard = page.getByRole('button', { + name: movie.name, + exact: true, + }); + await expect(originalCard).toBeVisible(); + await expect(originalCard).toHaveAttribute('aria-disabled', 'true'); + await page.goto('/workspace/settings/backup'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const path = await (await download).path(); + if (!path) throw new Error('Backup download is missing'); + const entry = JSON.parse(await readFile(path, 'utf8')).playlists.find( + (entry: { exportedId: string }) => entry.exportedId === playlistId + ); + expect(entry.userState.favorites[0].snapshot).toMatchObject({ + title: movie.name, + tmdbId: 949, + releaseYear: 1995, + }); +}); + +test('@xtream @collections a cold Recent heart cannot remove a different saved favorite', async ({ + page, +}) => { + await addXtreamPortal(page, { name: 'Cold Recent Portal' }); + const rawId = new URL(page.url()).pathname + .replace(/\/vod.*$/, '') + .split('/') + .pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const playlistId = decodeURIComponent(rawId); + await page.evaluate((id) => { + const original = { + id: 42, + xtream_id: 42, + type: 'live', + title: 'Original saved channel', + poster_url: '', + category_id: '1', + tmdb_id: 100, + }; + const replacement = { + ...original, + title: 'Viewed replacement channel', + tmdb_id: 200, + }; + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ [id]: ['live:42'] }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ [id]: { 'live:42': original } }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + [id]: [ + { + id: 'live:42', + viewedAt: '2026-10-01T12:00:00.000Z', + collectionItem: replacement, + }, + ], + }) + ); + }, playlistId); + await page.goto('/workspace/global-recent'); + await page.reload(); + const recent = page + .locator('app-channel-list-item') + .filter({ hasText: 'Viewed replacement channel' }); + await expect(recent).toBeVisible(); + await recent.hover(); + const heart = recent.locator('.favorite-button'); + await expect(heart).toHaveAttribute('aria-pressed', 'false'); + await heart.click(); + await expect( + page.getByText( + 'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.', + { exact: true } + ) + ).toBeVisible(); + await expect(heart).toHaveAttribute('aria-pressed', 'false'); + expect( + await page.evaluate( + (id) => + JSON.parse(localStorage.getItem('xtream-favorites') ?? '{}')[ + id + ], + playlistId + ) + ).toEqual(['live:42']); + await page.goto('/workspace/global-favorites'); + const original = page + .locator('app-channel-list-item') + .filter({ hasText: 'Original saved channel' }); + await expect(original).toBeVisible(); + await original.hover(); + await original.locator('.favorite-button').click(); + await expect(original).toHaveCount(0); + await page.goto('/workspace/global-recent'); + await page.reload(); + await expect(recent).toBeVisible(); + await recent.hover(); + await heart.click(); + await expect(heart).toHaveAttribute('aria-pressed', 'true'); + await page.goto('/workspace/global-favorites'); + await expect( + page + .locator('app-channel-list-item') + .filter({ hasText: 'Viewed replacement channel' }) + ).toBeVisible(); + await expect(original).toHaveCount(0); +}); + +for (const collection of ['Recent', 'Favorites'] as const) { + test(`@xtream @collections cold ${collection} details retain sparse identity for an explicit favorite save`, async ({ + page, + request, + }) => { + const response = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { + username: DEFAULT_USERNAME, + password: DEFAULT_PASSWORD, + action: 'get_vod_streams', + }, + }); + const movie = (await response.json())[0]; + let cold = false; + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + const action = original.searchParams.get('action'); + if (cold && action === 'get_vod_streams') { + await route.abort(); + return; + } + if (action !== 'get_vod_info') { + await route.fallback(); + return; + } + const url = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') url.searchParams.set(key, value); + }); + const response = await route.fetch({ url: url.toString() }); + const body = await response.json(); + Object.assign(body.payload.info, { + tmdb_id: 949, + releasedate: '1995-12-15', + }); + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Cold Detail Metadata Portal' }); + const rawId = new URL(page.url()).pathname + .replace(/\/vod.*$/, '') + .split('/') + .pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const playlistId = decodeURIComponent(rawId); + await page.evaluate( + ({ id, movie, collection }) => { + if (collection === 'Favorites') { + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ [id]: [`movie:${movie.stream_id}`] }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + [id]: { + [`movie:${movie.stream_id}`]: { + id: movie.stream_id, + xtream_id: movie.stream_id, + type: 'movie', + title: movie.name, + poster_url: movie.stream_icon, + category_id: movie.category_id, + }, + }, + }) + ); + localStorage.removeItem('xtream-recent-items'); + return; + } + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + [id]: [ + { + id: `movie:${movie.stream_id}`, + viewedAt: '2026-10-01T12:00:00.000Z', + collectionItem: { + id: movie.stream_id, + xtream_id: movie.stream_id, + type: 'movie', + title: movie.name, + poster_url: movie.stream_icon, + category_id: movie.category_id, + }, + }, + ], + }) + ); + }, + { id: playlistId, movie, collection } + ); + cold = true; + await page.goto( + collection === 'Recent' + ? '/workspace/global-recent' + : '/workspace/global-favorites' + ); + await page.reload(); + await page + .locator('app-content-card') + .filter({ hasText: movie.name }) + .first() + .click(); + if (collection === 'Favorites') { + const remove = page.getByRole('button', { + name: 'Remove from favorites', + exact: true, + }); + await expect(remove).toBeVisible(); + await remove.click(); + await expect(remove).not.toBeVisible(); + } + const heart = page.getByRole('button', { + name: 'Add to favorites', + exact: true, + }); + await expect(heart).toBeVisible(); + await heart.click(); + await expect( + page.getByRole('button', { + name: 'Remove from favorites', + exact: true, + }) + ).toBeVisible(); + await page.reload(); + const saved = await page.evaluate( + (id) => ({ + favorites: JSON.parse( + localStorage.getItem('xtream-collection-items') ?? '{}' + )[id], + recent: JSON.parse( + localStorage.getItem('xtream-recent-items') ?? '{}' + )[id]?.[0], + }), + playlistId + ); + expect(saved.favorites[`movie:${movie.stream_id}`]).toMatchObject({ + title: movie.name, + tmdb_id: 949, + release_year: 1995, + }); + if (collection === 'Recent') { + expect(saved.recent.viewedAt).toBe('2026-10-01T12:00:00.000Z'); + expect(saved.recent.collectionItem).toMatchObject({ + tmdb_id: 949, + release_year: 1995, + }); + } else { + expect(saved.recent).toBeUndefined(); + await expect( + page.getByRole('button', { + name: 'Remove from favorites', + exact: true, + }) + ).toBeVisible(); + await page + .getByRole('button', { name: 'Back', exact: true }) + .click(); + await expect( + page + .locator('app-content-card') + .filter({ hasText: movie.name }) + .first() + ).toBeVisible(); + } + }); +} + +test('@xtream @collections sparse favorite playback uses the current category while retaining saved metadata', async ({ + page, + request, +}) => { + await routeMovieClip(page); + await selectWebPlayer(page, 'HTML5 video player'); + const response = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { + username: DEFAULT_USERNAME, + password: DEFAULT_PASSWORD, + action: 'get_vod_streams', + }, + }); + const movie = (await response.json())[0]; + let detailOpened = false; + const recoveryCategories: string[] = []; + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + const action = original.searchParams.get('action'); + if (action !== 'get_vod_streams' && action !== 'get_vod_info') + return route.fallback(); + const url = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') url.searchParams.set(key, value); + }); + const response = await route.fetch({ url: url.toString() }); + const body = await response.json(); + if (action === 'get_vod_info') { + detailOpened = true; + delete body.payload.movie_data.stream_id; + delete body.payload.movie_data.container_extension; + Object.assign(body.payload.info, { + tmdb_id: 949, + releasedate: '1995-12-15', + }); + } else { + const category = original.searchParams.get('category_id'); + if (detailOpened && category) recoveryCategories.push(category); + for (const item of body.payload) { + delete item.tmdb_id; + delete item.release_year; + if (detailOpened && category === String(movie.category_id)) + item.container_extension = 'mp4'; + else delete item.container_extension; + } + } + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Sparse Category Portal' }); + const rawId = new URL(page.url()).pathname + .replace(/\/vod.*$/, '') + .split('/') + .pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const playlistId = decodeURIComponent(rawId); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await expect( + page.getByText(movie.name, { exact: true }).first() + ).toBeVisible(); + await page.evaluate( + ({ id, movie }) => { + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ [id]: [`movie:${movie.stream_id}`] }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + [id]: { + [`movie:${movie.stream_id}`]: { + id: movie.stream_id, + xtream_id: movie.stream_id, + type: 'movie', + title: 'Saved sparse favorite', + poster_url: movie.stream_icon, + category_id: '999999', + tmdb_id: 949, + release_year: 1995, + }, + }, + }) + ); + }, + { id: playlistId, movie } + ); + await page + .getByRole('link', { name: 'Global favorites', exact: true }) + .click(); + await page + .locator('app-content-card') + .filter({ hasText: 'Saved sparse favorite' }) + .click(); + await expect + .poll(() => recoveryCategories) + .toContain(String(movie.category_id)); + await page + .getByRole('button', { name: /^Play / }) + .first() + .click(); + await expect + .poll(() => + page + .locator('video') + .first() + .evaluate((video: HTMLVideoElement) => video.currentTime) + ) + .toBeGreaterThan(0); +}); + +test('@xtream @collections favoriting an unwatched replacement preserves shared Recent metadata and backup', async ({ + page, + request, +}) => { + const response = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { + username: DEFAULT_USERNAME, + password: DEFAULT_PASSWORD, + action: 'get_live_streams', + }, + }); + const channel = (await response.json())[0]; + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + if (original.searchParams.get('action') !== 'get_live_streams') + return route.fallback(); + const url = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') url.searchParams.set(key, value); + }); + const response = await route.fetch({ url: url.toString() }); + const body = await response.json(); + for (const item of body.payload) + if (item.stream_id === channel.stream_id) { + item.name = 'Unwatched replacement channel'; + item.tmdb_id = 200; + } + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Unwatched Replacement Portal' }); + const playlistPath = new URL(page.url()).pathname.replace(/\/vod.*$/, ''); + const rawId = playlistPath.split('/').pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const playlistId = decodeURIComponent(rawId); + await page.evaluate( + ({ id, channel }) => { + const key = `live:${channel.stream_id}`; + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ [id]: [key] }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ + [id]: { + [key]: { + id: channel.stream_id, + xtream_id: channel.stream_id, + type: 'live', + title: 'Watched original channel', + poster_url: '', + category_id: channel.category_id, + tmdb_id: 100, + }, + }, + }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + [id]: [{ id: key, viewedAt: '2026-10-01T12:00:00.000Z' }], + }) + ); + }, + { id: playlistId, channel } + ); + await page.goto('/workspace/global-favorites'); + const original = page + .locator('app-channel-list-item') + .filter({ hasText: 'Watched original channel' }); + await expect(original).toBeVisible(); + await original.hover(); + await original.locator('.favorite-button').click(); + await expect(original).toHaveCount(0); + await page.goto(`${playlistPath}/live`); + await page + .locator(`[data-category-id="${channel.category_id}"]`) + .first() + .click(); + const replacement = page + .locator('app-channel-list-item') + .filter({ hasText: 'Unwatched replacement channel' }); + await expect(replacement).toBeVisible(); + await replacement.hover(); + await replacement.locator('.favorite-button').click(); + await expect(replacement.locator('.favorite-button')).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await page.goto('/workspace/global-recent'); + await page.reload(); + await expect(original).toBeVisible(); + await expect(replacement).toHaveCount(0); + await page.goto('/workspace/settings/backup'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const path = await (await download).path(); + if (!path) throw new Error('Missing backup'); + const backup = JSON.parse(await readFile(path, 'utf8')); + const state = backup.playlists.find( + (entry: { exportedId: string }) => entry.exportedId === playlistId + ).userState; + expect(state.favorites[0].snapshot).toMatchObject({ + title: 'Unwatched replacement channel', + tmdbId: 200, + }); + expect(state.recentlyViewed[0]).toMatchObject({ + viewedAt: '2026-10-01T12:00:00.000Z', + collectionItem: { title: 'Watched original channel', tmdb_id: 100 }, + }); +}); + +test('@xtream @collections detail enrichment retains both category listings of one provider ID', async ({ + page, + request, +}) => { + let conflictingDuplicate = false; + const params = { username: DEFAULT_USERNAME, password: DEFAULT_PASSWORD }; + const movies = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { ...params, action: 'get_vod_streams' }, + }); + const movie = (await movies.json())[0]; + const categories = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { ...params, action: 'get_vod_categories' }, + }); + const second = (await categories.json()).find( + (category: { category_id: string }) => + String(category.category_id) !== String(movie.category_id) + ); + if (!second) throw new Error('Missing second mock category'); + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + const action = original.searchParams.get('action'); + if (action !== 'get_vod_streams' && action !== 'get_vod_info') + return route.fallback(); + const url = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') url.searchParams.set(key, value); + }); + const response = await route.fetch({ url: url.toString() }); + const body = await response.json(); + if (action === 'get_vod_info') { + Object.assign(body.payload.info, { + tmdb_id: 949, + releasedate: '1995-12-15', + }); + } else { + for (const item of body.payload) + if (item.stream_id === movie.stream_id) item.tmdb_id = 949; + const category = original.searchParams.get('category_id'); + if (!category || category === String(second.category_id)) + body.payload.push({ + ...movie, + name: 'Duplicate category listing', + category_id: second.category_id, + tmdb_id: conflictingDuplicate ? 999 : 949, + }); + } + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Duplicate Categories Portal' }); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await page.getByText(movie.name, { exact: true }).first().click(); + await page + .getByRole('button', { name: 'Add to favorites', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Remove from favorites', exact: true }) + ).toBeVisible(); + await page.getByRole('link', { name: 'Movies', exact: true }).click(); + await page + .locator(`[data-category-id="${movie.category_id}"]`) + .first() + .click(); + await expect( + page.getByText(movie.name, { exact: true }).first() + ).toBeVisible(); + await page + .locator(`[data-category-id="${second.category_id}"]`) + .first() + .click(); + await expect( + page.getByText('Duplicate category listing', { exact: true }) + ).toBeVisible(); + const savedTitle = await page.evaluate((id) => { + const snapshots: Record< + string, + Record + > = JSON.parse(localStorage.getItem('xtream-collection-items') ?? '{}'); + return Object.values(snapshots) + .map( + (items: Record) => + items[`movie:${id}`]?.title + ) + .find(Boolean); + }, movie.stream_id); + if (!savedTitle) throw new Error('Missing saved favorite'); + conflictingDuplicate = true; + await page.reload(); + await expect( + page.getByText('Duplicate category listing', { exact: true }) + ).toBeVisible(); + await page + .getByRole('link', { name: 'Global favorites', exact: true }) + .click(); + const card = page.getByRole('button', { name: savedTitle, exact: true }); + await expect(card).not.toHaveAttribute('aria-disabled', 'true'); + await card.click(); + await expect(page.locator('app-content-hero')).toBeVisible(); + const remove = page.getByRole('button', { + name: 'Remove from favorites', + exact: true, + }); + await expect(remove).toBeVisible(); + await remove.click(); + await page + .getByRole('button', { name: 'Add to favorites', exact: true }) + .click(); + await expect(remove).toBeVisible(); + // The hero can render before the detail response rewrite finishes. + await page.unrouteAll({ behavior: 'wait' }); +}); + +test('@xtream @collections cold replacement playback records its own Recent identity', async ({ + page, + request, +}) => { + test.setTimeout(60_000); + await routeMovieClip(page); + await selectWebPlayer(page, 'HTML5 video player'); + const response = await request.get(`${MOCK_SERVER}/player_api.php`, { + params: { + username: DEFAULT_USERNAME, + password: DEFAULT_PASSWORD, + action: 'get_vod_streams', + }, + }); + const movie = (await response.json())[0]; + let cold = false; + await page.route('**/localhost:3000/xtream**', async (route) => { + const original = new URL(route.request().url()); + const action = original.searchParams.get('action'); + if (cold && action === 'get_vod_streams') return route.abort(); + if (action !== 'get_vod_info') return route.fallback(); + const url = new URL(`${MOCK_SERVER}/xtream`); + original.searchParams.forEach((value, key) => { + if (key !== 'targetId') url.searchParams.set(key, value); + }); + const response = await route.fetch({ url: url.toString() }); + const body = await response.json(); + Object.assign(body.payload.info, { + tmdb_id: 200, + releasedate: '2026-01-01', + name: movie.name, + }); + body.payload.movie_data.container_extension = 'mp4'; + await route.fulfill({ response, json: body }); + }); + await addXtreamPortal(page, { name: 'Cold Played Identity Portal' }); + const rawId = new URL(page.url()).pathname + .replace(/\/vod.*$/, '') + .split('/') + .pop(); + if (!rawId) throw new Error('Missing playlist ID'); + const playlistId = decodeURIComponent(rawId); + await page.evaluate( + ({ id, movie }) => { + const key = `movie:${movie.stream_id}`; + const item = { + id: movie.stream_id, + xtream_id: movie.stream_id, + type: 'movie', + title: movie.name, + poster_url: movie.stream_icon, + category_id: movie.category_id, + tmdb_id: 200, + }; + localStorage.setItem( + 'xtream-favorites', + JSON.stringify({ [id]: [key] }) + ); + localStorage.setItem( + 'xtream-collection-items', + JSON.stringify({ [id]: { [key]: item } }) + ); + localStorage.setItem( + 'xtream-recent-items', + JSON.stringify({ + [id]: [ + { + id: key, + viewedAt: '2026-10-01T12:00:00.000Z', + collectionItem: { + ...item, + title: 'Previously watched original', + tmdb_id: 100, + }, + }, + ], + }) + ); + }, + { id: playlistId, movie } + ); + cold = true; + await page.goto('/workspace/global-favorites'); + await page.reload(); + await page + .locator('app-content-card') + .filter({ hasText: movie.name }) + .first() + .click(); + await page.getByRole('button', { name: /^Play / }).click(); + await expect + .poll(() => + page.evaluate( + (id) => + JSON.parse( + localStorage.getItem('xtream-recent-items') ?? '{}' + )[id]?.[0]?.collectionItem?.tmdb_id, + playlistId + ) + ) + .toBe(200); + await page.goto('/workspace/global-recent'); + await page.reload(); + await expect( + page.locator('app-content-card').filter({ hasText: movie.name }).first() + ).toBeVisible(); + await expect( + page.getByText('Previously watched original', { exact: true }) + ).toHaveCount(0); + await page.goto('/workspace/settings/backup'); + const download = page.waitForEvent('download'); + await page.getByRole('button', { name: 'Export', exact: true }).click(); + const path = await (await download).path(); + if (!path) throw new Error('Missing backup'); + const backup = JSON.parse(await readFile(path, 'utf8')); + const state = backup.playlists.find( + (entry: { exportedId: string }) => entry.exportedId === playlistId + ).userState; + expect(state.favorites[0].snapshot).toMatchObject({ + title: movie.name, + tmdbId: 200, + }); + expect(state.recentlyViewed[0].collectionItem).toMatchObject({ + title: movie.name, + tmdb_id: 200, + }); + expect(state.recentlyViewed[0].viewedAt).not.toBe( + '2026-10-01T12:00:00.000Z' + ); + await page.unrouteAll({ behavior: 'wait' }); +}); diff --git a/apps/web-e2e/src/xtream-series-playback.fixture.ts b/apps/web-e2e/src/xtream-series-playback.fixture.ts index aea6bc932..d89e315e5 100644 --- a/apps/web-e2e/src/xtream-series-playback.fixture.ts +++ b/apps/web-e2e/src/xtream-series-playback.fixture.ts @@ -94,12 +94,25 @@ export async function addXtreamPortal( * mock itself (the mock ignores the url parameter). */ export async function routeEpisodeClip(page: Page): Promise { + await routePlaybackClip(page, 'series'); +} + +/** A local movie clip keeps history tests independent of the external HLS stub. */ +export async function routeMovieClip(page: Page): Promise { + await routePlaybackClip(page, 'movie'); +} + +async function routePlaybackClip( + page: Page, + contentType: 'series' | 'movie' +): Promise { const episodeClip = readFileSync( join(__dirname, 'fixtures/playback/episode.webm') ); await page.route( (url) => - url.origin === MOCK_SERVER && url.pathname.startsWith('/series/'), + url.origin === MOCK_SERVER && + url.pathname.startsWith(`/${contentType}/`), async (route) => { // Chromium's media pipeline seeks through byte ranges; a plain // 200 to a Range request clamps every seek to the start, which diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index f5e4f69d9..3342d94ef 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1321,6 +1321,8 @@ "REMOVED_FROM_FAVORITES": "تمت الإزالة من المفضلة", "ADDED_TO_FAVORITES": "تمت الإضافة للمفضلة", "FAVORITES": "المفضلة", + "FAVORITE_IDENTITY_CONFLICT": "استبدل هذا المصدر عنصراً حفظته. احذف الإدخال القديم من المفضلة قبل إضافة هذا العنصر.", + "FAVORITE_SAVE_FAILED": "تعذر حفظ هذا العنصر في المفضلة. يرجى المحاولة مرة أخرى.", "RECENTLY_VIEWED": "تمت مشاهدتها مؤخرًا", "THIS_PLAYLIST": "قائمة التشغيل هذه", "ALL_PLAYLISTS": "جميع قوائم التشغيل", @@ -2050,6 +2052,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 c517dd02f..9d077805f 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1321,6 +1321,8 @@ "REMOVED_FROM_FAVORITES": "تحيد من المفضلة", "ADDED_TO_FAVORITES": "تزاد للمفضلة", "FAVORITES": "المفضلة", + "FAVORITE_IDENTITY_CONFLICT": "هاد المصدر بدّل واحد العنصر اللي حفظتي. حيد القديم من المفضلة قبل ما تزيد هادا.", + "FAVORITE_SAVE_FAILED": "ما قدرناش نحفظو هاد العنصر فالمفضلة. عاود حاول.", "RECENTLY_VIEWED": "متشافين مؤخراً", "THIS_PLAYLIST": "هاد قائمة التشغيل", "ALL_PLAYLISTS": "جميع قوائم التشغيل", @@ -2050,6 +2052,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 fa25f8fc0..ca3ebe475 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1321,6 +1321,8 @@ "REMOVED_FROM_FAVORITES": "Выдалена з выбранага", "ADDED_TO_FAVORITES": "Дададзена ў выбранае", "FAVORITES": "Выбранае", + "FAVORITE_IDENTITY_CONFLICT": "Крыніца замяніла захаваны вамі элемент. Выдаліце стары запіс з абранага, перш чым дадаць новы.", + "FAVORITE_SAVE_FAILED": "Не ўдалося захаваць абранае. Паспрабуйце яшчэ раз.", "RECENTLY_VIEWED": "Нядаўна праглянутае", "THIS_PLAYLIST": "Гэты плэйліст", "ALL_PLAYLISTS": "Усе плэйлісты", @@ -2050,6 +2052,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 5640bc7be..6f58f5e68 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 6c825cb04..8061bba98 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1321,6 +1321,8 @@ "REMOVED_FROM_FAVORITES": "Αφαιρέθηκε από τα αγαπημένα", "ADDED_TO_FAVORITES": "Προστέθηκε στα αγαπημένα", "FAVORITES": "Αγαπημένα", + "FAVORITE_IDENTITY_CONFLICT": "Αυτή η πηγή αντικατέστησε ένα αποθηκευμένο στοιχείο. Αφαιρέστε την παλιά καταχώριση από τα αγαπημένα πριν προσθέσετε τη νέα.", + "FAVORITE_SAVE_FAILED": "Δεν ήταν δυνατή η αποθήκευση του αγαπημένου. Δοκιμάστε ξανά.", "RECENTLY_VIEWED": "Πρόσφατα προβληθέντα", "THIS_PLAYLIST": "Αυτή η λίστα αναπαραγωγής", "ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής", @@ -2050,6 +2052,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 a5bc388db..248094bb5 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 69a217a2e..a8951419a 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 2d3628040..252958b6f 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 01195a3ed..1fcf8e01d 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 3d2fab021..12797c11f 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 fab37b338..5fe13c065 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1321,6 +1321,8 @@ "REMOVED_FROM_FAVORITES": "お気に入りから削除されました", "ADDED_TO_FAVORITES": "お気に入りに追加されました", "FAVORITES": "お気に入り", + "FAVORITE_IDENTITY_CONFLICT": "この配信元は保存済みの項目を別の項目に置き換えました。新しい項目を追加する前に、お気に入りから古い項目を削除してください。", + "FAVORITE_SAVE_FAILED": "お気に入りを保存できませんでした。もう一度お試しください。", "RECENTLY_VIEWED": "最近表示した項目", "THIS_PLAYLIST": "このプレイリスト", "ALL_PLAYLISTS": "すべてのプレイリスト", @@ -2050,6 +2052,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 fc7203a7d..6041a5d99 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1321,6 +1321,8 @@ "REMOVED_FROM_FAVORITES": "즐겨찾기에서 삭제됨", "ADDED_TO_FAVORITES": "즐겨찾기에 추가됨", "FAVORITES": "즐겨찾기", + "FAVORITE_IDENTITY_CONFLICT": "이 소스가 저장된 항목을 교체했습니다. 새 항목을 추가하려면 먼저 즐겨찾기에서 이전 항목을 삭제하세요.", + "FAVORITE_SAVE_FAILED": "즐겨찾기를 저장하지 못했습니다. 다시 시도해 주세요.", "RECENTLY_VIEWED": "최근 본 항목", "THIS_PLAYLIST": "이 재생목록", "ALL_PLAYLISTS": "모든 재생목록", @@ -2050,6 +2052,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 ef9328862..50c2b53fb 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 9722c6b7b..d142fc965 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 e02747f2e..3f6ac84ae 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 c10524532..c7ed8bdf3 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1321,6 +1321,8 @@ "REMOVED_FROM_FAVORITES": "Удалено из избранного", "ADDED_TO_FAVORITES": "Добавлено в избранное", "FAVORITES": "Избранное", + "FAVORITE_IDENTITY_CONFLICT": "Источник заменил сохранённый вами элемент. Удалите старую запись из избранного, прежде чем добавить новую.", + "FAVORITE_SAVE_FAILED": "Не удалось сохранить избранное. Попробуйте ещё раз.", "RECENTLY_VIEWED": "Недавно просмотренные", "THIS_PLAYLIST": "Этот плейлист", "ALL_PLAYLISTS": "Все плейлисты", @@ -2050,6 +2052,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 a320e6986..b6d0ad4dd 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1321,6 +1321,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", @@ -2050,6 +2052,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 60342d357..8765f04fe 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1321,6 +1321,8 @@ "REMOVED_FROM_FAVORITES": "已从收藏中移除", "ADDED_TO_FAVORITES": "已添加至收藏夹", "FAVORITES": "收藏", + "FAVORITE_IDENTITY_CONFLICT": "此来源替换了您保存的项目。请先从收藏夹中移除旧项目,再添加此项目。", + "FAVORITE_SAVE_FAILED": "无法保存此收藏。请重试。", "RECENTLY_VIEWED": "最近观看", "THIS_PLAYLIST": "本播放列表", "ALL_PLAYLISTS": "所有播放列表", @@ -2050,6 +2052,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 987f5d256..0eb292590 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1321,6 +1321,8 @@ "REMOVED_FROM_FAVORITES": "已從最愛中移除", "ADDED_TO_FAVORITES": "已新增至最愛", "FAVORITES": "我的最愛", + "FAVORITE_IDENTITY_CONFLICT": "此來源替換了您儲存的項目。請先從收藏中移除舊項目,再新增此項目。", + "FAVORITE_SAVE_FAILED": "無法儲存此收藏。請重試。", "RECENTLY_VIEWED": "最近觀看", "THIS_PLAYLIST": "此播放清單", "ALL_PLAYLISTS": "所有播放清單", @@ -2050,6 +2052,8 @@ } }, "GLOBAL_FAVORITES": { + "CATALOG_MISSING": "目前目錄中不存在", + "CATALOG_UNKNOWN": "目錄狀態未知", "TITLE": "全域我的最愛", "SUBTITLE": "來自所有播放清單的直播電視", "SEARCH_PLACEHOLDER": "搜尋我的最愛...", diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 76fd29c19..924ccf8e2 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -254,6 +254,18 @@ context panel use, out of that barrel for the same reason, and 22 KB, used only by the lazy settings page) out of the `@iptvnator/services` barrel. +`@iptvnator/services/pwa-xtream-backup-storage` exposes the framework-free browser +backup persistence implementation without re-exporting it from the eager services +barrel. The injected backup service wraps it; deferred restore loads it through a +local dynamic-import module with the data source's storage. Its map keys live in +a separate pure module so it does not import the Angular storage token at runtime. +Electron favorites likewise load their +legacy pending-conversion helper only when a favorites operation needs it. +Those lazy helpers value-import the pure normalizer through +`@iptvnator/shared/interfaces/xtream-restore-state`; their remaining contracts +use type-only imports so a deferred conversion does not load the interfaces +barrel's Angular/IndexedDB dependencies. + The web app's root shell (`app.component.ts`, `app.config.ts` and the services they construct) is on the renderer's initial path, where a barrel costs its whole library: esbuild keeps every Angular component module a barrel diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 6a18a5cc5..773c629d7 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -94,7 +94,9 @@ handler as the header Back. so the correct detail host still opens without switching playlist context. - Dashboard `Global Favorites` and `Recently Watched` widgets hand off Xtream and Stalker movies/series into the matching global collection route with detail - pre-opened. + pre-opened. Unavailable favorites instead open the all-playlists collection + filtered to that content type, with no detail handoff, so an active different + playlist cannot hide the saved entry. - The dashboard hero CTA and the Continue Watching cards' explicit "Resume episode" ⋮ action for Xtream and Stalker series carry a one-shot season/episode resume target. The collection-owned detail consumes it after @@ -333,6 +335,10 @@ Dashboard behavior to preserve: Xtream movie/series items into `/workspace/global-favorites` or `/workspace/global-recent` with collection detail pre-opened from navigation state. +- Global detail handoffs carry `All playlists` scope in collection view state. + The active playlist stays unchanged, but availability is checked against + the target favorite's current row even when it belongs to another source. + Missing or removed favorites still cannot reopen through stale history. - When a series recent (Xtream, or a Stalker row that `resolvePortalActivityWatchKind` answers `series` for) has a saved episode position, the dashboard hero and Continue Watching card should include that diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 186ba0e20..cbc6786bb 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -279,6 +279,9 @@ Implemented behavior: - Channel up/down and number select navigate `visibleChannels()` — the search-filtered, mode-conditionally sorted list derived by the same `deriveVisibleFavoriteChannels` helper the rendered sidebar uses +- Up/down skips favorites marked missing or unknown. Number select and status + retain the full visible list's numbering; selecting an unavailable row by + number does nothing. - Remote actions play via the same path as an explicit double-click (`activateItem(item, false, true)`), so external MPV/VLC starts immediately diff --git a/docs/architecture/sqlite-db-worker.md b/docs/architecture/sqlite-db-worker.md index e0d213da2..8df4034dd 100644 --- a/docs/architecture/sqlite-db-worker.md +++ b/docs/architecture/sqlite-db-worker.md @@ -656,6 +656,34 @@ 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 persist known provider-ID conflicts in `identity_conflict`; a later +sparse refresh cannot clear them. Full saved TMDB/year confirmation may clear the flag, +while absence alone does not set it. The flag is migrated on existing durable tables +and included as identity-conflict evidence in backup snapshots. +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. `DB_GET_CONTENT_BY_XTREAM_ID` accepts optional +normalized TMDB/year evidence from the displayed detail or played item. It excludes +known conflicting duplicates, ranks full confirmation first, and preserves legacy +lookup behavior when evidence is omitted. Favorite status/actions use the selected +internal row ID consistently. 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/tmdb-metadata-enrichment.md b/docs/architecture/tmdb-metadata-enrichment.md index 3b2af24bd..671f129c3 100644 --- a/docs/architecture/tmdb-metadata-enrichment.md +++ b/docs/architecture/tmdb-metadata-enrichment.md @@ -850,11 +850,13 @@ Contracts worth keeping: never been opened, and a provider's `"0"` all collapse to the same thing: no identity, and the title-only fallback. -Not covered: rows whose detail page has never been opened, playlists -refreshed after this landed (the columns are re-learned on the next detail -open, same as `backdrop_url`), and the PWA — its catalog is a session-scoped -cache rebuilt from the API on every load, so a stored id would never outlive -the detail view that resolved it. All of them keep the title-only path. +Rows whose detail page has never been opened keep the title-only fallback. +Refreshed catalog rows re-learn missing metadata on the next detail open. +The PWA also fills normalized identity on its session catalog and compatible +persisted collection snapshots, including identity-only patches. Favorites retain +that evidence across reload/refresh to detect reused provider IDs. Independent +Recent snapshots are enriched separately; a conflicting replacement never supplies +metadata to the older favorite. See [PWA collection persistence](xtream-portal-compatibility.md#pwa-collection-persistence). ### Stalker backdrops on activity rows diff --git a/docs/architecture/xtream-portal-compatibility.md b/docs/architecture/xtream-portal-compatibility.md index 8ce367a26..ccba3534c 100644 --- a/docs/architecture/xtream-portal-compatibility.md +++ b/docs/architecture/xtream-portal-compatibility.md @@ -37,6 +37,257 @@ 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. Display snapshots are replaced only after +the returning row confirms every saved TMDB ID/year; incomplete identity keeps +the saved title/artwork in collections and backups. Restore applies the same +confirmation guard before filling gaps in saved metadata from catalog rows. +When detail enrichment confirms the remaining identity fields, it immediately +copies the returning title, artwork and metadata into the saved snapshot without +waiting for another import. Blank catalog fields retain their saved values. +Conflicting known TMDB IDs or release years prevent automatic reconnection; +changed provider IDs never reconnect by title. Electron persists known conflicts in +`favorites.identity_conflict`, separately from catalog absence. Completed imports, +detail enrichment and destructive cache refresh reconcile this evidence; only a +candidate confirming every saved TMDB/year clears it. Sparse returns remain compatible +when no conflict was observed. Backup snapshots carry known conflicts across runtimes, +and explicit favorite removal/re-add starts independent ownership. +Adding a conflicting replacement returns an explicit failure and retains the +older favorite. Detail and recent-collection toggles stay inactive and explain +that the older entry must be removed from Favorites before the replacement can +be saved. Collection hearts change only after the storage write succeeds. +Recent collections also check the current catalog row's favorite status through +the data source when loading: a retained favorite's matching provider key alone +cannot mark an incompatible replacement as 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, including Continue Watching and the most-recent fallback. +Unavailable live candidates are excluded before EPG lookup, candidate limits and +slot reservation; Recent rail projections and EPG cards retain availability and cannot +borrow cached programmes. Unavailable series are excluded before continuation candidate +selection, so watched/season-zero positions cannot schedule replacement episode requests; +history and saved positions remain intact. 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. For duplicate category copies, ordinary +reads choose an unlocked compatible row before resolving the catalog link; a +locked earlier copy cannot hide a later unlocked copy. Detail favorite status, mutations and Recent recording carry the displayed normalized +TMDB/year through the worker lookup. That lookup excludes known conflicts and ranks +full confirmation before sparse compatible duplicates; omitted identity keeps legacy +lookup behavior. Async detail status ownership includes identity as well as typed provider +ID, so a late toggle cannot update a different displayed replacement. Fully confirming duplicates +rank before sparse compatible rows, with the catalog row ID breaking ties, so a +confirmed return can refresh saved artwork in reads, completed imports and exports. Backup reads remain +unfiltered. + +Legacy pending favorites are converted before destructive refresh, import +requests and ordinary collection reads, even if portal-status preflight is +offline. Ordinary collection reads isolate failed conversions per playlist and +still read healthy database favorites, retaining pending state for retry. This +also applies when localStorage access or key enumeration fails. Backup reads +remain strict and fail if storage access or conversion fails. 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. +In server-storage mode the same shared import/replay boundary waits for a fresh +user-data read and earlier writes, then confirms the restored lists and required +snapshots with the server before consuming pending state. Restore receipts remain +client metadata in the atomic cache blob, never server playlist entries. Hydration +preserves them and shrinks favorite ownership on remote removals. Local write completion is separate from server confirmation: an unconfirmed receipt +retains its replacement target until the server accepts it. Retry and pending export use +that target, rather than a conflict-merged list. Explicit favorite/history/position edits +update the target atomically with their list and receipt, preserving removals and re-adds. +Only confirmed receipts may shrink ownership from later remote removals. Receipt-bearing +writes reject browser quota failures rather than acknowledge only in memory; ordinary +receipt-free server caches retain their existing memory fallback. + +Browser imports convert desktop favorite snapshots into typed collection-cache +rows so saved titles and artwork remain visible offline. Browser exports include +the normalized nested favorite snapshots as well as their optional collection +cache, preserving metadata when the archive returns to Electron. +The backup boundary normalizes numeric-string TMDB IDs and years from older +browser snapshots with the shared metadata rules, preserving identity checks +after Electron restore. Nulls remain null; unusable placeholders are omitted. +Once all three browser catalogs have loaded successfully, a saved typed item +absent from those catalogs is retained with `missing` status. Favorites and +Recent persist that status across reload and disable catalog-dependent actions; +partial or failed hydration cannot establish absence. Returning compatible +catalog evidence can make the saved item available again without changing its +favorite date/order or an independent Recent snapshot's identity. Browser snapshots +record whether missing status came from absence or a known identity conflict. +A sparse returning typed row clears proven absence while retaining the saved +presentation and identity. It cannot clear a known conflict or an older missing +status whose cause is unknown; those still require identity confirmation. Favorite-status checks and stale detail-heart +mutations use the same retained-conflict rule; explicit removal with the saved original +identity remains available from Favorites. This +evidence survives reload and browser backup/restore for Favorites and Recent. +Favorite snapshot backups include allowlisted availability evidence in pending +state too, so retrying a failed snapshot write cannot enable a known conflict. +Older browser cache evidence is promoted only for the same typed identity and +compatible saved metadata; legacy optional-cache-only imports remain optional. +Duplicate category rows are ranked against the original collection snapshot: +fully confirming rows take precedence over compatible sparse rows, which take +precedence over conflicting rows. Favorite and Recent choose independently. +Catalog hydration keeps the saved snapshot separate from current provider rows. +Only a row confirming every saved TMDB ID/year may refresh that snapshot; +missing, partially confirmed or conflicting identity retains the original title, +artwork and metadata. Identity numbers use the shared Electron metadata normalizer: numeric strings +match numbers, and empty/zero placeholders are unknown. After confirmation, +blank catalog fields retain saved values. The favorite's added date and order always belong to user state. +Favorites and Recent reads/writes share this resolution. Recent/detail backdrop +writes check the actual catalog evidence, never the resolved collection row +which already carries saved identity. Browsing, reload and re-export therefore +cannot replace an imported snapshot merely because a provider reused its ID. +A known browser identity conflict marks the retained snapshot unavailable, so +collection and dashboard detail actions cannot open the replacement under the +saved title. This survives reload and clears only when the saved identity is +confirmed again. Legacy numeric aliases are deduplicated separately, with the +canonical typed snapshot taking precedence; stored copies never count as catalog +confirmation. Recent collection and dashboard mappings carry the same conflict +status into their detail guards. Explicit removal remains available. After removal, deliberately +favoriting the current item may replace the old snapshot (including legacy key +aliases); that replacement must persist before the new reference is saved, so +storage failure cannot silently resurrect the old title. Ordinary optional +snapshot-cache writes retain their existing quota behavior. The explicit browser +favorite and Recent writers, playback-position operations and pending-state +serialization load on demand. +After loading, each operation reads fresh storage and completes its mutations +synchronously so concurrent additions are not lost; Recent snapshots and viewing +dates still share one atomic write. +Browser collection actions carry the rendered row's saved TMDB/year identity, +independent of whether the catalog has hydrated. Status, add and remove compare +that identity with the retained favorite; removal rechecks storage so a stale +Recent heart cannot delete a different favorite. Explicit removal of the original +row still works while the provider is showing a conflicting replacement. +Movie/series detail hearts carry the displayed detail identity too, and metadata +arrival refreshes their status after metadata persistence, without accepting stale +lookup results or an old detail completing after navigation. After +explicit removal, a cold Recent save uses its independently stored matching +snapshot; it persists that snapshot before the favorite reference. Missing +compatible metadata or a required snapshot-write failure rejects the save. +Cold detail enrichment updates the matching independent Recent snapshot without +treating it as catalog evidence or rewriting the retained favorite. This lets a +sparse Recent item gain its detail identity before a new favorite is saved. +For a cold Favorites-only detail, compatible enrichment stays in a bounded, +data-source-local candidate until the user explicitly removes and re-adds the +favorite. It never confirms catalog availability or mutates the retained snapshot +just by opening details. A changed stored snapshot invalidates the candidate; +re-adding persists its enriched snapshot before the favorite reference, so quota +failure leaves the item removed and retryable rather than restoring stale identity. +When a replacement favorite needs the shared snapshot, any Recent entry still +using the previous snapshot first receives its own copy, preserving the watched +identity and date. Failure to save that copy aborts the replacement. Sparse catalog +rows may refresh category routing even before confirming all saved identity fields; +protected presentation stays unchanged, and known conflicts still block actions. +Detail metadata fills missing identity and presentation fields in the current PWA +catalog row and reconciles each compatible saved snapshot separately. Identity-only +patches also persist, so an initially sparse favorite gains protection before a +later provider-ID reuse. A conflicting detail response cannot enrich another known +identity; independent Recent snapshots never backfill a retained favorite. +When a provider ID appears in multiple catalog categories, detail enrichment fills +only compatible missing metadata in each original row. The initial detail candidate +is selected against the normalized patch identity before saved snapshots are +enriched; a conflicting last duplicate cannot discard identity learned in details. It preserves each row's +category, title, poster, rating and other provider fields; conflicting rows are +left untouched rather than replaced by one resolved catalog object. +Favorite status and save guards choose raw catalog duplicates against the saved or +explicitly displayed identity as well. This selection hint cannot create catalog +evidence when no actual row exists, and an unrelated duplicate cannot veto a +matching heart action. +Playback captures the displayed title, artwork and normalized detail identity before +waiting for confirmation. That metadata travels with the captured playlist even if +navigation or catalog changes happen before the save. Shared live playback from +Favorites and Recent forwards the selected row’s metadata through the same +confirmed-playback boundary as the portal live view. New playback keeps its own +typed `collectionItem` on the Recent entry; a cold catalog never gives the older +independent Recent snapshot a new timestamp for a different played identity. Its metadata and viewing date are +saved atomically, independently of any retained favorite, and remain available +after reload, export/restore and deferred retries. Reading Recent never copies +that independent snapshot into the favorite cache. Older Recent entries still +use the shared cache; compatible optional caches keep their quota behavior. +Initial browser imports and deferred retries share `PwaXtreamBackupStorage`; +`PwaXtreamBackupStateService` supplies its injected storage for the importer. +Both write the lists first and require the nested favorite snapshots to persist +before consuming pending state. A quota or storage failure keeps the archive +parked for another attempt, including when the provider returns an empty catalog. +Each browser list write stores its restore receipt in the same JSON blob, under +`__iptvnator_xtream_restore_receipts__` outside the playlist arrays. Ordinary +list edits preserve that internal metadata, so retries skip completed list writes +and cannot undo later removals, reordering, history updates or playback progress. +A bounded per-playlist receipt remembers the imported keys still owned by the +archive. Explicit removal shrinks that set atomically, requiring no extra quota +space for a tombstone; a later re-add owns its current snapshot. Catalog hydration and +detail/cache normalization never count as an explicit replacement. Automatic Recent +reads/enrichment cannot append remote history to an unconfirmed server restore target; +they may only detach metadata for a legacy row already owned by that target. Actual +Play, removals and reordering remain explicit user edits. Remaining +metadata retries therefore preserve genuine user replacements without discarding +parked archived identity after an ordinary read. Pending exports combine current +applied lists with authoritative archived snapshots, dates and positions for +untouched imported favorites; receipts never enter the exported manifest. +When legacy Recent shares a required imported favorite snapshot, its metadata +is copied into the Recent entry with the viewing date before snapshot replay. +Removing or replacing that favorite cannot erase the history’s only metadata. +Legacy optional-cache-only imports keep their best-effort behavior. Saved +unavailable evidence can pass to an independent Recent snapshot only when both +snapshots confirm the same normalized identity in both directions. Only status +is copied; a different played identity retains its own metadata and availability. +Unavailable live Recent rows keep their individual history-removal menu action; +catalog-dependent playlist navigation, EPG mapping and details remain gated. A deliberate new +import gets a fresh ID and can intentionally apply the archive again. +Legacy browser-only `collectionItems` remain an optional cache. If a combined +write exceeds quota, restoration retries only the required favorite fields; +optional rows or extra cache fields cannot block metadata that fits. Optional cache fields enrich a required snapshot only when every saved TMDB/year +field is confirmed by the normalized cache identity; conflicting or partial +identity evidence cannot supply artwork or new identity fields. The same +fallback applies when copying a required favorite into legacy Recent. A failed +required-only write still keeps pending state. +The PWA retry loads that storage implementation on demand through a local module. +Collection identity lookups index only requested provider IDs per cached array, +including all typed/category duplicates. Recent heart checks reuse that index +instead of repeatedly scanning the catalog. Catalog refresh or detail enrichment +replaces the array and therefore invalidates its index; loading completeness +still comes from the original caches, never the requested subset. + +Both Xtream data-source and portal-activity capabilities require the pending +favorite conversion bridge; activity reads also require playlist lookup. The +SQLite capability also requires the unfiltered favorite backup reader. An +unsupported atomic restore performs no favorite writes: a fresh archive stays +parked, while an older pending restore is retained without being overwritten. +Subsequent collection reads and early catalog imports must enforce the same +preflight before replaying those favorites. Ordinary reads still return existing +SQLite records. Exports that need to materialize parked favorites fail while +atomic restore is unsupported; exports with no pending favorites remain available. + +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 727ab15e0..33062f0ed 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 @@ -78,6 +78,7 @@ describe('XtreamRefreshFlowService', () => { let confirmPromise: Promise | undefined; let order: string[]; let databaseService: { + restoreXtreamFavoriteSnapshots: jest.Mock; createOperationId: jest.Mock; deleteXtreamPlaylistContent: jest.Mock; updateXtreamPlaylistDetails: jest.Mock; @@ -95,6 +96,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'); @@ -155,6 +160,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 a3d2788b0..9bd190dfc 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 @@ -181,6 +181,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-collection-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts index 1b03ae617..c6ec980de 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-collection-data.service.ts @@ -89,7 +89,7 @@ export class UnifiedCollectionDataService { const favoriteUids = params.mode === 'favorites' ? new Set(items.map((item) => item.uid)) - : await this.loadFavoriteUidSet(params); + : await this.loadFavoriteUidSet(params, items); if (requestId !== this.requestId) { return null; } @@ -188,7 +188,8 @@ export class UnifiedCollectionDataService { } private async loadFavoriteUidSet( - params: CollectionLoadRequest + params: CollectionLoadRequest, + recentItems: UnifiedCollectionItem[] ): Promise> { try { const favorites = await this.favoritesData.getFavorites( @@ -196,7 +197,20 @@ export class UnifiedCollectionDataService { params.playlistId, params.portalType ); - return new Set(favorites.map((item) => item.uid)); + const uids = new Set(favorites.map((item) => item.uid)); + await Promise.all( + recentItems.map(async (item) => { + if (item.sourceType !== 'xtream' || !uids.has(item.uid)) + return; + // Favorites can retain an older, incompatible movie with this UID. + // Let the owning data source check this exact recent catalog row. + const matches = await this.favoritesData + .isXtreamFavorite(item) + .catch(() => false); + if (!matches) uids.delete(item.uid); + }) + ); + return uids; } catch { return new Set(); } 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..5e4353bdc 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 @@ -12,10 +12,14 @@ import { import { UnifiedCollectionItem } from '@iptvnator/portal/shared/util'; import { XTREAM_DATA_SOURCE } from '@iptvnator/portal/xtream/data-access'; import { UnifiedFavoritesDataService } from './unified-favorites-data.service'; +import { UnifiedCollectionDataService } from './unified-collection-data.service'; +import { UnifiedRecentDataService } from './unified-recent-data.service'; describe('UnifiedFavoritesDataService', () => { let service: UnifiedFavoritesDataService; let electronApi: { + dbGetPlaylist: jest.Mock; + dbRestoreXtreamFavorites: jest.Mock; dbAddFavorite: jest.Mock; dbAddRecentItem: jest.Mock; dbClearPlaylistRecentItems: jest.Mock; @@ -32,11 +36,13 @@ describe('UnifiedFavoritesDataService', () => { dbRemoveRecentItemsBatch: jest.Mock; }; let databaseService: { + isFavorite: jest.Mock; getAllGlobalFavorites: jest.Mock; getContentByXtreamId: jest.Mock; getFavorites: jest.Mock; }; let xtreamDataSource: { + isFavorite: jest.Mock; addFavorite: jest.Mock; getContentByXtreamId: jest.Mock; getFavorites: jest.Mock; @@ -108,6 +114,8 @@ describe('UnifiedFavoritesDataService', () => { beforeEach(() => { electronApi = { + dbGetPlaylist: jest.fn(), + dbRestoreXtreamFavorites: jest.fn(), dbAddFavorite: jest.fn().mockResolvedValue({ success: true }), dbAddRecentItem: jest.fn(), dbClearPlaylistRecentItems: jest.fn(), @@ -160,11 +168,13 @@ describe('UnifiedFavoritesDataService', () => { ), }; databaseService = { + isFavorite: jest.fn().mockResolvedValue(true), getAllGlobalFavorites: jest.fn().mockResolvedValue([]), getContentByXtreamId: jest.fn().mockResolvedValue(null), getFavorites: jest.fn().mockResolvedValue([]), }; xtreamDataSource = { + isFavorite: jest.fn().mockResolvedValue(true), addFavorite: jest.fn().mockResolvedValue(undefined), getContentByXtreamId: jest.fn().mockResolvedValue(null), getFavorites: jest.fn().mockResolvedValue([]), @@ -198,6 +208,11 @@ describe('UnifiedFavoritesDataService', () => { TestBed.configureTestingModule({ providers: [ UnifiedFavoritesDataService, + UnifiedCollectionDataService, + { + provide: UnifiedRecentDataService, + useValue: { getRecentItems: jest.fn() }, + }, { provide: Store, useValue: store, @@ -226,6 +241,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([ { @@ -628,6 +692,124 @@ describe('UnifiedFavoritesDataService', () => { ); }); + it('keeps a recent item heart empty when SQLite rejects its saved identity', async () => { + const item: UnifiedCollectionItem = { + uid: 'xtream::xtream-1::movie:101', + name: 'Replacement movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 101, + contentId: 42, + }; + electronApi.dbAddFavorite.mockResolvedValue({ + success: false, + error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT', + }); + const collection = TestBed.inject(UnifiedCollectionDataService); + await expect(collection.toggleFavorite(item)).rejects.toThrow( + 'XTREAM_FAVORITE_IDENTITY_CONFLICT' + ); + expect(collection.favoriteUidSet().has(item.uid)).toBe(false); + electronApi.dbAddFavorite.mockResolvedValue({ success: true }); + await collection.toggleFavorite(item); + expect(collection.favoriteUidSet().has(item.uid)).toBe(true); + }); + + it.each([undefined, 99])( + 'checks the recent row identity against a saved favorite resolved to %s before setting its heart', + async (savedContentId) => { + const item: UnifiedCollectionItem = { + uid: 'xtream::xtream-1::movie:101', + name: 'Replacement movie', + contentType: 'movie', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + xtreamId: 101, + contentId: 42, + }; + jest.spyOn(service, 'getFavorites').mockResolvedValue([ + { + ...item, + name: 'Older favorite', + contentId: savedContentId, + favoriteId: 5, + }, + ]); + jest.spyOn( + TestBed.inject(UnifiedRecentDataService), + 'getRecentItems' + ).mockResolvedValue([item]); + databaseService.isFavorite.mockResolvedValue(false); + electronApi.dbAddFavorite.mockResolvedValue({ + success: false, + error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT', + }); + const collection = TestBed.inject(UnifiedCollectionDataService); + const request = { mode: 'recent', scope: 'all' } as const; + await collection.load(request); + expect(collection.favoriteUidSet().has(item.uid)).toBe(false); + expect(databaseService.isFavorite).toHaveBeenCalledWith( + 42, + 'xtream-1' + ); + await expect(collection.toggleFavorite(item)).rejects.toThrow( + 'XTREAM_FAVORITE_IDENTITY_CONFLICT' + ); + expect(electronApi.dbRemoveFavorite).not.toHaveBeenCalled(); + await collection.load(request); + expect(collection.favoriteUidSet().has(item.uid)).toBe(false); + databaseService.isFavorite.mockResolvedValue(true); + await collection.load(request); + expect(collection.favoriteUidSet().has(item.uid)).toBe(true); + } + ); + + it('passes the rendered PWA identity to status, add and stale-heart removal', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + const item: UnifiedCollectionItem = { + uid: 'xtream::xtream-1::movie:101', + name: 'Replacement', + sourceType: 'xtream', + contentType: 'movie', + playlistId: 'xtream-1', + playlistName: 'Test', + contentId: 101, + xtreamId: 101, + xtreamIdentity: { tmdbId: 200, releaseYear: 2026 }, + }; + const ref = { id: 101, type: 'movie', identity: item.xtreamIdentity }; + await service.isXtreamFavorite(item); + expect(xtreamDataSource.isFavorite).toHaveBeenCalledWith( + ref, + 'xtream-1' + ); + await service.addFavorite(item); + expect(xtreamDataSource.addFavorite).toHaveBeenCalledWith( + ref, + 'xtream-1', + undefined + ); + xtreamDataSource.removeFavorite.mockRejectedValue( + new Error('XTREAM_FAVORITE_IDENTITY_CONFLICT') + ); + const collection = TestBed.inject(UnifiedCollectionDataService); + collection.favoriteUidSet.set(new Set([item.uid])); + await expect(collection.toggleFavorite(item)).rejects.toThrow( + 'XTREAM_FAVORITE_IDENTITY_CONFLICT' + ); + expect(xtreamDataSource.removeFavorite).toHaveBeenCalledWith( + ref, + 'xtream-1' + ); + expect(collection.favoriteUidSet().has(item.uid)).toBe(true); + }); + it('uses the Xtream id as the favorite key in PWA when cached content is cold', async () => { Object.defineProperty(window, 'electron', { value: undefined, 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..1ab7727c4 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'; @@ -79,6 +80,26 @@ export class UnifiedFavoritesDataService { } } + /** A reused provider ID alone cannot determine a recent row's favorite status. */ + async isXtreamFavorite(item: UnifiedCollectionItem): Promise { + const contentId = + item.contentId ?? (await this.resolveXtreamContentId(item)); + if (contentId == null) return false; + if (this.electronActivityBridge) { + return this.dbService.isFavorite(contentId, item.playlistId); + } + return this.xtreamDataSource.isFavorite( + { + id: contentId, + type: item.contentType, + ...(item.xtreamIdentity + ? { identity: item.xtreamIdentity } + : {}), + }, + item.playlistId + ); + } + async removeFavorite(item: UnifiedCollectionItem): Promise { switch (item.sourceType) { case 'm3u': { @@ -92,10 +113,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, @@ -104,7 +130,13 @@ export class UnifiedFavoritesDataService { return; } await this.xtreamDataSource.removeFavorite( - { id: item.contentId, type: item.contentType }, + { + id: item.contentId, + type: item.contentType, + ...(item.xtreamIdentity + ? { identity: item.xtreamIdentity } + : {}), + }, item.playlistId ); break; @@ -149,18 +181,27 @@ export class UnifiedFavoritesDataService { const electron = this.electronActivityBridge; if (!electron) { await this.xtreamDataSource.addFavorite( - { id: contentId, type: item.contentType }, + { + id: contentId, + type: item.contentType, + ...(item.xtreamIdentity + ? { identity: item.xtreamIdentity } + : {}), + }, item.playlistId, item.posterUrl ?? item.logo ?? undefined ); return; } - await electron.dbAddFavorite( + const result = await electron.dbAddFavorite( contentId, item.playlistId, item.posterUrl ?? item.logo ?? undefined ); + if (!result.success) { + throw new Error(result.error ?? 'Favorite could not be saved'); + } } private async addStalkerFavorite( @@ -290,6 +331,7 @@ export class UnifiedFavoritesDataService { } const xtreamUpdates = this.buildXtreamPositionUpdates(items); + const electron = this.electronActivityBridge; if ( @@ -365,7 +407,13 @@ export class UnifiedFavoritesDataService { ) .map((item) => this.xtreamDataSource.removeFavorite( - { id: item.contentId, type: item.contentType }, + { + id: item.contentId, + type: item.contentType, + ...(item.xtreamIdentity + ? { identity: item.xtreamIdentity } + : {}), + }, item.playlistId ) ) @@ -373,19 +421,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 +629,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 +637,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 +659,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 +668,13 @@ 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, + xtreamIdentity: { + tmdbId: item.tmdb_id ?? undefined, + releaseYear: item.release_year ?? undefined, + }, }; } @@ -807,16 +851,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/data-access/src/lib/collection/unified-recent-data.service.spec.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts index be491d092..cba079ad0 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.spec.ts @@ -99,6 +99,8 @@ describe('UnifiedRecentDataService', () => { const createPortalActivityElectronApi = (): Window['electron'] => ({ + dbGetPlaylist: jest.fn(), + dbRestoreXtreamFavorites: jest.fn(), dbGetRecentlyViewed: jest.fn(), dbClearRecentlyViewed: jest.fn(), dbGetAllGlobalFavorites: jest.fn(), @@ -280,6 +282,39 @@ describe('UnifiedRecentDataService', () => { ); }); + it('passes the displayed live identity to cold PWA playback history', async () => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + const item = { + uid: 'xtream::xtream-1::live:290', + name: 'Played B', + contentType: 'live', + sourceType: 'xtream', + playlistId: 'xtream-1', + playlistName: 'Xtream One', + contentId: 290, + xtreamId: 290, + logo: 'b.jpg', + categoryId: '2', + xtreamIdentity: { tmdbId: 200, releaseYear: 2002 }, + } satisfies UnifiedCollectionItem; + await service.recordLivePlayback(item); + expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( + { id: 290, type: 'live' }, + 'xtream-1', + undefined, + { + title: 'Played B', + posterUrl: 'b.jpg', + categoryId: '2', + tmdbId: 200, + releaseYear: 2002, + } + ); + }); + it('uses the Xtream id as the PWA recent key when cached content is cold', async () => { Object.defineProperty(window, 'electron', { value: undefined, @@ -306,7 +341,9 @@ describe('UnifiedRecentDataService', () => { ); expect(xtreamDataSource.addRecentItem).toHaveBeenCalledWith( { id: 290, type: 'movie' }, - 'xtream-1' + 'xtream-1', + undefined, + { title: 'PWA Movie', posterUrl: undefined, categoryId: undefined } ); expect(dbService.addRecentItem).not.toHaveBeenCalled(); expect(recorded).toEqual( @@ -400,55 +437,60 @@ describe('UnifiedRecentDataService', () => { ); }); - it('loads Xtream playlist recent items through the active data source in PWA', async () => { - Object.defineProperty(window, 'electron', { - value: undefined, - configurable: true, - }); - store.select.mockReturnValue( - of([ + it.each(['present', 'missing', 'unknown'] as const)( + 'carries %s PWA Xtream availability into Recent', + async (catalogStatus) => { + Object.defineProperty(window, 'electron', { + value: undefined, + configurable: true, + }); + store.select.mockReturnValue( + of([ + { + _id: 'xtream-1', + title: 'Xtream PWA', + serverUrl: 'https://example.com', + } satisfies Partial, + ]) + ); + xtreamDataSource.getRecentItems.mockResolvedValue([ { - _id: 'xtream-1', - title: 'Xtream PWA', - serverUrl: 'https://example.com', - } satisfies Partial, - ]) - ); - xtreamDataSource.getRecentItems.mockResolvedValue([ - { - id: 202, - category_id: 20, - title: 'Movie One', - type: 'movie', - poster_url: 'movie.png', - backdrop_url: 'backdrop.png', - xtream_id: 202, - viewed_at: '2026-05-21T12:00:00.000Z', - }, - ]); + id: 202, + catalog_status: catalogStatus, + category_id: 20, + title: 'Movie One', + type: 'movie', + poster_url: 'movie.png', + backdrop_url: 'backdrop.png', + xtream_id: 202, + viewed_at: '2026-05-21T12:00:00.000Z', + }, + ]); - const items = await service.getRecentItems( - 'playlist', - 'xtream-1', - 'xtream' - ); + const items = await service.getRecentItems( + 'playlist', + 'xtream-1', + 'xtream' + ); - expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith( - 'xtream-1' - ); - expect(dbService.getRecentItems).not.toHaveBeenCalled(); - expect(items).toEqual([ - expect.objectContaining({ - uid: 'xtream::xtream-1::movie:202', - sourceType: 'xtream', - contentType: 'movie', - name: 'Movie One', - playlistName: 'Xtream PWA', - posterUrl: 'movie.png', - viewedAt: '2026-05-21T12:00:00.000Z', - }), - ]); - }); + expect(xtreamDataSource.getRecentItems).toHaveBeenCalledWith( + 'xtream-1' + ); + expect(dbService.getRecentItems).not.toHaveBeenCalled(); + expect(items).toEqual([ + expect.objectContaining({ + uid: 'xtream::xtream-1::movie:202', + catalogStatus, + sourceType: 'xtream', + contentType: 'movie', + name: 'Movie One', + playlistName: 'Xtream PWA', + posterUrl: 'movie.png', + viewedAt: '2026-05-21T12:00:00.000Z', + }), + ]); + } + ); it('does not load Stalker portals through the PWA Xtream global recent path', async () => { Object.defineProperty(window, 'electron', { diff --git a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts index 2a085b4eb..741a2d1f2 100644 --- a/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts +++ b/libs/portal/shared/data-access/src/lib/collection/unified-recent-data.service.ts @@ -274,6 +274,12 @@ export class UnifiedRecentDataService { } if (item.sourceType === 'xtream') { + const playedContent = { + title: item.name, + posterUrl: item.logo ?? item.posterUrl ?? undefined, + categoryId: item.categoryId, + ...item.xtreamIdentity, + }; const contentId = item.contentId ?? (item.xtreamId != null @@ -289,7 +295,9 @@ export class UnifiedRecentDataService { } else { await this.xtreamDataSource.addRecentItem( { id: contentId, type: item.contentType }, - item.playlistId + item.playlistId, + undefined, + playedContent ); } } @@ -491,6 +499,11 @@ export class UnifiedRecentDataService { item.xtream_id ), name: item.title, + catalogStatus: item.catalog_status, + xtreamIdentity: { + tmdbId: item.tmdb_id ?? undefined, + releaseYear: item.release_year ?? undefined, + }, contentType, sourceType: 'xtream', playlistId, 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..93d680eb2 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)" /> @@ -64,13 +73,13 @@ >
- @if (supportsEpgMapping) { + @if (supportsEpgMapping && !contextMenuUnavailable()) { } - @if (contextMenuChannel()?.m3uChannel) { + @if (contextMenuChannel()?.m3uChannel && !contextMenuUnavailable()) {