feat(downloads): finish manager MVP

This commit is contained in:
4gray committed 2026-08-01 16:29:49 +02:00
1 parent eb55c17a05
commit bcc2e36a05
48 files changed
+4145 -1844

No files matched your search

+657 -41
View File
@@ -1,4 +1,4 @@
import { mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from 'fs';
import { createServer } from 'http';
import type { AddressInfo } from 'net';
import { join } from 'path';
@@ -19,6 +19,75 @@ async function openDownloadsPage(page: Page): Promise<void> {
await page.waitForURL(/\/workspace\/downloads(?:\?.*)?$/);
}
async function navigateWithinWorkspace(
page: Page,
path: string
): Promise<void> {
await page.evaluate((target) => {
window.history.pushState(null, '', target);
window.dispatchEvent(new PopStateEvent('popstate'));
}, path);
await page.waitForURL((url) => url.pathname === path);
}
async function getPlaylistId(page: Page, title: string): Promise<string> {
const playlists = await page.evaluate(
async () => (await window.electron?.dbGetAppPlaylistMetas?.()) ?? []
);
const playlist = playlists.find((entry) => entry.title === title);
expect(playlist, `playlist "${title}" should exist`).toBeDefined();
return playlist?._id ?? '';
}
async function getSeriesTarget(
page: Page,
playlistId: string
): Promise<{ categoryId: number; xtreamId: number }> {
const target = await page.evaluate(
async (sourceId) =>
(await window.electron?.dbGetContent?.(sourceId, 'series'))?.[0] ??
null,
playlistId
);
expect(target, 'playlist should have an imported series target').not.toBe(
null
);
if (!target) {
throw new Error('Series target was not imported');
}
expect(target.xtream_id).toBeGreaterThan(0);
expect(target.category_id).toBeGreaterThan(0);
return {
categoryId: target.category_id,
xtreamId: target.xtream_id,
};
}
interface DownloadSeed {
contentType: 'episode' | 'vod';
downloadFolder: string;
episodeNumber?: number;
playlistId: string;
seasonNumber?: number;
seriesXtreamId?: number;
title: string;
url: string;
xtreamId: number;
}
async function startDownload(page: Page, seed: DownloadSeed): Promise<number> {
const result = await page.evaluate(
async (payload) => window.electron?.downloadsStart?.(payload),
seed
);
expect(result?.error ?? null).toBeNull();
expect(result?.id).toEqual(expect.any(Number));
if (typeof result?.id !== 'number') {
throw new Error(`Download did not return an id: ${seed.title}`);
}
return result.id;
}
interface RangeServerRequest {
ifRange?: string;
range?: string;
@@ -31,6 +100,13 @@ interface ThrottledRangeServer {
url: string;
}
interface TruncatedDownloadServer {
close: () => Promise<void>;
payload: Buffer;
totalBytes: number;
url: string;
}
const RANGE_SERVER_ETAG = '"e2e-range-etag"';
/**
@@ -38,7 +114,9 @@ const RANGE_SERVER_ETAG = '"e2e-range-etag"';
* window to pause mid-transfer, and answers Range requests with an immediate
* 206 so the resumed transfer finishes fast. Records Range/If-Range headers.
*/
async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
async function createThrottledRangeServer(
chunkIntervalMs = 150
): Promise<ThrottledRangeServer> {
const payload = Buffer.from(
Array.from({ length: 96 * 1024 }, (_, index) =>
String(index % 10)
@@ -85,7 +163,7 @@ async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
}
res.write(payload.subarray(sent, sent + 2 * 1024));
sent += 2 * 1024;
}, 150);
}, chunkIntervalMs);
res.on('close', () => clearInterval(timer));
});
@@ -103,6 +181,37 @@ async function createThrottledRangeServer(): Promise<ThrottledRangeServer> {
};
}
/**
* Ends a chunked response cleanly while Content-Range advertises a larger
* representation. The runtime therefore retains the valid .part for a Range
* retry instead of treating it as an unusable transport failure.
*/
async function createTruncatedDownloadServer(): Promise<TruncatedDownloadServer> {
const payload = Buffer.alloc(16 * 1024, 7);
const totalBytes = payload.length * 4;
const server = createServer((_req, res) => {
res.writeHead(200, {
'Content-Range': `bytes 0-${payload.length - 1}/${totalBytes}`,
'Content-Type': 'video/mp4',
ETag: '"e2e-truncated-etag"',
});
res.end(payload);
});
await new Promise<void>((resolve) =>
server.listen(0, '127.0.0.1', resolve)
);
const { port } = server.address() as AddressInfo;
return {
close: () =>
new Promise<void>((resolve) => server.close(() => resolve())),
payload,
totalBytes,
url: `http://127.0.0.1:${port}/media/e2e-truncated-movie.mp4`,
};
}
/**
* On a cold profile the renderer can query SQLite while the DB worker is
* still creating tables ("database is locked" / "no such table" on slow CI
@@ -147,9 +256,7 @@ test.describe('Electron Downloads', () => {
// First launch runs the IndexedDB→SQLite playlist migration before
// the playlists query resolves, so allow extra time here.
await expect(
app.mainWindow.getByText(
'Add a playlist to start downloading'
)
app.mainWindow.getByText('Add a playlist to start downloading')
).toBeVisible({ timeout: 20000 });
} finally {
await closeElectronApp(app);
@@ -198,55 +305,549 @@ test.describe('Electron Downloads', () => {
.getByRole('button', { name: 'Change Folder' })
.click();
await expect(
app.mainWindow.locator('.downloads__folder-inline-path')
app.mainWindow.getByTestId('downloads-folder')
).toContainText('e2e-downloads');
// Enqueue a download through the same renderer bridge the app uses.
const playlistId = await getPlaylistId(
app.mainWindow,
'Download Portal'
);
const startResult = await app.mainWindow.evaluate(
async ({ url, folder }) =>
async ({ url, folder, sourceId }) =>
window.electron?.downloadsStart?.({
playlistId: 'e2e-playlist',
playlistId: sourceId,
xtreamId: 4242,
contentType: 'vod',
title: 'E2E Movie',
url,
downloadFolder: folder,
}),
{ url: fileServer.resourceUrl, folder: downloadsDir }
{
url: fileServer.resourceUrl,
folder: downloadsDir,
sourceId: playlistId,
}
);
expect(startResult?.error ?? null).toBeNull();
expect(startResult?.id).toEqual(expect.any(Number));
const item = app.mainWindow.locator('.downloads__item');
await expect(item).toHaveCount(1, { timeout: 20000 });
await expect(
item.locator('.downloads__item-title-text')
).toHaveText('E2E Movie');
await expect(item.locator('.downloads__item-status')).toContainText(
'Completed',
{ timeout: 20000 }
const card = app.mainWindow.getByTestId(
`download-library-movie-${startResult?.id}`
);
await expect(card).toBeVisible({ timeout: 20000 });
await expect(
app.mainWindow.getByRole('heading', {
name: 'Ready to watch',
})
).toBeVisible();
// The file landed in the authorized folder.
expect(readdirSync(downloadsDir).length).toBeGreaterThan(0);
const finalPath = join(downloadsDir, 'E2E Movie.mp4');
expect(readdirSync(downloadsDir)).toEqual(['E2E Movie.mp4']);
expect(readFileSync(finalPath, 'utf8')).toBe(
'e2e download payload'
);
// Completed items expose play / reveal / remove actions.
await expect(
item.getByRole('button').filter({ hasText: 'play_arrow' })
card.getByRole('button', { name: 'Play: E2E Movie' }).last()
).toBeVisible();
await expect(
item.getByRole('button').filter({ hasText: 'folder_open' })
card.getByRole('button', {
name: 'Show in Folder: E2E Movie',
})
).toBeVisible();
await item
.getByRole('button')
.filter({ hasText: 'delete' })
await card
.getByRole('button', {
name: 'More actions: E2E Movie',
})
.click();
await app.mainWindow
.getByRole('menuitem', {
name: 'Remove from manager: E2E Movie',
})
.click();
await expect(
app.mainWindow.getByRole('heading', {
name: 'Remove from manager?',
})
).toBeVisible();
await app.mainWindow
.getByRole('button', {
name: 'Remove from manager',
exact: true,
})
.click();
await expect(
app.mainWindow.getByText('No downloads yet')
).toBeVisible();
expect(readFileSync(finalPath, 'utf8')).toBe(
'e2e download payload'
);
} finally {
await closeElectronApp(app);
await fileServer.close();
}
});
test('@downloads @electron keeps global and scoped libraries truthful while filters, search, grouping, and scrolling stay functional', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const fileServer = await createMutableTextServer('library payload', {
contentType: 'video/mp4',
resourcePath: '/media/library-item.mp4',
});
// Keep one real transfer alive while route scope changes prove that
// the shell badge remains global.
const activeServer = await createThrottledRangeServer(5000);
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Library Source A',
username: 'multisrc1',
password: 'multisrc1',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await addXtreamPortal(app.mainWindow, {
name: 'Library Source B',
username: 'multisrc2',
password: 'multisrc2',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
const sourceA = await getPlaylistId(
app.mainWindow,
'Library Source A'
);
const sourceB = await getPlaylistId(
app.mainWindow,
'Library Source B'
);
const { categoryId: seriesCategoryId, xtreamId: seriesXtreamId } =
await getSeriesTarget(app.mainWindow, sourceA);
await navigateWithinWorkspace(
app.mainWindow,
`/workspace/xtreams/${sourceB}/downloads`
);
const downloadsDir = join(dataDir, 'e2e-library-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('e2e-library-downloads');
const common = {
downloadFolder: downloadsDir,
url: fileServer.resourceUrl,
};
const alphaMovieId = await startDownload(app.mainWindow, {
...common,
playlistId: sourceA,
xtreamId: 8101,
contentType: 'vod',
title: 'Alpha Movie',
});
await startDownload(app.mainWindow, {
...common,
playlistId: sourceA,
xtreamId: 8201,
contentType: 'episode',
seriesXtreamId,
seasonNumber: 1,
episodeNumber: 1,
title: 'Aurora - S01E01 - Arrival',
});
await startDownload(app.mainWindow, {
...common,
playlistId: sourceA,
xtreamId: 8202,
contentType: 'episode',
seriesXtreamId,
seasonNumber: 1,
episodeNumber: 2,
title: 'Aurora - S01E02 - Signal',
});
for (let index = 0; index < 6; index += 1) {
await startDownload(app.mainWindow, {
...common,
playlistId: sourceA,
xtreamId: 8300 + index,
contentType: 'vod',
title: `Alpha Archive ${index + 1}`,
});
}
const betaMovieId = await startDownload(app.mainWindow, {
...common,
playlistId: sourceB,
xtreamId: 9101,
contentType: 'vod',
title: 'Beta Movie',
});
for (let index = 0; index < 4; index += 1) {
await startDownload(app.mainWindow, {
...common,
playlistId: sourceB,
xtreamId: 9300 + index,
contentType: 'vod',
title: `Beta Archive ${index + 1}`,
});
}
const cards = app.mainWindow.locator(
'article[data-test-id^="download-library-"]'
);
await expect(cards).toHaveCount(5, { timeout: 30000 });
await expect(
app.mainWindow.getByTestId(
`download-library-movie-${betaMovieId}`
)
).toBeVisible();
await expect(
app.mainWindow.getByTestId(
`download-library-movie-${alphaMovieId}`
)
).toHaveCount(0);
const activeMovieId = await startDownload(app.mainWindow, {
downloadFolder: downloadsDir,
playlistId: sourceA,
xtreamId: 8401,
contentType: 'vod',
title: 'Alpha Active Movie',
url: activeServer.url,
});
const activeItem = app.mainWindow.getByTestId(
`download-queue-item-${activeMovieId}`
);
const globalBadge = app.mainWindow.getByTestId(
'global-download-count'
);
await expect(globalBadge).toHaveText('1', { timeout: 20000 });
await expect(activeItem).toHaveCount(0);
await navigateWithinWorkspace(
app.mainWindow,
`/workspace/xtreams/${sourceA}/downloads`
);
await expect(globalBadge).toHaveText('1');
await expect(cards).toHaveCount(8);
await expect(activeItem).toBeVisible();
await expect(
activeItem.locator('.download-queue__status')
).toContainText('Downloading', { timeout: 20000 });
await expect(
app.mainWindow.getByTestId(
`download-library-movie-${alphaMovieId}`
)
).toBeVisible();
await expect(
app.mainWindow.getByTestId(
`download-library-movie-${betaMovieId}`
)
).toHaveCount(0);
await openDownloadsPage(app.mainWindow);
await expect(globalBadge).toHaveText('1');
await expect(cards).toHaveCount(13);
const seriesCard = app.mainWindow.getByTestId(
`download-library-series-${sourceA}-${seriesXtreamId}`
);
await expect(seriesCard).toContainText('2 episodes');
await expect(seriesCard).toContainText('Library Source A');
await app.mainWindow
.getByTestId('downloads-filter-in-progress')
.click();
await expect(cards).toHaveCount(0);
await expect(activeItem).toBeVisible();
await expect(
app.mainWindow.getByTestId('downloads-filter-in-progress')
).toContainText('1');
await app.mainWindow.getByTestId('downloads-filter-all').click();
await expect(cards).toHaveCount(13);
await seriesCard
.getByRole('button', {
name: 'Open details: Aurora',
exact: true,
})
.click();
const expectedSeriesPath = `/workspace/xtreams/${sourceA}/series/${seriesCategoryId}/${seriesXtreamId}`;
await app.mainWindow.waitForURL((url) =>
url.pathname.endsWith(expectedSeriesPath)
);
await openDownloadsPage(app.mainWindow);
await expect(seriesCard).toBeVisible();
const episodeButton = seriesCard.getByRole('button', {
name: 'Open downloaded episodes: Aurora',
});
await episodeButton.focus();
await episodeButton.press('Enter');
await expect(
app.mainWindow.getByRole('heading', { name: 'Aurora' })
).toBeVisible();
await expect(
app.mainWindow.locator(
'[data-test-id^="downloaded-series-episode-"]'
)
).toHaveCount(2);
const closeDialog = app.mainWindow.getByTestId(
'downloaded-series-close'
);
await expect(closeDialog).toHaveAccessibleName('Close');
await closeDialog.click();
await activeItem
.getByRole('button', { name: 'Pause Alpha Active Movie' })
.click();
await expect(
activeItem.locator('.download-queue__status')
).toContainText('Paused', { timeout: 20000 });
await expect(globalBadge).toHaveCount(0);
await app.mainWindow.getByTestId('downloads-filter-series').click();
await expect(cards).toHaveCount(1);
await expect(seriesCard).toBeVisible();
await app.mainWindow.getByTestId('downloads-filter-movie').click();
await expect(cards).toHaveCount(12);
await expect(seriesCard).toHaveCount(0);
await app.mainWindow.getByTestId('downloads-filter-all').click();
const search = app.mainWindow.getByRole('searchbox', {
name: 'Search',
});
await search.fill('Beta Movie');
await expect
.poll(
() => new URL(app.mainWindow.url()).searchParams.get('q'),
{ timeout: 10000 }
)
.toBe('Beta Movie');
await expect(cards).toHaveCount(1);
await expect(
app.mainWindow.getByTestId(
`download-library-movie-${betaMovieId}`
)
).toBeVisible();
await search.fill('');
await expect
.poll(
() => new URL(app.mainWindow.url()).searchParams.get('q'),
{ timeout: 10000 }
)
.toBeNull();
await expect(cards).toHaveCount(13);
const header = app.mainWindow.getByTestId('downloads-header');
const filters = app.mainWindow.getByTestId('downloads-filters');
const content = app.mainWindow.getByTestId('downloads-content');
const headerBefore = await header.boundingBox();
const filtersBefore = await filters.boundingBox();
const scroll = await content.evaluate((element) => {
const outer =
element.closest<HTMLElement>('.workspace-content');
element.scrollTop = element.scrollHeight;
return {
clientHeight: element.clientHeight,
outerScrollTop: outer?.scrollTop ?? -1,
scrollHeight: element.scrollHeight,
scrollTop: element.scrollTop,
};
});
const headerAfter = await header.boundingBox();
const filtersAfter = await filters.boundingBox();
expect(scroll.scrollHeight).toBeGreaterThan(scroll.clientHeight);
expect(scroll.scrollTop).toBeGreaterThan(0);
expect(scroll.outerScrollTop).toBe(0);
expect(headerAfter?.y).toBe(headerBefore?.y);
expect(filtersAfter?.y).toBe(filtersBefore?.y);
} finally {
await closeElectronApp(app);
await activeServer.close();
await fileServer.close();
}
});
test('@downloads @electron deletes retained failed partials when removing one item or clearing finished downloads', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const truncatedServer = await createTruncatedDownloadServer();
const app = await launchElectronApp(dataDir);
try {
await addXtreamPortal(app.mainWindow, {
name: 'Partial Cleanup Portal',
username: 'user1',
password: 'pass1',
});
await waitForXtreamWorkspaceReady(app.mainWindow);
await openDownloadsPage(app.mainWindow);
const downloadsDir = join(dataDir, 'e2e-partial-cleanup');
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('e2e-partial-cleanup');
const playlistId = await getPlaylistId(
app.mainWindow,
'Partial Cleanup Portal'
);
const removeId = await startDownload(app.mainWindow, {
playlistId,
xtreamId: 9501,
contentType: 'vod',
title: 'Remove Retained Partial',
url: truncatedServer.url,
downloadFolder: downloadsDir,
});
const clearId = await startDownload(app.mainWindow, {
playlistId,
xtreamId: 9502,
contentType: 'vod',
title: 'Clear Retained Partial',
url: truncatedServer.url,
downloadFolder: downloadsDir,
});
for (const id of [removeId, clearId]) {
await expect
.poll(
() =>
app.mainWindow.evaluate(
async (downloadId) =>
(
await window.electron?.downloadsGet?.(
downloadId
)
)?.status ?? null,
id
),
{ timeout: 30000 }
)
.toBe('failed');
}
const failedRows = await app.mainWindow.evaluate(
async (ids) =>
Promise.all(
ids.map((id) => window.electron?.downloadsGet?.(id))
),
[removeId, clearId]
);
const partialPaths = failedRows.map((row) => {
expect(row?.filePath).toEqual(expect.any(String));
expect(row?.bytesDownloaded).toBe(
truncatedServer.payload.length
);
expect(row?.totalBytes).toBe(truncatedServer.totalBytes);
return `${row?.filePath}.part`;
});
for (const partialPath of partialPaths) {
expect(existsSync(partialPath)).toBe(true);
expect(statSync(partialPath).size).toBe(
truncatedServer.payload.length
);
}
const removeItem = app.mainWindow.getByTestId(
`download-queue-item-${removeId}`
);
await expect(removeItem).toBeVisible();
await removeItem
.getByRole('button', {
name: 'Remove Remove Retained Partial from manager',
})
.click();
await expect(
app.mainWindow.getByRole('heading', {
name: 'Remove partial download?',
})
).toBeVisible();
await app.mainWindow
.getByRole('button', {
name: 'Remove from manager',
exact: true,
})
.click();
await expect(removeItem).toHaveCount(0);
await expect
.poll(() =>
app.mainWindow.evaluate(
async (id) =>
(await window.electron?.downloadsGet?.(id)) ?? null,
removeId
)
)
.toBeNull();
expect(existsSync(partialPaths[0])).toBe(false);
expect(existsSync(partialPaths[1])).toBe(true);
await app.mainWindow
.getByTestId('downloads-clear-finished')
.click();
await expect(
app.mainWindow.getByRole('heading', {
name: 'Clear finished downloads?',
})
).toBeVisible();
await app.mainWindow
.getByRole('dialog', {
name: 'Clear finished downloads?',
})
.getByRole('button', {
name: 'Clear finished',
exact: true,
})
.click();
await expect(
app.mainWindow.getByTestId(`download-queue-item-${clearId}`)
).toHaveCount(0);
await expect
.poll(() =>
app.mainWindow.evaluate(
async (id) =>
(await window.electron?.downloadsGet?.(id)) ?? null,
clearId
)
)
.toBeNull();
expect(existsSync(partialPaths[1])).toBe(false);
await expect(
app.mainWindow.getByText('No downloads yet')
).toBeVisible();
} finally {
await closeElectronApp(app);
await fileServer.close();
await truncatedServer.close();
}
});
@@ -280,26 +881,37 @@ test.describe('Electron Downloads', () => {
.getByRole('button', { name: 'Change Folder' })
.click();
await expect(
app.mainWindow.locator('.downloads__folder-inline-path')
app.mainWindow.getByTestId('downloads-folder')
).toContainText('e2e-pause-downloads');
const playlistId = await getPlaylistId(
app.mainWindow,
'Pause Portal'
);
const startResult = await app.mainWindow.evaluate(
async ({ url, folder }) =>
async ({ url, folder, sourceId }) =>
window.electron?.downloadsStart?.({
playlistId: 'e2e-playlist',
playlistId: sourceId,
xtreamId: 7373,
contentType: 'vod',
title: 'E2E Pause Movie',
url,
downloadFolder: folder,
}),
{ url: rangeServer.url, folder: downloadsDir }
{
url: rangeServer.url,
folder: downloadsDir,
sourceId: playlistId,
}
);
expect(startResult?.error ?? null).toBeNull();
expect(startResult?.id).toEqual(expect.any(Number));
const item = app.mainWindow.locator('.downloads__item');
await expect(item).toHaveCount(1, { timeout: 20000 });
await expect(item.locator('.downloads__item-status')).toContainText(
const item = app.mainWindow.getByTestId(
`download-queue-item-${startResult?.id}`
);
await expect(item).toBeVisible({ timeout: 20000 });
await expect(item.locator('.download-queue__status')).toContainText(
'Downloading',
{ timeout: 20000 }
);
@@ -307,10 +919,9 @@ test.describe('Electron Downloads', () => {
// Pause mid-transfer: the row flips to Paused and only a .part
// file exists on disk (final file absent, progress retained).
await item
.getByRole('button')
.filter({ hasText: 'pause' })
.getByRole('button', { name: 'Pause E2E Pause Movie' })
.click();
await expect(item.locator('.downloads__item-status')).toContainText(
await expect(item.locator('.download-queue__status')).toContainText(
'Paused',
{ timeout: 20000 }
);
@@ -325,13 +936,18 @@ test.describe('Electron Downloads', () => {
// Resume: the runtime must continue via Range/If-Range instead of
// restarting, and the assembled file must match the payload.
await item
.getByRole('button')
.filter({ hasText: 'play_arrow' })
.getByRole('button', { name: 'Resume E2E Pause Movie' })
.click();
await expect(item.locator('.downloads__item-status')).toContainText(
'Completed',
{ timeout: 30000 }
);
await expect(
app.mainWindow.getByTestId(
`download-library-movie-${startResult?.id}`
)
).toBeVisible({ timeout: 30000 });
await expect(
app.mainWindow.getByRole('heading', {
name: 'Ready to watch',
})
).toBeVisible();
const resumeRequest = rangeServer.requests.find(
(entry) => entry.range