Files
iptvnator/apps/electron-backend-e2e/src/series-download-queue.e2e.ts
T
4gray 96facd6f49 feat(downloads): queue season episode downloads (#1357)
* docs(downloads): specify season queueing

* docs(downloads): plan season queue implementation

* feat(downloads): define episode queue identity

* fix(downloads): align episode identity contract

* feat(downloads): coordinate season queue submissions

* fix(downloads): keep queue coordination provider neutral

* fix(downloads): reconcile legacy episode identities

* fix(downloads): fail closed on invalid stored coordinates

* refactor(downloads): adapt Xtream episode requests

* fix(downloads): use canonical Stalker episode ids

* test(downloads): cover Stalker adapter reactivity

* feat(downloads): add selected season queue action

* refactor(downloads): extract season download presenter

* feat(downloads): localize season queue feedback

* test(downloads): cover series batch queue flow

* test(downloads): harden series queue fixtures

* docs(downloads): describe season queueing

* docs(downloads): clarify season queue IPC contract

* fix(downloads): isolate season header build warnings

* fix(downloads): label season view toggles

* fix(downloads): preserve Xtream episode headers

* fix(downloads): fail closed on stale episode state

* fix(downloads): align renderer queue safeguards

* fix(downloads): block ambiguous episode actions

* fix(downloads): accept nullable legacy coordinates

* fix(downloads): preserve scoped episode ownership

* fix(downloads): probe restored files asynchronously

* fix(downloads): bound restored file probes

* fix(downloads): release timed out file probes

* fix(downloads): bound file probe callers

* fix(downloads): refresh stable season skips

* fix(downloads): fail closed before provider prep

* fix(downloads): preserve retained partial ownership

* fix(downloads): reconcile partial cleanup completion

* fix(downloads): await authoritative list refresh

* fix(downloads): coalesce list refreshes

* fix(downloads): preserve specials season identity

* fix(stalker): preserve specials season mapping

* fix(downloads): distinguish missing Xtream seasons
2026-08-03 08:53:44 +02:00

192 lines
6.3 KiB
TypeScript

import { mkdirSync } from 'fs';
import { join } from 'path';
import type { Page } from '@playwright/test';
import {
addXtreamPortal,
clickCategoryByNameExact,
clickGridListCardByTitle,
closeElectronApp,
expect,
launchElectronApp,
openWorkspaceSection,
resetMockServers,
test,
waitForXtreamImportToFinish,
} from './electron-test-fixtures';
import {
fetchXtreamSeriesFixture,
getXtreamTitle,
} from './portal-mock-fixtures';
const downloadQueueCredentials = {
password: 'downloadqueue',
username: 'downloadqueue',
};
const expectedEpisodeIds = [80_000, 80_001, 80_002, 80_003];
type EpisodeQueueRow = {
episodeNumber: number | null;
seriesXtreamId: number | null;
status: string;
xtreamId: number;
};
const expectedEpisodeQueue: EpisodeQueueRow[] = [
{
episodeNumber: 1,
seriesXtreamId: 30_000,
status: 'downloading',
xtreamId: expectedEpisodeIds[0],
},
{
episodeNumber: 2,
seriesXtreamId: 30_000,
status: 'queued',
xtreamId: expectedEpisodeIds[1],
},
{
episodeNumber: 3,
seriesXtreamId: 30_000,
status: 'queued',
xtreamId: expectedEpisodeIds[2],
},
{
episodeNumber: 4,
seriesXtreamId: 30_000,
status: 'queued',
xtreamId: expectedEpisodeIds[3],
},
];
type SeriesFixture = Awaited<ReturnType<typeof fetchXtreamSeriesFixture>>;
function waitForQueueStabilityInterval(): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, 1_500));
}
function readFirstSeriesTitle(fixture: SeriesFixture): string {
const firstSeries = fixture.items[0];
const title = firstSeries ? getXtreamTitle(firstSeries) : '';
if (
!title ||
Number(firstSeries?.series_id) !== 30_000 ||
fixture.items.length !== 4
) {
throw new Error('Series download queue fixture is invalid.');
}
return title;
}
async function readEpisodeQueue(page: Page): Promise<EpisodeQueueRow[]> {
return page.evaluate(async () => {
const downloads = (await window.electron?.downloadsGetList?.()) ?? [];
return downloads
.filter((download) => download.contentType === 'episode')
.map((download) => ({
episodeNumber: download.episodeNumber ?? null,
seriesXtreamId: download.seriesXtreamId ?? null,
status: download.status,
xtreamId: download.xtreamId,
}));
});
}
test.describe('Electron Series Download Queue', () => {
test('@downloads @xtream @electron queues individual and season episode downloads without duplicates', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const seriesFixture = await fetchXtreamSeriesFixture(
request,
downloadQueueCredentials
);
const firstSeriesTitle = readFirstSeriesTitle(seriesFixture);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
...downloadQueueCredentials,
name: 'Series Download Queue',
});
await waitForXtreamImportToFinish(app.mainWindow);
await app.mainWindow
.getByRole('button', { name: 'Open downloads' })
.click();
await app.mainWindow.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
const downloadsDir = join(dataDir, 'series-downloads');
mkdirSync(downloadsDir, { recursive: true });
await app.electronApp.evaluate(({ dialog }, folder) => {
dialog.showOpenDialog = async () =>
({
canceled: false,
filePaths: [folder],
}) as Awaited<ReturnType<typeof dialog.showOpenDialog>>;
}, downloadsDir);
await app.mainWindow
.getByRole('button', { name: 'Change Folder' })
.click();
await expect(
app.mainWindow.getByTestId('downloads-folder')
).toContainText('series-downloads');
await openWorkspaceSection(app.mainWindow, 'Series');
await clickCategoryByNameExact(
app.mainWindow,
seriesFixture.categoryName
);
await clickGridListCardByTitle(app.mainWindow, firstSeriesTitle);
await app.mainWindow.waitForURL(
/\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/
);
const firstEpisodeAction = app.mainWindow.getByTestId(
`episode-download-${expectedEpisodeIds[0]}`
);
const secondEpisodeAction = app.mainWindow.getByTestId(
`episode-download-${expectedEpisodeIds[1]}`
);
await expect(firstEpisodeAction).toBeEnabled({ timeout: 20_000 });
await expect(secondEpisodeAction).toBeEnabled();
await firstEpisodeAction.click();
await secondEpisodeAction.click();
await expect
.poll(() => readEpisodeQueue(app.mainWindow), {
timeout: 20_000,
})
.toEqual(expectedEpisodeQueue.slice(0, 2));
const seasonAction = app.mainWindow.getByTestId('download-season');
await expect(seasonAction).toBeVisible();
await expect(seasonAction).toContainText('Download season (2)');
await expect(seasonAction).toBeEnabled();
await seasonAction.click();
await expect(
app.mainWindow.getByText('Added 2 · Skipped 2', {
exact: true,
})
).toBeVisible({ timeout: 20_000 });
await expect
.poll(() => readEpisodeQueue(app.mainWindow), {
timeout: 20_000,
})
.toEqual(expectedEpisodeQueue);
await expect(seasonAction).toBeDisabled();
await expect(seasonAction).toContainText('Download season (0)');
await waitForQueueStabilityInterval();
expect(await readEpisodeQueue(app.mainWindow)).toEqual(
expectedEpisodeQueue
);
} finally {
await closeElectronApp(app);
}
});
});