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,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": "搜尋我的最愛...",