From 877fdc370bfcf425dd90730e9a3729c0ddc8f0b1 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 2 Aug 2026 15:00:15 +0200 Subject: [PATCH] test(downloads): cover series batch queue flow --- .../src/series-download-queue.e2e.ts | 210 ++++++++++++++++++ apps/xtream-mock-server/src/app/scenarios.ts | 15 ++ .../xtream-mock-server/src/app/server.spec.ts | 32 +++ apps/xtream-mock-server/src/app/server.ts | 79 ++++++- 4 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 apps/electron-backend-e2e/src/series-download-queue.e2e.ts diff --git a/apps/electron-backend-e2e/src/series-download-queue.e2e.ts b/apps/electron-backend-e2e/src/series-download-queue.e2e.ts new file mode 100644 index 000000000..fe4c9fffa --- /dev/null +++ b/apps/electron-backend-e2e/src/series-download-queue.e2e.ts @@ -0,0 +1,210 @@ +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; + status: string; + xtreamId: number; +}; + +type SeriesFixture = Awaited>; + +function waitForQueueStabilityInterval(): Promise { + 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, + episodeIds: readonly number[] +): Promise { + return page.evaluate( + async (expectedIds) => { + const downloads = + (await window.electron?.downloadsGetList?.()) ?? []; + return downloads + .filter( + (download) => + download.contentType === 'episode' && + expectedIds.includes(download.xtreamId) + ) + .map((download) => ({ + episodeNumber: download.episodeNumber ?? null, + status: download.status, + xtreamId: download.xtreamId, + })); + }, + [...episodeIds] + ); +} + +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>; + }, 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, expectedEpisodeIds), + { timeout: 20_000 } + ) + .toEqual([ + { + episodeNumber: 1, + status: 'downloading', + xtreamId: expectedEpisodeIds[0], + }, + { + episodeNumber: 2, + status: 'queued', + xtreamId: expectedEpisodeIds[1], + }, + ]); + + 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( + async () => + ( + await readEpisodeQueue( + app.mainWindow, + expectedEpisodeIds + ) + ).map((row) => row.xtreamId), + { timeout: 20_000 } + ) + .toEqual(expectedEpisodeIds); + + const queuedSeason = await readEpisodeQueue( + app.mainWindow, + expectedEpisodeIds + ); + expect(new Set(queuedSeason.map((row) => row.xtreamId)).size).toBe( + 4 + ); + expect(queuedSeason.map((row) => row.status)).toEqual([ + 'downloading', + 'queued', + 'queued', + 'queued', + ]); + await expect(seasonAction).toBeDisabled(); + await expect(seasonAction).toContainText('Download season (0)'); + + await waitForQueueStabilityInterval(); + const stableQueue = await readEpisodeQueue( + app.mainWindow, + expectedEpisodeIds + ); + expect(stableQueue).toHaveLength(4); + expect(new Set(stableQueue.map((row) => row.xtreamId)).size).toBe( + 4 + ); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 81e32ff8d..2452860c6 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -21,6 +21,8 @@ export interface ScenarioConfig { performanceFixture?: 'catalog-100k'; /** Build series details on demand instead of during portal initialization. */ deferSeriesDetails?: true; + /** Optional local stream fixture for deterministic download queue tests. */ + downloadStreamFixture?: 'slow-series'; } /** @@ -99,6 +101,19 @@ export const SCENARIOS: Record = { accountStatus: 'Active', expiryDate: '2099-12-31', }, + 'downloadqueue:downloadqueue': { + name: 'download-queue', + description: + 'Download queue — 1 series category, 4 series with 4 episodes each', + seed: 8080, + categoryCount: { live: 0, vod: 0, series: 1 }, + itemsPerCategory: 4, + seasonsPerSeries: 1, + episodesPerSeason: 4, + accountStatus: 'Active', + expiryDate: '2099-12-31', + downloadStreamFixture: 'slow-series', + }, 'epg:epg': { name: 'epg-fixture', description: diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 9e26995b1..f38feccfe 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -117,6 +117,38 @@ describe('Xtream mock server factory', () => { } }); + it('serves the download queue series fixture locally without changing ordinary series redirects', async () => { + const running = await startLoopbackServer( + createXtreamMockApp({ host: '127.0.0.1', port: 0 }) + ); + try { + const localSeries = await fetch( + `${running.origin}/series/downloadqueue/downloadqueue/80000.mkv`, + { redirect: 'manual' } + ); + const ordinarySeries = await fetch( + `${running.origin}/series/user1/pass1/80000.mkv`, + { redirect: 'manual' } + ); + + expect(localSeries.status).toBe(200); + expect(localSeries.headers.get('content-type')).toContain( + 'video/mp4' + ); + expect( + Number(localSeries.headers.get('content-length')) + ).toBeGreaterThan(1024 * 1024); + await localSeries.body?.cancel(); + + expect(ordinarySeries.status).toBe(302); + expect(ordinarySeries.headers.get('location')).toBe( + 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8' + ); + } finally { + await running.close(); + } + }); + it('keeps a non-EPG timezone stream empty across repeated short-EPG requests', async () => { const running = await startLoopbackServer( createXtreamMockApp({ host: '127.0.0.1', port: 0 }) diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index a5aebc464..f1d4e0f1d 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -14,6 +14,7 @@ import { XtreamPerformanceController, } from './performance-control.js'; import { dispatchAction } from './routes/dispatch.js'; +import { getScenario } from './scenarios.js'; export { createXtreamMockServerShutdown } from './server-lifecycle.js'; @@ -46,6 +47,9 @@ const DEFAULT_NORMAL_HOST = '0.0.0.0'; const DEFAULT_CONTROL_HOST = '127.0.0.1'; const PERFORMANCE_USERNAME = 'performance'; const PERFORMANCE_PASSWORD = 'performance'; +const DOWNLOAD_STREAM_BYTES = 8 * 1024 * 1024; +const DOWNLOAD_STREAM_CHUNK_INTERVAL_MS = 100; +const DOWNLOAD_STREAM_CHUNK = Buffer.alloc(32 * 1024); const marketingRasterAssetRoot = join( process.cwd(), 'apps/xtream-mock-server/public/marketing' @@ -248,10 +252,21 @@ function installStreamRoutes( } response.redirect(HLS_STUB); }; + const seriesResponse = (request: Request, response: Response) => { + if (isPerformanceMediaRequest(request, controlEnabled)) { + response.status(410).json({ error: 'performance-media-disabled' }); + return; + } + if (isSlowSeriesDownloadRequest(request)) { + streamSlowSeriesDownload(request, response); + return; + } + response.redirect(HLS_STUB); + }; app.get('/live/:username/:password/:streamId.m3u8', streamResponse); app.get('/live/:username/:password/:streamId.ts', streamResponse); app.get('/movie/:username/:password/:streamId.:ext', streamResponse); - app.get('/series/:username/:password/:streamId.:ext', streamResponse); + app.get('/series/:username/:password/:streamId.:ext', seriesResponse); app.all( '/timeshift/:username/:password/:duration/:start/:streamId.ts', streamResponse @@ -259,6 +274,68 @@ function installStreamRoutes( app.all('/streaming/timeshift.php', streamResponse); } +function isSlowSeriesDownloadRequest(request: Request): boolean { + const username = String(request.params['username'] ?? ''); + const password = String(request.params['password'] ?? ''); + return ( + getScenario(username, password).downloadStreamFixture === 'slow-series' + ); +} + +function streamSlowSeriesDownload(request: Request, response: Response): void { + let sentBytes = 0; + let stopped = false; + let timer: NodeJS.Timeout | undefined; + + const stop = () => { + stopped = true; + if (timer) { + clearTimeout(timer); + timer = undefined; + } + response.off('drain', scheduleChunk); + }; + const scheduleChunk = () => { + if (!stopped && !timer) { + timer = setTimeout(writeChunk, DOWNLOAD_STREAM_CHUNK_INTERVAL_MS); + } + }; + const writeChunk = () => { + timer = undefined; + if (stopped || response.destroyed || response.writableEnded) { + stop(); + return; + } + + const remainingBytes = DOWNLOAD_STREAM_BYTES - sentBytes; + const chunk = + remainingBytes >= DOWNLOAD_STREAM_CHUNK.length + ? DOWNLOAD_STREAM_CHUNK + : DOWNLOAD_STREAM_CHUNK.subarray(0, remainingBytes); + sentBytes += chunk.length; + const canContinue = response.write(chunk); + + if (sentBytes >= DOWNLOAD_STREAM_BYTES) { + response.end(); + } else if (canContinue) { + scheduleChunk(); + } else { + response.once('drain', scheduleChunk); + } + }; + + request.once('close', stop); + response.once('close', stop); + response.once('finish', stop); + response + .status(200) + .type('video/mp4') + .set('Content-Length', String(DOWNLOAD_STREAM_BYTES)) + .set('Cache-Control', 'no-store') + .flushHeaders(); + scheduleChunk(); +} + function isPerformanceMediaRequest( request: Request, controlEnabled: boolean