Release v1.4.34 with DLC import package-name and 413 fixes

- Use DLC filename as package name in dcrypt fallback instead of
  inferring from individual URLs (fixes mangled package names)
- Add paste endpoint fallback when dcrypt upload returns 413
- Split decryptDlcViaDcrypt into tryDcryptUpload/tryDcryptPaste
- Add DCRYPT_PASTE_URL constant
- Expand container tests for 413 fallback and dual-failure scenarios

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-01 01:51:40 +01:00
parent edbfba6663
commit 778124312c
4 changed files with 127 additions and 30 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.4.33", "version": "1.4.34",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

View File

@ -8,6 +8,7 @@ export const APP_VERSION: string = packageJson.version;
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste";
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}"; export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8"); export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8"); export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");

View File

@ -1,7 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants"; import { DCRYPT_PASTE_URL, DCRYPT_UPLOAD_URL, DLC_AES_IV, DLC_AES_KEY, DLC_SERVICE_URL } from "./constants";
import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils"; import { compactErrorText, inferPackageNameFromLinks, isHttpLink, sanitizeFilename, uniquePreserveOrder } from "./utils";
import { ParsedPackageInput } from "../shared/types"; import { ParsedPackageInput } from "../shared/types";
@ -162,9 +162,17 @@ async function decryptDlcLocal(filePath: string): Promise<ParsedPackageInput[]>
return parsePackagesFromDlcXml(xmlData); return parsePackagesFromDlcXml(xmlData);
} }
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> { function extractLinksFromResponse(text: string): string[] {
const fileName = path.basename(filePath); const payload = decodeDcryptPayload(text);
const blob = new Blob([new Uint8Array(readDlcFileWithLimit(filePath))]); let links = extractUrlsRecursive(payload);
if (links.length === 0) {
links = extractUrlsRecursive(text);
}
return uniquePreserveOrder(links.filter((l) => isHttpLink(l)));
}
async function tryDcryptUpload(fileContent: Buffer, fileName: string): Promise<string[] | null> {
const blob = new Blob([new Uint8Array(fileContent)]);
const form = new FormData(); const form = new FormData();
form.set("dlcfile", blob, fileName); form.set("dlcfile", blob, fileName);
@ -172,22 +180,44 @@ async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput
method: "POST", method: "POST",
body: form body: form
}); });
if (response.status === 413) {
return null;
}
const text = await response.text(); const text = await response.text();
if (!response.ok) { if (!response.ok) {
throw new Error(compactErrorText(text)); throw new Error(compactErrorText(text));
} }
const payload = decodeDcryptPayload(text); return extractLinksFromResponse(text);
let packages = extractPackagesFromPayload(payload);
if (packages.length === 1) {
const regrouped = groupLinksByName(packages[0].links);
if (regrouped.length > 1) {
packages = regrouped;
} }
async function tryDcryptPaste(fileContent: Buffer): Promise<string[]> {
const form = new FormData();
form.set("content", fileContent.toString("ascii"));
const response = await fetch(DCRYPT_PASTE_URL, {
method: "POST",
body: form
});
const text = await response.text();
if (!response.ok) {
throw new Error(compactErrorText(text));
} }
if (packages.length === 0) { return extractLinksFromResponse(text);
packages = groupLinksByName(extractUrlsRecursive(text));
} }
return packages;
async function decryptDlcViaDcrypt(filePath: string): Promise<ParsedPackageInput[]> {
const fileContent = readDlcFileWithLimit(filePath);
const fileName = path.basename(filePath);
const packageName = sanitizeFilename(path.basename(filePath, ".dlc")) || "Paket";
let links = await tryDcryptUpload(fileContent, fileName);
if (links === null) {
links = await tryDcryptPaste(fileContent);
}
if (links.length === 0) {
return [];
}
return [{ name: packageName, links }];
} }
export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> { export async function importDlcContainers(filePaths: string[]): Promise<ParsedPackageInput[]> {

View File

@ -27,18 +27,21 @@ describe("container", () => {
// Just needs to be short enough to pass file limits but fail parsing, triggering dcrypt fallback // Just needs to be short enough to pass file limits but fail parsing, triggering dcrypt fallback
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content...")); fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
const fetchSpy = vi.fn(async () => { const fetchSpy = vi.fn(async (url: string | URL | Request) => {
// Mock dcrypt response for valid.dlc const urlStr = String(url);
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("http://example.com/file1.rar\nhttp://example.com/file2.rar", { status: 200 }); return new Response("http://example.com/file1.rar\nhttp://example.com/file2.rar", { status: 200 });
}
return new Response("", { status: 404 });
}); });
globalThis.fetch = fetchSpy as unknown as typeof fetch; globalThis.fetch = fetchSpy as unknown as typeof fetch;
const result = await importDlcContainers([oversizedFilePath, validFilePath]); const result = await importDlcContainers([oversizedFilePath, validFilePath]);
// Expect the oversized to be silently skipped, and valid to be parsed into 2 packages (one per link name) // Expect the oversized to be silently skipped, and valid to be parsed into 1 package with DLC filename
expect(result).toHaveLength(2); expect(result).toHaveLength(1);
expect(result[0].links).toEqual(["http://example.com/file1.rar"]); expect(result[0].name).toBe("valid");
expect(result[1].links).toEqual(["http://example.com/file2.rar"]); expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
expect(fetchSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledTimes(1);
}); });
@ -62,18 +65,21 @@ describe("container", () => {
const fetchSpy = vi.fn(async (url: string | URL | Request) => { const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url); const urlStr = String(url);
if (urlStr.includes("rc")) { if (urlStr.includes("service.jdownloader.org")) {
// Mock local RC service failure (returning 404 or empty string) // Mock local RC service failure (returning 404)
return new Response("", { status: 404 }); return new Response("", { status: 404 });
} else { }
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
// Mock dcrypt fallback success // Mock dcrypt fallback success
return new Response("http://fallback.com/1", { status: 200 }); return new Response("http://fallback.com/1", { status: 200 });
} }
return new Response("", { status: 404 });
}); });
globalThis.fetch = fetchSpy as unknown as typeof fetch; globalThis.fetch = fetchSpy as unknown as typeof fetch;
const result = await importDlcContainers([filePath]); const result = await importDlcContainers([filePath]);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].name).toBe("fallback");
expect(result[0].links).toEqual(["http://fallback.com/1"]); expect(result[0].links).toEqual(["http://fallback.com/1"]);
// Should have tried both! // Should have tried both!
expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy).toHaveBeenCalledTimes(2);
@ -90,16 +96,73 @@ describe("container", () => {
if (urlStr.includes("service.jdownloader.org")) { if (urlStr.includes("service.jdownloader.org")) {
return new Response(`<rc>${Buffer.alloc(16).toString("base64")}</rc>`, { status: 200 }); return new Response(`<rc>${Buffer.alloc(16).toString("base64")}</rc>`, { status: 200 });
} }
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("http://example.com/fallback1", { status: 200 }); return new Response("http://example.com/fallback1", { status: 200 });
}
return new Response("", { status: 404 });
}); });
globalThis.fetch = fetchSpy as unknown as typeof fetch; globalThis.fetch = fetchSpy as unknown as typeof fetch;
const result = await importDlcContainers([filePath]); const result = await importDlcContainers([filePath]);
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].name).toBe("invalid-local");
expect(result[0].links).toEqual(["http://example.com/fallback1"]); expect(result[0].links).toEqual(["http://example.com/fallback1"]);
expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy).toHaveBeenCalledTimes(2);
}); });
it("falls back to paste endpoint when upload returns 413", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const filePath = path.join(dir, "big-dlc.dlc");
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
return new Response("", { status: 404 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("Request Entity Too Large", { status: 413 });
}
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
return new Response("http://paste-fallback.com/file1.rar\nhttp://paste-fallback.com/file2.rar", { status: 200 });
}
return new Response("", { status: 404 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
const result = await importDlcContainers([filePath]);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("big-dlc");
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
// local RC + upload + paste = 3 calls
expect(fetchSpy).toHaveBeenCalledTimes(3);
});
it("throws when both dcrypt endpoints fail", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir);
const filePath = path.join(dir, "doomed.dlc");
fs.writeFileSync(filePath, Buffer.from("not a valid dlc payload at all"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
return new Response("", { status: 404 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("Request Entity Too Large", { status: 413 });
}
if (urlStr.includes("dcrypt.it/decrypt/paste")) {
return new Response("paste failure", { status: 500 });
}
return new Response("", { status: 500 });
});
globalThis.fetch = fetchSpy as unknown as typeof fetch;
await expect(importDlcContainers([filePath])).rejects.toThrow(/DLC konnte nicht importiert werden/i);
});
it("throws clear error when all dlc imports fail", async () => { it("throws clear error when all dlc imports fail", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-"));
tempDirs.push(dir); tempDirs.push(dir);
@ -111,7 +174,10 @@ describe("container", () => {
if (urlStr.includes("service.jdownloader.org")) { if (urlStr.includes("service.jdownloader.org")) {
return new Response("", { status: 404 }); return new Response("", { status: 404 });
} }
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
return new Response("upstream failure", { status: 500 }); return new Response("upstream failure", { status: 500 });
}
return new Response("", { status: 500 });
}); });
globalThis.fetch = fetchSpy as unknown as typeof fetch; globalThis.fetch = fetchSpy as unknown as typeof fetch;