diff --git a/src/main/debug-server.ts b/src/main/debug-server.ts index 0ccab85..0fcd63f 100644 --- a/src/main/debug-server.ts +++ b/src/main/debug-server.ts @@ -76,6 +76,21 @@ function readSupportHistory() { return loadHistory(getStoragePaths()); } +function extractDebugClientIp(req: http.IncomingMessage): string { + const forwarded = req.headers["x-forwarded-for"]; + const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded; + const forwardedIp = String(forwardedValue || "").split(",")[0]?.trim(); + if (forwardedIp) { + return forwardedIp; + } + const realIp = String(req.headers["x-real-ip"] || "").trim(); + if (realIp) { + return realIp; + } + const remote = String(req.socket.remoteAddress || req.socket.address()?.address || "").trim(); + return remote.replace(/^::ffff:/i, ""); +} + function getAiManifestPath(baseDir: string = runtimeBaseDir): string { return path.join(baseDir, AI_MANIFEST_FILE); } @@ -431,7 +446,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi if (traceConfig.enabled && traceConfig.logDebugRequests) { logTraceEvent("INFO", "debug-http", "Request", { method: req.method || "GET", - url: sanitizeRequestUrlForTrace(req.url || "/") + url: sanitizeRequestUrlForTrace(req.url || "/"), + clientIp: extractDebugClientIp(req) }); } @@ -449,7 +465,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi if (traceConfig.enabled && traceConfig.logDebugRequests) { logTraceEvent("WARN", "debug-http", "Unauthorized request", { method: req.method || "GET", - url: sanitizeRequestUrlForTrace(req.url || "/") + url: sanitizeRequestUrlForTrace(req.url || "/"), + clientIp: extractDebugClientIp(req) }); } jsonResponse(res, 401, { error: "Unauthorized" }); diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 3793603..be6750e 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -434,6 +434,11 @@ function shouldPreflightFinalizeItemFromDisk(item: DownloadItem): boolean { const text = `${item.fullStatus || ""} ${item.lastError || ""}`.toLowerCase(); return text.includes("resume-link erneuern") || text.includes("resume link erneuern") + || text.includes("direktlink erneuern") + || text.includes("direktlink erschöpft") + || text.includes("direct_link_retry_exhausted") + || text.includes("download_underflow") + || text.includes("resume_download_underflow") || text.includes("range_ignored_on_resume") || text.includes("server ignorierte range"); } @@ -8142,16 +8147,21 @@ export class DownloadManager extends EventEmitter { if (response.status === 416 && existingBytes > 0) { await response.arrayBuffer().catch(() => undefined); const rangeTotal = parseContentRangeTotal(response.headers.get("content-range")); - const expectedTotal = knownTotal && knownTotal > 0 ? knownTotal : rangeTotal; - if (expectedTotal && existingBytes === expectedTotal) { - item.totalBytes = expectedTotal; + const expectedTotal = rangeTotal && rangeTotal > 0 + ? rangeTotal + : (knownTotal && knownTotal > 0 ? knownTotal : null); + const closeEnoughToExpected = expectedTotal != null + && Math.abs(existingBytes - expectedTotal) <= ALLOCATION_UNIT_SIZE; + if (expectedTotal != null && closeEnoughToExpected) { + const finalizedTotal = Math.max(existingBytes, expectedTotal); + item.totalBytes = finalizedTotal; item.downloadedBytes = existingBytes; item.progressPercent = 100; item.speedBps = 0; item.updatedAt = nowMs(); logAttemptEvent("INFO", "HTTP 416 als vollständig behandelt", { existingBytes, - expectedTotal + expectedTotal: finalizedTotal }); return { resumable: true }; } diff --git a/tests/debug-server.test.ts b/tests/debug-server.test.ts index d9be324..a8a04cf 100644 --- a/tests/debug-server.test.ts +++ b/tests/debug-server.test.ts @@ -405,6 +405,22 @@ describe("debug-server", () => { expect(payload.supportBundle?.estimatedEntries).toBeGreaterThan(0); }); + it("writes the client IP into the debug trace log", async () => { + const fixture = await createFixture(); + const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, { + headers: { + "X-Forwarded-For": "159.195.63.46" + } + }); + expect(response.ok).toBe(true); + + await new Promise((resolve) => setTimeout(resolve, 200)); + const traceLogPath = getTraceLogPath(); + expect(traceLogPath).toBeTruthy(); + const traceText = fs.readFileSync(traceLogPath!, "utf8"); + expect(traceText).toContain("clientIp=159.195.63.46"); + }); + it("serves package details and package log by package query", async () => { const fixture = await createFixture(); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 6e56ed7..5a2996d 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -1332,6 +1332,92 @@ describe("download manager", () => { expect(unrestrictCalls).toBe(0); }); + it("completes Debrid-Link direct-link retries from disk during start preflight", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(60 * 1024, 23); + const pkgDir = path.join(root, "downloads", "queued-directlink-complete"); + fs.mkdirSync(pkgDir, { recursive: true }); + const targetPath = path.join(pkgDir, "queued-directlink-complete.part10.rar"); + fs.writeFileSync(targetPath, binary); + let unrestrictCalls = 0; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("debrid-link.com/api/v2/downloader/add")) { + unrestrictCalls += 1; + throw new Error(`unexpected debrid-link unrestrict ${url}`); + } + return originalFetch(input, init); + }; + + const session = emptySession(); + const packageId = "queued-directlink-complete-pkg"; + const itemId = "queued-directlink-complete-item"; + const createdAt = Date.now() - 10_000; + + session.packageOrder = [packageId]; + session.packages[packageId] = { + id: packageId, + name: "queued-directlink-complete", + outputDir: pkgDir, + extractDir: path.join(root, "extract", "queued-directlink-complete"), + status: "queued", + itemIds: [itemId], + cancelled: false, + enabled: true, + createdAt, + updatedAt: createdAt + }; + session.items[itemId] = { + id: itemId, + packageId, + url: "https://dummy/queued-directlink-complete", + provider: "debridlink", + status: "queued", + retries: 14, + speedBps: 0, + downloadedBytes: 0, + totalBytes: binary.length, + progressPercent: 0, + fileName: "queued-directlink-complete.part10.rar", + targetPath, + resumable: true, + attempts: 0, + lastError: "direct_link_retry_exhausted:HTTP 416", + fullStatus: "Direktlink erneuern, Retry 15/inf", + createdAt, + updatedAt: createdAt + }; + + const manager = new DownloadManager( + { + ...defaultSettings(), + debridLinkApiKeys: "dl-test-key", + providerOrder: ["debridlink"], + providerPrimary: "debridlink", + providerSecondary: "none", + providerTertiary: "none", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + retryLimit: 0, + autoExtract: false + }, + session, + createStoragePaths(path.join(root, "state")) + ); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = manager.getSnapshot().session.items[itemId]; + expect(item?.status).toBe("completed"); + expect(item?.progressPercent).toBe(100); + expect(item?.downloadedBytes).toBe(binary.length); + expect(item?.fullStatus).toContain("Fertig"); + expect(unrestrictCalls).toBe(0); + }); + it("retries direct-link exhaustion caused by HTTP 416 in-session and then completes", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root);