fix(xtream): preserve favorites across catalog refreshes

This commit is contained in:
4gray committed 2026-10-13 23:31:25 +02:00
1 parent ec4b9286a7
commit 8ccfcb68cc
121 files changed
+4000 -398

No files matched your search

@@ -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.
@@ -0,0 +1,138 @@
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
contentCardByTitle,
expect,
launchElectronApp,
openSources,
openWorkspaceSection,
resetMockServers,
sourceRowByTitle,
test,
waitForXtreamWorkspaceReady,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import { fetchXtreamVodFixture, getXtreamTitle } from './portal-mock-fixtures';
import { readSavedFavorites } from './support/xtream-favorites-catalog';
test('@critical @persistence @xtream @electron rejects a reused favorite identity until the older saved entry is explicitly removed', async ({
dataDir,
request,
}) => {
test.setTimeout(90_000);
await resetMockServers(request, ['xtream']);
const credentials = { username: 'minimal', password: 'minimal' };
const fixture = await fetchXtreamVodFixture(request, credentials);
const item = fixture.items[0];
const title = getXtreamTitle(item);
const sourceTitle = 'Favorite identity conflict';
const oldTitle = 'Older saved movie';
let app: LaunchedElectronApp | undefined;
try {
app = await launchElectronApp(dataDir);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: sourceTitle,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const playlistId = /\/xtreams\/([^/]+)/.exec(
new URL(app.mainWindow.url()).pathname
)?.[1];
if (!playlistId) throw new Error('Expected a playlist route');
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
// Seed an old snapshot and conflicting current metadata via the real
// worker bridge. The subsequent save/removal runs through the UI.
await app.mainWindow.evaluate(
async ({ playlistId, xtreamId, oldTitle }) => {
const row = await window.electron.dbGetContentByXtreamId(
xtreamId,
playlistId,
'movie'
);
if (!row) throw new Error('Expected imported content');
const metadata =
await window.electron.dbSetContentMetadataIfMissing(
row.id,
{
tmdbId: 12345,
releaseYear: 2000,
}
);
if (!metadata.success)
throw new Error('Could not set current movie identity');
const restored = await window.electron.dbRestoreXtreamFavorites(
playlistId,
[
{
xtreamId,
contentType: 'movie',
addedAt: '2024-01-01',
position: 3,
snapshot: {
title: oldTitle,
tmdbId: 99999999,
},
},
]
);
if (!restored.success)
throw new Error('Could not seed the older favorite');
},
{ playlistId, xtreamId: Number(item.stream_id), oldTitle }
);
const [before] = await readSavedFavorites(app.mainWindow, playlistId);
expect(before).toMatchObject({ id: null, title: oldTitle });
await clickGridListCardByTitle(app.mainWindow, title);
const addButton = app.mainWindow
.getByRole('button', { name: 'Add to favorites', exact: true })
.first();
await expect(addButton).toBeVisible();
await addButton.click();
await expect(
app.mainWindow.getByText(
'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.',
{ exact: true }
)
).toBeVisible();
await expect(addButton).toBeVisible();
expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual([
before,
]);
await openWorkspaceSection(app.mainWindow, 'Global favorites');
const oldCard = contentCardByTitle(app.mainWindow, oldTitle);
await oldCard
.getByRole('button', { name: /remove from favorites/i })
.click();
await expect(oldCard).toHaveCount(0);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, sourceTitle).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await clickGridListCardByTitle(app.mainWindow, title);
await addButton.click();
await expect(
app.mainWindow
.getByRole('button', {
name: 'Remove from favorites',
exact: true,
})
.first()
).toBeVisible();
const [saved] = await readSavedFavorites(app.mainWindow, playlistId);
expect(saved.title).toBe(title);
expect(saved.id).not.toBeNull();
expect(saved.favorite_id).not.toBe(before.favorite_id);
} finally {
if (app) await closeElectronApp(app);
}
});
@@ -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);
}
});
@@ -33,6 +33,7 @@ import {
waitForStalkerCatalog,
waitForXtreamWorkspaceReady,
writeTemporaryM3uFile,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import {
fetchStalkerCategoryFixture,
@@ -48,6 +49,12 @@ import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
import {
createFavoritesCatalogProxy,
readSavedFavorites,
refreshFavoriteCatalog,
savedFavoriteIdentity,
} from './support/xtream-favorites-catalog';
test.describe('Electron Favorites', () => {
for (const origin of ['favorites', 'recent', 'dashboard'] as const) {
@@ -430,6 +437,262 @@ test.describe('Electron Favorites', () => {
}
});
test('@critical @persistence @xtream @electron keeps durable favorites through partial refresh, repeated refresh, restart and catalog recovery', async ({
dataDir,
request,
}) => {
test.setTimeout(150_000);
await resetMockServers(request, ['xtream']);
const credentials = { username: 'minimal', password: 'minimal' };
const fixture = await fetchXtreamVodFixture(request, credentials);
const proxy = await createFavoritesCatalogProxy();
const portalTitle = 'Durable Partial Favorites';
let app: LaunchedElectronApp | undefined;
try {
app = await launchElectronApp(dataDir);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: portalTitle,
serverUrl: proxy.origin,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const { playlistId, titles } = await saveTwoMovieFavorites(
app.mainWindow,
fixture
);
const original = savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
);
const missing = original.find((row) => row.title === titles[0]);
if (!missing) throw new Error('The newly saved movie is missing');
expect(missing.favoriteId).toBeGreaterThan(0);
proxy.setMode({ kind: 'omit', xtreamId: missing.xtreamId });
// Both real refresh entry points must retain the same durable records.
for (const entry of ['sources', 'header'] as const) {
await refreshFavoriteCatalog(
app.mainWindow,
proxy,
playlistId,
portalTitle,
entry
);
const saved = await readSavedFavorites(
app.mainWindow,
playlistId
);
expect(savedFavoriteIdentity(saved)).toEqual(original);
expect(
saved.find((row) => row.favorite_id === missing.favoriteId)
).toMatchObject({
id: null,
title: titles[0],
catalog_status: 'missing',
});
expect(
saved.find((row) => row.favorite_id !== missing.favoriteId)
).toMatchObject({
id: expect.any(Number),
catalog_status: 'present',
});
await expectSavedMovieCards(app.mainWindow, titles);
const card = contentCardByTitle(app.mainWindow, titles[0]);
await expect(card).toContainText('Not in the current catalog');
const activation = card.locator('.content-card__activation');
await expect(activation).toHaveAttribute(
'aria-disabled',
'true'
);
const detailRequests = proxy.responseCount('get_vod_info');
await activation.focus();
await app.mainWindow.keyboard.press('Enter');
await expect(
app.mainWindow.locator('app-content-hero')
).toHaveCount(0);
expect(proxy.responseCount('get_vod_info')).toBe(
detailRequests
);
await switchUnifiedCollectionScope(
app.mainWindow,
'All playlists'
);
await expect(
contentCardByTitle(app.mainWindow, titles[0])
).toContainText('Not in the current catalog');
}
// The provider still omits the movie while a new Electron process opens.
app = await restartElectronApp(app, dataDir);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await expectSavedMovieCards(app.mainWindow, titles);
expect(
savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
)
).toEqual(original);
await expect(
contentCardByTitle(app.mainWindow, titles[0])
).toContainText('Not in the current catalog');
proxy.setMode({ kind: 'normal' });
await refreshFavoriteCatalog(
app.mainWindow,
proxy,
playlistId,
portalTitle,
'sources'
);
const recovered = await readSavedFavorites(
app.mainWindow,
playlistId
);
expect(savedFavoriteIdentity(recovered)).toEqual(original);
expect(recovered).toHaveLength(2);
expect(
recovered.every(
(row) => row.id != null && row.catalog_status === 'present'
)
).toBe(true);
await expectSavedMovieCards(app.mainWindow, titles);
const recoveredCard = contentCardByTitle(app.mainWindow, titles[0]);
await expect(
recoveredCard.locator('.content-card__activation')
).not.toHaveAttribute('aria-disabled', 'true');
await recoveredCard.click();
await expect(
app.mainWindow.locator('app-content-hero')
).toContainText(titles[0]);
await expect(
app.mainWindow
.getByRole('button', { name: /remove from favorites/i })
.first()
).toBeVisible();
} finally {
try {
if (app) await closeElectronApp(app);
} finally {
await proxy.close();
}
}
});
test('@persistence @xtream @electron distinguishes an empty catalog from HTTP failure and keeps explicit favorite removal after recovery', async ({
dataDir,
request,
}) => {
test.setTimeout(120_000);
await resetMockServers(request, ['xtream']);
const credentials = { username: 'minimal', password: 'minimal' };
const fixture = await fetchXtreamVodFixture(request, credentials);
const proxy = await createFavoritesCatalogProxy();
const portalTitle = 'Durable Empty Favorites';
let app: LaunchedElectronApp | undefined;
try {
app = await launchElectronApp(dataDir);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: portalTitle,
serverUrl: proxy.origin,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const { playlistId, titles } = await saveTwoMovieFavorites(
app.mainWindow,
fixture
);
const original = savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
);
for (const kind of ['empty', 'failure'] as const) {
proxy.setMode({ kind });
await refreshFavoriteCatalog(
app.mainWindow,
proxy,
playlistId,
portalTitle,
'sources',
kind === 'empty' ? 'completed' : 'failed'
);
const saved = await readSavedFavorites(
app.mainWindow,
playlistId
);
expect(savedFavoriteIdentity(saved)).toEqual(original);
expect(
saved.every(
(row) =>
row.id === null &&
row.catalog_status ===
(kind === 'empty' ? 'missing' : 'unknown')
)
).toBe(true);
await expectSavedMovieCards(app.mainWindow, titles);
for (const title of titles) {
await expect(
contentCardByTitle(app.mainWindow, title)
).toContainText(
kind === 'empty'
? 'Not in the current catalog'
: 'Catalog status unknown'
);
}
}
const removedCard = contentCardByTitle(app.mainWindow, titles[0]);
await removedCard
.getByRole('button', { name: /remove from favorites/i })
.click();
await expect(removedCard).toHaveCount(0);
const retained = original.filter((row) => row.title !== titles[0]);
expect(
savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
)
).toEqual(retained);
proxy.setMode({ kind: 'normal' });
// Replaying a stale refresh snapshot must never resurrect an explicit removal.
for (let refresh = 0; refresh < 2; refresh++) {
await refreshFavoriteCatalog(
app.mainWindow,
proxy,
playlistId,
portalTitle,
'sources'
);
const saved = await readSavedFavorites(
app.mainWindow,
playlistId
);
expect(savedFavoriteIdentity(saved)).toEqual(retained);
expect(saved[0]).toMatchObject({
id: expect.any(Number),
catalog_status: 'present',
});
await expectSavedMovieCards(app.mainWindow, [titles[1]]);
await expect(
contentCardByTitle(app.mainWindow, titles[0])
).toHaveCount(0);
await switchUnifiedCollectionScope(
app.mainWindow,
'All playlists'
);
await expect(
contentCardByTitle(app.mainWindow, titles[0])
).toHaveCount(0);
await expectVisibleContentCardTitle(app.mainWindow, titles[1]);
}
} finally {
try {
if (app) await closeElectronApp(app);
} finally {
await proxy.close();
}
}
});
test('opens Xtream favorite movies and series inline from favorites without switching the playlist or showing the sidebar', async ({
dataDir,
request,
@@ -740,6 +1003,44 @@ const xtreamCredentials = {
password: defaultXtreamPassword,
};
async function saveTwoMovieFavorites(
page: Page,
fixture: Awaited<ReturnType<typeof fetchXtreamVodFixture>>
): 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<void> {
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.
@@ -0,0 +1,178 @@
import { createServer } from 'node:http';
import type { Page } from '@playwright/test';
import type { ElectronBridgeXtreamFavorite } from '@iptvnator/shared/interfaces';
import {
expect,
openSources,
refreshSource,
sourceRowByTitle,
waitForXtreamWorkspaceReady,
xtreamMockServer,
} from '../electron-test-fixtures';
export type FavoritesCatalogMode =
| { kind: 'normal' | 'empty' | 'failure' | 'offline' }
| { kind: 'omit'; xtreamId: number };
export interface FavoritesCatalogProxy {
origin: string;
setMode(mode: FavoritesCatalogMode): void;
responseCount(action: string): number;
close(): Promise<void>;
}
/** Main-process HTTP cannot be intercepted by Playwright's page.route(). */
export async function createFavoritesCatalogProxy(): Promise<FavoritesCatalogProxy> {
let mode: FavoritesCatalogMode = { kind: 'normal' };
const responses = new Map<string, number>();
const pending = new Set<AbortController>();
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', xtreamMockServer);
const action = url.searchParams.get('action') ?? 'get_account_info';
const requestMode = mode;
response.once('finish', () => {
responses.set(action, (responses.get(action) ?? 0) + 1);
});
response.setHeader('Content-Type', 'application/json');
response.setHeader('Cache-Control', 'no-store');
if (
requestMode.kind === 'offline' ||
(action === 'get_vod_streams' && requestMode.kind === 'failure')
) {
response.writeHead(503).end('{"error":"fixture-unavailable"}');
return;
}
if (action === 'get_vod_streams' && requestMode.kind === 'empty') {
response.end('[]');
return;
}
const controller = new AbortController();
pending.add(controller);
const timeout = setTimeout(() => controller.abort(), 10_000);
void (async () => {
try {
const upstream = await fetch(url, {
redirect: 'manual',
signal: controller.signal,
});
let body = await upstream.text();
if (
action === 'get_vod_streams' &&
requestMode.kind === 'omit'
) {
const movies = JSON.parse(body) as {
stream_id: number | string;
}[];
body = JSON.stringify(
movies.filter(
(movie) =>
Number(movie.stream_id) !== requestMode.xtreamId
)
);
}
response.writeHead(upstream.status).end(body);
} catch {
if (!response.destroyed) {
response
.writeHead(502)
.end('{"error":"fixture-upstream-failed"}');
}
} finally {
clearTimeout(timeout);
pending.delete(controller);
}
})();
});
await new Promise<void>((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<void>((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<void> {
await openSources(page);
if (entry === 'header') {
await sourceRowByTitle(page, title).first().click();
await waitForXtreamWorkspaceReady(page);
}
const requestsBefore = proxy.responseCount('get_vod_streams');
if (entry === 'sources') {
await refreshSource(page, title, { confirm: true });
} else {
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
await dialog
.getByRole('button', { name: 'Refresh playlist', exact: true })
.click();
await expect(dialog).toHaveCount(0);
}
// A completed status from the previous generation cannot satisfy this refresh.
await expect
.poll(() => proxy.responseCount('get_vod_streams'), {
timeout: 30_000,
})
.toBeGreaterThan(requestsBefore);
await expect
.poll(
() =>
page.evaluate(
(id) =>
window.electron.dbGetAppState(
`xtream-import-status:${id}:movie`
),
playlistId
),
{ timeout: 30_000 }
)
.toBe(expectedImportState);
await expect(page.locator('.workspace-loading-overlay')).toHaveCount(0, {
timeout: 30_000,
});
}
@@ -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],
@@ -1,3 +1,8 @@
import type {
ElectronBridgeFavoriteReorderUpdate,
XtreamFavoriteRemovalRef,
XtreamBackupFavoriteItem,
} from '@iptvnator/shared/interfaces';
import type { SourceProbeContext } from '@iptvnator/shared/interfaces';
import { contextBridge, ipcRenderer, webFrame, webUtils } from 'electron';
import {
@@ -804,6 +809,17 @@ const electronApi: ElectronBridgeApi = {
ipcRenderer.invoke('DB_DELETE_PLAYLIST', playlistId, operationId),
dbDeleteXtreamContent: (playlistId: string, operationId?: string) =>
ipcRenderer.invoke('DB_DELETE_XTREAM_CONTENT', playlistId, operationId),
dbRestoreXtreamFavorites: (
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
restoreId?: string
) =>
ipcRenderer.invoke(
'DB_RESTORE_XTREAM_FAVORITES',
playlistId,
favorites,
restoreId
),
dbRestoreXtreamUserData: (
playlistId: string,
favorites: unknown[],
@@ -940,17 +956,21 @@ const electronApi: ElectronBridgeApi = {
playlistId,
backdropUrl
),
dbRemoveFavorite: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_REMOVE_FAVORITE', contentId, playlistId),
dbRemoveFavorite: (
contentId: XtreamFavoriteRemovalRef,
playlistId: string
) => ipcRenderer.invoke('DB_REMOVE_FAVORITE', contentId, playlistId),
dbIsFavorite: (contentId: number, playlistId: string) =>
ipcRenderer.invoke('DB_IS_FAVORITE', contentId, playlistId),
dbGetFavoritesForBackup: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_FAVORITES_FOR_BACKUP', playlistId),
dbGetFavorites: (playlistId: string) =>
ipcRenderer.invoke('DB_GET_FAVORITES', playlistId),
dbGetGlobalFavorites: () => ipcRenderer.invoke('DB_GET_GLOBAL_FAVORITES'),
dbGetAllGlobalFavorites: () =>
ipcRenderer.invoke('DB_GET_ALL_GLOBAL_FAVORITES'),
dbReorderGlobalFavorites: (
updates: { content_id: number; playlist_id: string; position: number }[]
updates: ElectronBridgeFavoriteReorderUpdate[]
) => ipcRenderer.invoke('DB_REORDER_GLOBAL_FAVORITES', updates),
// Recently viewed (playlist-specific)
dbGetRecentItems: (playlistId: string) =>
@@ -1081,7 +1101,12 @@ const electronApi: ElectronBridgeApi = {
dbReplaceAllPlaybackPositions: (
playlistId: string,
items: ElectronBridgePlaybackPositionInput[]
) => ipcRenderer.invoke('DB_REPLACE_ALL_PLAYBACK_POSITIONS', playlistId, items),
) =>
ipcRenderer.invoke(
'DB_REPLACE_ALL_PLAYBACK_POSITIONS',
playlistId,
items
),
dbClearAllPlaybackPositions: (playlistId: string) =>
ipcRenderer.invoke('DB_CLEAR_ALL_PLAYBACK_POSITIONS', playlistId),
dbClearPlaybackPosition: (
@@ -1186,11 +1211,14 @@ const electronApi: ElectronBridgeApi = {
// the next resize. Applied at DOMContentLoaded, never earlier — see
// preload-zoom-level.ts for the Linux/Windows ready-to-show trap.
applyPersistedZoomLevel({
requestPersistedZoomLevel: () => ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL),
requestPersistedZoomLevel: () =>
ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL),
...frameZoomPorts,
whenDocumentParsed: (apply) => {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', apply, { once: true });
document.addEventListener('DOMContentLoaded', apply, {
once: true,
});
} else {
apply();
}
@@ -63,7 +63,7 @@ describe('content-metadata.operations', () => {
sqlMock.mockClear();
});
it('writes the whole patch in one statement', async () => {
it('writes the whole content patch then preserves it in saved favorites', async () => {
const { db, set, update } = createDbMock();
await expect(
@@ -75,7 +75,8 @@ describe('content-metadata.operations', () => {
})
).resolves.toEqual({ success: true });
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledTimes(2);
expect(update).toHaveBeenNthCalledWith(2, schema.favorites);
expect(update).toHaveBeenCalledWith(schema.content);
const written = set.mock.calls[0][0];
@@ -138,9 +139,9 @@ describe('content-metadata.operations', () => {
const guards = orMock.mock.calls[0].map(sqlText);
expect(guards.some((guard) => guard.includes("= ''"))).toBe(true);
expect(
guards.filter((guard) => guard.includes("= ''"))
).toHaveLength(1);
expect(guards.filter((guard) => guard.includes("= ''"))).toHaveLength(
1
);
});
it.each([
@@ -166,7 +167,8 @@ describe('content-metadata.operations', () => {
backdropUrl: 'https://example.com/backdrop.jpg',
});
expect(update).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledTimes(2);
expect(update).toHaveBeenNthCalledWith(2, schema.favorites);
expect(update).toHaveBeenCalledWith(schema.content);
expect(update).not.toHaveBeenCalledWith(
schema.recentlyViewed as unknown as never
@@ -5,6 +5,7 @@ import {
ContentMetadataPatch,
normalizeContentMetadataPatch,
} from '@iptvnator/shared/interfaces';
import { favoriteMatchesContentId } from './favorite-catalog';
import type { AppDatabase } from '../database.types';
/**
@@ -104,6 +105,20 @@ export async function persistContentMetadataIfMissing(
)
)
.where(and(eq(schema.content.id, contentId), anyAbsent));
// Favorites own their metadata beyond the lifetime of this catalog row.
// Capture enrichment when learned, before a later refresh can delete it.
await db
.update(schema.favorites)
.set(
Object.fromEntries(
columns.map(({ key, column }) => [
key,
sql`COALESCE(NULLIF(${schema.favorites[key]}, ''), (SELECT ${column} FROM content WHERE id = ${contentId}))`,
])
)
)
.where(favoriteMatchesContentId(contentId));
}
export async function setContentMetadataIfMissing(
@@ -0,0 +1,118 @@
import { and, eq, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
/** A covering provider-id lookup per favorite, never a scan of the catalog. */
export function resolvedFavoriteContentId() {
return sql<number | null>`(SELECT candidate.id FROM content candidate
INNER JOIN categories owner ON owner.id = candidate.category_id
WHERE candidate.xtream_id = ${schema.favorites.xtreamId}
AND candidate.type = ${schema.favorites.type}
AND owner.playlist_id = ${schema.favorites.playlistId}
AND (
(${schema.favorites.tmdbId} IS NULL OR candidate.tmdb_id IS NULL
OR ${schema.favorites.tmdbId} = candidate.tmdb_id)
AND (${schema.favorites.releaseYear} IS NULL OR candidate.release_year IS NULL
OR ${schema.favorites.releaseYear} = candidate.release_year))
ORDER BY candidate.id LIMIT 1)`;
}
export function favoriteCatalogStatus() {
return sql<'present' | 'missing' | 'unknown'>`CASE
WHEN ${schema.appState.value} IN ('importing', 'failed', 'cancelled') THEN 'unknown'
WHEN ${schema.content.id} IS NOT NULL THEN 'present'
WHEN ${schema.appState.value} = 'completed' THEN 'missing'
ELSE 'unknown' END`;
}
export function favoriteImportStateKey() {
return sql`'xtream-import-status:' || ${schema.favorites.playlistId} || ':' || ${schema.favorites.type}`;
}
export async function findFavoriteContent(
db: AppDatabase,
playlistId: string,
identity:
| { contentId: number }
| { xtreamId: number; type: 'live' | 'movie' | 'series' }
) {
const rows = await db
.select({ content: schema.content })
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
'contentId' in identity
? eq(schema.content.id, identity.contentId)
: and(
eq(schema.content.xtreamId, identity.xtreamId),
eq(schema.content.type, identity.type)
)
)
)
.orderBy(schema.content.id)
.limit(1);
return rows[0]?.content;
}
/** The import lifecycle already persists these keys; completion relinks cache pointers. */
export async function reconcileCompletedFavoriteCatalog(
db: AppDatabase,
key: string,
value: string
) {
if (value !== 'completed') return;
const match = /^xtream-import-status:(.+):(live|movie|series)$/.exec(key);
if (!match) return;
db.transaction((tx) => {
tx.update(schema.favorites)
.set({ contentId: resolvedFavoriteContentId() })
.where(
and(
eq(schema.favorites.playlistId, match[1]),
eq(
schema.favorites.type,
match[2] as 'live' | 'movie' | 'series'
)
)
)
.run();
tx.update(schema.favorites)
.set({
title: sql`COALESCE((SELECT NULLIF(TRIM(title), '') FROM content WHERE id = favorites.content_id), favorites.title)`,
posterUrl: sql`COALESCE((SELECT NULLIF(TRIM(poster_url), '') FROM content WHERE id = favorites.content_id), favorites.poster_url)`,
backdropUrl: sql`COALESCE((SELECT NULLIF(TRIM(backdrop_url), '') FROM content WHERE id = favorites.content_id), favorites.backdrop_url)`,
tmdbId: sql`COALESCE((SELECT tmdb_id FROM content WHERE id = favorites.content_id), favorites.tmdb_id)`,
releaseYear: sql`COALESCE((SELECT release_year FROM content WHERE id = favorites.content_id), favorites.release_year)`,
originalTitle: sql`COALESCE((SELECT NULLIF(TRIM(original_title), '') FROM content WHERE id = favorites.content_id), favorites.original_title)`,
})
.where(
and(
eq(schema.favorites.playlistId, match[1]),
eq(
schema.favorites.type,
match[2] as 'live' | 'movie' | 'series'
)
)
)
.run();
});
}
/** Every duplicate category row represents the same typed provider identity. */
export function favoriteMatchesContentId(
contentId: number | ReturnType<typeof sql.placeholder>
) {
return sql`EXISTS (SELECT 1 FROM content candidate
INNER JOIN categories owner ON owner.id = candidate.category_id
WHERE candidate.id = ${contentId}
AND owner.playlist_id = ${schema.favorites.playlistId}
AND candidate.type = ${schema.favorites.type}
AND candidate.xtream_id = ${schema.favorites.xtreamId}
AND (${schema.favorites.tmdbId} IS NULL OR candidate.tmdb_id IS NULL OR ${schema.favorites.tmdbId} = candidate.tmdb_id)
AND (${schema.favorites.releaseYear} IS NULL OR candidate.release_year IS NULL OR ${schema.favorites.releaseYear} = candidate.release_year))`;
}
@@ -0,0 +1,82 @@
import { and, asc, desc, eq, sql } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import type { AppDatabase } from '../database.types';
import { unlockedCategoryCondition } from '../parental-lock-state';
import {
favoriteCatalogStatus,
favoriteImportStateKey,
resolvedFavoriteContentId,
} from './favorite-catalog';
/** Nullable catalog joins preserve the saved title and artwork when an item disappears. */
export function selectFavoriteRows(
db: AppDatabase,
playlistId?: string,
includeLocked = false
) {
return (
db
.select({
favorite_id: schema.favorites.id,
id: schema.content.id,
category_id: schema.content.categoryId,
title: sql<string>`COALESCE(NULLIF(TRIM(${schema.content.title}), ''), ${schema.favorites.title})`,
rating: schema.content.rating,
added: schema.content.added,
poster_url: sql<
string | null
>`COALESCE(NULLIF(TRIM(${schema.content.posterUrl}), ''), ${schema.favorites.posterUrl})`,
backdrop_url: sql<
string | null
>`COALESCE(NULLIF(TRIM(${schema.content.backdropUrl}), ''), ${schema.favorites.backdropUrl})`,
tmdb_id: sql<
number | null
>`COALESCE(${schema.content.tmdbId}, ${schema.favorites.tmdbId})`,
release_year: sql<
number | null
>`COALESCE(${schema.content.releaseYear}, ${schema.favorites.releaseYear})`,
original_title: sql<
string | null
>`COALESCE(NULLIF(TRIM(${schema.content.originalTitle}), ''), ${schema.favorites.originalTitle})`,
xtream_id: schema.favorites.xtreamId,
type: schema.favorites.type,
tv_archive: schema.content.tvArchive,
tv_archive_duration: schema.content.tvArchiveDuration,
playlist_id: schema.playlists.id,
playlist_name: schema.playlists.name,
added_at: schema.favorites.addedAt,
position: schema.favorites.position,
catalog_status: favoriteCatalogStatus(),
})
.from(schema.favorites)
.leftJoin(
schema.content,
eq(schema.content.id, resolvedFavoriteContentId())
)
.leftJoin(
schema.categories,
eq(schema.categories.id, schema.content.categoryId)
)
.innerJoin(
schema.playlists,
eq(schema.playlists.id, schema.favorites.playlistId)
)
.leftJoin(
schema.appState,
eq(schema.appState.key, favoriteImportStateKey())
)
// While locked, unresolved category ownership must fail closed too.
.where(
and(
playlistId === undefined
? undefined
: eq(schema.favorites.playlistId, playlistId),
includeLocked ? undefined : unlockedCategoryCondition()
)
)
.orderBy(
asc(schema.favorites.position),
desc(schema.favorites.addedAt)
)
);
}
@@ -0,0 +1,148 @@
import { createHash } from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import {
normalizeContentMetadataPatch,
type XtreamBackupFavoriteItem,
} from '@iptvnator/shared/interfaces';
import type { AppDatabase } from '../database.types';
import {
checkpointOperation,
reportOperationProgress,
type OperationControl,
} from './operation-control';
function restoreReceiptKey(
playlistId: string,
items: XtreamBackupFavoriteItem[],
restoreId?: string
) {
// Canonical field order prevents a JSON reserialization from inventing a
// second legacy conversion and resurrecting a favorite removed meanwhile.
const payload = items.map((item) => ({
contentType: item.contentType,
xtreamId: item.xtreamId,
addedAt: item.addedAt ?? null,
position: item.position ?? null,
snapshot: item.snapshot
? {
title: item.snapshot.title,
posterUrl: item.snapshot.posterUrl ?? null,
backdropUrl: item.snapshot.backdropUrl ?? null,
tmdbId: item.snapshot.tmdbId ?? null,
releaseYear: item.snapshot.releaseYear ?? null,
originalTitle: item.snapshot.originalTitle ?? null,
}
: null,
}));
const digest = createHash('sha256')
.update(restoreId === undefined ? JSON.stringify(payload) : restoreId)
.digest('hex');
return `xtream-favorites-restore:${[...playlistId].length}:${playlistId}:${restoreId === undefined ? 'legacy' : 'backup'}:${digest}`;
}
function favoriteValue(
db: AppDatabase,
playlistId: string,
item: XtreamBackupFavoriteItem,
index: number
) {
if (
!Number.isInteger(item.xtreamId) ||
!['movie', 'series', 'live'].includes(item.contentType)
) {
throw new Error('Invalid saved favorite identity');
}
const candidate = db
.select({ content: schema.content })
.from(schema.content)
.innerJoin(
schema.categories,
eq(schema.content.categoryId, schema.categories.id)
)
.where(
and(
eq(schema.categories.playlistId, playlistId),
eq(schema.content.xtreamId, item.xtreamId),
eq(schema.content.type, item.contentType)
)
)
.orderBy(schema.content.id)
.limit(1)
.get()?.content;
const metadata = normalizeContentMetadataPatch(item.snapshot);
const conflicts =
candidate &&
((metadata?.tmdbId != null &&
candidate.tmdbId != null &&
metadata.tmdbId !== candidate.tmdbId) ||
(metadata?.releaseYear != null &&
candidate.releaseYear != null &&
metadata.releaseYear !== candidate.releaseYear));
const content = conflicts ? undefined : candidate;
return {
playlistId,
xtreamId: item.xtreamId,
type: item.contentType,
contentId: content?.id ?? null,
title:
item.snapshot?.title?.trim() ||
content?.title ||
`Saved ${item.contentType} #${item.xtreamId}`,
posterUrl: item.snapshot?.posterUrl ?? content?.posterUrl,
backdropUrl: metadata?.backdropUrl ?? content?.backdropUrl,
tmdbId: metadata?.tmdbId ?? content?.tmdbId,
releaseYear: metadata?.releaseYear ?? content?.releaseYear,
originalTitle: metadata?.originalTitle ?? content?.originalTitle,
addedAt: item.addedAt ?? new Date().toISOString(),
position: item.position ?? index,
};
}
/**
* One atomic conversion: pending legacy/backup state and its receipt commit
* together. Reads and writes are synchronous inside the transaction; a failed
* insert leaves neither partial favorites nor a receipt that would suppress retry.
* Existing canonical favorites win over replayed metadata and display order.
*/
export async function restoreXtreamFavorites(
db: AppDatabase,
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
control?: OperationControl,
restoreId?: string
): Promise<{ success: boolean }> {
if (favorites.length === 0) return { success: true };
await checkpointOperation(control);
const key = restoreReceiptKey(playlistId, favorites, restoreId);
db.transaction((tx) => {
if (
tx
.select({ key: schema.appState.key })
.from(schema.appState)
.where(eq(schema.appState.key, key))
.get()
)
return;
for (const [index, item] of favorites.entries()) {
tx.insert(schema.favorites)
.values(favoriteValue(tx, playlistId, item, index))
.onConflictDoNothing({
target: [
schema.favorites.playlistId,
schema.favorites.type,
schema.favorites.xtreamId,
],
})
.run();
}
tx.insert(schema.appState).values({ key, value: 'completed' }).run();
});
await reportOperationProgress(control, {
phase: 'restoring-favorites',
current: favorites.length,
total: favorites.length,
increment: favorites.length,
});
return { success: true };
}
@@ -0,0 +1,53 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { resolve, join } from 'node:path';
describe('durable Xtream favorites against initialized SQLite', () => {
it.each([
'refresh',
'empty',
'restore',
'identity',
'collision',
'scope',
'lock',
'receipt',
])(
'%s preserves the saved favorite independently of catalog rows',
(scenario) => {
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-favorites-'));
try {
expect(
execFileSync(
createRequire(__filename)('electron'),
[
'--import',
'tsx',
resolve(__dirname, 'testing/favorites-durable.ts'),
scenario,
],
{
cwd: process.cwd(),
encoding: 'utf8',
timeout: 30_000,
env: {
...process.env,
ELECTRON_RUN_AS_NODE: '1',
IPTVNATOR_E2E_DATA_DIR: dataDir,
TSX_TSCONFIG_PATH: resolve(
process.cwd(),
'tsconfig.base.json'
),
},
}
)
).toBe('favorites verified');
} finally {
rmSync(dataDir, { recursive: true, force: true });
}
},
35_000
);
});
@@ -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
@@ -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<number>`${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<number>`${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
@@ -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 };
}
@@ -0,0 +1,392 @@
import assert from 'node:assert/strict';
import { createFavoriteTestCatalog } from './favorites-test-catalog';
import { eq, sql } from 'drizzle-orm';
import { closeDatabase, initDatabase } from '@iptvnator/shared/database';
import * as schema from '@iptvnator/shared/database/schema';
import { setParentalLockActive } from '../../parental-lock-state';
import {
addFavorite,
getFavorites,
getFavoritesForBackup,
getAllGlobalFavorites,
getGlobalFavorites,
removeFavorite,
reorderGlobalFavorites,
isFavorite,
} from '../favorites.operations';
import {
deleteXtreamContent,
restoreXtreamUserData,
restoreXtreamFavorites,
} from '../xtream.operations';
import { persistContentMetadataIfMissing } from '../content-metadata.operations';
import { setAppState } from '../playlist.operations';
async function main() {
assert(process.env['IPTVNATOR_E2E_DATA_DIR']);
console.log = () => undefined;
const db = await initDatabase();
await db.insert(schema.playlists).values([
{ id: 'p', name: 'Source', type: 'xtream' },
{ id: 'other', name: 'Other source', type: 'xtream' },
]);
const catalog = createFavoriteTestCatalog(db);
const status = (value: string, type = 'movie') =>
setAppState(db, `xtream-import-status:p:${type}`, value);
const favorites = () => getFavorites(db, 'p');
const backup = [
{
xtreamId: 7,
contentType: 'movie' as const,
addedAt: '2025-01-01',
position: 4,
},
];
await catalog(1, 7);
await addFavorite(db, 1, 'p');
const [original] = await favorites();
const scenario = process.argv[2];
if (scenario === 'refresh') {
await persistContentMetadataIfMissing(db, 1, {
tmdbId: 123,
releaseYear: 2001,
});
const removed = await deleteXtreamContent(db, 'p');
await catalog(2, 8, 'DEF');
await restoreXtreamUserData(db, 'p', removed.favorites, []);
await status('completed');
const [saved] = await favorites();
assert(saved, 'ABC must remain after only DEF is imported');
assert.equal(saved.title, 'ABC');
assert.equal(saved.tmdb_id, 123);
assert.equal(saved.release_year, 2001);
assert.equal(saved.id, null);
assert.equal(saved.favorite_id, original.favorite_id);
assert.equal(saved.catalog_status, 'missing');
assert.deepEqual(
removed.favorites,
[],
'refresh must not replay canonical favorites'
);
assert.equal((await getAllGlobalFavorites(db))[0].title, 'ABC');
await deleteXtreamContent(db, 'p');
await catalog(3, 7, 'ABC renamed', 'movie', 'p', {
posterUrl: '',
backdropUrl: '',
});
await status('completed');
const [returned] = await favorites();
assert.equal(returned.id, 3);
assert.equal(returned.favorite_id, original.favorite_id);
assert.equal(returned.catalog_status, 'present');
assert.equal(returned.poster_url, original.poster_url);
assert.equal(
(await db.select().from(schema.favorites))[0].posterUrl,
original.poster_url
);
assert.equal(
(await db.select().from(schema.favorites))[0].contentId,
3
);
assert(await isFavorite(db, 3, 'p'));
await addFavorite(db, 3, 'p');
assert.equal((await favorites()).length, 1);
await removeFavorite(db, 3, 'p');
assert.deepEqual(await favorites(), []);
} else if (scenario === 'empty') {
await deleteXtreamContent(db, 'p');
for (const value of ['idle', 'importing', 'failed', 'cancelled']) {
await status(value);
assert.equal(
(await favorites())[0]?.catalog_status,
'unknown',
value
);
}
await status('completed');
assert.equal((await favorites())[0]?.catalog_status, 'missing');
await deleteXtreamContent(db, 'p');
assert.equal((await favorites())[0]?.catalog_status, 'unknown');
await reorderGlobalFavorites(db, [
{
favorite_id: original.favorite_id,
content_id: 999,
playlist_id: 'p',
position: 9,
},
]);
assert.equal((await favorites())[0]?.position, 9);
await removeFavorite(db, { favoriteId: original.favorite_id }, 'other');
assert.equal((await favorites()).length, 1);
await removeFavorite(db, { favoriteId: original.favorite_id }, 'p');
assert.deepEqual(await favorites(), []);
await catalog(2, 7);
await status('completed');
assert.deepEqual(
await favorites(),
[],
'removed missing favorites must not reappear'
);
} else if (scenario === 'restore') {
await restoreXtreamUserData(
db,
'p',
[
{
...backup[0],
xtreamId: 99,
snapshot: {
title: 'Saved offline',
tmdbId: 31,
releaseYear: 2001,
},
},
],
[]
);
assert.equal(
(await favorites()).length,
2,
'restore must not wipe existing favorite'
);
await restoreXtreamUserData(
db,
'p',
[{ ...backup[0], xtreamId: 99 }],
[]
);
const rows = await favorites();
assert.equal(rows.length, 2);
assert.equal(
rows.find((row) => row.xtream_id === 99)?.title,
'Saved offline'
);
await restoreXtreamUserData(
db,
'p',
[{ ...backup[0], xtreamId: 100 }],
[]
);
assert((await favorites()).find((row) => row.xtream_id === 100)?.title);
await catalog(3, 100, 'Recovered title');
await status('completed');
await deleteXtreamContent(db, 'p');
assert.equal(
(await favorites()).find((row) => row.xtream_id === 100)?.title,
'Recovered title',
'legacy fallback must acquire a durable snapshot when content returns'
);
await status('completed');
assert.equal(
(await favorites()).find((row) => row.xtream_id === 99)
?.catalog_status,
'missing'
);
} else if (scenario === 'identity') {
await catalog(10, 7, 'ABC in second category');
assert(
await isFavorite(db, 10, 'p'),
'every category row of one provider identity must be favorite'
);
await reorderGlobalFavorites(db, [
{ content_id: 10, playlist_id: 'p', position: 12 },
]);
assert.equal((await favorites())[0].position, 12);
await removeFavorite(db, 10, 'p');
assert.deepEqual(await favorites(), []);
await addFavorite(db, 1, 'p');
await catalog(2, 7, 'Other movie', 'movie', 'other');
await catalog(3, 7, 'Live', 'live', 'p', {
tvArchive: 1,
tvArchiveDuration: 7,
});
await catalog(4, 7, 'Series', 'series');
await addFavorite(db, 2, 'other');
await addFavorite(db, 3, 'p');
await addFavorite(db, 4, 'p');
await persistContentMetadataIfMissing(db, 4, { tmdbId: 345 });
await assert.rejects(
addFavorite(db, 2, 'p'),
'cross-playlist content must be rejected'
);
assert.equal((await favorites()).length, 3);
const [live] = await getGlobalFavorites(db);
assert.equal(live.tv_archive, 1);
assert.equal(live.tv_archive_duration, 7);
await deleteXtreamContent(db, 'p');
await catalog(5, 7, 'Returned live', 'live');
await status('completed', 'live');
await status('completed');
const rows = await favorites();
assert.equal(rows.find((row) => row.type === 'movie')?.id, null);
assert.equal(rows.find((row) => row.type === 'live')?.id, 5);
await catalog(11, 7, 'Unrelated series', 'series', 'p', {
tmdbId: 456,
});
await status('completed', 'series');
assert.equal(
(await favorites()).find((row) => row.type === 'series')?.id,
null
);
assert.equal((await getAllGlobalFavorites(db)).length, 4);
} else if (scenario === 'collision') {
await removeFavorite(db, 1, 'p');
await db
.update(schema.content)
.set({ tmdbId: 10, releaseYear: 2000 })
.where(eq(schema.content.id, 1));
await addFavorite(db, 1, 'p');
for (const [id, tmdbId, releaseYear] of [
[2, 20, 2000],
[3, 10, 2020],
]) {
await deleteXtreamContent(db, 'p');
await catalog(id, 7, 'Unrelated', 'movie', 'p', {
tmdbId,
releaseYear,
});
await status('completed');
const [saved] = await favorites();
assert.equal(saved?.id, null);
assert.equal(saved?.title, 'ABC');
assert.equal(saved?.catalog_status, 'missing');
const beforeAdd = await db.select().from(schema.favorites);
assert.equal(await isFavorite(db, id, 'p'), false);
assert.deepEqual(await addFavorite(db, id, 'p'), {
success: false,
error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT',
});
assert.equal(await isFavorite(db, id, 'p'), false);
assert.deepEqual(
await db.select().from(schema.favorites),
beforeAdd
);
}
await deleteXtreamContent(db, 'p');
await catalog(4, 7, 'ABC renamed', 'movie', 'p', {
tmdbId: 10,
releaseYear: 2000,
});
await status('completed');
assert.equal((await favorites())[0]?.id, 4);
} else if (scenario === 'scope') {
await catalog(2, 7, 'Other', 'movie', 'other');
await addFavorite(db, 2, 'other');
await deleteXtreamContent(db, 'p');
await db.delete(schema.playlists).where(eq(schema.playlists.id, 'p'));
assert.deepEqual(await favorites(), []);
assert.equal((await getAllGlobalFavorites(db)).length, 1);
} else if (scenario === 'receipt') {
const items = [
{ ...backup[0], xtreamId: 99, snapshot: { title: 'Offline' } },
];
await restoreXtreamFavorites(db, 'p', items);
const saved = (await favorites()).find((row) => row.xtream_id === 99);
assert(saved);
await removeFavorite(db, { favoriteId: saved.favorite_id }, 'p');
await restoreXtreamFavorites(db, 'p', items);
assert.equal(
(await favorites()).length,
1,
'legacy receipt prevents stale replay'
);
await restoreXtreamFavorites(db, 'p', [
{
snapshot: { title: 'Offline' },
position: 4,
addedAt: '2025-01-01',
contentType: 'movie',
xtreamId: 99,
},
]);
assert.equal(
(await favorites()).length,
1,
'receipt ignores JSON property order'
);
await restoreXtreamFavorites(
db,
'p',
items,
undefined,
'explicit-backup'
);
assert.equal(
(await favorites()).length,
2,
'new backup can intentionally restore'
);
await db.run(
sql`CREATE TRIGGER reject_test_favorite BEFORE INSERT ON favorites WHEN NEW.xtream_id = 100 BEGIN SELECT RAISE(ABORT, 'injected failure'); END`
);
await assert.rejects(
restoreXtreamFavorites(
db,
'p',
[
{ ...items[0], xtreamId: 101 },
{ ...items[0], xtreamId: 100 },
],
undefined,
'retry-failed'
)
);
assert.equal(
(await favorites()).length,
2,
'favorite writes roll back together with receipt'
);
await db.run(sql`DROP TRIGGER reject_test_favorite`);
await restoreXtreamFavorites(
db,
'p',
[
{ ...items[0], xtreamId: 101 },
{ ...items[0], xtreamId: 100 },
],
undefined,
'retry-failed'
);
assert.equal((await favorites()).length, 4);
await db.delete(schema.playlists).where(eq(schema.playlists.id, 'p'));
assert.deepEqual(
(await db.select().from(schema.appState)).filter((row) =>
row.key.startsWith('xtream-')
),
[],
'playlist deletion clears restore receipts'
);
} else if (scenario === 'lock') {
setParentalLockActive(true);
await db.update(schema.categories).set({ locked: true });
assert.deepEqual(await favorites(), []);
assert.equal(
(await getFavoritesForBackup(db, 'p')).length,
1,
'backup includes locked favorites'
);
setParentalLockActive(false);
assert.equal((await favorites()).length, 1);
await deleteXtreamContent(db, 'p');
setParentalLockActive(true);
assert.deepEqual(
await favorites(),
[],
'unknown category ownership must be withheld'
);
assert.equal(
(await getFavoritesForBackup(db, 'p'))[0]?.title,
'ABC',
'backup includes missing favorites while locked'
);
setParentalLockActive(false);
assert.equal((await favorites()).length, 1);
}
closeDatabase();
process.stdout.write('favorites verified');
}
main().catch((error) => {
console.error(error);
closeDatabase();
process.exitCode = 1;
});
@@ -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,
});
};
}
@@ -52,14 +52,6 @@ function createDeleteHarness() {
{ hidden: true, id: 11, type: 'live', xtreamId: 101 },
{ hidden: false, id: 12, type: 'movies', xtreamId: 102 },
];
const favorites = [
{
addedAt: '2026-01-01T00:00:00.000Z',
contentType: 'live',
position: 3,
xtreamId: 201,
},
];
const recentlyViewed = [
{
contentType: 'movie',
@@ -79,9 +71,6 @@ function createDeleteHarness() {
.mockReturnValueOnce(
createSelectStep(categories, 'select:categories', timeline)
)
.mockReturnValueOnce(
createSelectStep(favorites, 'select:favorites', timeline)
)
.mockReturnValueOnce(
createSelectStep(recentlyViewed, 'select:recently-viewed', timeline)
)
@@ -110,7 +99,15 @@ function createDeleteHarness() {
return {
countFilters,
db: { select, transaction } as unknown as AppDatabase,
db: {
select,
transaction,
update: jest.fn(() => ({
set: jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
})),
})),
} as unknown as AppDatabase,
deleteFilters,
timeline,
transaction,
@@ -145,14 +142,7 @@ describe('Xtream delete performance phases', () => {
recording.capture
)
).resolves.toEqual({
favorites: [
{
addedAt: '2026-01-01T00:00:00.000Z',
contentType: 'live',
position: 3,
xtreamId: 201,
},
],
favorites: [],
hiddenCategories: [{ categoryType: 'live', xtreamId: 101 }],
recentlyViewed: [
{
@@ -169,7 +159,7 @@ describe('Xtream delete performance phases', () => {
expect(harness.transaction).toHaveBeenCalledTimes(2);
// Progress reporting performs a second cooperative checkpoint after
// each committed batch.
expect(checkpoint).toHaveBeenCalledTimes(4);
expect(checkpoint).toHaveBeenCalledTimes(5);
expect(onProgress.mock.calls.map(([value]) => value)).toEqual([
{
phase: 'deleting-content',
@@ -184,9 +174,8 @@ describe('Xtream delete performance phases', () => {
increment: 2,
},
]);
expect(harness.timeline.slice(1, 5)).toEqual([
expect(harness.timeline.slice(2, 5)).toEqual([
'select:categories',
'select:favorites',
'select:recently-viewed',
'select:content',
]);
@@ -264,7 +253,7 @@ describe('Xtream delete performance phases', () => {
{
checkpoint: () => {
checkpointCount += 1;
if (checkpointCount === 2) {
if (checkpointCount === 3) {
throw cancellation;
}
},
@@ -286,6 +275,11 @@ describe('Xtream delete performance phases', () => {
const from = jest.fn(() => ({ where }));
const db = {
select: jest.fn(() => ({ from })),
update: jest.fn(() => ({
set: jest.fn(() => ({
where: jest.fn().mockResolvedValue(undefined),
})),
})),
} as unknown as AppDatabase;
const recording = createRecordingOperationPhaseCapture();
@@ -1,3 +1,5 @@
import { restoreXtreamFavorites } from './favorite-restore';
export { restoreXtreamFavorites } from './favorite-restore';
import { and, eq, inArray } from 'drizzle-orm';
import * as schema from '@iptvnator/shared/database/schema';
import {
@@ -69,37 +71,11 @@ async function collectXtreamDeletionRows(
categoryType: category.type,
}));
let favorites: XtreamBackupFavoriteItem[] = [];
const favorites: XtreamBackupFavoriteItem[] = [];
let recentlyViewed: XtreamBackupRecentlyViewedItem[] = [];
let contentRowCounts: CategoryRowCount[] = [];
if (categoryIds.length > 0) {
const favoritedContent = await db
.select({
xtreamId: schema.content.xtreamId,
contentType: schema.content.type,
addedAt: schema.favorites.addedAt,
position: schema.favorites.position,
})
.from(schema.favorites)
.innerJoin(
schema.content,
eq(schema.favorites.contentId, schema.content.id)
)
.where(
and(
eq(schema.favorites.playlistId, playlistId),
inArray(schema.content.categoryId, categoryIds)
)
);
favorites = favoritedContent.map((favorite) => ({
xtreamId: favorite.xtreamId,
contentType: favorite.contentType,
addedAt: favorite.addedAt ?? undefined,
position: favorite.position,
}));
const recentlyViewedContent = await db
.select({
xtreamId: schema.content.xtreamId,
@@ -186,6 +162,18 @@ export async function deleteXtreamContent(
recentlyViewed: XtreamBackupRecentlyViewedItem[];
hiddenCategories: XtreamBackupHiddenCategory[];
}> {
await checkpointOperation(control);
await db
.update(schema.appState)
.set({ value: 'idle' })
.where(
inArray(
schema.appState.key,
['live', 'movie', 'series'].map(
(type) => `xtream-import-status:${playlistId}:${type}`
)
)
);
const collection = capturePhase
? await capturePhase.captureAsync(
XTREAM_DATABASE_PERFORMANCE_PHASE.SQLITE_XTREAM_DELETE_COLLECT_USER_DATA,
@@ -265,10 +253,7 @@ export async function restoreXtreamUserData(
recentlyViewed: XtreamBackupRecentlyViewedItem[],
control?: OperationControl
): Promise<{ success: boolean }> {
await checkpointOperation(control);
await db
.delete(schema.favorites)
.where(eq(schema.favorites.playlistId, playlistId));
await restoreXtreamFavorites(db, playlistId, favorites, control);
await checkpointOperation(control);
await db
@@ -276,61 +261,12 @@ export async function restoreXtreamUserData(
.where(eq(schema.recentlyViewed.playlistId, playlistId));
const contentByIdentity = await getContentIdentityMap(db, playlistId, [
...favorites.map((item) => ({
contentType: item.contentType,
xtreamId: item.xtreamId,
})),
...recentlyViewed.map((item) => ({
contentType: item.contentType,
xtreamId: item.xtreamId,
})),
]);
const favoriteValues = favorites
.map((item, index) => {
const contentId = contentByIdentity.get(
toContentIdentityKey(item.contentType, item.xtreamId)
);
if (!contentId) {
return null;
}
return {
contentId,
playlistId,
addedAt: item.addedAt ?? new Date().toISOString(),
position: item.position ?? index,
};
})
.filter(
(
value
): value is {
contentId: number;
playlistId: string;
addedAt: string;
position: number | null;
} => value !== null
);
let restoredFavorites = 0;
const totalFavorites = favoriteValues.length;
for (const chunk of chunkValues(favoriteValues, DEFAULT_BATCH_SIZE)) {
await checkpointOperation(control);
await db.transaction((tx) => {
tx.insert(schema.favorites).values(chunk).run();
});
restoredFavorites += chunk.length;
await reportOperationProgress(control, {
phase: 'restoring-favorites',
current: restoredFavorites,
total: totalFavorites,
increment: chunk.length,
});
}
const recentlyViewedValues = recentlyViewed
.map((item) => {
const contentId = contentByIdentity.get(
@@ -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,
}));
@@ -259,6 +259,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
payload: { playlistId, favorites, recentlyViewed, operationId },
forwardsEvents: true,
},
{
operation: 'DB_RESTORE_XTREAM_FAVORITES',
args: [playlistId, favorites, 'restore-1'],
payload: { playlistId, favorites, restoreId: 'restore-1' },
},
{
operation: 'DB_ADD_FAVORITE',
args: [12, playlistId, 'https://image.example/backdrop.jpg'],
@@ -278,6 +283,11 @@ export const workerIpcContractCases: WorkerIpcContractCase[] = [
args: [12, playlistId],
payload: { contentId: 12, playlistId },
},
{
operation: 'DB_GET_FAVORITES_FOR_BACKUP',
args: [playlistId],
payload: { playlistId },
},
{
operation: 'DB_GET_FAVORITES',
args: [playlistId],
@@ -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',
@@ -1,3 +1,7 @@
import type {
ElectronBridgeFavoriteReorderUpdate,
XtreamFavoriteRemovalRef,
} from '@iptvnator/shared/interfaces';
import { migrateAppPlaylists } from '../database/operations/playlist-migration.operations';
import {
closeWorkerDatabase,
@@ -34,6 +38,7 @@ import {
addFavorite,
getAllGlobalFavorites,
getFavorites,
getFavoritesForBackup,
getGlobalFavorites,
isFavorite,
removeFavorite,
@@ -111,6 +116,7 @@ import {
import {
deleteXtreamContent,
restoreXtreamUserData,
restoreXtreamFavorites,
} from '../database/operations/xtream.operations';
import {
armWorkerPerformanceCapture,
@@ -1046,6 +1052,20 @@ async function executeRequest(
);
}
case 'DB_RESTORE_XTREAM_FAVORITES': {
const { playlistId, favorites, restoreId } = message.payload as {
playlistId: string;
favorites: XtreamBackupFavoriteItem[];
restoreId?: string;
};
return restoreXtreamFavorites(
db,
playlistId,
favorites,
undefined,
restoreId
);
}
case 'DB_ADD_FAVORITE': {
const payload = message.payload as {
contentId: number;
@@ -1059,7 +1079,7 @@ async function executeRequest(
case 'DB_REMOVE_FAVORITE': {
const payload = message.payload as {
contentId: number;
contentId: XtreamFavoriteRemovalRef;
playlistId: string;
};
return removeFavorite(db, payload.contentId, payload.playlistId);
@@ -1073,6 +1093,10 @@ async function executeRequest(
return isFavorite(db, payload.contentId, payload.playlistId);
}
case 'DB_GET_FAVORITES_FOR_BACKUP': {
const { playlistId } = message.payload as { playlistId: string };
return getFavoritesForBackup(db, playlistId);
}
case 'DB_GET_FAVORITES': {
const payload = message.payload as { playlistId: string };
return getFavorites(db, payload.playlistId);
@@ -1086,11 +1110,7 @@ async function executeRequest(
case 'DB_REORDER_GLOBAL_FAVORITES': {
const payload = message.payload as {
updates: {
content_id: number;
playlist_id: string;
position: number;
}[];
updates: ElectronBridgeFavoriteReorderUpdate[];
};
return reorderGlobalFavorites(db, payload.updates);
}
@@ -1207,7 +1227,11 @@ async function executeRequest(
playlistId: string;
items: Parameters<typeof replaceAllPlaybackPositions>[2];
};
return replaceAllPlaybackPositions(db, payload.playlistId, payload.items);
return replaceAllPlaybackPositions(
db,
payload.playlistId,
payload.items
);
}
case 'DB_CLEAR_ALL_PLAYBACK_POSITIONS': {
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "تمت الإزالة من المفضلة",
"ADDED_TO_FAVORITES": "تمت الإضافة للمفضلة",
"FAVORITES": "المفضلة",
"FAVORITE_IDENTITY_CONFLICT": "استبدل هذا المصدر عنصراً حفظته. احذف الإدخال القديم من المفضلة قبل إضافة هذا العنصر.",
"FAVORITE_SAVE_FAILED": "تعذر حفظ هذا العنصر في المفضلة. يرجى المحاولة مرة أخرى.",
"RECENTLY_VIEWED": "تمت مشاهدتها مؤخرًا",
"THIS_PLAYLIST": "قائمة التشغيل هذه",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "غير موجود في الكتالوج الحالي",
"CATALOG_UNKNOWN": "حالة الكتالوج غير معروفة",
"TITLE": "المفضلة العامة",
"SUBTITLE": "البث المباشر من جميع قوائم التشغيل",
"SEARCH_PLACEHOLDER": "البحث في المفضلة...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "تحيد من المفضلة",
"ADDED_TO_FAVORITES": "تزاد للمفضلة",
"FAVORITES": "المفضلة",
"FAVORITE_IDENTITY_CONFLICT": "هاد المصدر بدّل واحد العنصر اللي حفظتي. حيد القديم من المفضلة قبل ما تزيد هادا.",
"FAVORITE_SAVE_FAILED": "ما قدرناش نحفظو هاد العنصر فالمفضلة. عاود حاول.",
"RECENTLY_VIEWED": "متشافين مؤخراً",
"THIS_PLAYLIST": "هاد قائمة التشغيل",
"ALL_PLAYLISTS": "جميع قوائم التشغيل",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "ما كاينش فالفهرس الحالي",
"CATALOG_UNKNOWN": "حالة الفهرس ما معروفةش",
"TITLE": "المفضلة العامة",
"SUBTITLE": "البث المباشر من جميع قوائم التشغيل",
"SEARCH_PLACEHOLDER": "قلب على المفضلة...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Выдалена з выбранага",
"ADDED_TO_FAVORITES": "Дададзена ў выбранае",
"FAVORITES": "Выбранае",
"FAVORITE_IDENTITY_CONFLICT": "Крыніца замяніла захаваны вамі элемент. Выдаліце стары запіс з абранага, перш чым дадаць новы.",
"FAVORITE_SAVE_FAILED": "Не ўдалося захаваць абранае. Паспрабуйце яшчэ раз.",
"RECENTLY_VIEWED": "Нядаўна праглянутае",
"THIS_PLAYLIST": "Гэты плэйліст",
"ALL_PLAYLISTS": "Усе плэйлісты",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Няма ў бягучым каталогу",
"CATALOG_UNKNOWN": "Стан каталога невядомы",
"TITLE": "Усе фаварыты",
"SUBTITLE": "Прамы эфір з усіх плэйлістоў",
"SEARCH_PLACEHOLDER": "Пошук у выбраным...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Aus Favoriten entfernt",
"ADDED_TO_FAVORITES": "Zu den Favoriten hinzugefügt",
"FAVORITES": "Favoriten",
"FAVORITE_IDENTITY_CONFLICT": "Diese Quelle hat einen gespeicherten Eintrag ersetzt. Entferne den alten Eintrag aus den Favoriten, bevor du diesen hinzufügst.",
"FAVORITE_SAVE_FAILED": "Der Favorit konnte nicht gespeichert werden. Bitte versuche es erneut.",
"RECENTLY_VIEWED": "Zuletzt angesehen",
"THIS_PLAYLIST": "Diese Playlist",
"ALL_PLAYLISTS": "Alle Playlists",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Nicht im aktuellen Katalog",
"CATALOG_UNKNOWN": "Katalogstatus unbekannt",
"TITLE": "Globale Favoriten",
"SUBTITLE": "Live-TV aus allen Playlists",
"SEARCH_PLACEHOLDER": "Favoriten durchsuchen...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Αφαιρέθηκε από τα αγαπημένα",
"ADDED_TO_FAVORITES": "Προστέθηκε στα αγαπημένα",
"FAVORITES": "Αγαπημένα",
"FAVORITE_IDENTITY_CONFLICT": "Αυτή η πηγή αντικατέστησε ένα αποθηκευμένο στοιχείο. Αφαιρέστε την παλιά καταχώριση από τα αγαπημένα πριν προσθέσετε τη νέα.",
"FAVORITE_SAVE_FAILED": "Δεν ήταν δυνατή η αποθήκευση του αγαπημένου. Δοκιμάστε ξανά.",
"RECENTLY_VIEWED": "Πρόσφατα προβληθέντα",
"THIS_PLAYLIST": "Αυτή η λίστα αναπαραγωγής",
"ALL_PLAYLISTS": "Όλες οι λίστες αναπαραγωγής",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Δεν υπάρχει στον τρέχοντα κατάλογο",
"CATALOG_UNKNOWN": "Άγνωστη κατάσταση καταλόγου",
"TITLE": "Βασικά Αγαπημένα",
"SUBTITLE": "Ζωντανή TV από όλες τις λίστες αναπαραγωγής",
"SEARCH_PLACEHOLDER": "Αναζήτηση αγαπημένων...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Removed from favorites",
"ADDED_TO_FAVORITES": "Added to favorites",
"FAVORITES": "Favorites",
"FAVORITE_IDENTITY_CONFLICT": "This source replaced an item you saved. Remove the older entry from Favorites before adding this one.",
"FAVORITE_SAVE_FAILED": "Could not save this favorite. Please try again.",
"RECENTLY_VIEWED": "Recently viewed",
"THIS_PLAYLIST": "This playlist",
"ALL_PLAYLISTS": "All playlists",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Not in the current catalog",
"CATALOG_UNKNOWN": "Catalog status unknown",
"TITLE": "Global Favorites",
"SUBTITLE": "Live TV from all playlists",
"SEARCH_PLACEHOLDER": "Search favorites...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Eliminado de favoritos",
"ADDED_TO_FAVORITES": "Añadido a favoritos",
"FAVORITES": "Favoritos",
"FAVORITE_IDENTITY_CONFLICT": "Esta fuente ha sustituido un elemento guardado. Elimina la entrada anterior de Favoritos antes de añadir esta.",
"FAVORITE_SAVE_FAILED": "No se pudo guardar este favorito. Inténtalo de nuevo.",
"RECENTLY_VIEWED": "Vistos recientemente",
"THIS_PLAYLIST": "Esta lista de reproducción",
"ALL_PLAYLISTS": "Todas las listas",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "No está en el catálogo actual",
"CATALOG_UNKNOWN": "Estado del catálogo desconocido",
"TITLE": "Favoritos globales",
"SUBTITLE": "TV en vivo de todas las listas",
"SEARCH_PLACEHOLDER": "Buscar favoritos…",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Retiré des favoris",
"ADDED_TO_FAVORITES": "Ajouté aux favoris",
"FAVORITES": "Favoris",
"FAVORITE_IDENTITY_CONFLICT": "Cette source a remplacé un élément enregistré. Supprimez l’ancienne entrée des favoris avant d’ajouter celle-ci.",
"FAVORITE_SAVE_FAILED": "Impossible d’enregistrer ce favori. Veuillez réessayer.",
"RECENTLY_VIEWED": "Récemment consultés",
"THIS_PLAYLIST": "Cette liste de lecture",
"ALL_PLAYLISTS": "Toutes les listes de lecture",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Absent du catalogue actuel",
"CATALOG_UNKNOWN": "État du catalogue inconnu",
"TITLE": "Favoris globaux",
"SUBTITLE": "TV en direct de toutes les listes de lecture",
"SEARCH_PLACEHOLDER": "Rechercher dans les favoris…",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Eltávolítva a kedvencek közül",
"ADDED_TO_FAVORITES": "Hozzáadva a kedvencekhez",
"FAVORITES": "Kedvencek",
"FAVORITE_IDENTITY_CONFLICT": "Ez a forrás lecserélt egy mentett elemet. Az új hozzáadása előtt távolítsd el a régit a kedvencekből.",
"FAVORITE_SAVE_FAILED": "Nem sikerült menteni a kedvencet. Próbáld újra.",
"RECENTLY_VIEWED": "Legutóbb megtekintett",
"THIS_PLAYLIST": "Ez a lejátszási lista",
"ALL_PLAYLISTS": "Összes lejátszási lista",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Nincs a jelenlegi katalógusban",
"CATALOG_UNKNOWN": "A katalógus állapota ismeretlen",
"TITLE": "Összesített kedvencek",
"SUBTITLE": "Élő csatornák az összes lejátszási listából",
"SEARCH_PLACEHOLDER": "Keresés a kedvencek között…",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Rimosso dai preferiti",
"ADDED_TO_FAVORITES": "Aggiunto ai preferiti",
"FAVORITES": "Preferiti",
"FAVORITE_IDENTITY_CONFLICT": "Questa fonte ha sostituito un elemento salvato. Rimuovi la vecchia voce dai preferiti prima di aggiungere questa.",
"FAVORITE_SAVE_FAILED": "Impossibile salvare questo preferito. Riprova.",
"RECENTLY_VIEWED": "Visti di recente",
"THIS_PLAYLIST": "Questa playlist",
"ALL_PLAYLISTS": "Tutte le playlist",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Non presente nel catalogo attuale",
"CATALOG_UNKNOWN": "Stato del catalogo sconosciuto",
"TITLE": "Preferiti globali",
"SUBTITLE": "TV in diretta da tutte le playlist",
"SEARCH_PLACEHOLDER": "Cerca tra i preferiti...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "お気に入りから削除されました",
"ADDED_TO_FAVORITES": "お気に入りに追加されました",
"FAVORITES": "お気に入り",
"FAVORITE_IDENTITY_CONFLICT": "この配信元は保存済みの項目を別の項目に置き換えました。新しい項目を追加する前に、お気に入りから古い項目を削除してください。",
"FAVORITE_SAVE_FAILED": "お気に入りを保存できませんでした。もう一度お試しください。",
"RECENTLY_VIEWED": "最近表示した項目",
"THIS_PLAYLIST": "このプレイリスト",
"ALL_PLAYLISTS": "すべてのプレイリスト",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "現在のカタログにありません",
"CATALOG_UNKNOWN": "カタログの状態は不明です",
"TITLE": "グローバルお気に入り",
"SUBTITLE": "すべてのプレイリストのライブTV",
"SEARCH_PLACEHOLDER": "お気に入りを検索...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "즐겨찾기에서 삭제됨",
"ADDED_TO_FAVORITES": "즐겨찾기에 추가됨",
"FAVORITES": "즐겨찾기",
"FAVORITE_IDENTITY_CONFLICT": "이 소스가 저장된 항목을 교체했습니다. 새 항목을 추가하려면 먼저 즐겨찾기에서 이전 항목을 삭제하세요.",
"FAVORITE_SAVE_FAILED": "즐겨찾기를 저장하지 못했습니다. 다시 시도해 주세요.",
"RECENTLY_VIEWED": "최근 본 항목",
"THIS_PLAYLIST": "이 재생목록",
"ALL_PLAYLISTS": "모든 재생목록",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "현재 카탈로그에 없음",
"CATALOG_UNKNOWN": "카탈로그 상태 알 수 없음",
"TITLE": "전체 즐겨찾기",
"SUBTITLE": "모든 재생목록의 라이브 TV",
"SEARCH_PLACEHOLDER": "즐겨찾기 검색...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Uit favorieten verwijderd",
"ADDED_TO_FAVORITES": "Toegevoegd aan favorieten",
"FAVORITES": "Favorieten",
"FAVORITE_IDENTITY_CONFLICT": "Deze bron heeft een opgeslagen item vervangen. Verwijder het oude item uit Favorieten voordat je dit toevoegt.",
"FAVORITE_SAVE_FAILED": "Deze favoriet kon niet worden opgeslagen. Probeer het opnieuw.",
"RECENTLY_VIEWED": "Recent bekeken",
"THIS_PLAYLIST": "Deze afspeellijst",
"ALL_PLAYLISTS": "Alle afspeellijsten",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Niet in de huidige catalogus",
"CATALOG_UNKNOWN": "Catalogusstatus onbekend",
"TITLE": "Algemene favorieten",
"SUBTITLE": "Live tv uit alle afspeellijsten",
"SEARCH_PLACEHOLDER": "Zoek favorieten...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Usunięto z ulubionych",
"ADDED_TO_FAVORITES": "Dodano do ulubionych",
"FAVORITES": "Ulubione",
"FAVORITE_IDENTITY_CONFLICT": "To źródło zastąpiło zapisany element. Usuń starszy wpis z ulubionych przed dodaniem nowego.",
"FAVORITE_SAVE_FAILED": "Nie udało się zapisać ulubionego. Spróbuj ponownie.",
"RECENTLY_VIEWED": "Ostatnio oglądane",
"THIS_PLAYLIST": "Ta lista odtwarzania",
"ALL_PLAYLISTS": "Wszystkie listy odtwarzania",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Brak w bieżącym katalogu",
"CATALOG_UNKNOWN": "Nieznany stan katalogu",
"TITLE": "Globalne ulubione",
"SUBTITLE": "Telewizja na żywo ze wszystkich list odtwarzania",
"SEARCH_PLACEHOLDER": "Szukaj ulubionych...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Removido dos favoritos",
"ADDED_TO_FAVORITES": "Adicionado aos favoritos",
"FAVORITES": "Favoritos",
"FAVORITE_IDENTITY_CONFLICT": "Esta fonte substituiu um item guardado. Remova a entrada antiga dos favoritos antes de adicionar esta.",
"FAVORITE_SAVE_FAILED": "Não foi possível guardar este favorito. Tente novamente.",
"RECENTLY_VIEWED": "Visualizados recentemente",
"THIS_PLAYLIST": "Esta playlist",
"ALL_PLAYLISTS": "Todas as playlists",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Não está no catálogo atual",
"CATALOG_UNKNOWN": "Estado do catálogo desconhecido",
"TITLE": "Favoritos globais",
"SUBTITLE": "TV ao vivo de todas as playlists",
"SEARCH_PLACEHOLDER": "Pesquisar favoritos...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Удалено из избранного",
"ADDED_TO_FAVORITES": "Добавлено в избранное",
"FAVORITES": "Избранное",
"FAVORITE_IDENTITY_CONFLICT": "Источник заменил сохранённый вами элемент. Удалите старую запись из избранного, прежде чем добавить новую.",
"FAVORITE_SAVE_FAILED": "Не удалось сохранить избранное. Попробуйте ещё раз.",
"RECENTLY_VIEWED": "Недавно просмотренные",
"THIS_PLAYLIST": "Этот плейлист",
"ALL_PLAYLISTS": "Все плейлисты",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Нет в текущем каталоге",
"CATALOG_UNKNOWN": "Статус каталога неизвестен",
"TITLE": "Глобальные избранные",
"SUBTITLE": "Live TV из всех плейлистов",
"SEARCH_PLACEHOLDER": "Поиск в избранном...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "Favorilerden kaldırıldı",
"ADDED_TO_FAVORITES": "Favorilere eklendi",
"FAVORITES": "Favoriler",
"FAVORITE_IDENTITY_CONFLICT": "Bu kaynak kaydettiğiniz bir öğeyi değiştirdi. Bunu eklemeden önce eski kaydı Favorilerden kaldırın.",
"FAVORITE_SAVE_FAILED": "Bu favori kaydedilemedi. Lütfen tekrar deneyin.",
"RECENTLY_VIEWED": "Son izlenenler",
"THIS_PLAYLIST": "Bu oynatma listesi",
"ALL_PLAYLISTS": "Tüm oynatma listeleri",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "Mevcut katalogda bulunmuyor",
"CATALOG_UNKNOWN": "Katalog durumu bilinmiyor",
"TITLE": "Tüm Favoriler",
"SUBTITLE": "Tüm oynatma listelerinden canlı TV",
"SEARCH_PLACEHOLDER": "Favorilerde ara...",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "已从收藏中移除",
"ADDED_TO_FAVORITES": "已添加至收藏夹",
"FAVORITES": "收藏",
"FAVORITE_IDENTITY_CONFLICT": "此来源替换了您保存的项目。请先从收藏夹中移除旧项目,再添加此项目。",
"FAVORITE_SAVE_FAILED": "无法保存此收藏。请重试。",
"RECENTLY_VIEWED": "最近观看",
"THIS_PLAYLIST": "本播放列表",
"ALL_PLAYLISTS": "所有播放列表",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "当前目录中不存在",
"CATALOG_UNKNOWN": "目录状态未知",
"TITLE": "全局收藏",
"SUBTITLE": "来自所有播放列表的直播电视",
"SEARCH_PLACEHOLDER": "搜索收藏…",
+4
View File
@@ -1271,6 +1271,8 @@
"REMOVED_FROM_FAVORITES": "已從最愛中移除",
"ADDED_TO_FAVORITES": "已新增至最愛",
"FAVORITES": "我的最愛",
"FAVORITE_IDENTITY_CONFLICT": "此來源替換了您儲存的項目。請先從收藏中移除舊項目,再新增此項目。",
"FAVORITE_SAVE_FAILED": "無法儲存此收藏。請重試。",
"RECENTLY_VIEWED": "最近觀看",
"THIS_PLAYLIST": "此播放清單",
"ALL_PLAYLISTS": "所有播放清單",
@@ -1977,6 +1979,8 @@
}
},
"GLOBAL_FAVORITES": {
"CATALOG_MISSING": "目前目錄中不存在",
"CATALOG_UNKNOWN": "目錄狀態未知",
"TITLE": "全域我的最愛",
"SUBTITLE": "來自所有播放清單的直播電視",
"SEARCH_PLACEHOLDER": "搜尋我的最愛...",
+20
View File
@@ -656,6 +656,26 @@ is unavailable or the SQLite playlist migration has not completed.
5. `DB_GET_GLOBAL_FAVORITES`
6. `DB_GET_ALL_GLOBAL_FAVORITES`
7. `DB_REORDER_GLOBAL_FAVORITES`
8. `DB_RESTORE_XTREAM_FAVORITES`
9. `DB_GET_FAVORITES_FOR_BACKUP`
Xtream favorites survive catalog/category deletion through nullable `content_id`
links and independent typed provider identities plus display snapshots. Reads
return the durable `favorite_id`, nullable catalog IDs and `catalog_status`;
remove accepts `{ favoriteId }` and reorder accepts `favorite_id` without a
catalog row. Legacy content-ID callers resolve playlist/type/provider identity,
including duplicate category rows. Import completion reconciles links and saved
metadata; detail enrichment also updates the snapshot. Known identity conflicts
remain unresolved. See [Xtream compatibility](./xtream-portal-compatibility.md#durable-electron-favorites).
`DB_RESTORE_XTREAM_FAVORITES` materializes pending/backup snapshots without an
online catalog. Inserts and their replay receipt share one synchronous worker
transaction, so retry after removal does not resurrect an item. Explicit backup
imports supply a fresh restore ID; legacy pending payloads use a canonical
hash. `DB_DELETE_XTREAM_CONTENT` returns no canonical favorites to replay.
`DB_GET_FAVORITES_FOR_BACKUP` deliberately includes locked/unresolved rows;
ordinary UI reads preserve parental-lock filtering. A failed export read must
propagate to the caller.
### Recently viewed
@@ -37,6 +37,60 @@ See [Nx Workspace Boundaries](./nx-workspace-boundaries.md),
[Portal Detail Navigation](./portal-detail-navigation.md), and
[VOD Multi-Source](./vod-multi-source.md).
## Durable Electron Favorites
SQLite favorites are user-owned records keyed by playlist, normalized content
kind (`live`, `movie`, `series`), and provider ID. The nullable catalog link uses
`ON DELETE SET NULL`; only explicit favorite removal/clear or playlist deletion
removes a saved record. Refresh and import-cache cleanup never replay canonical
favorites from a temporary snapshot. The two Refresh entry points continue to
share `XtreamRefreshFlowService` and its per-playlist single-flight guard.
Favorite rows retain a durable `favorite_id`, saved title/artwork, added date
and order. Their `id`/`category_id` may be null; consumers must not invent a
catalog ID. Remove/reorder can use the stable favorite ID while detail toggles
resolve any category row through the same typed provider identity. Completed
imports reconnect valid identities and refresh display snapshots. Metadata
learned in detail views is copied into the saved snapshot before catalog loss.
Conflicting known TMDB IDs or release years prevent automatic reconnection;
changed provider IDs never reconnect by title.
Adding a conflicting replacement returns an explicit failure and retains the
older favorite. The detail toggle stays inactive and explains that the older
entry must be removed from Favorites before the replacement can be saved.
`catalog_status` is `present`, `missing`, or `unknown`. Only a completed import
of the relevant type can prove absence. Failed, cancelled and in-progress
imports remain unknown. Collections and dashboard rails retain unresolved rows
with saved titles/status and removal/order controls; they suppress playback,
details, downloads and EPG requests that depend on catalog resolution. History
navigation rechecks current collection state. Removing a live row or making it
unavailable closes its active detail and invalidates pending selection work,
including a retained player during a channel switch. Unresolved records are not
promoted to dashboard hero slides. Parental locks still gate display: an
unresolved category cannot prove that it is unlocked, so those records remain
stored but are withheld while locks apply.
Legacy pending favorites are converted before destructive refresh, import
requests and ordinary collection reads, even if portal-status preflight is
offline. Backup merges convert any older pending favorites before parking the
incoming archive. Favorite inserts and a replay receipt commit together;
retries cannot resurrect a subsequently removed favorite. Deliberate backup import creates a
fresh receipt ID, which stays internal and is never exported. Identity-only old
backups use a labelled saved-item fallback until catalog metadata returns.
Backups include unresolved snapshots and locked records through a dedicated
unfiltered read; a failed favorites read fails export rather than producing an
empty collection. New backup imports materialize favorites before waiting for
an online catalog. Existing history, hidden-category and playback-position
restore contracts remain separate.
Both Xtream data-source and portal-activity capabilities require the pending
favorite conversion bridge; activity reads also require playlist lookup.
Records already deleted by an older build cannot be recovered without a
surviving pending state or backup. Opening an Xtream section may initialize
content when its cache is not ready; startup imports and explicit refresh use
the same durable persistence invariant. This does not add a refresh scheduler.
## PWA Collection Persistence
Collection operations carry `{ id, type }`: `id` remains the SQLite row ID
@@ -77,6 +77,7 @@ describe('XtreamRefreshFlowService', () => {
let confirmPromise: Promise<void> | undefined;
let order: string[];
let databaseService: {
restoreXtreamFavoriteSnapshots: jest.Mock;
createOperationId: jest.Mock;
deleteXtreamPlaylistContent: jest.Mock;
updateXtreamPlaylistDetails: jest.Mock;
@@ -94,6 +95,10 @@ describe('XtreamRefreshFlowService', () => {
order = [];
databaseService = {
restoreXtreamFavoriteSnapshots: jest.fn(() => {
order.push('save-favorites');
return Promise.resolve();
}),
createOperationId: jest.fn((prefix: string) => `${prefix}-op`),
deleteXtreamPlaylistContent: jest.fn(() => {
order.push('delete');
@@ -154,6 +159,53 @@ describe('XtreamRefreshFlowService', () => {
localStorage.clear();
});
it('converts legacy pending favorites before deleting or replacing the old catalog', async () => {
localStorage.setItem(
'xtream-restore-playlist-1',
JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 7 }],
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
})
);
service.confirmAndRefresh(
createPlaylistMeta(),
createReporter().reporter
);
await confirmPromise;
expect(
databaseService.restoreXtreamFavoriteSnapshots
).toHaveBeenCalledWith(
'playlist-1',
[{ contentType: 'movie', xtreamId: 7 }],
undefined
);
expect(order.indexOf('save-favorites')).toBeLessThan(
order.indexOf('delete')
);
});
it('preserves legacy pending state and the catalog when durable conversion fails', async () => {
const pending = JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 7 }],
});
localStorage.setItem('xtream-restore-playlist-1', pending);
databaseService.restoreXtreamFavoriteSnapshots.mockRejectedValue(
new Error('SQLite unavailable')
);
jest.spyOn(console, 'error').mockImplementation(() => undefined);
service.confirmAndRefresh(
createPlaylistMeta(),
createReporter().reporter
);
await confirmPromise;
expect(
databaseService.deleteXtreamPlaylistContent
).not.toHaveBeenCalled();
expect(localStorage.getItem('xtream-restore-playlist-1')).toBe(pending);
});
it('ignores a confirmation that arrives while the playlist is already busy', async () => {
const { reporter, calls } = createReporter({ isBusy: () => true });
@@ -152,6 +152,17 @@ export class XtreamRefreshFlowService {
);
await reporter.waitForVisibleProgress?.();
// Migrate an older parked favorite list before replacing its state.
// A durable receipt prevents replay after an explicit user removal.
const pending = this.pendingRestoreService.getOrThrow(item._id);
if (pending) {
await this.databaseService.restoreXtreamFavoriteSnapshots(
item._id,
pending.favorites,
pending.favoritesRestoreId
);
}
// Delete content/categories and update the timestamp in parallel —
// all three operations are fully independent.
const updateDate = Date.now();
@@ -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();
}
);
}
@@ -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');
@@ -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<string, UnifiedCollectionItem[]>();
for (const item of items) {
if (item.contentType !== 'live') {
if (item.contentType !== 'live' || isCatalogUnavailable(item)) {
continue;
}
@@ -226,6 +226,55 @@ describe('UnifiedFavoritesDataService', () => {
service = TestBed.inject(UnifiedFavoritesDataService);
});
it('keeps an unavailable Xtream favorite readable and removable without a catalog row', async () => {
databaseService.getAllGlobalFavorites.mockResolvedValue([
{
id: null,
category_id: null,
favorite_id: 77,
catalog_status: 'missing',
playlist_id: 'xtream-1',
playlist_name: 'Xtream One',
xtream_id: 101,
title: 'ABC',
type: 'movie',
poster_url: 'abc.png',
position: 3,
},
]);
const [item] = await service.getFavorites('all');
expect(item).toEqual(
expect.objectContaining({
name: 'ABC',
favoriteId: 77,
catalogStatus: 'missing',
contentId: undefined,
posterUrl: 'abc.png',
position: 3,
})
);
await service.removeFavorite(item);
expect(electronApi.dbRemoveFavorite).toHaveBeenCalledWith(
{ favoriteId: 77 },
'xtream-1'
);
await service.reorder([item], {
scope: 'playlist',
playlistId: 'xtream-1',
portalType: 'xtream',
});
expect(electronApi.dbReorderGlobalFavorites).toHaveBeenCalledWith([
{ favorite_id: 77, playlist_id: 'xtream-1', position: 0 },
]);
electronApi.dbRemoveFavorite.mockClear();
await service.clearFavorites([item]);
expect(electronApi.dbRemoveFavorite).toHaveBeenCalledWith(
{ favoriteId: 77 },
'xtream-1'
);
});
it('maps global Xtream favorites across live, movie, and series content types', async () => {
databaseService.getAllGlobalFavorites.mockResolvedValue([
{
@@ -1,3 +1,4 @@
import type { ElectronBridgeFavoriteReorderUpdate } from '@iptvnator/shared/interfaces';
import { inject, Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
@@ -31,7 +32,7 @@ import {
} from '@iptvnator/portal/shared/util';
import {
XTREAM_DATA_SOURCE,
XtreamContentItem,
XtreamFavoriteItem,
} from '@iptvnator/portal/xtream/data-access';
const GLOBAL_FAVORITES_ORDER_KEY = 'global-favorites-channel-order-v1';
@@ -92,10 +93,15 @@ export class UnifiedFavoritesDataService {
break;
}
case 'xtream': {
if (item.contentId == null) {
const electron = this.electronActivityBridge;
if (electron && item.favoriteId != null) {
await electron.dbRemoveFavorite(
{ favoriteId: item.favoriteId },
item.playlistId
);
return;
}
const electron = this.electronActivityBridge;
if (item.contentId == null) return;
if (electron) {
await electron.dbRemoveFavorite(
item.contentId,
@@ -290,6 +296,7 @@ export class UnifiedFavoritesDataService {
}
const xtreamUpdates = this.buildXtreamPositionUpdates(items);
const electron = this.electronActivityBridge;
if (
@@ -373,19 +380,7 @@ export class UnifiedFavoritesDataService {
return;
}
await Promise.all(
items
.filter(
(
item
): item is UnifiedCollectionItem & {
readonly contentId: number;
} => item.contentId != null
)
.map((item) =>
electron.dbRemoveFavorite(item.contentId, item.playlistId)
)
);
await Promise.all(items.map((item) => this.removeFavorite(item)));
}
private async clearStalkerFavorites(
@@ -593,7 +588,7 @@ export class UnifiedFavoritesDataService {
logo: ct === 'live' ? (row.poster_url ?? null) : null,
posterUrl: ct !== 'live' ? (row.poster_url ?? null) : null,
xtreamId: row.xtream_id,
categoryId: row.category_id,
categoryId: row.category_id ?? undefined,
tvgId: ct === 'live' ? String(row.xtream_id) : undefined,
rating: row.rating ?? undefined,
tvArchive: row.tv_archive ?? null,
@@ -601,12 +596,14 @@ export class UnifiedFavoritesDataService {
addedAt:
normalizeStalkerDate(row.added_at) || new Date(0).toISOString(),
position: row.position ?? 0,
contentId: row.id,
contentId: row.id ?? undefined,
favoriteId: row.favorite_id,
catalogStatus: row.catalog_status,
};
}
private mapXtreamContentItem(
item: XtreamContentItem,
item: XtreamFavoriteItem,
playlistId: string,
playlistName?: string
): UnifiedCollectionItem {
@@ -621,7 +618,7 @@ export class UnifiedFavoritesDataService {
logo: ct === 'live' ? (item.poster_url ?? null) : null,
posterUrl: ct !== 'live' ? (item.poster_url ?? null) : null,
xtreamId: item.xtream_id,
categoryId: item.category_id,
categoryId: item.category_id ?? undefined,
tvgId: ct === 'live' ? String(item.xtream_id) : undefined,
rating: item.rating ?? undefined,
tvArchive: item.tv_archive ?? null,
@@ -630,7 +627,9 @@ export class UnifiedFavoritesDataService {
normalizeStalkerDate(item.added_at ?? item.added) ||
new Date(0).toISOString(),
position: item.position ?? 0,
contentId: item.id,
contentId: item.id ?? undefined,
favoriteId: item.favorite_id,
catalogStatus: item.catalog_status,
};
}
@@ -807,16 +806,16 @@ export class UnifiedFavoritesDataService {
}
private buildXtreamPositionUpdates(items: UnifiedCollectionItem[]) {
const updates: {
content_id: number;
playlist_id: string;
position: number;
}[] = [];
const updates: ElectronBridgeFavoriteReorderUpdate[] = [];
for (const item of items) {
if (item.sourceType === 'xtream' && item.contentId != null) {
if (
item.sourceType === 'xtream' &&
(item.favoriteId != null || item.contentId != null)
) {
updates.push({
content_id: item.contentId,
...(item.favoriteId != null
? { favorite_id: item.favoriteId }
: { content_id: item.contentId }),
playlist_id: item.playlistId,
position: updates.length,
});
@@ -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()) {
<div class="card-info">
<h3>{{ title() }}</h3>
@if (date()) {
@if (statusLabel()) {
<p class="date-info" data-testid="favorite-catalog-status">
{{ statusLabel() }}
</p>
} @else if (date()) {
<p class="date-info">
{{
date()
| date: 'd MMM, HH:mm' : undefined : currentLocale()
| date
: 'd MMM, HH:mm'
: undefined
: currentLocale()
}}
</p>
}
@@ -11,6 +11,10 @@
.content-card__activation {
border-radius: inherit;
outline: none;
&[aria-disabled='true'] {
cursor: default;
}
}
&:has(> .content-card__activation:focus-visible) {
@@ -38,6 +38,8 @@ export class ContentCardComponent {
/** Title to display */
readonly title = input.required<string>();
readonly disabled = input(false);
readonly statusLabel = input('');
/** Content type (live, movie, series) */
readonly type = input<string>();
@@ -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 {
@@ -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)"
/>
@@ -1,3 +1,4 @@
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
import { ChannelScrollFocusDirective } from '@iptvnator/ui/components';
import {
CdkDragDrop,
@@ -165,12 +166,20 @@ export class GlobalFavoritesListComponent {
});
}
readonly isUnavailable = isCatalogUnavailable;
onPlaybackRequested(channel: UnifiedFavoriteChannel): void {
if (!isCatalogUnavailable(channel))
this.playbackRequested.emit(channel);
}
onChannelClick(channel: UnifiedFavoriteChannel): void {
if (isCatalogUnavailable(channel)) return;
this.channelSelected.emit(channel);
}
onChannelActivate(channel: UnifiedFavoriteChannel): void {
if (this.openStreamOnDoubleClick()) {
if (this.openStreamOnDoubleClick() && !isCatalogUnavailable(channel)) {
this.playbackRequested.emit(channel);
}
}
@@ -200,6 +209,7 @@ export class GlobalFavoritesListComponent {
}
hasChannelContextMenu(channel: UnifiedFavoriteChannel): boolean {
if (isCatalogUnavailable(channel)) return false;
return (
Boolean(channel.m3uChannel) ||
this.mode() === 'recent' ||
@@ -211,6 +221,7 @@ export class GlobalFavoritesListComponent {
}
canOpenInPlaylist(channel: UnifiedFavoriteChannel): boolean {
if (isCatalogUnavailable(channel)) return false;
return getLiveCollectionPlaylistNavigation(channel) !== null;
}
@@ -226,7 +237,7 @@ export class GlobalFavoritesListComponent {
openEpgMapping(): void {
const item = this.contextMenuChannel();
if (!item) {
if (!item || isCatalogUnavailable(item)) {
return;
}
@@ -1,3 +1,4 @@
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
import {
computed,
effect,
@@ -50,6 +51,8 @@ export interface CollectionDetailState {
*/
export function createCollectionDetailState(options: {
mode: Signal<CollectionMode>;
items: Signal<UnifiedCollectionItem[]>;
isLoading: Signal<boolean>;
portalType: Signal<string | undefined>;
/** Absent while the host projects no detail template. */
detailTemplate: Signal<unknown>;
@@ -72,6 +75,7 @@ export function createCollectionDetailState(options: {
);
const canOpen = (candidate: UnifiedCollectionItem): boolean =>
!isCatalogUnavailable(candidate) &&
Boolean(options.detailTemplate()) &&
candidate.contentType !== 'live' &&
(candidate.sourceType === 'xtream' ||
@@ -82,6 +86,7 @@ export function createCollectionDetailState(options: {
next: UnifiedCollectionItem,
nextSeriesResume?: SeriesResumeTarget | null
): void => {
if (isCatalogUnavailable(next)) return;
options.onOpen(next.contentType);
item.set(next);
seriesResume.set(nextSeriesResume ?? null);
@@ -128,6 +133,38 @@ export function createCollectionDetailState(options: {
});
});
const syncFromHistory = (): void => {
if (options.isLoading()) return;
const detailState = getOpenCollectionDetailItemState(
window.history.state
);
const savedItem = detailState?.item;
const currentItem =
savedItem &&
options.items().find((row) => row.uid === savedItem.uid);
// The catalog may have changed since this history entry was written.
// Unrelated dashboard discoveries need not be a saved favorite.
const detailItem =
currentItem ??
(savedItem?.favoriteId != null && options.mode() === 'favorites'
? null
: savedItem);
if (detailItem && canOpen(detailItem)) {
open(detailItem, detailState?.seriesResume);
return;
}
if (item()) {
clear();
}
};
effect(() => {
options.items();
if (!options.isLoading()) untracked(syncFromHistory);
});
return {
item,
seriesResume,
@@ -147,20 +184,6 @@ export function createCollectionDetailState(options: {
canOpen,
open,
clear,
syncFromHistory(): void {
const detailState = getOpenCollectionDetailItemState(
window.history.state
);
const detailItem = detailState?.item;
if (detailItem && canOpen(detailItem)) {
open(detailItem, detailState.seriesResume);
return;
}
if (item()) {
clear();
}
},
syncFromHistory,
};
}
@@ -762,6 +762,39 @@ describe('UnifiedCollectionPageComponent', () => {
expect(fixture.componentInstance.allItems()).toEqual(liveItems);
});
it('does not open a stale history detail when the favorite is now missing', async () => {
const item: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::movie:77',
name: 'ABC',
contentType: 'movie',
sourceType: 'xtream',
playlistId: 'xtream-1',
playlistName: 'Xtream',
xtreamId: 77,
favoriteId: 11,
catalogStatus: 'present',
};
favoritesData.getFavorites.mockResolvedValue([
{ ...item, catalogStatus: 'missing' },
]);
window.history.replaceState(
{ [OPEN_COLLECTION_DETAIL_STATE_KEY]: { item } },
document.title
);
const hostFixture = TestBed.createComponent(
HostUnifiedCollectionPageComponent
);
hostFixture.detectChanges();
await hostFixture.whenStable();
hostFixture.detectChanges();
expect(
hostFixture.nativeElement.querySelector('.detail-probe')
).toBeNull();
expect(
hostFixture.componentInstance.pageComponent?.selectedDetailItem()
).toBeNull();
});
it('opens inline detail from history state when a detail template is projected', async () => {
const item: UnifiedCollectionItem = {
uid: 'xtream::xtream-1::77',
@@ -1,3 +1,4 @@
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
import { NgTemplateOutlet } from '@angular/common';
import {
AfterContentInit,
@@ -228,6 +229,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
}),
});
private readonly detailState = createCollectionDetailState({
items: this.allItems,
isLoading: this.isLoading,
mode: this.mode,
portalType: this.portalType,
detailTemplate: this.detailTemplate,
@@ -309,6 +312,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
}
onGridItemSelected(item: UnifiedCollectionItem): void {
if (isCatalogUnavailable(item)) return;
this.viewStateSync.commitCurrent();
if (this.detailState.canOpen(item)) {
@@ -20,7 +20,16 @@
<div class="grid-container">
@for (item of filteredItems(); track trackByUid($index, item)) {
<app-content-card
[allowPostersOnly]="!hasActiveSearch()"
[allowPostersOnly]="!hasActiveSearch() && !isUnavailable(item)"
[disabled]="isUnavailable(item)"
[statusLabel]="
isUnavailable(item)
? ((item.catalogStatus === 'missing'
? 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
: 'WORKSPACE.GLOBAL_FAVORITES.CATALOG_UNKNOWN'
) | translate)
: ''
"
[posterUrl]="item.posterUrl ?? ''"
[title]="item.name"
[type]="item.contentType"
@@ -63,6 +63,29 @@ describe('UnifiedGridTabComponent posters-only wall', () => {
fixture.componentRef.setInput('items', ITEMS);
});
it('retains the title and removal action while blocking an unavailable favorite', () => {
const item = {
...ITEMS[0],
catalogStatus: 'missing',
} as UnifiedCollectionItem;
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
const selected = jest.fn();
const removed = jest.fn();
fixture.componentInstance.itemSelected.subscribe(selected);
fixture.componentInstance.removeItem.subscribe(removed);
fixture.componentInstance.onCardClick(item);
fixture.componentInstance.onRemove(item);
expect(selected).not.toHaveBeenCalled();
expect(removed).toHaveBeenCalledWith(item);
expect(fixture.nativeElement.textContent).toContain(
'WORKSPACE.GLOBAL_FAVORITES.CATALOG_MISSING'
);
expect(fixture.debugElement.query(By.css('.card-info'))).not.toBeNull();
});
const cardInfos = () => fixture.debugElement.queryAll(By.css('.card-info'));
const overlays = () =>
fixture.debugElement.queryAll(By.css('.cover-title-overlay'));
@@ -12,7 +12,10 @@ import {
UnifiedCollectionItem,
} from '@iptvnator/portal/shared/util';
import { ContentCardComponent } from '../content-card/content-card.component';
import { foldSearchText } from '@iptvnator/shared/interfaces';
import {
foldSearchText,
isCatalogUnavailable,
} from '@iptvnator/shared/interfaces';
@Component({
selector: 'app-unified-grid-tab',
@@ -49,7 +52,10 @@ export class UnifiedGridTabComponent {
: all;
});
readonly isUnavailable = isCatalogUnavailable;
onCardClick(item: UnifiedCollectionItem): void {
if (isCatalogUnavailable(item)) return;
this.itemSelected.emit(item);
}
@@ -1,3 +1,4 @@
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
import { computed, Signal } from '@angular/core';
import {
deriveVisibleFavoriteChannels,
@@ -49,7 +50,10 @@ export function createUnifiedLiveChannelRows(options: {
})
),
fullscreenPanel: computed(() =>
all().filter((channel) => channel.radio !== 'true')
all().filter(
(channel) =>
channel.radio !== 'true' && !isCatalogUnavailable(channel)
)
),
};
}
@@ -92,5 +96,7 @@ export function toUnifiedFavoriteChannel(
addedAt: item.addedAt ?? new Date(0).toISOString(),
position: item.position ?? 0,
contentId: item.contentId,
favoriteId: item.favoriteId,
catalogStatus: item.catalogStatus,
};
}
@@ -1,3 +1,4 @@
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
import { inject, WritableSignal } from '@angular/core';
import {
ResolvedLiveCollectionDetail,
@@ -161,6 +162,7 @@ export function createUnifiedLiveSelection(options: {
isAutoOpen = false,
startPlayback = false
): Promise<void> {
if (isCatalogUnavailable(item)) return;
const activeDetail = options.activeDetail();
// The row is on screen only when it is both the highlight and the
// resolved item: while a replacement resolves, `activeUid` already
@@ -1,3 +1,4 @@
import { isCatalogUnavailable } from '@iptvnator/shared/interfaces';
import {
ChangeDetectionStrategy,
Component,
@@ -258,7 +259,15 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
}
const activeUid = this.activeUid();
if (activeUid && !items.some((item) => item.uid === activeUid)) {
const selected = items.find((item) => item.uid === activeUid);
// During a switch the highlight belongs to the pending selection,
// while the mounted detail still belongs to the previous item.
const retainedUid = this.activeItem()?.uid;
const retained = items.find((item) => item.uid === retainedUid);
if (
(activeUid && (!selected || isCatalogUnavailable(selected))) ||
(retainedUid && (!retained || isCatalogUnavailable(retained)))
) {
this.onClose();
}
});
@@ -319,7 +328,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
async onChannelSelected(channel: UnifiedFavoriteChannel): Promise<void> {
const item = this.itemFor(channel);
if (item) {
if (item && !isCatalogUnavailable(item)) {
await this.selection.activate(item);
}
}
@@ -328,7 +337,7 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
channel: UnifiedFavoriteChannel
): Promise<void> {
const item = this.itemFor(channel);
if (item) {
if (item && !isCatalogUnavailable(item)) {
await this.selection.activate(item, false, true);
}
}
@@ -419,7 +428,9 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
}
private openInPlaylist(item: UnifiedCollectionItem): void {
const target = getLiveCollectionPlaylistNavigation(item);
const current = this.items().find((row) => row.uid === item.uid);
if (!current || isCatalogUnavailable(current)) return;
const target = getLiveCollectionPlaylistNavigation(current);
if (target) {
void this.router.navigate(target.link, { state: target.state });
}
@@ -22,6 +22,7 @@ import { OpenInPlaylistChipComponent } from '../open-in-playlist-chip/open-in-pl
import {
buildCurrentEpgItem,
buildLiveItem,
createDeferred,
} from './unified-live-tab.spec-data';
import {
StubAudioPlayerComponent,
@@ -212,6 +213,92 @@ describe('UnifiedLiveTabComponent open in playlist', () => {
);
});
it.each(['missing', 'unknown'] as const)(
'closes an active favorite when its catalog status becomes %s',
async (catalogStatus) => {
const item = await activate('xtream');
fixture.componentRef.setInput('items', [
{ ...item, catalogStatus },
]);
fixture.detectChanges();
await fixture.whenStable();
expect(component.activeItem()).toBeNull();
expect(component.activeDetail()).toBeNull();
expect(component.activeUid()).toBeNull();
expect(chip()).toBeNull();
component.onOpenInPlaylistRequested(component.channelsForList()[0]);
expect(navigate).not.toHaveBeenCalled();
}
);
it.each(['missing', 'unknown'] as const)(
'discards a pending favorite selection when its catalog status becomes %s',
async (catalogStatus) => {
const item = buildLiveItem('xtream');
const detail = {
epgMode: 'portal',
playback: {
streamUrl: 'https://example.com/xtream.m3u8',
title: item.name,
},
epgItems: [],
};
const pending = createDeferred<typeof detail>();
streamResolver.resolveLiveDetail.mockReturnValue(pending.promise);
fixture.componentRef.setInput('items', [item]);
fixture.detectChanges();
const activation = component.onChannelPlaybackRequested(
component.channelsForList()[0]
);
expect(component.isSelecting()).toBe(true);
fixture.componentRef.setInput('items', [
{ ...item, catalogStatus },
]);
fixture.detectChanges();
pending.resolve(detail);
await activation;
fixture.detectChanges();
expect(component.activeItem()).toBeNull();
expect(component.activeDetail()).toBeNull();
expect(component.isSelecting()).toBe(false);
expect(
TestBed.inject(PORTAL_PLAYER).openResolvedPlayback
).not.toHaveBeenCalled();
}
);
it.each(['missing', 'removed'] as const)(
'revokes a retained %s favorite while another selection is pending',
async (status) => {
const first = await activate('xtream');
const second = { ...first, uid: 'xtream::pl-2::21', xtreamId: 21 };
const pending = createDeferred<null>();
streamResolver.resolveLiveDetail.mockReturnValue(pending.promise);
fixture.componentRef.setInput('items', [first, second]);
fixture.detectChanges();
const activation = component.onChannelSelected(
component.channelsForList()[1]
);
expect(component.isSelecting()).toBe(true);
fixture.componentRef.setInput(
'items',
status === 'removed'
? [second]
: [{ ...first, catalogStatus: 'missing' }, second]
);
fixture.detectChanges();
expect(component.activeDetail()).toBeNull();
expect(component.activeItem()).toBeNull();
pending.resolve(null);
await activation;
expect(component.activeDetail()).toBeNull();
}
);
it('keeps the chip out of the panel for a Stalker radio station', async () => {
const item = { ...buildLiveItem('stalker'), radio: 'true' };
fixture.componentRef.setInput('items', [item]);
@@ -1,9 +1,12 @@
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
import { StalkerPortalItem } from '@iptvnator/shared/interfaces';
import { CollectionContentType } from './unified-collection-item.interface';
export interface XtreamFavoriteRow {
readonly id: number;
readonly category_id: number;
readonly id: number | null;
readonly category_id: number | null;
readonly favorite_id?: number;
readonly catalog_status?: XtreamFavoriteCatalogStatus;
readonly playlist_id: string;
readonly playlist_name: string;
readonly xtream_id: number;
@@ -1,3 +1,4 @@
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
/**
* Unified interface for displaying favorites or recently-viewed items
* from any source (M3U, Xtream, Stalker) in one list.
@@ -55,6 +56,9 @@ export interface UnifiedCollectionItem {
/** Xtream DB content id — used for reorder / remove IPC */
contentId?: number;
/** Durable favorite identity, usable even after its catalog row is gone. */
favoriteId?: number;
catalogStatus?: XtreamFavoriteCatalogStatus;
/** Whether the Xtream provider has timeshift / archive enabled for this stream */
tvArchive?: number | null;
@@ -1,3 +1,4 @@
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
/**
* Unified interface for displaying live TV favorites from any source
* (M3U playlists, Xtream Codes portals, Stalker portals) in one list.
@@ -69,6 +70,8 @@ export interface UnifiedFavoriteChannel {
/** Xtream DB content id — used for REORDER IPC call */
contentId?: number;
favoriteId?: number;
catalogStatus?: XtreamFavoriteCatalogStatus;
/** Stalker portal credentials needed to resolve the live stream */
stalkerPortalUrl?: string;
@@ -671,6 +671,8 @@ function buildDashboardCollectionDetailItem(
posterUrl: item.poster_url ?? null,
xtreamId: toOptionalNumber(item.xtream_id),
contentId: toOptionalNumber(item.id),
favoriteId: item.favorite_id,
catalogStatus: item.catalogStatus,
categoryId: item.category_id,
addedAt: 'added_at' in item ? item.added_at : undefined,
viewedAt: 'viewed_at' in item ? item.viewed_at : undefined,
@@ -840,6 +842,13 @@ function normalizeCollectionDetailItem(
logo: toOptionalPathSegment(record['logo']) ?? null,
xtreamId: toOptionalNumber(record['xtreamId']),
contentId: toOptionalNumber(record['contentId']),
favoriteId: toOptionalNumber(record['favoriteId']),
catalogStatus:
record['catalogStatus'] === 'missing' ||
record['catalogStatus'] === 'unknown' ||
record['catalogStatus'] === 'present'
? record['catalogStatus']
: undefined,
stalkerId: toOptionalPathSegment(record['stalkerId']),
stalkerCmd: toOptionalPathSegment(record['stalkerCmd']),
stalkerPortalUrl: toOptionalPathSegment(record['stalkerPortalUrl']),
@@ -39,7 +39,8 @@ export function createDbServiceMock() {
saveXtreamContent: jest.fn().mockResolvedValue(0),
searchXtreamContent: jest.fn().mockResolvedValue([]),
getFavorites: jest.fn().mockResolvedValue([]),
addToFavorites: jest.fn().mockResolvedValue(undefined),
addToFavorites: jest.fn().mockResolvedValue(true),
addToFavoritesOrThrow: jest.fn().mockResolvedValue(undefined),
removeFromFavorites: jest.fn().mockResolvedValue(undefined),
isFavorite: jest.fn().mockResolvedValue(false),
getRecentItems: jest.fn().mockResolvedValue([]),
@@ -54,6 +55,7 @@ export function createDbServiceMock() {
favorites: [],
recentlyViewed: [],
}),
restoreXtreamFavoriteSnapshots: jest.fn().mockResolvedValue(undefined),
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
};
}
@@ -468,7 +468,9 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
// Favorites Operations
// =========================================================================
async getFavorites(playlistId: string): Promise<XtreamContentItem[]> {
async getFavorites(
playlistId: string
): Promise<import('./xtream-data-source.interface').XtreamFavoriteItem[]> {
return this.dbService.getFavorites(playlistId);
}
@@ -477,7 +479,7 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
playlistId: string,
backdropUrl?: string
): Promise<void> {
await this.dbService.addToFavorites(
await this.dbService.addToFavoritesOrThrow(
xtreamCollectionId(contentId),
playlistId,
backdropUrl
@@ -683,6 +685,17 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
};
}
async restoreFavoriteSnapshots(
playlistId: string,
state: XtreamPendingRestoreState
): Promise<void> {
await this.dbService.restoreXtreamFavoriteSnapshots(
playlistId,
state.favorites,
state.favoritesRestoreId
);
}
async restoreUserData(
playlistId: string,
restoreState: XtreamPendingRestoreState,
@@ -731,9 +744,10 @@ export class ElectronXtreamDataSource implements IXtreamDataSource {
}
}
await this.restoreFavoriteSnapshots(playlistId, restoreState);
await this.dbService.restoreXtreamUserData(
playlistId,
restoreState.favorites,
[],
restoreState.recentlyViewed,
options
);
@@ -43,11 +43,9 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
playlistId,
{ backdropUrl: 'backdrop.jpg' }
);
expect(harness.dbService.addToFavorites).toHaveBeenCalledWith(
202,
playlistId,
'backdrop.jpg'
);
expect(
harness.dbService.addToFavoritesOrThrow
).toHaveBeenCalledWith(202, playlistId, 'backdrop.jpg');
expect(
harness.dbService.removeFromFavorites
).toHaveBeenCalledWith(202, playlistId);
@@ -70,6 +68,15 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
}
);
it('propagates a failed favorite write instead of acknowledging it', async () => {
harness.dbService.addToFavoritesOrThrow.mockRejectedValue(
new Error('XTREAM_FAVORITE_IDENTITY_CONFLICT')
);
await expect(
harness.dataSource.addFavorite(202, playlistId)
).rejects.toThrow();
});
it('delegates favorites operations to the DB', async () => {
harness.dbService.isFavorite.mockResolvedValue(true);
@@ -78,7 +85,9 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
playlistId,
'https://example.com/backdrop.png'
);
expect(harness.dbService.addToFavorites).toHaveBeenCalledWith(
expect(
harness.dbService.addToFavoritesOrThrow
).toHaveBeenCalledWith(
202,
playlistId,
'https://example.com/backdrop.png'
@@ -442,7 +451,7 @@ describe('ElectronXtreamDataSource (user data delegation)', () => {
harness.dbService.restoreXtreamUserData
).toHaveBeenCalledWith(
playlistId,
[{ xtreamId: 202, type: 'movie' }],
[],
[{ xtreamId: 101, type: 'live' }],
options
);
@@ -1,3 +1,4 @@
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
import { InjectionToken } from '@angular/core';
import {
ContentMetadataPatch,
@@ -92,6 +93,16 @@ export interface XtreamContentItem {
playlist_name?: string;
}
export interface XtreamFavoriteItem extends Omit<
XtreamContentItem,
'id' | 'category_id'
> {
id: number | null;
category_id: number | string | null;
favorite_id?: number;
catalog_status?: XtreamFavoriteCatalogStatus;
}
/**
* Category from the database with additional fields
*/
@@ -332,7 +343,7 @@ export interface IXtreamDataSource {
/**
* Get all favorites for a playlist
*/
getFavorites(playlistId: string): Promise<XtreamContentItem[]>;
getFavorites(playlistId: string): Promise<XtreamFavoriteItem[]>;
/**
* Add content to favorites.
@@ -512,6 +523,11 @@ export interface IXtreamDataSource {
/**
* Restore user data after refresh
*/
restoreFavoriteSnapshots?(
playlistId: string,
restoreState: XtreamPendingRestoreState
): Promise<void>;
restoreUserData(
playlistId: string,
restoreState: XtreamPendingRestoreState,
@@ -1,5 +1,8 @@
import type { XtreamFavoriteCatalogStatus } from '@iptvnator/shared/interfaces';
export interface FavoriteItem {
content_id: number;
content_id: number | null;
favorite_id?: number;
catalog_status?: XtreamFavoriteCatalogStatus;
playlist_id: string;
type: 'live' | 'movie' | 'vod' | 'series';
title: string;
@@ -6,7 +6,7 @@ import {
} from '../data-sources/xtream-data-source.interface';
import { FavoriteItem } from './favorite-item.interface';
function normalizeCategoryId(categoryId: string | number): number {
function normalizeCategoryId(categoryId: string | number | null): number {
const numericCategoryId = Number(categoryId);
return Number.isFinite(numericCategoryId) ? numericCategoryId : 0;
}
@@ -48,6 +48,8 @@ export class FavoritesService {
map((items) =>
items.map((item) => ({
content_id: item.id,
favorite_id: item.favorite_id,
catalog_status: item.catalog_status,
playlist_id: playlistId,
type: item.type as FavoriteItem['type'],
title: item.title,
@@ -0,0 +1,81 @@
import { TestBed } from '@angular/core/testing';
import {
createContentTestStore,
createContentTestProviders,
createPendingRestoreServiceMock,
PENDING_RESTORE_STATE,
TEST_PLAYLIST as PLAYLIST,
} from './with-content.feature.spec-helpers';
jest.mock('@iptvnator/portal/shared/util', () => ({
createLogger: () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}),
}));
const TestStore = createContentTestStore(async () => 'active');
describe('withContent durable favorite conversion', () => {
let store: InstanceType<typeof TestStore>;
let dataSource: {
hasCategories: jest.Mock;
getCategories: jest.Mock;
getContent: jest.Mock;
restoreFavoriteSnapshots: jest.Mock;
};
let pendingRestoreService: ReturnType<
typeof createPendingRestoreServiceMock
>;
beforeEach(() => {
localStorage.clear();
dataSource = {
hasCategories: jest.fn().mockResolvedValue(false),
getCategories: jest.fn().mockResolvedValue([]),
getContent: jest.fn().mockResolvedValue([]),
restoreFavoriteSnapshots: jest.fn().mockResolvedValue(undefined),
};
pendingRestoreService = createPendingRestoreServiceMock();
TestBed.configureTestingModule({
providers: createContentTestProviders(TestStore, {
dataSource,
pendingRestoreService,
dataService: { sendIpcEvent: jest.fn() },
xtreamApiService: { cancelSession: jest.fn() },
databaseService: {
createOperationId: jest.fn(() => 'import-1'),
getXtreamImportStatus: jest.fn().mockResolvedValue('idle'),
setXtreamImportStatus: jest.fn().mockResolvedValue(true),
clearXtreamImportCache: jest.fn().mockResolvedValue(true),
},
}),
});
store = TestBed.inject(TestStore);
});
afterEach(() => localStorage.clear());
it('converts legacy favorites before fetching a catalog even when the network fails', async () => {
pendingRestoreService.getOrThrow.mockReturnValue(PENDING_RESTORE_STATE);
dataSource.hasCategories.mockResolvedValue(false);
dataSource.getCategories.mockRejectedValue(new Error('Server offline'));
await store.initializeContent();
expect(dataSource.restoreFavoriteSnapshots).toHaveBeenCalledWith(
PLAYLIST.id,
PENDING_RESTORE_STATE
);
expect(
dataSource.restoreFavoriteSnapshots.mock.invocationCallOrder[0]
).toBeLessThan(dataSource.getCategories.mock.invocationCallOrder[0]);
expect(pendingRestoreService.clear).not.toHaveBeenCalled();
});
it('stops before catalog requests when saved favorites cannot be persisted', async () => {
pendingRestoreService.getOrThrow.mockReturnValue(PENDING_RESTORE_STATE);
dataSource.restoreFavoriteSnapshots.mockRejectedValue(
new Error('SQLite unavailable')
);
await store.initializeContent();
expect(dataSource.getCategories).not.toHaveBeenCalled();
expect(dataSource.getContent).not.toHaveBeenCalled();
expect(pendingRestoreService.clear).not.toHaveBeenCalled();
});
});
@@ -959,6 +959,16 @@ export function withContent() {
initialRestoreSnapshot !== null,
});
// Legacy favorites become durable before any network request,
// even if the provider stays offline through this import.
if (initialRestoreSnapshot) {
await dataSource.restoreFavoriteSnapshots?.(
ctx.playlistId,
initialRestoreSnapshot.state
);
throwIfImportCancelled(importSessionId);
}
// Electron content persistence maps remote category IDs
// to internal DB category rows, so categories must exist
// before content import starts.
@@ -1,4 +1,6 @@
import { TestBed } from '@angular/core/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import {
DataService,
DatabaseService,
@@ -75,6 +77,11 @@ function configureStore(
TestBed.configureTestingModule({
providers: [
XtreamStore,
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
{
provide: TranslateService,
useValue: { instant: (key: string) => key },
},
{ provide: XTREAM_DATA_SOURCE, useValue: dataSource },
{ provide: XtreamApiService, useValue: xtreamApiService },
{ provide: DatabaseService, useValue: {} },
@@ -1,4 +1,6 @@
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { TestBed } from '@angular/core/testing';
import { patchState, signalStore } from '@ngrx/signals';
import { XTREAM_DATA_SOURCE } from './data-sources/xtream-data-source.interface';
@@ -20,6 +22,8 @@ const TestFavoritesStore = signalStore(
describe('withFavorites', () => {
const originalElectron = window.electron;
const snackBar = { open: jest.fn() };
const translate = { instant: jest.fn((key: string) => key) };
const runtime = { supportsXtreamSqliteDataSource: false };
let store: InstanceType<typeof TestFavoritesStore>;
let dataSource: {
@@ -30,6 +34,7 @@ describe('withFavorites', () => {
};
beforeEach(() => {
snackBar.open.mockClear();
runtime.supportsXtreamSqliteDataSource = false;
dataSource = {
addFavorite: jest.fn().mockResolvedValue(undefined),
@@ -41,6 +46,8 @@ describe('withFavorites', () => {
TestBed.configureTestingModule({
providers: [
TestFavoritesStore,
{ provide: MatSnackBar, useValue: snackBar },
{ provide: TranslateService, useValue: translate },
{ provide: RuntimeCapabilitiesService, useValue: runtime },
{
provide: XTREAM_DATA_SOURCE,
@@ -60,6 +67,27 @@ describe('withFavorites', () => {
});
});
it.each([
[
'XTREAM_FAVORITE_IDENTITY_CONFLICT',
'PORTALS.FAVORITE_IDENTITY_CONFLICT',
],
['write failed', 'PORTALS.FAVORITE_SAVE_FAILED'],
])(
'keeps the heart inactive and explains rejected saves: %s',
async (message, key) => {
dataSource.getContentByXtreamId.mockResolvedValue({ id: 42 });
dataSource.addFavorite.mockRejectedValue(new Error(message));
await expect(
store.toggleFavorite(7, 'playlist-1', 'movie')
).resolves.toBe(false);
expect(store.isFavorite()).toBe(false);
expect(snackBar.open).toHaveBeenCalledWith(key, undefined, {
duration: 6000,
});
}
);
it.each([false, true])(
'toggles the typed target using its persisted status %s rather than the previous detail state',
async (persistedStatus) => {
@@ -1,5 +1,8 @@
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { inject } from '@angular/core';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { XTREAM_FAVORITE_IDENTITY_CONFLICT } from '@iptvnator/shared/interfaces';
import {
patchState,
signalStoreFeature,
@@ -19,7 +22,9 @@ export const withFavorites = function () {
(
store,
dataSource = inject(XTREAM_DATA_SOURCE),
runtime = inject(RuntimeCapabilitiesService)
runtime = inject(RuntimeCapabilitiesService),
snackBar = inject(MatSnackBar),
translate = inject(TranslateService)
) => ({
async toggleFavorite(
xtreamId: number | string,
@@ -70,11 +75,25 @@ export const withFavorites = function () {
return false;
} else {
// Add to favorites
await dataSource.addFavorite(
{ id: contentId, type: contentType },
playlistId,
backdropUrl
);
try {
await dataSource.addFavorite(
{ id: contentId, type: contentType },
playlistId,
backdropUrl
);
} catch (error) {
patchState(store, { isFavorite: false });
const key =
error instanceof Error &&
error.message ===
XTREAM_FAVORITE_IDENTITY_CONFLICT
? 'PORTALS.FAVORITE_IDENTITY_CONFLICT'
: 'PORTALS.FAVORITE_SAVE_FAILED';
snackBar.open(translate.instant(key), undefined, {
duration: 6000,
});
return false;
}
patchState(store, { isFavorite: true });
return true;
}
@@ -6,6 +6,7 @@ describe('DatabaseService browser guards', () => {
let consoleErrorSpy: jest.SpyInstance;
beforeEach(() => {
localStorage.clear();
Object.defineProperty(window, 'electron', {
configurable: true,
writable: true,
@@ -19,6 +20,7 @@ describe('DatabaseService browser guards', () => {
});
afterEach(() => {
localStorage.clear();
consoleErrorSpy.mockRestore();
Object.defineProperty(window, 'electron', {
configurable: true,
@@ -27,6 +29,92 @@ describe('DatabaseService browser guards', () => {
});
});
it('does not report success when the favorites worker rejects a conflicting identity', async () => {
Object.defineProperty(window, 'electron', {
configurable: true,
value: {
dbAddFavorite: jest.fn().mockResolvedValue({
success: false,
error: 'XTREAM_FAVORITE_IDENTITY_CONFLICT',
}),
},
});
await expect(service.addToFavorites(42, 'playlist-1')).resolves.toBe(
false
);
await expect(
service.addToFavoritesOrThrow(42, 'playlist-1')
).rejects.toThrow('XTREAM_FAVORITE_IDENTITY_CONFLICT');
});
it('recovers legacy favorites on collection reads without a portal status or catalog request', async () => {
const pending = {
favorites: [{ contentType: 'movie', xtreamId: 7 }],
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
};
localStorage.setItem(
'xtream-restore-playlist-1',
JSON.stringify(pending)
);
const bridge = {
dbGetPlaylist: jest.fn().mockResolvedValue({ type: 'xtream' }),
dbRestoreXtreamFavorites: jest
.fn()
.mockResolvedValue({ success: true }),
dbGetFavorites: jest.fn().mockResolvedValue([]),
dbGetAllGlobalFavorites: jest.fn().mockResolvedValue([]),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: bridge,
});
await service.getFavorites('playlist-1');
expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledWith(
'playlist-1',
pending.favorites,
undefined
);
expect(
bridge.dbRestoreXtreamFavorites.mock.invocationCallOrder[0]
).toBeLessThan(bridge.dbGetFavorites.mock.invocationCallOrder[0]);
await service.getAllGlobalFavorites();
expect(bridge.dbRestoreXtreamFavorites).toHaveBeenCalledTimes(2);
expect(localStorage.getItem('xtream-restore-playlist-1')).toBe(
JSON.stringify(pending)
);
});
it('ignores orphan legacy state but never treats failed conversion as an empty backup', async () => {
localStorage.setItem(
'xtream-restore-orphan',
JSON.stringify({
favorites: [{ contentType: 'movie', xtreamId: 7 }],
})
);
const bridge = {
dbGetPlaylist: jest.fn().mockResolvedValue(null),
dbRestoreXtreamFavorites: jest
.fn()
.mockRejectedValue(new Error('SQLite unavailable')),
dbGetFavoritesForBackup: jest.fn().mockResolvedValue([]),
};
Object.defineProperty(window, 'electron', {
configurable: true,
value: bridge,
});
await expect(service.getFavoritesOrThrow('orphan')).resolves.toEqual(
[]
);
expect(bridge.dbRestoreXtreamFavorites).not.toHaveBeenCalled();
bridge.dbGetPlaylist.mockResolvedValue({ type: 'xtream' });
await expect(service.getFavoritesOrThrow('orphan')).rejects.toThrow(
'SQLite unavailable'
);
expect(bridge.dbGetFavoritesForBackup).toHaveBeenCalledTimes(1);
});
it('treats app state persistence as unavailable without the Electron bridge', async () => {
await expect(service.getAppState('xtream-key')).resolves.toBeNull();
await expect(
@@ -1,3 +1,8 @@
import { materializePendingXtreamFavorites } from './xtream-pending-favorites';
import type {
ElectronBridgeXtreamFavorite,
XtreamFavoriteRemovalRef,
} from '@iptvnator/shared/interfaces';
/**
* Database service for Electron renderer process
* Communicates with the main process database via IPC
@@ -100,7 +105,9 @@ export interface GlobalRecentItem extends XtreamContent {
viewed_at: string;
}
export interface GlobalFavoriteItem extends XtreamContent {
export type XtreamFavorite = ElectronBridgeXtreamFavorite;
export interface GlobalFavoriteItem extends XtreamFavorite {
playlist_id: string;
playlist_name: string;
added_at: string;
@@ -288,8 +295,23 @@ export class DatabaseService {
}
/**
* Restore favorites and recently viewed items after Xtream refresh
* Materialize saved favorites without requiring the provider catalog.
*/
async restoreXtreamFavoriteSnapshots(
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
restoreId?: string
): Promise<void> {
if (favorites.length === 0) return;
const result = await window.electron.dbRestoreXtreamFavorites(
playlistId,
favorites,
restoreId
);
if (!result.success)
throw new Error('Restoring saved favorites failed.');
}
async restoreXtreamUserData(
playlistId: string,
favorites: XtreamBackupFavoriteItem[],
@@ -719,6 +741,7 @@ export class DatabaseService {
}
try {
await materializePendingXtreamFavorites(window.electron);
const items = await window.electron.dbGetGlobalFavorites();
return items || [];
} catch (error) {
@@ -736,6 +759,7 @@ export class DatabaseService {
}
try {
await materializePendingXtreamFavorites(window.electron);
const items = await window.electron.dbGetAllGlobalFavorites();
return items || [];
} catch (error) {
@@ -795,9 +819,8 @@ export class DatabaseService {
if (typeof window.electron?.dbAddFavorite !== 'function') {
return false;
}
try {
await window.electron.dbAddFavorite(
await this.addToFavoritesOrThrow(
contentId,
playlistId,
backdropUrl
@@ -809,11 +832,30 @@ export class DatabaseService {
}
}
/** User-triggered saves must expose a rejected write to the detail toggle. */
async addToFavoritesOrThrow(
contentId: number,
playlistId: string,
backdropUrl?: string
): Promise<void> {
if (typeof window.electron?.dbAddFavorite !== 'function') {
throw new Error('Favorite storage is unavailable');
}
const result = await window.electron.dbAddFavorite(
contentId,
playlistId,
backdropUrl
);
if (!result.success) {
throw new Error(result.error ?? 'Favorite could not be saved');
}
}
/**
* Remove content from favorites
*/
async removeFromFavorites(
contentId: number,
contentId: XtreamFavoriteRemovalRef,
playlistId: string
): Promise<boolean> {
if (typeof window.electron?.dbRemoveFavorite !== 'function') {
@@ -845,15 +887,22 @@ export class DatabaseService {
}
}
/**
* Get all favorites for a playlist
*/
async getFavorites(playlistId: string): Promise<XtreamContent[]> {
/** Backup reads must fail rather than export an empty list after an IPC error. */
async getFavoritesOrThrow(playlistId: string): Promise<XtreamFavorite[]> {
await materializePendingXtreamFavorites(window.electron, playlistId);
return window.electron.dbGetFavoritesForBackup(playlistId);
}
async getFavorites(playlistId: string): Promise<XtreamFavorite[]> {
if (typeof window.electron?.dbGetFavorites !== 'function') {
return [];
}
try {
await materializePendingXtreamFavorites(
window.electron,
playlistId
);
return await window.electron.dbGetFavorites(playlistId);
} catch (error) {
console.error('Error getting favorites:', error);
@@ -209,12 +209,8 @@ describe('PlaylistBackupService export → import round-trip', () => {
const snapshot =
collaborators.pendingRestoreService.getSnapshotOrThrow('xtream-1');
expect(snapshot).not.toBeNull();
// Pending state must remain exportable even while catalog/storage
// reads are unavailable; it is already the complete restore input.
jest.spyOn(
collaborators.databaseService,
'getFavorites'
).mockRejectedValue(new Error('Catalog unavailable'));
// Durable favorites remain readable independently of the catalog;
// other pending state does not require a completed import.
jest.spyOn(
collaborators.playbackPositionService,
'getAllPlaybackPositionsOrThrow'
@@ -230,6 +226,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
'honors empty pending collections and optional source pins (present: %s)',
async (pinsPresent) => {
const state = seedState();
state.xtreamFavorites = [];
const collaborators = createStatefulBackupCollaborators(state);
const pending: XtreamPendingRestoreState = {
hiddenCategories: [],
@@ -259,6 +256,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
it('keeps absent pending pins absent when the pin store is unavailable', async () => {
const state = seedState();
state.xtreamFavorites = [];
const collaborators = createStatefulBackupCollaborators(state);
collaborators.vodSourcePinService.isAvailable = false;
const pending: XtreamPendingRestoreState = {
@@ -273,6 +271,91 @@ describe('PlaylistBackupService export → import round-trip', () => {
expect(xtreamEntry(exported.manifest).userState).toEqual(pending);
});
it('preserves older pending favorites before merging another archive', async () => {
const state = seedState();
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const archive = await service.exportBackup();
state.xtreamFavorites = [];
collaborators.pendingRestoreService.set('xtream-1', {
favorites: [
{
contentType: 'movie',
xtreamId: 999,
snapshot: { title: 'Older saved ABC' },
},
],
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
});
expect((await service.importBackup(archive.json)).failed).toBe(0);
expect(
state.xtreamFavorites.map((row) => row.xtream_id).sort()
).toEqual([501, 999]);
expect(
state.xtreamFavorites.find((row) => row.xtream_id === 999)?.title
).toBe('Older saved ABC');
});
it('restores and re-exports saved titles while the catalog is offline', async () => {
const state = seedState();
state.xtreamFavorites = [
{
xtream_id: 501,
type: 'movie',
title: 'Saved ABC',
poster_url: 'https://example.test/abc.jpg',
position: 3,
},
];
const collaborators = createStatefulBackupCollaborators(state);
const service = createPlaylistBackupService(collaborators);
const backup = await service.exportBackup();
state.xtreamFavorites = [];
state.xtreamCategories = [];
collaborators.databaseService.getXtreamImportStatus = async () =>
'idle';
expect((await service.importBackup(backup.json)).failed).toBe(0);
expect(state.xtreamFavorites).toEqual([
{
xtream_id: 501,
type: 'movie',
title: 'Saved ABC',
poster_url: 'https://example.test/abc.jpg',
position: 3,
},
]);
const exported = await service.exportBackup();
expect(xtreamEntry(exported.manifest).userState.favorites).toEqual(
xtreamEntry(backup.manifest).userState.favorites
);
expect(xtreamEntry(exported.manifest).userState).not.toHaveProperty(
'favoritesRestoreId'
);
});
it('refuses to export an empty favorites list after a storage read failure, including during pending import', async () => {
const collaborators = createStatefulBackupCollaborators(seedState());
const service = createPlaylistBackupService(collaborators);
jest.spyOn(
collaborators.databaseService,
'getFavoritesOrThrow'
).mockRejectedValue(new Error('Favorites read failed'));
await expect(service.exportBackup()).rejects.toThrow(
'Favorites read failed'
);
collaborators.pendingRestoreService.set('xtream-1', {
favorites: [],
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
});
await expect(service.exportBackup()).rejects.toThrow(
'Favorites read failed'
);
});
it('refuses to export if pending storage cannot be read', async () => {
const collaborators = createStatefulBackupCollaborators(seedState());
jest.spyOn(
@@ -347,7 +430,7 @@ describe('PlaylistBackupService export → import round-trip', () => {
expect(pins).not.toHaveBeenCalled();
expect(
collaborators.pendingRestoreService.getOrThrow('xtream-1')
).toEqual(userState);
).toEqual({ ...userState, favoritesRestoreId: expect.any(String) });
const parkedExport = await service.exportBackup();
expect(xtreamEntry(parkedExport.manifest).userState).toEqual(
userState
@@ -119,11 +119,14 @@ export function createPlaylistBackupService(
},
databaseService: {
getAllXtreamCategories: jest.fn().mockResolvedValue([]),
getFavorites: jest.fn().mockResolvedValue([]),
getFavoritesOrThrow: jest.fn().mockResolvedValue([]),
getRecentItems: jest.fn().mockResolvedValue([]),
getXtreamImportStatus: jest.fn().mockResolvedValue('idle'),
hasXtreamCategories: jest.fn().mockResolvedValue(false),
hasXtreamContent: jest.fn().mockResolvedValue(false),
restoreXtreamFavoriteSnapshots: jest
.fn()
.mockResolvedValue(undefined),
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
updateCategoryVisibility: jest.fn().mockResolvedValue(true),
},
@@ -178,6 +181,8 @@ export interface FakeXtreamContentRow {
added_at?: string;
position?: number | null;
viewed_at?: string;
title?: string;
poster_url?: string;
}
/**
@@ -206,6 +211,7 @@ export function createStatefulBackupCollaborators(
state: FakeBackupBackendState
) {
let lastParsedRawM3u: string | null = null;
const favoriteReceipts = new Set<string>();
return {
playlistsService: {
@@ -270,7 +276,7 @@ export function createStatefulBackupCollaborators(
row.playlist_id === playlistId && row.type === type
)
.map((row) => ({ ...row })),
getFavorites: async () =>
getFavoritesOrThrow: async () =>
state.xtreamFavorites.map((row) => ({ ...row })),
getRecentItems: async () =>
state.xtreamRecent.map((row) => ({ ...row })),
@@ -289,21 +295,47 @@ export function createStatefulBackupCollaborators(
return true;
},
restoreXtreamUserData: async (
restoreXtreamFavoriteSnapshots: async (
_playlistId: string,
favorites: XtreamBackupFavoriteItem[],
restoreId?: string
) => {
const receipt = restoreId ?? JSON.stringify(favorites);
if (!favorites.length || favoriteReceipts.has(receipt)) return;
favoriteReceipts.add(receipt);
const additions = favorites.filter(
(item) =>
!state.xtreamFavorites.some(
(row) =>
row.xtream_id === item.xtreamId &&
row.type === item.contentType
)
);
state.xtreamFavorites.push(
...additions.map((item) => ({
xtream_id: item.xtreamId,
type: item.contentType,
...(item.snapshot
? {
title: item.snapshot.title,
poster_url:
item.snapshot.posterUrl ?? undefined,
}
: {}),
...(item.addedAt !== undefined
? { added_at: item.addedAt }
: {}),
...(item.position !== undefined
? { position: item.position }
: {}),
}))
);
},
restoreXtreamUserData: async (
_playlistId: string,
_favorites: XtreamBackupFavoriteItem[],
recentlyViewed: XtreamBackupRecentlyViewedItem[]
) => {
state.xtreamFavorites = favorites.map((item) => ({
xtream_id: item.xtreamId,
type: item.contentType,
...(item.addedAt !== undefined
? { added_at: item.addedAt }
: {}),
...(item.position !== undefined
? { position: item.position }
: {}),
}));
state.xtreamRecent = recentlyViewed.map((item) => ({
xtream_id: item.xtreamId,
type: item.contentType,
@@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { PlaylistsService } from './playlists.service';
import { SettingsStore } from './settings-store.service';
import { toXtreamBackupFavorite } from './xtream-favorite-backup';
import { DatabaseService } from './database-electron.service';
import { VodSourcePinService } from './vod-source-pin.service';
import { RuntimeCapabilitiesService } from './runtime-capabilities.service';
@@ -318,15 +319,30 @@ export class PlaylistBackupService {
};
const pending = this.pendingRestoreService.getOrThrow(playlist._id);
if (pending) {
// A parked import is authoritative, including empty collections.
// Reading the incomplete catalog instead would silently lose it.
// Other parked user state is authoritative during an import. Favorites
// already have a durable owner: materialize old parked identities once,
// then export the current saved records (including explicit removals).
let favorites = pending.favorites;
if (this.runtime.supportsXtreamSqliteDataSource) {
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlist._id,
pending.favorites,
pending.favoritesRestoreId
);
favorites = (
await this.databaseService.getFavoritesOrThrow(playlist._id)
).map(toXtreamBackupFavorite);
}
const sourcePins =
pending.sourcePins ??
(await this.readSourcePinsForExport(playlist._id));
return {
...entry,
userState: {
...pending,
hiddenCategories: pending.hiddenCategories,
favorites,
recentlyViewed: pending.recentlyViewed,
playbackPositions: pending.playbackPositions,
...this.optionalLockedXtreamCategories(playlist._id),
...(sourcePins ? { sourcePins } : {}),
},
@@ -349,7 +365,7 @@ export class PlaylistBackupService {
this.databaseService.getAllXtreamCategories(playlist._id, 'live'),
this.databaseService.getAllXtreamCategories(playlist._id, 'movies'),
this.databaseService.getAllXtreamCategories(playlist._id, 'series'),
this.databaseService.getFavorites(playlist._id),
this.databaseService.getFavoritesOrThrow(playlist._id),
this.databaseService.getRecentItems(playlist._id),
this.playbackPositionService.getAllPlaybackPositionsOrThrow(
playlist._id
@@ -371,14 +387,7 @@ export class PlaylistBackupService {
...this.mapHiddenCategories(seriesCategories, 'series'),
],
...this.optionalLockedXtreamCategories(playlist._id),
favorites: favorites.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamBackupContentType,
...(item.added_at ? { addedAt: item.added_at } : {}),
...(item.position !== undefined
? { position: item.position }
: {}),
})),
favorites: favorites.map(toXtreamBackupFavorite),
recentlyViewed: recent.map((item) => ({
xtreamId: item.xtream_id,
contentType: item.type as XtreamBackupContentType,
@@ -925,7 +934,20 @@ export class PlaylistBackupService {
// arbitrary categories during restore.
const restoreState: XtreamPendingRestoreState =
normalizeXtreamPendingRestoreState(entry.userState);
// A deliberate re-import is a fresh user action. Do not accept an archive's
// internal receipt or confuse it with a retry of an older parked import.
restoreState.favoritesRestoreId = createRandomId();
// Preserve an older unfinished import before parking the incoming backup.
// Canonical favorites are additive; legacy-only ones need the same protection.
const previous = this.pendingRestoreService.getOrThrow(playlistId);
if (previous && this.runtime.supportsXtreamSqliteDataSource) {
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlistId,
previous.favorites,
previous.favoritesRestoreId
);
}
const restoreSnapshot = this.pendingRestoreService.set(
playlistId,
restoreState
@@ -939,6 +961,11 @@ export class PlaylistBackupService {
// Browser storage has no catalog cache to wait for: apply at once.
const sqlite = this.runtime.supportsXtreamSqliteDataSource;
if (sqlite) {
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlistId,
restoreState.favorites,
restoreState.favoritesRestoreId
);
this.playbackPositionService.assertSupportsAtomicReplacement();
if (!(await this.hasCompletedOfflineCache(playlistId))) return;
}
@@ -1007,9 +1034,14 @@ export class PlaylistBackupService {
): Promise<void> {
this.playbackPositionService.assertSupportsAtomicReplacement();
await this.restoreXtreamCategoryVisibility(playlistId, state);
await this.databaseService.restoreXtreamUserData(
await this.databaseService.restoreXtreamFavoriteSnapshots(
playlistId,
state.favorites,
state.favoritesRestoreId
);
await this.databaseService.restoreXtreamUserData(
playlistId,
[],
state.recentlyViewed
);
@@ -97,6 +97,7 @@ export function createRestoreCollaborators() {
let pendingSnapshot: XtreamPendingRestoreSnapshot | null = null;
let lastConsumedRevision: number | null = null;
const pendingRestoreService = {
getOrThrow: jest.fn(() => pendingSnapshot?.state ?? null),
set: jest.fn(
(
playlistId: string,
@@ -125,18 +126,13 @@ export function createRestoreCollaborators() {
? 'consumed'
: 'superseded';
}
if (
pendingSnapshot.revision !== expectedSnapshot.revision
) {
if (pendingSnapshot.revision !== expectedSnapshot.revision) {
return 'superseded';
}
await apply(pendingSnapshot.state);
if (
!pendingRestoreService.clear(
playlistId,
pendingSnapshot.state
)
!pendingRestoreService.clear(playlistId, pendingSnapshot.state)
) {
return 'consume-failed';
}
@@ -158,11 +154,14 @@ export function createRestoreCollaborators() {
(_playlistId: string, type: string) =>
Promise.resolve(categoryRowsByType[type] ?? [])
),
getFavorites: jest.fn().mockResolvedValue([]),
getFavoritesOrThrow: jest.fn().mockResolvedValue([]),
getRecentItems: jest.fn().mockResolvedValue([]),
getXtreamImportStatus: jest.fn().mockResolvedValue('completed'),
hasXtreamCategories: jest.fn().mockResolvedValue(true),
hasXtreamContent: jest.fn().mockResolvedValue(true),
restoreXtreamFavoriteSnapshots: jest
.fn()
.mockResolvedValue(undefined),
restoreXtreamUserData: jest.fn().mockResolvedValue(undefined),
updateCategoryVisibility: jest.fn().mockResolvedValue(true),
},
@@ -118,6 +118,7 @@ describe('RuntimeCapabilitiesService', () => {
onPlaybackPositionUpdate: jest.fn(),
dbDeleteXtreamContent: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
dbRestoreXtreamFavorites: jest.fn(),
dbReplaceAllPlaybackPositions: jest.fn(),
downloadsStart: jest.fn(),
downloadsCancel: jest.fn(),
@@ -196,6 +197,13 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsEpgDataManagement).toBe(true);
expect(service.supportsEpgGuide).toBe(true);
expect(service.supportsEpgProgramSearch).toBe(true);
for (const method of ['dbGetPlaylist', 'dbRestoreXtreamFavorites']) {
const implementation = testWindow.electron[method];
delete testWindow.electron[method];
expect(service.supportsPortalActivityStorage).toBe(false);
testWindow.electron[method] = implementation;
expect(service.supportsPortalActivityStorage).toBe(true);
}
});
it('keeps feature-specific capabilities false when an Electron bridge is partial', () => {
@@ -661,6 +669,7 @@ function createXtreamSqliteBridge(): Record<string, jest.Mock> {
dbRemoveFavorite: jest.fn(),
dbRemoveRecentItem: jest.fn(),
dbRestoreXtreamUserData: jest.fn(),
dbRestoreXtreamFavorites: jest.fn(),
dbReplaceAllPlaybackPositions: jest.fn(),
dbSaveCategories: jest.fn(),
dbSaveContent: jest.fn(),
@@ -223,6 +223,7 @@ export class RuntimeCapabilitiesService {
...playbackPositionStorageMethods,
'dbDeleteXtreamContent',
'dbRestoreXtreamUserData',
'dbRestoreXtreamFavorites',
].every((methodName) => this.hasElectronMethod(methodName));
}
@@ -274,6 +275,8 @@ export class RuntimeCapabilitiesService {
get supportsPortalActivityStorage(): boolean {
return [
'dbGetPlaylist',
'dbRestoreXtreamFavorites',
'dbGetRecentlyViewed',
'dbClearRecentlyViewed',
'dbGetAllGlobalFavorites',
@@ -0,0 +1,36 @@
import {
ElectronBridgeXtreamFavorite,
XtreamBackupFavoriteItem,
} from '@iptvnator/shared/interfaces';
/** Export the saved display snapshot, including records without catalog rows. */
export function toXtreamBackupFavorite(
item: ElectronBridgeXtreamFavorite
): XtreamBackupFavoriteItem {
return {
xtreamId: item.xtream_id,
contentType: item.type as XtreamBackupFavoriteItem['contentType'],
...(item.added_at ? { addedAt: item.added_at } : {}),
...(item.position !== undefined ? { position: item.position } : {}),
...(item.title
? {
snapshot: {
title: item.title,
...(item.poster_url != null
? { posterUrl: item.poster_url }
: {}),
...(item.backdrop_url != null
? { backdropUrl: item.backdrop_url }
: {}),
...(item.tmdb_id != null ? { tmdbId: item.tmdb_id } : {}),
...(item.release_year != null
? { releaseYear: item.release_year }
: {}),
...(item.original_title != null
? { originalTitle: item.original_title }
: {}),
},
}
: {}),
};
}
@@ -0,0 +1,48 @@
import {
ElectronBridgeApi,
getXtreamPendingRestoreStorageKey,
normalizeXtreamPendingRestoreState,
} from '@iptvnator/shared/interfaces';
/** Reads must recover older parked favorites without entering a network bootstrap. */
export async function materializePendingXtreamFavorites(
bridge: Pick<
ElectronBridgeApi,
'dbGetPlaylist' | 'dbRestoreXtreamFavorites'
>,
playlistId?: string
): Promise<void> {
const prefix = getXtreamPendingRestoreStorageKey('');
const ids = playlistId
? [playlistId]
: Array.from({ length: localStorage.length }, (_, index) =>
localStorage.key(index)
)
.filter(
(key): key is string => key !== null && key.startsWith(prefix)
)
.map((key) => key.slice(prefix.length));
for (const id of ids) {
const raw = localStorage.getItem(getXtreamPendingRestoreStorageKey(id));
if (!raw) continue;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
continue;
}
const state = normalizeXtreamPendingRestoreState(parsed);
if (!state.favorites.length) continue;
// Old localStorage can outlive deletion of its owning SQLite playlist.
// Never recreate a playlist or let an orphan block other saved entries.
const playlist = await bridge.dbGetPlaylist(id);
if (!playlist || playlist.type !== 'xtream') continue;
const result = await bridge.dbRestoreXtreamFavorites(
id,
state.favorites,
state.favoritesRestoreId
);
if (!result.success)
throw new Error('Recovering saved favorites failed.');
}
}
+22 -2
View File
@@ -46,13 +46,30 @@ root is resolved in this order:
Users may skip releases. The application must apply every required migration in
dependency order when opening an older database, preserving user data without
requiring intermediate application installations or a database reset. This is
the repository policy mirrored in `AGENTS.md` and `CLAUDE.md`.
the repository policy in `AGENTS.md`, which `CLAUDE.md` imports.
`src/lib/connection.ts` owns initialization: `createTables()` creates missing
schema objects, then `runMigrations()` applies column/index migrations and
dedicated schema/data upgrades. One-off data migrations can record completion
in `app_state`. Keep existing migration paths when adding new ones.
Xtream favorites are migrated by `src/lib/favorites-table.ts` before legacy
content/category deduplication. The transactional table rebuild backfills typed
provider identity and saved display metadata while retaining favorite IDs,
dates and positions. Duplicate favorites of the same playlist/type/provider ID
collapse to the earliest favorite record. `content_id` becomes nullable with
`ON DELETE SET NULL`; owning playlist deletion still cascades. The legacy
content deduplicator updates references without replacing favorite IDs.
Favorites whose owning playlist was already missing remain stored. The rebuild
temporarily disables foreign-key enforcement outside its transaction, permits
only those pre-existing playlist violations by favorite ID, and rolls back if
it introduces another violation. The original enforcement setting is restored
on both success and failure.
Historical upgrade tests include tagged 0.19–0.24 schemas, duplicate categories,
orphaned favorites, repeated startup and forced rollbacks. Keep snapshot
migration before content deduplication so the originally favorited row's
metadata survives.
When changing initialization:
- Create required tables before migrating them. `CREATE TABLE IF NOT EXISTS`
@@ -102,7 +119,7 @@ playlists) are guarded in `runMigrations()`:
The #1580 index-ordering fix is included in 0.24 through PR #1550.
`src/lib/connection-upgrades.spec.ts` exercises `initDatabase()` with real
SQLite under the Electron runtime, using fresh-install schema snapshots from
tags 0.19–0.23 and a fresh current database. The `epg_channel_id` column is absent
tags 0.19–0.24 and a fresh current database. The `epg_channel_id` column is absent
in 0.19, present from 0.20, and indexed from 0.23. Each case checks current Drizzle
tables, columns/types, and named indexes/uniqueness, as well as preserved user
rows, foreign keys, database integrity, index availability, and repeated startup;
@@ -115,3 +132,6 @@ the index ahead of its column migration makes the 0.19 case fail again.
Run this coverage with `pnpm nx test database --runInBand`. The Electron UI and
worker upgrade flow is also covered by
`pnpm nx run electron-backend-e2e:e2e-ci--src/legacy-playlist-migration.e2e.ts`.
For an orphaned 0.24 favorites table, initial startup and restart through the
real Electron UI and worker are covered by
`pnpm nx run electron-backend-e2e:e2e-ci--src/favorites-legacy.e2e.ts`.
@@ -40,7 +40,11 @@ function createSqliteMock(
const rule = rules.find(([pattern]) => compact.includes(pattern));
return {
all: jest.fn(() => []),
all: jest.fn(() =>
compact === "PRAGMA table_info('favorites')"
? [{ name: 'xtream_id' }]
: []
),
get: jest.fn(),
run: jest.fn(),
...(rule?.[1] ?? {}),
@@ -299,7 +303,7 @@ describe('runMigrations Xtream cache deduplication', () => {
'SELECT id FROM content WHERE category_id = ?',
{ all: () => [{ id: 10 }, { id: 11 }] },
],
['INSERT INTO favorites', { run: moveFavoritesRun }],
['UPDATE OR IGNORE favorites', { run: moveFavoritesRun }],
[
'DELETE FROM favorites WHERE content_id = ?',
{ run: deleteFavoritesRun },
@@ -5,7 +5,19 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
describe('database initialization across skipped releases (#1580)', () => {
it.each(['0.19.0', '0.20.0', '0.21.0', '0.22.0', '0.23.0', 'fresh'])(
it.each([
'0.19.0',
'0.20.0',
'0.21.0',
'0.22.0',
'0.23.0',
'0.24.0',
'0.19.0-duplicates',
'0.19.0-orphans',
'0.24.0-orphans',
'fresh',
'rollback',
])(
'preserves %s data and indexes across upgrade and repeated startup',
(version) => {
const dataDir = mkdtempSync(join(tmpdir(), 'iptvnator-upgrade-'));
@@ -13,12 +25,12 @@ describe('database initialization across skipped releases (#1580)', () => {
'electron'
) as string;
const fixture =
version === 'fresh'
? 'fresh'
version === 'fresh' || version === 'rollback'
? version
: resolve(
__dirname,
'testing/fixtures',
`v${version}-schema.sql`
`v${version.replace(/-(duplicates|orphans)$/, '')}-schema.sql`
);
try {
const output = execFileSync(
@@ -28,6 +40,11 @@ describe('database initialization across skipped releases (#1580)', () => {
'tsx',
resolve(__dirname, 'testing/connection-upgrade.ts'),
fixture,
version.endsWith('-duplicates')
? 'deduplicate'
: version.endsWith('-orphans')
? 'orphans'
: '',
],
{
cwd: process.cwd(),
@@ -486,6 +486,9 @@ describe('database schema statements', () => {
).runMigrations;
const sqlite = {
exec: jest.fn(),
pragma: jest.fn((statement: string) =>
statement === 'foreign_keys' ? 1 : []
),
prepare: jest.fn((statement: string) => {
if (statement.includes('SELECT value FROM app_state')) {
return {
@@ -556,6 +559,9 @@ describe('database schema statements', () => {
}
).runMigrations;
const sqlite = {
pragma: jest.fn((statement: string) =>
statement === 'foreign_keys' ? 1 : []
),
exec: jest.fn((statement: string) => {
if (statement.includes('idx_epg_programs_source')) {
callOrder.push('epg-source-index');
+11 -14
View File
@@ -7,6 +7,11 @@ import {
ensurePlaylistsPayloadLast,
playlistsTableSql,
} from './playlists-table';
import {
ensureDurableFavorites,
favoritesTableSql,
FAVORITES_RECEIPT_CLEANUP_TRIGGER,
} from './favorites-table';
import { xtreamCacheUniqueIndexesExist } from './xtream-cache-unique-indexes';
/**
* Database connection and initialization for IPTVnator
@@ -183,15 +188,8 @@ const CREATE_TABLE_STATEMENTS = [
FOREIGN KEY (content_id) REFERENCES content (id) ON DELETE CASCADE,
FOREIGN KEY (playlist_id) REFERENCES playlists (id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id INTEGER NOT NULL,
playlist_id TEXT NOT NULL,
added_at TEXT DEFAULT (datetime('now')),
position INTEGER DEFAULT 0,
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE CASCADE,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
)`,
favoritesTableSql(),
FAVORITES_RECEIPT_CLEANUP_TRIGGER,
`CREATE INDEX IF NOT EXISTS idx_content_type ON content(type)`,
`CREATE INDEX IF NOT EXISTS idx_content_category ON content(category_id)`,
`CREATE INDEX IF NOT EXISTS idx_categories_playlist ON categories(playlist_id)`,
@@ -407,6 +405,7 @@ const INDEX_MIGRATION_STATEMENTS = [
// v1.3.0 -> v1.4.0: Prevent duplicate Xtream categories/content rows
`CREATE UNIQUE INDEX IF NOT EXISTS categories_playlist_type_xtream_unique ON categories(playlist_id, type, xtream_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS content_category_type_xtream_unique ON content(category_id, type, xtream_id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS favorites_playlist_type_xtream_unique ON favorites(playlist_id, type, xtream_id)`,
// v1.6.0 -> v1.7.0: Query global favorites in stable display order
`CREATE INDEX IF NOT EXISTS favorites_playlist_position_idx ON favorites(playlist_id, position, added_at DESC)`,
// v1.7.1 -> v1.7.2: Query playlist-scoped EPG by source URL and channel/time
@@ -550,11 +549,7 @@ function deduplicateXtreamCache(sqliteDb: Database.Database): void {
ORDER BY id ASC`
);
const moveFavorites = sqliteDb.prepare(
`INSERT INTO favorites (content_id, playlist_id, added_at, position)
SELECT ?, playlist_id, added_at, COALESCE(position, 0)
FROM favorites
WHERE content_id = ?
ON CONFLICT(content_id, playlist_id) DO NOTHING`
`UPDATE OR IGNORE favorites SET content_id = ? WHERE content_id = ?`
);
const deleteFavorites = sqliteDb.prepare(
`DELETE FROM favorites WHERE content_id = ?`
@@ -1183,6 +1178,8 @@ function runMigrations(sqliteDb: Database.Database): void {
if (!upgradeContentTitleFtsTokenizer(sqliteDb)) {
ensureContentTitleFts(sqliteDb);
}
// Capture the favorited row's metadata before duplicate cache rows disappear.
ensureDurableFavorites(sqliteDb);
deduplicateXtreamCache(sqliteDb);
normalizeXtreamContentAddedEpochs(sqliteDb);
runMigrationStatements(sqliteDb, INDEX_MIGRATION_STATEMENTS);
@@ -0,0 +1,106 @@
import type Database from 'better-sqlite3';
export function favoritesTableSql(name = 'favorites'): string {
return `CREATE TABLE IF NOT EXISTS ${name} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content_id INTEGER,
playlist_id TEXT NOT NULL,
xtream_id INTEGER NOT NULL,
type TEXT NOT NULL CHECK (type IN ('live', 'movie', 'series')),
title TEXT NOT NULL,
poster_url TEXT,
backdrop_url TEXT,
tmdb_id INTEGER,
release_year INTEGER,
original_title TEXT,
added_at TEXT DEFAULT (datetime('now')),
position INTEGER DEFAULT 0,
FOREIGN KEY (content_id) REFERENCES content(id) ON DELETE SET NULL,
FOREIGN KEY (playlist_id) REFERENCES playlists(id) ON DELETE CASCADE
)`;
}
/** Runs after column upgrades, before cache deduplication and dependent indexes. */
export function ensureDurableFavorites(sqlite: Database.Database): void {
const columns = sqlite.prepare("PRAGMA table_info('favorites')").all() as {
name: string;
}[];
if (columns.some((column) => column.name === 'xtream_id')) return;
// Historical profiles may already contain favorites with a missing owner.
// Retain those exact records, but never let this rebuild introduce new ones.
const orphanIds = new Set(
(
sqlite
.prepare(
`SELECT f.id FROM favorites f
LEFT JOIN playlists p ON p.id = f.playlist_id
WHERE p.id IS NULL`
)
.all() as { id: number }[]
).map(({ id }) => id)
);
const foreignKeys = sqlite.pragma('foreign_keys', { simple: true });
// This must happen outside the transaction; SQLite ignores it inside one.
sqlite.pragma('foreign_keys = OFF');
try {
sqlite.transaction(() => {
sqlite.exec(favoritesTableSql('favorites_durable'));
// Multiple categories may contain the same provider item. Keep the first
// saved favorite's primary key, timestamp and position deterministically.
sqlite.exec(`INSERT INTO favorites_durable
(id, content_id, playlist_id, xtream_id, type, title, poster_url,
backdrop_url, tmdb_id, release_year, original_title, added_at, position)
SELECT id, content_id, playlist_id, xtream_id, type, title, poster_url,
backdrop_url, tmdb_id, release_year, original_title, added_at, position
FROM (
SELECT f.id, c.id AS content_id, f.playlist_id,
COALESCE(c.xtream_id, -f.id) AS xtream_id,
COALESCE(c.type, 'movie') AS type,
COALESCE(c.title, 'Saved favorite') AS title,
c.poster_url, c.backdrop_url, c.tmdb_id, c.release_year, c.original_title,
f.added_at, f.position,
ROW_NUMBER() OVER (PARTITION BY f.playlist_id, c.type,
COALESCE(c.xtream_id, -f.id) ORDER BY f.id) AS identity_rank
FROM favorites f LEFT JOIN content c ON c.id = f.content_id
) WHERE identity_rank = 1`);
sqlite.exec('DROP TABLE favorites');
sqlite.exec('ALTER TABLE favorites_durable RENAME TO favorites');
sqlite.exec(
'CREATE UNIQUE INDEX favorites_content_playlist_unique ON favorites(content_id, playlist_id)'
);
sqlite.exec(
'CREATE INDEX favorites_playlist_idx ON favorites(playlist_id)'
);
sqlite.exec(
'CREATE INDEX favorites_content_idx ON favorites(content_id)'
);
const violations = sqlite.pragma(
'foreign_key_check(favorites)'
) as {
rowid: number;
parent: string;
}[];
if (
violations.some(
({ rowid, parent }) =>
parent !== 'playlists' || !orphanIds.has(rowid)
)
) {
throw new Error(
'Favorites migration introduced a foreign key violation'
);
}
})();
} finally {
sqlite.pragma(`foreign_keys = ${foreignKeys ? 'ON' : 'OFF'}`);
}
}
/** Receipts are playlist-owned even though app_state is a generic key/value table. */
export const FAVORITES_RECEIPT_CLEANUP_TRIGGER = `CREATE TRIGGER IF NOT EXISTS favorites_receipts_playlist_delete
AFTER DELETE ON playlists BEGIN
DELETE FROM app_state WHERE substr(key, 1, length('xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':')) =
'xtream-favorites-restore:' || length(OLD.id) || ':' || OLD.id || ':'
OR key IN ('xtream-import-status:' || OLD.id || ':live',
'xtream-import-status:' || OLD.id || ':movie', 'xtream-import-status:' || OLD.id || ':series');
END`;
+16 -3
View File
@@ -170,17 +170,30 @@ export const favorites = sqliteTable(
'favorites',
{
id: integer('id').primaryKey({ autoIncrement: true }),
contentId: integer('content_id')
.notNull()
.references(() => content.id, { onDelete: 'cascade' }),
contentId: integer('content_id').references(() => content.id, {
onDelete: 'set null',
}),
playlistId: text('playlist_id')
.notNull()
.references(() => playlists.id, { onDelete: 'cascade' }),
xtreamId: integer('xtream_id').notNull(),
type: text('type', { enum: ['live', 'movie', 'series'] }).notNull(),
title: text('title').notNull(),
posterUrl: text('poster_url'),
backdropUrl: text('backdrop_url'),
tmdbId: integer('tmdb_id'),
releaseYear: integer('release_year'),
originalTitle: text('original_title'),
addedAt: text('added_at').default(sql`CURRENT_TIMESTAMP`),
/** Display order position in the global favorites list (lower = first) */
position: integer('position').default(0),
},
(table) => ({
identityUnique: uniqueIndex('favorites_playlist_type_xtream_unique').on(
table.playlistId,
table.type,
table.xtreamId
),
contentPlaylistUnique: uniqueIndex(
'favorites_content_playlist_unique'
).on(table.contentId, table.playlistId),
@@ -1,3 +1,4 @@
import { verifyFavoriteMigrationRollback } from './favorites-migration-rollback';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import Database from 'better-sqlite3';
@@ -25,7 +26,6 @@ function seed(sqlite: Database.Database) {
VALUES (1, 'saved', 'Movies', 'movies', 42, 1);
INSERT INTO content (id, category_id, title, xtream_id, type, added)
VALUES (1, 1, 'Saved movie', 7, 'movie', '1700000000');
INSERT INTO favorites (content_id, playlist_id) VALUES (1, 'saved');
INSERT INTO recently_viewed (content_id, playlist_id) VALUES (1, 'saved');
INSERT INTO playback_positions
(playlist_id, content_xtream_id, content_type, position_seconds, duration_seconds)
@@ -33,6 +33,18 @@ function seed(sqlite: Database.Database) {
INSERT INTO downloads (playlist_id, xtream_id, content_type, title, url, status)
VALUES ('saved', 7, 'vod', 'Saved movie', 'https://source.invalid/movie', 'completed');
`);
const favoriteColumns = sqlite.pragma('table_info(favorites)') as {
name: string;
}[];
const durable = favoriteColumns.some(({ name }) => name === 'xtream_id');
sqlite.exec(
durable
? `INSERT INTO favorites (id, content_id, playlist_id, added_at, position, xtream_id, type, title) VALUES (42, 1, 'saved', '2025-01-01', 9, 7, 'movie', 'Saved movie')`
: `INSERT INTO favorites (id, content_id, playlist_id, added_at) VALUES (42, 1, 'saved', '2025-01-01')`
);
if (favoriteColumns.some(({ name }) => name === 'position')) {
sqlite.exec('UPDATE favorites SET position = 9');
}
const columns = sqlite.pragma('table_info(content)') as { name: string }[];
if (columns.some(({ name }) => name === 'epg_channel_id')) {
sqlite.exec("UPDATE content SET epg_channel_id = 'retained-epg-id'");
@@ -139,6 +151,18 @@ function epgIndex(sqlite: Database.Database) {
.get();
}
function foreignKeyViolations(sqlite: Database.Database) {
// Rebuilding a table can reorder its FK definitions without changing the
// affected row or parent, so compare identities rather than FK ordinals.
return (
sqlite.pragma('foreign_key_check') as {
table: string;
rowid: number;
parent: string;
}[]
).map(({ table, rowid, parent }) => ({ table, rowid, parent }));
}
function verifyCurrentSchema(sqlite: Database.Database) {
for (const table of Object.values(currentSchema)) {
const { name, columns, indexes } = getTableConfig(table);
@@ -173,6 +197,11 @@ function verifyCurrentSchema(sqlite: Database.Database) {
async function main() {
const fixture = process.argv[2];
if (fixture === 'rollback') {
verifyFavoriteMigrationRollback();
process.stdout.write('upgrade verified');
return;
}
assert(
process.env['IPTVNATOR_E2E_DATA_DIR'],
'Never open the user database'
@@ -191,7 +220,21 @@ async function main() {
legacy.pragma('foreign_keys = ON');
if (fixture !== 'fresh') legacy.exec(readFileSync(fixture, 'utf8'));
seed(legacy);
if (process.argv[3] === 'orphans') {
legacy.pragma('foreign_keys = OFF');
legacy.exec(`INSERT INTO favorites (id, content_id, playlist_id, added_at)
VALUES (43, 1, 'deleted-playlist', '2024-01-01')`);
legacy.pragma('foreign_keys = ON');
}
const before = snapshot(legacy);
const originalViolations = foreignKeyViolations(legacy);
if (process.argv[3] === 'deduplicate') {
legacy.exec(`INSERT INTO categories (id, playlist_id, name, type, xtream_id, hidden)
VALUES (2, 'saved', 'Movies', 'movies', 42, 1);
INSERT INTO content (id, category_id, title, xtream_id, type, added)
VALUES (2, 2, 'Original favorite title', 7, 'movie', '1700000000');
UPDATE favorites SET content_id = 2;`);
}
const originalIndex = epgIndex(legacy);
legacy.close();
@@ -214,7 +257,37 @@ async function main() {
for (const { query, rows } of before) {
assert.deepEqual(sqlite.prepare(query).all(), rows, query);
}
assert.deepEqual(sqlite.pragma('foreign_key_check'), []);
assert.deepEqual(
sqlite
.prepare(
'SELECT id, content_id, playlist_id, xtream_id, type, title, added_at FROM favorites'
)
.get(),
{
id: 42,
content_id: 1,
playlist_id: 'saved',
xtream_id: 7,
type: 'movie',
title:
process.argv[3] === 'deduplicate'
? 'Original favorite title'
: 'Saved movie',
added_at: '2025-01-01',
}
);
const favoriteForeignKeys = sqlite.pragma(
'foreign_key_list(favorites)'
) as { from: string; on_delete: string }[];
assert.equal(
favoriteForeignKeys.find((key) => key.from === 'content_id')
?.on_delete,
'SET NULL'
);
assert.deepEqual(
foreignKeyViolations(sqlite),
originalViolations
);
assert.deepEqual(sqlite.pragma('integrity_check'), [
{ integrity_check: 'ok' },
]);
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more