mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 13:16:16 -08:00
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:
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
Reference in new issue
Block a user