mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
fix(xtream): preserve favorites across catalog refreshes
This commit is contained in:
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();
|
||||
}
|
||||
|
||||
+8
-6
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
+18
-24
@@ -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': {
|
||||
|
||||
@@ -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": "البحث في المفضلة...",
|
||||
|
||||
@@ -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": "قلب على المفضلة...",
|
||||
|
||||
@@ -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": "Пошук у выбраным...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Αναζήτηση αγαπημένων...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "お気に入りを検索...",
|
||||
|
||||
@@ -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": "즐겨찾기 검색...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "Поиск в избранном...",
|
||||
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "搜索收藏…",
|
||||
|
||||
@@ -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": "搜尋我的最愛...",
|
||||
|
||||
Reference in new issue
Block a user