From 778124312c1ab08c5e039b9bd86e07f96c07c603 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sun, 1 Mar 2026 01:51:40 +0100 Subject: [PATCH] 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 --- package.json | 2 +- src/main/constants.ts | 1 + src/main/container.ts | 58 +++++++++++++++++++------ tests/container.test.ts | 96 ++++++++++++++++++++++++++++++++++------- 4 files changed, 127 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 8db7fb1..4f60c90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "1.4.33", + "version": "1.4.34", "description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)", "main": "build/main/main/main.js", "author": "Sucukdeluxe", diff --git a/src/main/constants.ts b/src/main/constants.ts index b1956e7..778c34a 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -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 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_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8"); export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8"); diff --git a/src/main/container.ts b/src/main/container.ts index a4e6bd0..1b6ba74 100644 --- a/src/main/container.ts +++ b/src/main/container.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; 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 { ParsedPackageInput } from "../shared/types"; @@ -162,9 +162,17 @@ async function decryptDlcLocal(filePath: string): Promise return parsePackagesFromDlcXml(xmlData); } -async function decryptDlcViaDcrypt(filePath: string): Promise { - const fileName = path.basename(filePath); - const blob = new Blob([new Uint8Array(readDlcFileWithLimit(filePath))]); +function extractLinksFromResponse(text: string): string[] { + const payload = decodeDcryptPayload(text); + 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 { + const blob = new Blob([new Uint8Array(fileContent)]); const form = new FormData(); form.set("dlcfile", blob, fileName); @@ -172,22 +180,44 @@ async function decryptDlcViaDcrypt(filePath: string): Promise 1) { - packages = regrouped; - } + return extractLinksFromResponse(text); +} + +async function tryDcryptPaste(fileContent: Buffer): Promise { + 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) { - packages = groupLinksByName(extractUrlsRecursive(text)); + return extractLinksFromResponse(text); +} + +async function decryptDlcViaDcrypt(filePath: string): Promise { + 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); } - return packages; + if (links.length === 0) { + return []; + } + return [{ name: packageName, links }]; } export async function importDlcContainers(filePaths: string[]): Promise { diff --git a/tests/container.test.ts b/tests/container.test.ts index 503f8e7..e94fcd2 100644 --- a/tests/container.test.ts +++ b/tests/container.test.ts @@ -21,24 +21,27 @@ describe("container", () => { tempDirs.push(dir); const oversizedFilePath = path.join(dir, "oversized.dlc"); fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1)); - + // Create a valid mockup DLC that would be skipped if an error was thrown const validFilePath = path.join(dir, "valid.dlc"); // 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...")); - const fetchSpy = vi.fn(async () => { - // Mock dcrypt response for valid.dlc - return new Response("http://example.com/file1.rar\nhttp://example.com/file2.rar", { status: 200 }); + const fetchSpy = vi.fn(async (url: string | URL | Request) => { + 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("", { status: 404 }); }); globalThis.fetch = fetchSpy as unknown as typeof fetch; 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(result).toHaveLength(2); - expect(result[0].links).toEqual(["http://example.com/file1.rar"]); - expect(result[1].links).toEqual(["http://example.com/file2.rar"]); + + // Expect the oversized to be silently skipped, and valid to be parsed into 1 package with DLC filename + expect(result).toHaveLength(1); + expect(result[0].name).toBe("valid"); + expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]); expect(fetchSpy).toHaveBeenCalledTimes(1); }); @@ -56,24 +59,27 @@ describe("container", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); tempDirs.push(dir); const filePath = path.join(dir, "fallback.dlc"); - + // A file large enough to trigger local decryption attempt (needs > 89 bytes to pass the slice check) 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("rc")) { - // Mock local RC service failure (returning 404 or empty string) + if (urlStr.includes("service.jdownloader.org")) { + // Mock local RC service failure (returning 404) return new Response("", { status: 404 }); - } else { + } + if (urlStr.includes("dcrypt.it/decrypt/upload")) { // Mock dcrypt fallback success return new Response("http://fallback.com/1", { 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("fallback"); expect(result[0].links).toEqual(["http://fallback.com/1"]); // Should have tried both! expect(fetchSpy).toHaveBeenCalledTimes(2); @@ -90,16 +96,73 @@ describe("container", () => { if (urlStr.includes("service.jdownloader.org")) { return new Response(`${Buffer.alloc(16).toString("base64")}`, { status: 200 }); } - return new Response("http://example.com/fallback1", { status: 200 }); + if (urlStr.includes("dcrypt.it/decrypt/upload")) { + return new Response("http://example.com/fallback1", { 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("invalid-local"); expect(result[0].links).toEqual(["http://example.com/fallback1"]); 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 () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dlc-")); tempDirs.push(dir); @@ -111,7 +174,10 @@ describe("container", () => { if (urlStr.includes("service.jdownloader.org")) { return new Response("", { status: 404 }); } - return new Response("upstream failure", { status: 500 }); + if (urlStr.includes("dcrypt.it/decrypt/upload")) { + return new Response("upstream failure", { status: 500 }); + } + return new Response("", { status: 500 }); }); globalThis.fetch = fetchSpy as unknown as typeof fetch;