fix(xtream): preserve favorites across catalog refreshes

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

No files matched your search

@@ -0,0 +1,138 @@
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
contentCardByTitle,
expect,
launchElectronApp,
openSources,
openWorkspaceSection,
resetMockServers,
sourceRowByTitle,
test,
waitForXtreamWorkspaceReady,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import { fetchXtreamVodFixture, getXtreamTitle } from './portal-mock-fixtures';
import { readSavedFavorites } from './support/xtream-favorites-catalog';
test('@critical @persistence @xtream @electron rejects a reused favorite identity until the older saved entry is explicitly removed', async ({
dataDir,
request,
}) => {
test.setTimeout(90_000);
await resetMockServers(request, ['xtream']);
const credentials = { username: 'minimal', password: 'minimal' };
const fixture = await fetchXtreamVodFixture(request, credentials);
const item = fixture.items[0];
const title = getXtreamTitle(item);
const sourceTitle = 'Favorite identity conflict';
const oldTitle = 'Older saved movie';
let app: LaunchedElectronApp | undefined;
try {
app = await launchElectronApp(dataDir);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: sourceTitle,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const playlistId = /\/xtreams\/([^/]+)/.exec(
new URL(app.mainWindow.url()).pathname
)?.[1];
if (!playlistId) throw new Error('Expected a playlist route');
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
// Seed an old snapshot and conflicting current metadata via the real
// worker bridge. The subsequent save/removal runs through the UI.
await app.mainWindow.evaluate(
async ({ playlistId, xtreamId, oldTitle }) => {
const row = await window.electron.dbGetContentByXtreamId(
xtreamId,
playlistId,
'movie'
);
if (!row) throw new Error('Expected imported content');
const metadata =
await window.electron.dbSetContentMetadataIfMissing(
row.id,
{
tmdbId: 12345,
releaseYear: 2000,
}
);
if (!metadata.success)
throw new Error('Could not set current movie identity');
const restored = await window.electron.dbRestoreXtreamFavorites(
playlistId,
[
{
xtreamId,
contentType: 'movie',
addedAt: '2024-01-01',
position: 3,
snapshot: {
title: oldTitle,
tmdbId: 99999999,
},
},
]
);
if (!restored.success)
throw new Error('Could not seed the older favorite');
},
{ playlistId, xtreamId: Number(item.stream_id), oldTitle }
);
const [before] = await readSavedFavorites(app.mainWindow, playlistId);
expect(before).toMatchObject({ id: null, title: oldTitle });
await clickGridListCardByTitle(app.mainWindow, title);
const addButton = app.mainWindow
.getByRole('button', { name: 'Add to favorites', exact: true })
.first();
await expect(addButton).toBeVisible();
await addButton.click();
await expect(
app.mainWindow.getByText(
'This source replaced an item you saved. Remove the older entry from Favorites before adding this one.',
{ exact: true }
)
).toBeVisible();
await expect(addButton).toBeVisible();
expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual([
before,
]);
await openWorkspaceSection(app.mainWindow, 'Global favorites');
const oldCard = contentCardByTitle(app.mainWindow, oldTitle);
await oldCard
.getByRole('button', { name: /remove from favorites/i })
.click();
await expect(oldCard).toHaveCount(0);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, sourceTitle).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await app.mainWindow
.getByRole('link', { name: 'Movies', exact: true })
.click();
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
await clickGridListCardByTitle(app.mainWindow, title);
await addButton.click();
await expect(
app.mainWindow
.getByRole('button', {
name: 'Remove from favorites',
exact: true,
})
.first()
).toBeVisible();
const [saved] = await readSavedFavorites(app.mainWindow, playlistId);
expect(saved.title).toBe(title);
expect(saved.id).not.toBeNull();
expect(saved.favorite_id).not.toBe(before.favorite_id);
} finally {
if (app) await closeElectronApp(app);
}
});
@@ -0,0 +1,322 @@
import type { XtreamPendingRestoreState } from '@iptvnator/shared/interfaces';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import {
addXtreamPortal,
closeElectronApp,
contentCardByTitle,
electronMainPath,
expect,
launchElectronApp,
openSources,
openWorkspaceSection,
resetMockServers,
restartElectronApp,
sourceRowByTitle,
switchUnifiedCollectionScope,
test,
waitForXtreamWorkspaceReady,
workspaceRoot,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import {
createFavoritesCatalogProxy,
readSavedFavorites,
savedFavoriteIdentity,
} from './support/xtream-favorites-catalog';
test('@critical @persistence @xtream @electron recovers legacy pending favorites without catalog initialization while the portal is unreachable', async ({
dataDir,
request,
}) => {
test.setTimeout(90_000);
await resetMockServers(request, ['xtream']);
const proxy = await createFavoritesCatalogProxy();
const sourceTitle = 'Offline Legacy Favorites';
const savedTitle = 'Saved offline movie';
const fallbackTitle = 'Saved movie #910002';
const legacyState: XtreamPendingRestoreState = {
favorites: [
{
contentType: 'movie',
xtreamId: 910001,
addedAt: '2024-06-01T10:00:00.000Z',
position: 4,
snapshot: { title: savedTitle },
},
{
contentType: 'movie',
xtreamId: 910002,
addedAt: '2024-06-02T10:00:00.000Z',
position: 5,
},
],
hiddenCategories: [],
recentlyViewed: [],
playbackPositions: [],
};
const serializedLegacyState = JSON.stringify(legacyState);
let app: LaunchedElectronApp | undefined;
try {
app = await launchElectronApp(dataDir);
await addXtreamPortal(app.mainWindow, {
name: sourceTitle,
serverUrl: proxy.origin,
username: 'minimal',
password: 'minimal',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const playlistId = /\/xtreams\/([^/]+)/.exec(
new URL(app.mainWindow.url()).pathname
)?.[1];
if (!playlistId)
throw new Error('The imported Xtream route has no playlist ID');
await openSources(app.mainWindow);
proxy.setMode({ kind: 'offline' });
const catalogActions = [
'get_live_categories',
'get_vod_categories',
'get_series_categories',
'get_live_streams',
'get_vod_streams',
'get_series',
];
const catalogRequestsBefore = catalogActions.map((action) =>
proxy.responseCount(action)
);
const accountRequestsBefore = proxy.responseCount('get_account_info');
// Recreate an older profile: only pending localStorage owns the favorites.
// Calling the raw bridge here must not accidentally perform conversion.
await app.mainWindow.evaluate(
async ({ id, legacy }) => {
const deleted = await window.electron.dbDeleteXtreamContent(id);
if (!deleted.success)
throw new Error('Could not clear the test catalog');
localStorage.setItem(`xtream-restore-${id}`, legacy);
},
{ id: playlistId, legacy: serializedLegacyState }
);
expect(await readSavedFavorites(app.mainWindow, playlistId)).toEqual(
[]
);
// Discard the already-loaded renderer store so the next visit must probe
// the unavailable account before it can consider initializeContent().
app = await restartElectronApp(app, dataDir);
await openSources(app.mainWindow);
await expect(
sourceRowByTitle(app.mainWindow, sourceTitle).first()
).toBeVisible();
await sourceRowByTitle(app.mainWindow, sourceTitle).first().click();
await expect(
app.mainWindow.getByRole('heading', {
name: 'Portal unavailable',
exact: true,
})
).toBeVisible();
expect(proxy.responseCount('get_account_info')).toBeGreaterThan(
accountRequestsBefore
);
expect(
catalogActions.map((action) => proxy.responseCount(action))
).toEqual(catalogRequestsBefore);
await openWorkspaceSection(app.mainWindow, 'Global favorites');
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
for (const title of [savedTitle, fallbackTitle]) {
const card = contentCardByTitle(app.mainWindow, title);
await expect(card).toHaveCount(1);
await expect(card).toContainText('Catalog status unknown');
await expect(
card.locator('.content-card__activation')
).toHaveAttribute('aria-disabled', 'true');
}
const converted = await readSavedFavorites(app.mainWindow, playlistId);
expect(converted).toHaveLength(2);
expect(
converted.every(
(row) => row.id === null && row.catalog_status === 'unknown'
)
).toBe(true);
expect(savedFavoriteIdentity(converted)).toEqual([
{
favoriteId: expect.any(Number),
xtreamId: 910001,
title: savedTitle,
addedAt: legacyState.favorites[0].addedAt,
position: 4,
},
{
favoriteId: expect.any(Number),
xtreamId: 910002,
title: fallbackTitle,
addedAt: legacyState.favorites[1].addedAt,
position: 5,
},
]);
expect(
catalogActions.map((action) => proxy.responseCount(action))
).toEqual(catalogRequestsBefore);
const removedCard = contentCardByTitle(app.mainWindow, savedTitle);
await removedCard
.getByRole('button', { name: /remove from favorites/i })
.click();
await expect(removedCard).toHaveCount(0);
const retained = savedFavoriteIdentity(
converted.filter((row) => row.xtream_id === 910002)
);
expect(
savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
)
).toEqual(retained);
expect(
await app.mainWindow.evaluate(
(id) => localStorage.getItem(`xtream-restore-${id}`),
playlistId
)
).toBe(serializedLegacyState);
// The unchanged legacy payload remains eligible for reads after restart;
// the worker's durable conversion receipt must prevent resurrection.
app = await restartElectronApp(app, dataDir);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, sourceTitle).first().click();
await expect(
app.mainWindow.getByRole('heading', {
name: 'Portal unavailable',
exact: true,
})
).toBeVisible();
await openWorkspaceSection(app.mainWindow, 'Global favorites');
await switchUnifiedCollectionScope(app.mainWindow, 'All playlists');
await expect(
contentCardByTitle(app.mainWindow, fallbackTitle)
).toContainText('Catalog status unknown');
await expect(
contentCardByTitle(app.mainWindow, savedTitle)
).toHaveCount(0);
expect(
savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
)
).toEqual(retained);
expect(
await app.mainWindow.evaluate(
(id) => localStorage.getItem(`xtream-restore-${id}`),
playlistId
)
).toBe(serializedLegacyState);
expect(
catalogActions.map((action) => proxy.responseCount(action))
).toEqual(catalogRequestsBefore);
} finally {
try {
if (app) await closeElectronApp(app);
} finally {
await proxy.close();
}
}
});
test('@critical @persistence @xtream @electron upgrades orphaned legacy favorites without blocking startup or losing saved rows', async ({
dataDir,
}) => {
test.setTimeout(90_000);
const schema = await readFile(
join(
workspaceRoot,
'libs/shared/database/src/lib/testing/fixtures/v0.24.0-schema.sql'
),
'utf8'
);
const databaseFile = join(dataDir, 'databases/iptvnator.db');
const dependency = join(workspaceRoot, 'node_modules/better-sqlite3');
const wrapper = join(dataDir, 'legacy-favorites-startup.cjs');
const seed = `
INSERT INTO playlists (id, name, type) VALUES ('legacy', 'Legacy favorites', 'xtream');
INSERT INTO categories (id, playlist_id, name, type, xtream_id)
VALUES (1, 'legacy', 'Movies', 'movies', 1);
INSERT INTO content (id, category_id, title, xtream_id, type)
VALUES (1, 1, 'Legacy saved movie', 7, 'movie');
INSERT INTO favorites (id, content_id, playlist_id, added_at, position)
VALUES (42, 1, 'legacy', '2024-01-01', 3),
(43, 1, 'deleted-playlist', '2024-01-02', 4);`;
await mkdir(join(dataDir, 'databases'), { recursive: true });
await writeFile(
wrapper,
`const Database = require(${JSON.stringify(dependency)});
const db = new Database(${JSON.stringify(databaseFile)});
db.pragma('foreign_keys = OFF');
db.exec(${JSON.stringify(schema)});
db.exec(${JSON.stringify(seed)});
db.close();
require(${JSON.stringify(electronMainPath)});`
);
let app: LaunchedElectronApp | undefined;
try {
app = await launchElectronApp(dataDir, { entryPoint: wrapper });
for (let startup = 0; startup < 2; startup++) {
if (startup) app = await restartElectronApp(app, dataDir);
await openWorkspaceSection(app.mainWindow, 'Global favorites');
await expect(
contentCardByTitle(app.mainWindow, 'Legacy saved movie')
).toHaveCount(1);
expect(
savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, 'legacy')
)
).toEqual([
{
favoriteId: 42,
xtreamId: 7,
title: 'Legacy saved movie',
addedAt: '2024-01-01',
position: 3,
},
]);
// Orphaned favorites have no source to display, but must remain in
// storage alongside healthy favorites after both startup paths.
const rows = await app.electronApp.evaluate(
(_electron, { databaseFile, dependency }) => {
const Database = process
.getBuiltinModule('module')
.createRequire(dependency)(dependency);
const db = new Database(databaseFile, { readonly: true });
try {
return db
.prepare(
'SELECT id, playlist_id, content_id, title, added_at, position FROM favorites ORDER BY id'
)
.all();
} finally {
db.close();
}
},
{ databaseFile, dependency }
);
expect(rows).toEqual([
{
id: 42,
playlist_id: 'legacy',
content_id: 1,
title: 'Legacy saved movie',
added_at: '2024-01-01',
position: 3,
},
{
id: 43,
playlist_id: 'deleted-playlist',
content_id: 1,
title: 'Legacy saved movie',
added_at: '2024-01-02',
position: 4,
},
]);
}
} finally {
if (app) await closeElectronApp(app);
}
});
@@ -33,6 +33,7 @@ import {
waitForStalkerCatalog,
waitForXtreamWorkspaceReady,
writeTemporaryM3uFile,
type LaunchedElectronApp,
} from './electron-test-fixtures';
import {
fetchStalkerCategoryFixture,
@@ -48,6 +49,12 @@ import {
routePlayableStreams,
startAndConfirmPlayback,
} from './playable-stream-fixture';
import {
createFavoritesCatalogProxy,
readSavedFavorites,
refreshFavoriteCatalog,
savedFavoriteIdentity,
} from './support/xtream-favorites-catalog';
test.describe('Electron Favorites', () => {
for (const origin of ['favorites', 'recent', 'dashboard'] as const) {
@@ -430,6 +437,262 @@ test.describe('Electron Favorites', () => {
}
});
test('@critical @persistence @xtream @electron keeps durable favorites through partial refresh, repeated refresh, restart and catalog recovery', async ({
dataDir,
request,
}) => {
test.setTimeout(150_000);
await resetMockServers(request, ['xtream']);
const credentials = { username: 'minimal', password: 'minimal' };
const fixture = await fetchXtreamVodFixture(request, credentials);
const proxy = await createFavoritesCatalogProxy();
const portalTitle = 'Durable Partial Favorites';
let app: LaunchedElectronApp | undefined;
try {
app = await launchElectronApp(dataDir);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: portalTitle,
serverUrl: proxy.origin,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const { playlistId, titles } = await saveTwoMovieFavorites(
app.mainWindow,
fixture
);
const original = savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
);
const missing = original.find((row) => row.title === titles[0]);
if (!missing) throw new Error('The newly saved movie is missing');
expect(missing.favoriteId).toBeGreaterThan(0);
proxy.setMode({ kind: 'omit', xtreamId: missing.xtreamId });
// Both real refresh entry points must retain the same durable records.
for (const entry of ['sources', 'header'] as const) {
await refreshFavoriteCatalog(
app.mainWindow,
proxy,
playlistId,
portalTitle,
entry
);
const saved = await readSavedFavorites(
app.mainWindow,
playlistId
);
expect(savedFavoriteIdentity(saved)).toEqual(original);
expect(
saved.find((row) => row.favorite_id === missing.favoriteId)
).toMatchObject({
id: null,
title: titles[0],
catalog_status: 'missing',
});
expect(
saved.find((row) => row.favorite_id !== missing.favoriteId)
).toMatchObject({
id: expect.any(Number),
catalog_status: 'present',
});
await expectSavedMovieCards(app.mainWindow, titles);
const card = contentCardByTitle(app.mainWindow, titles[0]);
await expect(card).toContainText('Not in the current catalog');
const activation = card.locator('.content-card__activation');
await expect(activation).toHaveAttribute(
'aria-disabled',
'true'
);
const detailRequests = proxy.responseCount('get_vod_info');
await activation.focus();
await app.mainWindow.keyboard.press('Enter');
await expect(
app.mainWindow.locator('app-content-hero')
).toHaveCount(0);
expect(proxy.responseCount('get_vod_info')).toBe(
detailRequests
);
await switchUnifiedCollectionScope(
app.mainWindow,
'All playlists'
);
await expect(
contentCardByTitle(app.mainWindow, titles[0])
).toContainText('Not in the current catalog');
}
// The provider still omits the movie while a new Electron process opens.
app = await restartElectronApp(app, dataDir);
await openSources(app.mainWindow);
await sourceRowByTitle(app.mainWindow, portalTitle).first().click();
await waitForXtreamWorkspaceReady(app.mainWindow);
await expectSavedMovieCards(app.mainWindow, titles);
expect(
savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
)
).toEqual(original);
await expect(
contentCardByTitle(app.mainWindow, titles[0])
).toContainText('Not in the current catalog');
proxy.setMode({ kind: 'normal' });
await refreshFavoriteCatalog(
app.mainWindow,
proxy,
playlistId,
portalTitle,
'sources'
);
const recovered = await readSavedFavorites(
app.mainWindow,
playlistId
);
expect(savedFavoriteIdentity(recovered)).toEqual(original);
expect(recovered).toHaveLength(2);
expect(
recovered.every(
(row) => row.id != null && row.catalog_status === 'present'
)
).toBe(true);
await expectSavedMovieCards(app.mainWindow, titles);
const recoveredCard = contentCardByTitle(app.mainWindow, titles[0]);
await expect(
recoveredCard.locator('.content-card__activation')
).not.toHaveAttribute('aria-disabled', 'true');
await recoveredCard.click();
await expect(
app.mainWindow.locator('app-content-hero')
).toContainText(titles[0]);
await expect(
app.mainWindow
.getByRole('button', { name: /remove from favorites/i })
.first()
).toBeVisible();
} finally {
try {
if (app) await closeElectronApp(app);
} finally {
await proxy.close();
}
}
});
test('@persistence @xtream @electron distinguishes an empty catalog from HTTP failure and keeps explicit favorite removal after recovery', async ({
dataDir,
request,
}) => {
test.setTimeout(120_000);
await resetMockServers(request, ['xtream']);
const credentials = { username: 'minimal', password: 'minimal' };
const fixture = await fetchXtreamVodFixture(request, credentials);
const proxy = await createFavoritesCatalogProxy();
const portalTitle = 'Durable Empty Favorites';
let app: LaunchedElectronApp | undefined;
try {
app = await launchElectronApp(dataDir);
await addXtreamPortal(app.mainWindow, {
...credentials,
name: portalTitle,
serverUrl: proxy.origin,
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const { playlistId, titles } = await saveTwoMovieFavorites(
app.mainWindow,
fixture
);
const original = savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
);
for (const kind of ['empty', 'failure'] as const) {
proxy.setMode({ kind });
await refreshFavoriteCatalog(
app.mainWindow,
proxy,
playlistId,
portalTitle,
'sources',
kind === 'empty' ? 'completed' : 'failed'
);
const saved = await readSavedFavorites(
app.mainWindow,
playlistId
);
expect(savedFavoriteIdentity(saved)).toEqual(original);
expect(
saved.every(
(row) =>
row.id === null &&
row.catalog_status ===
(kind === 'empty' ? 'missing' : 'unknown')
)
).toBe(true);
await expectSavedMovieCards(app.mainWindow, titles);
for (const title of titles) {
await expect(
contentCardByTitle(app.mainWindow, title)
).toContainText(
kind === 'empty'
? 'Not in the current catalog'
: 'Catalog status unknown'
);
}
}
const removedCard = contentCardByTitle(app.mainWindow, titles[0]);
await removedCard
.getByRole('button', { name: /remove from favorites/i })
.click();
await expect(removedCard).toHaveCount(0);
const retained = original.filter((row) => row.title !== titles[0]);
expect(
savedFavoriteIdentity(
await readSavedFavorites(app.mainWindow, playlistId)
)
).toEqual(retained);
proxy.setMode({ kind: 'normal' });
// Replaying a stale refresh snapshot must never resurrect an explicit removal.
for (let refresh = 0; refresh < 2; refresh++) {
await refreshFavoriteCatalog(
app.mainWindow,
proxy,
playlistId,
portalTitle,
'sources'
);
const saved = await readSavedFavorites(
app.mainWindow,
playlistId
);
expect(savedFavoriteIdentity(saved)).toEqual(retained);
expect(saved[0]).toMatchObject({
id: expect.any(Number),
catalog_status: 'present',
});
await expectSavedMovieCards(app.mainWindow, [titles[1]]);
await expect(
contentCardByTitle(app.mainWindow, titles[0])
).toHaveCount(0);
await switchUnifiedCollectionScope(
app.mainWindow,
'All playlists'
);
await expect(
contentCardByTitle(app.mainWindow, titles[0])
).toHaveCount(0);
await expectVisibleContentCardTitle(app.mainWindow, titles[1]);
}
} finally {
try {
if (app) await closeElectronApp(app);
} finally {
await proxy.close();
}
}
});
test('opens Xtream favorite movies and series inline from favorites without switching the playlist or showing the sidebar', async ({
dataDir,
request,
@@ -740,6 +1003,44 @@ const xtreamCredentials = {
password: defaultXtreamPassword,
};
async function saveTwoMovieFavorites(
page: Page,
fixture: Awaited<ReturnType<typeof fetchXtreamVodFixture>>
): Promise<{ playlistId: string; titles: [string, string] }> {
const playlistId = /\/xtreams\/([^/]+)/.exec(
new URL(page.url()).pathname
)?.[1];
if (!playlistId)
throw new Error('The imported Xtream route has no playlist ID');
await page.getByRole('link', { name: 'Movies', exact: true }).click();
await clickCategoryByNameExact(page, fixture.categoryName);
const first = await clickFirstGridListCard(page);
await addCurrentDetailToFavorites(page);
await goBackFromDetail(page);
const second = fixture.items
.map(getXtreamTitle)
.find((title) => title && title !== first);
if (!second) throw new Error('The movie fixture needs two distinct titles');
await clickGridListCardByTitle(page, second);
await addCurrentDetailToFavorites(page);
await goBackFromDetail(page);
await expect
.poll(async () => (await readSavedFavorites(page, playlistId)).length)
.toBe(2);
return { playlistId, titles: [first, second] };
}
async function expectSavedMovieCards(
page: Page,
titles: readonly string[]
): Promise<void> {
await openPlaylistFavorites(page);
for (const title of titles) {
await expectVisibleContentCardTitle(page, title);
await expect(contentCardByTitle(page, title)).toHaveCount(1);
}
}
// By accessible name, not class: the Xtream movie detail's favorite control is
// an icon-only button that carries its label in aria-label, while series and
// Stalker details still use the labeled variant. This matches both.
@@ -0,0 +1,178 @@
import { createServer } from 'node:http';
import type { Page } from '@playwright/test';
import type { ElectronBridgeXtreamFavorite } from '@iptvnator/shared/interfaces';
import {
expect,
openSources,
refreshSource,
sourceRowByTitle,
waitForXtreamWorkspaceReady,
xtreamMockServer,
} from '../electron-test-fixtures';
export type FavoritesCatalogMode =
| { kind: 'normal' | 'empty' | 'failure' | 'offline' }
| { kind: 'omit'; xtreamId: number };
export interface FavoritesCatalogProxy {
origin: string;
setMode(mode: FavoritesCatalogMode): void;
responseCount(action: string): number;
close(): Promise<void>;
}
/** Main-process HTTP cannot be intercepted by Playwright's page.route(). */
export async function createFavoritesCatalogProxy(): Promise<FavoritesCatalogProxy> {
let mode: FavoritesCatalogMode = { kind: 'normal' };
const responses = new Map<string, number>();
const pending = new Set<AbortController>();
const server = createServer((request, response) => {
const url = new URL(request.url ?? '/', xtreamMockServer);
const action = url.searchParams.get('action') ?? 'get_account_info';
const requestMode = mode;
response.once('finish', () => {
responses.set(action, (responses.get(action) ?? 0) + 1);
});
response.setHeader('Content-Type', 'application/json');
response.setHeader('Cache-Control', 'no-store');
if (
requestMode.kind === 'offline' ||
(action === 'get_vod_streams' && requestMode.kind === 'failure')
) {
response.writeHead(503).end('{"error":"fixture-unavailable"}');
return;
}
if (action === 'get_vod_streams' && requestMode.kind === 'empty') {
response.end('[]');
return;
}
const controller = new AbortController();
pending.add(controller);
const timeout = setTimeout(() => controller.abort(), 10_000);
void (async () => {
try {
const upstream = await fetch(url, {
redirect: 'manual',
signal: controller.signal,
});
let body = await upstream.text();
if (
action === 'get_vod_streams' &&
requestMode.kind === 'omit'
) {
const movies = JSON.parse(body) as {
stream_id: number | string;
}[];
body = JSON.stringify(
movies.filter(
(movie) =>
Number(movie.stream_id) !== requestMode.xtreamId
)
);
}
response.writeHead(upstream.status).end(body);
} catch {
if (!response.destroyed) {
response
.writeHead(502)
.end('{"error":"fixture-upstream-failed"}');
}
} finally {
clearTimeout(timeout);
pending.delete(controller);
}
})();
});
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Favorites fixture did not bind a TCP port');
}
return {
origin: `http://127.0.0.1:${address.port}`,
setMode: (next) => {
mode = next;
},
responseCount: (action) => responses.get(action) ?? 0,
close: () =>
new Promise<void>((resolve, reject) => {
pending.forEach((controller) => controller.abort());
server.close((error) => (error ? reject(error) : resolve()));
server.closeAllConnections();
}),
};
}
export function readSavedFavorites(page: Page, playlistId: string) {
return page.evaluate(
(id) => window.electron.dbGetFavorites(id),
playlistId
);
}
/** Exclude the replaceable catalog link while retaining every user-owned field. */
export function savedFavoriteIdentity(rows: ElectronBridgeXtreamFavorite[]) {
return rows.map((row) => ({
favoriteId: row.favorite_id,
xtreamId: row.xtream_id,
title: row.title,
addedAt: row.added_at,
position: row.position,
}));
}
export async function refreshFavoriteCatalog(
page: Page,
proxy: FavoritesCatalogProxy,
playlistId: string,
title: string,
entry: 'sources' | 'header',
expectedImportState: 'completed' | 'failed' = 'completed'
): Promise<void> {
await openSources(page);
if (entry === 'header') {
await sourceRowByTitle(page, title).first().click();
await waitForXtreamWorkspaceReady(page);
}
const requestsBefore = proxy.responseCount('get_vod_streams');
if (entry === 'sources') {
await refreshSource(page, title, { confirm: true });
} else {
await page
.getByRole('button', { name: 'Refresh playlist', exact: true })
.click();
const dialog = page.locator('mat-dialog-container');
await dialog
.getByRole('button', { name: 'Refresh playlist', exact: true })
.click();
await expect(dialog).toHaveCount(0);
}
// A completed status from the previous generation cannot satisfy this refresh.
await expect
.poll(() => proxy.responseCount('get_vod_streams'), {
timeout: 30_000,
})
.toBeGreaterThan(requestsBefore);
await expect
.poll(
() =>
page.evaluate(
(id) =>
window.electron.dbGetAppState(
`xtream-import-status:${id}:movie`
),
playlistId
),
{ timeout: 30_000 }
)
.toBe(expectedImportState);
await expect(page.locator('.workspace-loading-overlay')).toHaveCount(0, {
timeout: 30_000,
});
}