diff --git a/package-lock.json b/package-lock.json index 70b384f..238067b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "1.7.41", + "version": "1.7.43", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "1.7.41", + "version": "1.7.43", "license": "MIT", "dependencies": { "adm-zip": "^0.5.16", diff --git a/package.json b/package.json index 768e77c..837e6c4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.7.42", + "version": "1.7.43", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index f2e10e7..643e1de 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -108,6 +108,8 @@ const ARCHIVE_SETTLE_POLL_MS = 250; const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000; +const MAX_SAME_DIRECT_URL_ATTEMPTS = 3; + const LARGE_BINARY_FILE_RE = /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|7z(?:\.\d+)?|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|ts|m2ts|webm|mp3|flac|aac|wav)$/i; function itemExpectedMinBytes(item: DownloadItem): number { @@ -6422,6 +6424,27 @@ export class DownloadManager extends EventEmitter { error: errorText, abortReason: reason || "none" }); + const directLinkRetryMatch = errorText.match(/^direct_link_retry_exhausted:(.+)$/); + if (directLinkRetryMatch && active.genericErrorRetries < maxGenericErrorRetries) { + active.genericErrorRetries += 1; + item.retries += 1; + const exhaustedReason = compactErrorText(directLinkRetryMatch[1] || errorText); + const refreshDelayMs = retryDelayWithJitter(active.genericErrorRetries, 200); + logger.warn( + `Direktlink erschöpft: item=${item.fileName || item.id}, ` + + `retry=${active.genericErrorRetries}/${retryDisplayLimit}, error=${exhaustedReason}, provider=${item.provider || "?"}` + ); + this.queueRetry( + item, + active, + refreshDelayMs, + `Direktlink erneuern, Retry ${active.genericErrorRetries}/${retryDisplayLimit}` + ); + item.lastError = exhaustedReason; + this.persistSoon(); + this.emitState(); + return; + } const shouldFreshRetry = !active.freshRetryUsed && isFetchFailure(errorText); const isHttp416 = /(^|\D)416(\D|$)/.test(errorText); if (isHttp416) { @@ -6623,7 +6646,8 @@ export class DownloadManager extends EventEmitter { const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit); const retryDisplayLimit = retryLimitLabel(configuredRetryLimit); - const maxAttempts = configuredRetryLimit <= 0 ? Number.MAX_SAFE_INTEGER : configuredRetryLimit + 1; + const maxAttemptsBySetting = configuredRetryLimit <= 0 ? Number.MAX_SAFE_INTEGER : configuredRetryLimit + 1; + const maxAttempts = Math.max(1, Math.min(MAX_SAME_DIRECT_URL_ATTEMPTS, maxAttemptsBySetting)); let lastError = ""; let effectiveTargetPath = targetPath; @@ -7414,15 +7438,21 @@ export class DownloadManager extends EventEmitter { }); if (attempt < maxAttempts) { item.retries += 1; - item.fullStatus = `Downloadfehler, retry ${attempt}/${retryDisplayLimit}`; + item.fullStatus = `Downloadfehler, retry ${attempt}/${maxAttempts} (Direktlink)`; this.emitState(); await sleep(retryDelayWithJitter(attempt, 250)); continue; } + if (maxAttemptsBySetting > maxAttempts) { + throw new Error(`direct_link_retry_exhausted:${lastError || "Download fehlgeschlagen"}`); + } throw new Error(lastError || "Download fehlgeschlagen"); } } + if (maxAttemptsBySetting > maxAttempts) { + throw new Error(`direct_link_retry_exhausted:${lastError || "Download fehlgeschlagen"}`); + } throw new Error(lastError || "Download fehlgeschlagen"); } diff --git a/src/main/extractor.ts b/src/main/extractor.ts index d847c94..e36bf30 100644 --- a/src/main/extractor.ts +++ b/src/main/extractor.ts @@ -299,8 +299,36 @@ function archiveNameKey(fileName: string): string { return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || ""); } +function stripDuplicateSuffixBeforeExtension(fileName: string): string { + return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); +} + +function hasDuplicateSuffixBeforeExtension(fileName: string): boolean { + return stripDuplicateSuffixBeforeExtension(fileName) !== String(fileName || ""); +} + +function archiveDetectionName(fileName: string): string { + return stripDuplicateSuffixBeforeExtension(path.basename(String(fileName || ""))); +} + +function archiveCandidateIdentity(filePath: string): string { + const normalizedPath = path.join(path.dirname(filePath), archiveDetectionName(filePath)); + return pathSetKey(normalizedPath); +} + +function prefersArchiveCandidate(nextCandidate: string, currentCandidate: string): boolean { + const nextName = path.basename(nextCandidate); + const currentName = path.basename(currentCandidate); + const nextHasDuplicateSuffix = hasDuplicateSuffixBeforeExtension(nextName); + const currentHasDuplicateSuffix = hasDuplicateSuffixBeforeExtension(currentName); + if (nextHasDuplicateSuffix !== currentHasDuplicateSuffix) { + return !nextHasDuplicateSuffix; + } + return ARCHIVE_SORT_COLLATOR.compare(nextName, currentName) < 0; +} + function archiveSortKey(filePath: string): string { - const fileName = path.basename(filePath).toLowerCase(); + const fileName = archiveDetectionName(filePath).toLowerCase(); return fileName .replace(/\.part0*1\.rar$/i, "") .replace(/\.zip\.\d{3}$/i, "") @@ -314,7 +342,7 @@ function archiveSortKey(filePath: string): string { } function archiveTypeRank(filePath: string): number { - const fileName = path.basename(filePath).toLowerCase(); + const fileName = archiveDetectionName(filePath).toLowerCase(); if (/\.part0*1\.rar$/i.test(fileName)) { return 0; } @@ -359,20 +387,23 @@ export async function findArchiveCandidates(packageDir: string): Promise path.basename(filePath).toLowerCase())); - const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(filePath)); - const singleRar = files.filter((filePath) => /\.rar$/i.test(filePath) && !/\.part\d+\.rar$/i.test(filePath)); - const zipSplit = files.filter((filePath) => /\.zip\.001$/i.test(filePath)); + const fileNamesLower = new Set(files.map((filePath) => archiveDetectionName(filePath).toLowerCase())); + const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(archiveDetectionName(filePath))); + const singleRar = files.filter((filePath) => { + const fileName = archiveDetectionName(filePath); + return /\.rar$/i.test(fileName) && !/\.part\d+\.rar$/i.test(fileName); + }); + const zipSplit = files.filter((filePath) => /\.zip\.001$/i.test(archiveDetectionName(filePath))); const zip = files.filter((filePath) => { - const fileName = path.basename(filePath); + const fileName = archiveDetectionName(filePath); if (!/\.zip$/i.test(fileName)) { return false; } return !fileNamesLower.has(`${fileName}.001`.toLowerCase()); }); - const sevenSplit = files.filter((filePath) => /\.7z\.001$/i.test(filePath)); + const sevenSplit = files.filter((filePath) => /\.7z\.001$/i.test(archiveDetectionName(filePath))); const seven = files.filter((filePath) => { - const fileName = path.basename(filePath); + const fileName = archiveDetectionName(filePath); if (!/\.7z$/i.test(fileName)) { return false; } @@ -381,20 +412,24 @@ export async function findArchiveCandidates(packageDir: string): Promise /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath)); // Generic .001 splits (HJSplit etc.) — exclude already-recognized .zip.001 and .7z.001 const genericSplit = files.filter((filePath) => { - const fileName = path.basename(filePath).toLowerCase(); + const fileName = archiveDetectionName(filePath).toLowerCase(); if (!/\.001$/.test(fileName)) return false; if (/\.zip\.001$/.test(fileName) || /\.7z\.001$/.test(fileName)) return false; return true; }); const unique: string[] = []; - const seen = new Set(); + const seen = new Map(); for (const candidate of [...multipartRar, ...singleRar, ...zipSplit, ...zip, ...sevenSplit, ...seven, ...tarCompressed, ...genericSplit]) { - const key = pathSetKey(candidate); - if (seen.has(key)) { + const key = archiveCandidateIdentity(candidate); + const existingIndex = seen.get(key); + if (existingIndex !== undefined) { + if (prefersArchiveCandidate(candidate, unique[existingIndex])) { + unique[existingIndex] = candidate; + } continue; } - seen.add(key); + seen.set(key, unique.length); unique.push(candidate); } diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index c63c8d1..d4e5930 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -210,6 +210,120 @@ describe("download manager", () => { } }); + it("requests a fresh direct link after repeated same-link download failures", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); + tempDirs.push(root); + const binary = Buffer.alloc(256 * 1024, 17); + let badCalls = 0; + let goodCalls = 0; + let unrestrictCalls = 0; + + const server = http.createServer((req, res) => { + const route = req.url || ""; + if (route === "/bad") { + badCalls += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + const end = Math.min(binary.length, start + 64 * 1024); + const chunk = binary.subarray(start, end); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.write(chunk); + res.socket?.destroy(); + return; + } + + if (route === "/good") { + goodCalls += 1; + const range = String(req.headers.range || ""); + const match = range.match(/bytes=(\d+)-/i); + const start = match ? Number(match[1]) : 0; + const chunk = binary.subarray(start); + if (start > 0) { + res.statusCode = 206; + res.setHeader("Content-Range", `bytes ${start}-${binary.length - 1}/${binary.length}`); + } else { + res.statusCode = 200; + } + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(chunk.length)); + res.end(chunk); + return; + } + + res.statusCode = 404; + res.end("not-found"); + }); + + 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 badUrl = `http://127.0.0.1:${address.port}/bad`; + const goodUrl = `http://127.0.0.1:${address.port}/good`; + + 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("/unrestrict/link")) { + unrestrictCalls += 1; + return new Response( + JSON.stringify({ + download: unrestrictCalls === 1 ? badUrl : goodUrl, + filename: "refresh-link.mkv", + filesize: binary.length + }), + { + 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"), + autoExtract: false, + autoReconnect: false, + retryLimit: 0 + }, + emptySession(), + createStoragePaths(path.join(root, "state")) + ); + + manager.addPackages([{ name: "fresh-link", links: ["https://dummy/fresh-link"] }]); + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 12000); + + const item = Object.values(manager.getSnapshot().session.items)[0]; + expect(item?.status).toBe("completed"); + expect(item?.downloadedBytes).toBe(binary.length); + expect(unrestrictCalls).toBeGreaterThanOrEqual(2); + expect(badCalls).toBe(3); + expect(goodCalls).toBeGreaterThanOrEqual(1); + expect(fs.existsSync(item.targetPath)).toBe(true); + expect(fs.statSync(item.targetPath).size).toBe(binary.length); + } finally { + server.close(); + await once(server, "close"); + } + }); + it("assigns unique target paths for same filenames in parallel", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); diff --git a/tests/extractor.test.ts b/tests/extractor.test.ts index 572a795..872cf7e 100644 --- a/tests/extractor.test.ts +++ b/tests/extractor.test.ts @@ -950,6 +950,39 @@ describe("extractor", () => { // .zip.001 should appear once from zipSplit detection, not duplicated by genericSplit expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1); }); + + it("ignores duplicate-suffixed multipart rar volumes as standalone candidates", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-rar-dup-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1.rar"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2.rar"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part1 (1).rar"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part2 (1).rar"), "data", "utf8"); + fs.writeFileSync(path.join(packageDir, "Sanctuary720-01x07.part5 (1).rar"), "data", "utf8"); + + const candidates = await findArchiveCandidates(packageDir); + const names = candidates.map((c) => path.basename(c)); + + expect(names).toContain("Sanctuary720-01x07.part1.rar"); + expect(names).not.toContain("Sanctuary720-01x07.part1 (1).rar"); + expect(names).not.toContain("Sanctuary720-01x07.part2 (1).rar"); + expect(names).not.toContain("Sanctuary720-01x07.part5 (1).rar"); + }); + + it("keeps single rar files with duplicate suffix as valid candidates", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-single-rar-dup-")); + tempDirs.push(root); + const packageDir = path.join(root, "pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + + fs.writeFileSync(path.join(packageDir, "Movie (1).rar"), "data", "utf8"); + + const candidates = await findArchiveCandidates(packageDir); + expect(candidates.map((c) => path.basename(c))).toContain("Movie (1).rar"); + }); }); describe("classifyExtractionError", () => {