mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 01:56:16 -08:00
test(downloads): cover series batch queue flow
This commit is contained in:
1 parent
ebb1afe8db
commit
877fdc370b
4 files changed
+335
-1
No files matched your search
@@ -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<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,
|
||||
episodeIds: readonly number[]
|
||||
): Promise<EpisodeQueueRow[]> {
|
||||
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<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, 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, ScenarioConfig> = {
|
||||
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:
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user