mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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
This commit is contained in:
1 parent
d3cc18dc72
commit
96facd6f49
79 files changed
+8352
-1255
No files matched your search
@@ -0,0 +1,191 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user