mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
* 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
192 lines
6.3 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|