Rebuild download completion verification
This commit is contained in:
parent
1a076c49cb
commit
1a0f49b29c
132
src/main/download-completion.ts
Normal file
132
src/main/download-completion.ts
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { ALLOCATION_UNIT_SIZE } from "./constants";
|
||||||
|
|
||||||
|
export type DownloadCompletionSource =
|
||||||
|
| "content-range"
|
||||||
|
| "content-length"
|
||||||
|
| "provider-metadata"
|
||||||
|
| "stream-end";
|
||||||
|
|
||||||
|
export type DownloadCompletionPlan = {
|
||||||
|
expectedTotal: number | null;
|
||||||
|
source: DownloadCompletionSource;
|
||||||
|
canFinishEarly: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function planDownloadCompletion(args: {
|
||||||
|
existingBytes: number;
|
||||||
|
responseStatus: number;
|
||||||
|
contentLength: number;
|
||||||
|
totalFromRange: number | null;
|
||||||
|
knownTotal: number | null;
|
||||||
|
correctedTotal: number | null;
|
||||||
|
}): DownloadCompletionPlan {
|
||||||
|
const existingBytes = Math.max(0, Math.floor(Number(args.existingBytes) || 0));
|
||||||
|
const responseStatus = Math.floor(Number(args.responseStatus) || 0);
|
||||||
|
const contentLength = Math.max(0, Math.floor(Number(args.contentLength) || 0));
|
||||||
|
const totalFromRange = Number.isFinite(args.totalFromRange || NaN)
|
||||||
|
? Math.max(0, Math.floor(args.totalFromRange || 0))
|
||||||
|
: 0;
|
||||||
|
const correctedTotal = Number.isFinite(args.correctedTotal || NaN)
|
||||||
|
? Math.max(0, Math.floor(args.correctedTotal || 0))
|
||||||
|
: 0;
|
||||||
|
const knownTotal = Number.isFinite(args.knownTotal || NaN)
|
||||||
|
? Math.max(0, Math.floor(args.knownTotal || 0))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (correctedTotal > 0) {
|
||||||
|
return {
|
||||||
|
expectedTotal: correctedTotal,
|
||||||
|
source: totalFromRange > 0 ? "content-range" : "content-length",
|
||||||
|
canFinishEarly: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalFromRange > 0) {
|
||||||
|
return {
|
||||||
|
expectedTotal: totalFromRange,
|
||||||
|
source: "content-range",
|
||||||
|
canFinishEarly: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentLength > 0) {
|
||||||
|
return {
|
||||||
|
expectedTotal: responseStatus === 206 ? existingBytes + contentLength : contentLength,
|
||||||
|
source: "content-length",
|
||||||
|
canFinishEarly: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knownTotal > 0) {
|
||||||
|
return {
|
||||||
|
expectedTotal: knownTotal,
|
||||||
|
source: "provider-metadata",
|
||||||
|
canFinishEarly: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
expectedTotal: null,
|
||||||
|
source: "stream-end",
|
||||||
|
canFinishEarly: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateDownloadedFileCompletion(args: {
|
||||||
|
actualBytes: number;
|
||||||
|
plan: DownloadCompletionPlan;
|
||||||
|
}): {
|
||||||
|
ok: boolean;
|
||||||
|
totalBytes: number;
|
||||||
|
acceptedMetadataMismatch: boolean;
|
||||||
|
error?: string;
|
||||||
|
} {
|
||||||
|
const actualBytes = Math.max(0, Math.floor(Number(args.actualBytes) || 0));
|
||||||
|
const expectedTotal = Number.isFinite(args.plan.expectedTotal || NaN)
|
||||||
|
? Math.max(0, Math.floor(args.plan.expectedTotal || 0))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
expectedTotal > 0 &&
|
||||||
|
(args.plan.source === "content-range" || args.plan.source === "content-length") &&
|
||||||
|
actualBytes + ALLOCATION_UNIT_SIZE < expectedTotal
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
totalBytes: expectedTotal,
|
||||||
|
acceptedMetadataMismatch: false,
|
||||||
|
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actualBytes <= 0 && expectedTotal > 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
totalBytes: expectedTotal,
|
||||||
|
acceptedMetadataMismatch: false,
|
||||||
|
error: `download_underflow:${actualBytes}/${expectedTotal}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.plan.source === "provider-metadata") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
totalBytes: actualBytes,
|
||||||
|
acceptedMetadataMismatch: expectedTotal > 0 && Math.abs(actualBytes - expectedTotal) > ALLOCATION_UNIT_SIZE
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.plan.source === "stream-end") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
totalBytes: actualBytes,
|
||||||
|
acceptedMetadataMismatch: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
totalBytes: Math.max(actualBytes, expectedTotal),
|
||||||
|
acceptedMetadataMismatch: false
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -50,6 +50,7 @@ function releaseTlsSkip(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||||
|
import { planDownloadCompletion, validateDownloadedFileCompletion } from "./download-completion";
|
||||||
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys } from "./debrid";
|
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys } from "./debrid";
|
||||||
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor";
|
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, type ExtractArchiveFailureInfo } from "./extractor";
|
||||||
import { validateFileAgainstManifest } from "./integrity";
|
import { validateFileAgainstManifest } from "./integrity";
|
||||||
@ -8363,6 +8364,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Only add existingBytes for 206 responses; for 200 the Content-Length is the full file
|
// Only add existingBytes for 206 responses; for 200 the Content-Length is the full file
|
||||||
item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength;
|
item.totalBytes = response.status === 206 ? existingBytes + contentLength : contentLength;
|
||||||
}
|
}
|
||||||
|
const completionPlan = planDownloadCompletion({
|
||||||
|
existingBytes,
|
||||||
|
responseStatus: response.status,
|
||||||
|
contentLength,
|
||||||
|
totalFromRange,
|
||||||
|
knownTotal,
|
||||||
|
correctedTotal: correctedRealDebridTotal?.totalBytes || null
|
||||||
|
});
|
||||||
|
|
||||||
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
|
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
|
||||||
logAttemptEvent("INFO", "HTTP-Antwort akzeptiert", {
|
logAttemptEvent("INFO", "HTTP-Antwort akzeptiert", {
|
||||||
@ -8723,12 +8732,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Use totalBytes (from unrestrict or Content-Length header) as
|
// Use totalBytes (from unrestrict or Content-Length header) as
|
||||||
// primary check, fall back to raw contentLength for providers
|
// primary check, fall back to raw contentLength for providers
|
||||||
// that don't report fileSize (e.g. Mega-Debrid Web).
|
// that don't report fileSize (e.g. Mega-Debrid Web).
|
||||||
const expectedTotal = (item.totalBytes && item.totalBytes > 0) ? item.totalBytes : 0;
|
if (completionPlan.canFinishEarly && completionPlan.expectedTotal && written >= completionPlan.expectedTotal) {
|
||||||
const expectedFromResponse = contentLength > 0 ? contentLength : 0;
|
|
||||||
if (expectedTotal > 0 && existingBytes + written >= expectedTotal) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (expectedTotal === 0 && expectedFromResponse > 0 && written >= expectedFromResponse) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -8870,6 +8874,20 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const finalizedStat = await fs.promises.stat(effectiveTargetPath);
|
||||||
|
if (Number.isFinite(finalizedStat.size) && finalizedStat.size >= 0 && finalizedStat.size !== written) {
|
||||||
|
logAttemptEvent("WARN", "Dateigroesse nach Stream-Abschluss korrigiert", {
|
||||||
|
attempt,
|
||||||
|
previousWritten: written,
|
||||||
|
statSize: finalizedStat.size
|
||||||
|
});
|
||||||
|
written = finalizedStat.size;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore stat race; validation below will handle empty/missing files
|
||||||
|
}
|
||||||
|
|
||||||
// Detect tiny error-response files (e.g. hoster returning "Forbidden" with HTTP 200).
|
// Detect tiny error-response files (e.g. hoster returning "Forbidden" with HTTP 200).
|
||||||
// No legitimate file-hoster download is < 512 bytes.
|
// No legitimate file-hoster download is < 512 bytes.
|
||||||
if (written > 0 && written < 512) {
|
if (written > 0 && written < 512) {
|
||||||
@ -8900,21 +8918,37 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const exactLengthRequired = isLargeBinaryLikePath(item.fileName || effectiveTargetPath);
|
const completionValidation = validateDownloadedFileCompletion({
|
||||||
if (item.totalBytes && item.totalBytes > 0 && written < item.totalBytes) {
|
actualBytes: written,
|
||||||
const shortfall = item.totalBytes - written;
|
plan: completionPlan
|
||||||
|
});
|
||||||
|
if (!completionValidation.ok) {
|
||||||
|
const shortfall = Math.max(0, completionValidation.totalBytes - written);
|
||||||
if (preAllocated) {
|
if (preAllocated) {
|
||||||
try {
|
try {
|
||||||
await fs.promises.truncate(effectiveTargetPath, written);
|
await fs.promises.truncate(effectiveTargetPath, written);
|
||||||
} catch { /* best-effort */ }
|
} catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
logger.warn(`Download-Underflow: erwartet=${item.totalBytes}, erhalten=${written}, shortfall=${shortfall} fuer ${item.fileName}`);
|
logger.warn(`Download-Underflow: erwartet=${completionValidation.totalBytes}, erhalten=${written}, shortfall=${shortfall} fuer ${item.fileName}`);
|
||||||
if (exactLengthRequired || shortfall > ALLOCATION_UNIT_SIZE) {
|
item.downloadedBytes = written;
|
||||||
item.downloadedBytes = written;
|
item.progressPercent = completionValidation.totalBytes > 0
|
||||||
item.progressPercent = Math.max(0, Math.min(99, Math.floor((written / item.totalBytes) * 100)));
|
? Math.max(0, Math.min(99, Math.floor((written / completionValidation.totalBytes) * 100)))
|
||||||
item.speedBps = 0;
|
: 0;
|
||||||
throw new Error(`download_underflow:${written}/${item.totalBytes}`);
|
item.speedBps = 0;
|
||||||
}
|
throw new Error(completionValidation.error || `download_underflow:${written}/${completionValidation.totalBytes}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completionValidation.acceptedMetadataMismatch) {
|
||||||
|
logger.warn(
|
||||||
|
`Provider-Groesseninfo verworfen, HTTP-EOF als vollstaendig akzeptiert: ` +
|
||||||
|
`${item.fileName} erwartet=${completionPlan.expectedTotal}, erhalten=${written}`
|
||||||
|
);
|
||||||
|
logAttemptEvent("WARN", "Provider-Groesseninfo weicht von finaler Dateigroesse ab", {
|
||||||
|
attempt,
|
||||||
|
expectedTotal: completionPlan.expectedTotal,
|
||||||
|
actualBytes: written,
|
||||||
|
source: completionPlan.source
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate pre-allocated files to actual bytes written to prevent zero-padded tail
|
// Truncate pre-allocated files to actual bytes written to prevent zero-padded tail
|
||||||
@ -8926,6 +8960,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item.downloadedBytes = written;
|
item.downloadedBytes = written;
|
||||||
|
item.totalBytes = completionValidation.totalBytes > 0 ? completionValidation.totalBytes : item.totalBytes;
|
||||||
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100;
|
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100;
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
item.fullStatus = "Finalisierend...";
|
item.fullStatus = "Finalisierend...";
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { EventEmitter, once } from "node:events";
|
|||||||
import AdmZip from "adm-zip";
|
import AdmZip from "adm-zip";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { DownloadManager, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList } from "../src/main/download-manager";
|
import { DownloadManager, extractArchiveNameFromExtractorLogMessage, getAuthoritativeRealDebridTotal, resolveArchiveItemsFromList } from "../src/main/download-manager";
|
||||||
|
import { planDownloadCompletion, validateDownloadedFileCompletion } from "../src/main/download-completion";
|
||||||
import { defaultSettings } from "../src/main/constants";
|
import { defaultSettings } from "../src/main/constants";
|
||||||
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
import { parseDebridLinkApiKeys } from "../src/shared/debrid-link-keys";
|
||||||
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits";
|
||||||
@ -45,6 +46,38 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("download completion planning", () => {
|
||||||
|
it("does not allow early finish on provider metadata alone", () => {
|
||||||
|
expect(planDownloadCompletion({
|
||||||
|
existingBytes: 0,
|
||||||
|
responseStatus: 200,
|
||||||
|
contentLength: 0,
|
||||||
|
totalFromRange: null,
|
||||||
|
knownTotal: 192 * 1024,
|
||||||
|
correctedTotal: null
|
||||||
|
})).toEqual({
|
||||||
|
expectedTotal: 192 * 1024,
|
||||||
|
source: "provider-metadata",
|
||||||
|
canFinishEarly: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts provider metadata mismatches after a clean stream end", () => {
|
||||||
|
expect(validateDownloadedFileCompletion({
|
||||||
|
actualBytes: 256 * 1024,
|
||||||
|
plan: {
|
||||||
|
expectedTotal: 192 * 1024,
|
||||||
|
source: "provider-metadata",
|
||||||
|
canFinishEarly: false
|
||||||
|
}
|
||||||
|
})).toEqual({
|
||||||
|
ok: true,
|
||||||
|
totalBytes: 256 * 1024,
|
||||||
|
acceptedMetadataMismatch: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
async function waitFor(predicate: () => boolean, timeoutMs = 15000): Promise<void> {
|
async function waitFor(predicate: () => boolean, timeoutMs = 15000): Promise<void> {
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
while (!predicate()) {
|
while (!predicate()) {
|
||||||
@ -1863,6 +1896,215 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not stop early on provider-only totals when the HTTP response is longer", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-total-mismatch-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const actual = Buffer.alloc(256 * 1024, 77);
|
||||||
|
const advertised = 192 * 1024;
|
||||||
|
let unrestrictCalls = 0;
|
||||||
|
|
||||||
|
const server = http.createServer((_req, res) => {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.setHeader("Transfer-Encoding", "chunked");
|
||||||
|
res.write(actual.subarray(0, 96 * 1024));
|
||||||
|
setTimeout(() => {
|
||||||
|
res.end(actual.subarray(96 * 1024));
|
||||||
|
}, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/provider-mismatch`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
unrestrictCalls += 1;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
download: directUrl,
|
||||||
|
filename: "provider-mismatch.rar",
|
||||||
|
filesize: advertised
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 1,
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false
|
||||||
|
},
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "provider-mismatch", links: ["https://dummy/provider-mismatch"] }]);
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||||
|
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(item?.downloadedBytes).toBe(actual.length);
|
||||||
|
expect(item?.totalBytes).toBe(actual.length);
|
||||||
|
expect(unrestrictCalls).toBe(1);
|
||||||
|
expect(fs.readFileSync(item.targetPath).equals(actual)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not double-count resumed bytes when deciding early completion", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-resume-early-finish-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const actual = Buffer.alloc(256 * 1024, 91);
|
||||||
|
const partialSize = 96 * 1024;
|
||||||
|
const pkgDir = path.join(root, "downloads", "resume-early-finish");
|
||||||
|
fs.mkdirSync(pkgDir, { recursive: true });
|
||||||
|
const targetPath = path.join(pkgDir, "resume-early-finish.mkv");
|
||||||
|
fs.writeFileSync(targetPath, actual.subarray(0, partialSize));
|
||||||
|
let unrestrictCalls = 0;
|
||||||
|
const starts: number[] = [];
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const range = String(req.headers.range || "");
|
||||||
|
const match = range.match(/bytes=(\d+)-/i);
|
||||||
|
const start = match ? Number(match[1]) : 0;
|
||||||
|
starts.push(start);
|
||||||
|
|
||||||
|
if (start <= 0) {
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.end("expected resume");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = actual.subarray(start);
|
||||||
|
const firstChunkBytes = 64 * 1024;
|
||||||
|
res.statusCode = 206;
|
||||||
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
|
res.setHeader("Content-Range", `bytes ${start}-${actual.length - 1}/${actual.length}`);
|
||||||
|
res.setHeader("Content-Length", String(remaining.length));
|
||||||
|
res.write(remaining.subarray(0, firstChunkBytes));
|
||||||
|
setTimeout(() => {
|
||||||
|
res.end(remaining.subarray(firstChunkBytes));
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(0, "127.0.0.1");
|
||||||
|
await once(server, "listening");
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("server address unavailable");
|
||||||
|
}
|
||||||
|
const directUrl = `http://127.0.0.1:${address.port}/resume-early-finish`;
|
||||||
|
|
||||||
|
globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||||
|
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||||
|
if (url.includes("/unrestrict/link")) {
|
||||||
|
unrestrictCalls += 1;
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
download: directUrl,
|
||||||
|
filename: "resume-early-finish.mkv",
|
||||||
|
filesize: actual.length
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return originalFetch(input, init);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "resume-early-finish-pkg";
|
||||||
|
const itemId = "resume-early-finish-item";
|
||||||
|
const createdAt = Date.now() - 10_000;
|
||||||
|
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "resume-early-finish",
|
||||||
|
outputDir: pkgDir,
|
||||||
|
extractDir: path.join(root, "extract", "resume-early-finish"),
|
||||||
|
status: "queued",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/resume-early-finish",
|
||||||
|
provider: null,
|
||||||
|
status: "queued",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: partialSize,
|
||||||
|
totalBytes: actual.length,
|
||||||
|
progressPercent: Math.floor((partialSize / actual.length) * 100),
|
||||||
|
fileName: "resume-early-finish.mkv",
|
||||||
|
targetPath,
|
||||||
|
resumable: true,
|
||||||
|
attempts: 0,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Wartet",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 1,
|
||||||
|
autoExtract: false,
|
||||||
|
autoReconnect: false
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||||
|
|
||||||
|
const item = manager.getSnapshot().session.items[itemId];
|
||||||
|
expect(item?.status).toBe("completed");
|
||||||
|
expect(item?.downloadedBytes).toBe(actual.length);
|
||||||
|
expect(item?.totalBytes).toBe(actual.length);
|
||||||
|
expect(unrestrictCalls).toBe(1);
|
||||||
|
expect(starts).toEqual([partialSize]);
|
||||||
|
expect(fs.readFileSync(targetPath).equals(actual)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
server.close();
|
||||||
|
await once(server, "close");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("assigns unique target paths for same filenames in parallel", async () => {
|
it("assigns unique target paths for same filenames in parallel", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user