Release v1.7.43

This commit is contained in:
Sucukdeluxe 2026-03-08 02:18:53 +01:00
parent 4dd43c8d91
commit 2a51c443b8
6 changed files with 231 additions and 19 deletions

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.41", "version": "1.7.43",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.41", "version": "1.7.43",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.42", "version": "1.7.43",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -108,6 +108,8 @@ const ARCHIVE_SETTLE_POLL_MS = 250;
const ARCHIVE_SETTLE_MAX_WAIT_MS = 5000; 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; 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 { function itemExpectedMinBytes(item: DownloadItem): number {
@ -6422,6 +6424,27 @@ export class DownloadManager extends EventEmitter {
error: errorText, error: errorText,
abortReason: reason || "none" 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 shouldFreshRetry = !active.freshRetryUsed && isFetchFailure(errorText);
const isHttp416 = /(^|\D)416(\D|$)/.test(errorText); const isHttp416 = /(^|\D)416(\D|$)/.test(errorText);
if (isHttp416) { if (isHttp416) {
@ -6623,7 +6646,8 @@ export class DownloadManager extends EventEmitter {
const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit); const configuredRetryLimit = normalizeRetryLimit(this.settings.retryLimit);
const retryDisplayLimit = retryLimitLabel(configuredRetryLimit); 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 lastError = "";
let effectiveTargetPath = targetPath; let effectiveTargetPath = targetPath;
@ -7414,15 +7438,21 @@ export class DownloadManager extends EventEmitter {
}); });
if (attempt < maxAttempts) { if (attempt < maxAttempts) {
item.retries += 1; item.retries += 1;
item.fullStatus = `Downloadfehler, retry ${attempt}/${retryDisplayLimit}`; item.fullStatus = `Downloadfehler, retry ${attempt}/${maxAttempts} (Direktlink)`;
this.emitState(); this.emitState();
await sleep(retryDelayWithJitter(attempt, 250)); await sleep(retryDelayWithJitter(attempt, 250));
continue; continue;
} }
if (maxAttemptsBySetting > maxAttempts) {
throw new Error(`direct_link_retry_exhausted:${lastError || "Download fehlgeschlagen"}`);
}
throw new Error(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"); throw new Error(lastError || "Download fehlgeschlagen");
} }

View File

@ -299,8 +299,36 @@ function archiveNameKey(fileName: string): string {
return process.platform === "win32" ? String(fileName || "").toLowerCase() : String(fileName || ""); 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 { function archiveSortKey(filePath: string): string {
const fileName = path.basename(filePath).toLowerCase(); const fileName = archiveDetectionName(filePath).toLowerCase();
return fileName return fileName
.replace(/\.part0*1\.rar$/i, "") .replace(/\.part0*1\.rar$/i, "")
.replace(/\.zip\.\d{3}$/i, "") .replace(/\.zip\.\d{3}$/i, "")
@ -314,7 +342,7 @@ function archiveSortKey(filePath: string): string {
} }
function archiveTypeRank(filePath: string): number { function archiveTypeRank(filePath: string): number {
const fileName = path.basename(filePath).toLowerCase(); const fileName = archiveDetectionName(filePath).toLowerCase();
if (/\.part0*1\.rar$/i.test(fileName)) { if (/\.part0*1\.rar$/i.test(fileName)) {
return 0; return 0;
} }
@ -359,20 +387,23 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
return []; return [];
} }
const fileNamesLower = new Set(files.map((filePath) => path.basename(filePath).toLowerCase())); const fileNamesLower = new Set(files.map((filePath) => archiveDetectionName(filePath).toLowerCase()));
const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(filePath)); const multipartRar = files.filter((filePath) => /\.part0*1\.rar$/i.test(archiveDetectionName(filePath)));
const singleRar = files.filter((filePath) => /\.rar$/i.test(filePath) && !/\.part\d+\.rar$/i.test(filePath)); const singleRar = files.filter((filePath) => {
const zipSplit = files.filter((filePath) => /\.zip\.001$/i.test(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 zip = files.filter((filePath) => {
const fileName = path.basename(filePath); const fileName = archiveDetectionName(filePath);
if (!/\.zip$/i.test(fileName)) { if (!/\.zip$/i.test(fileName)) {
return false; return false;
} }
return !fileNamesLower.has(`${fileName}.001`.toLowerCase()); 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 seven = files.filter((filePath) => {
const fileName = path.basename(filePath); const fileName = archiveDetectionName(filePath);
if (!/\.7z$/i.test(fileName)) { if (!/\.7z$/i.test(fileName)) {
return false; return false;
} }
@ -381,20 +412,24 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath)); const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath));
// Generic .001 splits (HJSplit etc.) — exclude already-recognized .zip.001 and .7z.001 // Generic .001 splits (HJSplit etc.) — exclude already-recognized .zip.001 and .7z.001
const genericSplit = files.filter((filePath) => { const genericSplit = files.filter((filePath) => {
const fileName = path.basename(filePath).toLowerCase(); const fileName = archiveDetectionName(filePath).toLowerCase();
if (!/\.001$/.test(fileName)) return false; if (!/\.001$/.test(fileName)) return false;
if (/\.zip\.001$/.test(fileName) || /\.7z\.001$/.test(fileName)) return false; if (/\.zip\.001$/.test(fileName) || /\.7z\.001$/.test(fileName)) return false;
return true; return true;
}); });
const unique: string[] = []; const unique: string[] = [];
const seen = new Set<string>(); const seen = new Map<string, number>();
for (const candidate of [...multipartRar, ...singleRar, ...zipSplit, ...zip, ...sevenSplit, ...seven, ...tarCompressed, ...genericSplit]) { for (const candidate of [...multipartRar, ...singleRar, ...zipSplit, ...zip, ...sevenSplit, ...seven, ...tarCompressed, ...genericSplit]) {
const key = pathSetKey(candidate); const key = archiveCandidateIdentity(candidate);
if (seen.has(key)) { const existingIndex = seen.get(key);
if (existingIndex !== undefined) {
if (prefersArchiveCandidate(candidate, unique[existingIndex])) {
unique[existingIndex] = candidate;
}
continue; continue;
} }
seen.add(key); seen.set(key, unique.length);
unique.push(candidate); unique.push(candidate);
} }

View File

@ -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<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: 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 () => { 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);

View File

@ -950,6 +950,39 @@ describe("extractor", () => {
// .zip.001 should appear once from zipSplit detection, not duplicated by genericSplit // .zip.001 should appear once from zipSplit detection, not duplicated by genericSplit
expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1); 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", () => { describe("classifyExtractionError", () => {