feat(downloads): redesign download manager (#1313)

* docs(downloads): specify manager MVP redesign

* docs(downloads): plan manager MVP implementation

* docs(downloads): tighten manager validation plan

* fix(downloads): keep renderer download state global

* fix(downloads): make active count accessible

* feat(downloads): derive queue and library view model

* test(downloads): close view model coverage gaps

* fix(downloads): stabilize malformed view model data

* refactor(downloads): isolate library navigation

* fix(downloads): report library navigation failures

* feat(downloads): add ready-to-watch library

* feat(downloads): add active download queue

* feat(downloads): finish manager MVP

* docs(downloads): clarify detail-first offline behavior

* docs(downloads): plan detail navigation follow-up

* fix(downloads): open completed movies in details

* test(downloads): cover pending series navigation

* fix(downloads): honor the global cover size

* fix(downloads): prefer local playback in shared details

* fix(downloads): preserve external launch priority

* fix(downloads): prefer local playback in Xtream details

* test(downloads): cover offline detail journey

* docs(downloads): document offline detail behavior

* docs(downloads): format detail navigation plan

* fix(downloads): open Stalker items in provider details

* docs(downloads): clarify Stalker navigation fallback

* fix(xtream): isolate reused detail identities

* fix(xtream): ignore stale VOD positions

* fix(downloads): keep offline Xtream playback available

* docs(downloads): clarify provider playback availability

* docs(downloads): design missing-file recovery

* docs(downloads): plan missing-file recovery

* feat(downloads): derive completed file availability

* feat(downloads): recover missing completed files

* feat(downloads): refresh missing local files

* feat(downloads): separate missing files from ready media

* feat(downloads): surface missing files for recovery

* refactor(downloads): simplify ready cards

* test(downloads): cover missing-file and series journeys

* feat(downloads): finish missing-file recovery

* docs(downloads): design offline detail views

* docs(downloads): plan offline detail views

* feat(downloads): persist offline metadata snapshots

* fix(downloads): complete metadata snapshot bridge contract

* feat(downloads): manage offline metadata snapshots

* fix(downloads): harden metadata snapshot updates

* fix(downloads): restrict snapshot artwork

* fix(downloads): guard restart artwork URL

* fix(downloads): refine artwork URL checks

* feat(downloads): expose offline metadata updates

* fix(downloads): keep metadata service change focused

* fix(downloads): preserve metadata error conventions

* feat(downloads): derive offline detail content

* fix(downloads): preserve unknown episode coordinates

* feat(downloads): add focused offline detail routes

* fix(downloads): ignore fragments in shell route state

* fix(downloads): normalize fragments before queries

* feat(downloads): open ready cards in offline details

* fix(downloads): use native disabled card styles

* feat(downloads): enrich offline detail metadata

* fix(downloads): harden offline metadata resolution

* fix(downloads): preserve stalker provider titles

* fix(downloads): distinguish stalker metadata seeds

* fix(downloads): stabilize offline metadata refresh

* fix(downloads): throttle sparse metadata refreshes

* fix(downloads): type metadata language settings

* feat(downloads): render offline movie and series details

* fix(downloads): harden offline detail interactions

* fix(downloads): close offline detail edge cases

* feat(downloads): hand off to provider-only details

* fix(downloads): preserve stalker provider handoff

* feat(downloads): capture metadata at download time

* fix(downloads): preserve snapshot source semantics

* fix(downloads): preserve episode snapshot identity

* docs(downloads): document offline details flow

* docs(downloads): clarify stalker provider fallback

* test(downloads): cover offline detail journeys

* test(downloads): stabilize offline detail selectors

* style(downloads): format changed files

* docs(downloads): clean design spec formatting

* fix(downloads): preserve offline library ownership

* test(downloads): fix Windows workspace navigation

* test(database): preserve Electron tsconfig resolution

* perf(downloads): avoid blocking file availability probes
This commit is contained in:
4gray authored and GitHub committed 2026-08-01 18:09:31 +02:00
1 parent 46c58f4f57
commit 760099358b
188 files changed
+31245 -2281

No files matched your search

@@ -0,0 +1,204 @@
import { createServer } from 'http';
import type { AddressInfo } from 'net';
import type { Page } from '@playwright/test';
import { expect, type LaunchedElectronApp } from './electron-test-fixtures';
import {
getStalkerTitle,
type StalkerContentItem,
} from './portal-mock-fixtures';
interface DownloadSeed {
contentType: 'episode' | 'vod';
downloadFolder: string;
episodeNumber?: number;
playlistId: string;
seasonNumber?: number;
seriesXtreamId?: number;
title: string;
url: string;
xtreamId: number;
}
interface RangeServerRequest {
ifRange?: string;
range?: string;
}
interface ThrottledRangeServer {
close: () => Promise<void>;
payload: Buffer;
requests: RangeServerRequest[];
url: string;
}
interface TruncatedDownloadServer {
close: () => Promise<void>;
payload: Buffer;
totalBytes: number;
url: string;
}
export const RANGE_SERVER_ETAG = '"e2e-range-etag"';
const downloadPlayCaptureKey = '__iptvnatorE2eDownloadPlayPaths';
export function getStalkerSeriesDownloadTarget(
item: StalkerContentItem | undefined
): { id: number; title: string } {
const id = Number(
String(item?.id ?? '')
.split(':')[0]
.trim()
);
const title = item ? getStalkerTitle(item) : '';
if (!Number.isSafeInteger(id) || id <= 0 || !title) {
throw new Error('Stalker series fixture returned an invalid target.');
}
return { id, title };
}
export 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;
}
export async function installDownloadPlayCapture(
app: LaunchedElectronApp
): Promise<void> {
await app.electronApp.evaluate(({ ipcMain }, captureKey) => {
const globalRef = globalThis as typeof globalThis &
Record<string, string[] | undefined>;
globalRef[captureKey] = [];
ipcMain.removeHandler('DOWNLOADS_PLAY_FILE');
ipcMain.handle(
'DOWNLOADS_PLAY_FILE',
async (_event, filePath: string) => {
globalRef[captureKey]?.push(filePath);
return { success: true };
}
);
}, downloadPlayCaptureKey);
}
export async function getDownloadPlayPaths(
app: LaunchedElectronApp
): Promise<string[]> {
return app.electronApp.evaluate((_, captureKey) => {
const globalRef = globalThis as typeof globalThis &
Record<string, string[] | undefined>;
return globalRef[captureKey] ?? [];
}, downloadPlayCaptureKey);
}
/**
* Serves a payload slowly on the first (full) request so the UI has a wide
* window to pause mid-transfer, and answers Range requests with an immediate
* 206 so the resumed transfer finishes fast. Records Range/If-Range headers.
*/
export async function createThrottledRangeServer(
chunkIntervalMs = 150
): Promise<ThrottledRangeServer> {
const payload = Buffer.from(
Array.from({ length: 96 * 1024 }, (_, index) =>
String(index % 10)
).join('')
);
const requests: RangeServerRequest[] = [];
const server = createServer((req, res) => {
const range = req.headers.range;
const ifRange = req.headers['if-range'];
requests.push({
ifRange: typeof ifRange === 'string' ? ifRange : undefined,
range: typeof range === 'string' ? range : undefined,
});
const offset = range
? Number(/^bytes=(\d+)-$/.exec(range)?.[1] ?? Number.NaN)
: 0;
if (range && Number.isFinite(offset)) {
res.writeHead(206, {
'Content-Length': payload.length - offset,
'Content-Range': `bytes ${offset}-${payload.length - 1}/${payload.length}`,
'Content-Type': 'video/mp4',
ETag: RANGE_SERVER_ETAG,
});
res.end(payload.subarray(offset));
return;
}
res.writeHead(200, {
'Content-Length': payload.length,
'Content-Type': 'video/mp4',
ETag: RANGE_SERVER_ETAG,
});
// First 16 KiB immediately, then a trickle: the transfer stays alive
// for tens of seconds unless it is paused or resumed via Range.
let sent = 16 * 1024;
res.write(payload.subarray(0, sent));
const timer = setInterval(() => {
if (sent >= payload.length) {
clearInterval(timer);
res.end();
return;
}
res.write(payload.subarray(sent, sent + 2 * 1024));
sent += 2 * 1024;
}, chunkIntervalMs);
res.on('close', () => clearInterval(timer));
});
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,
requests,
url: `http://127.0.0.1:${port}/media/e2e-pause-movie.mp4`,
};
}
/**
* 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.
*/
export 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`,
};
}
File diff suppressed because it is too large. Load diff