diff --git a/CHANGELOG.md b/CHANGELOG.md index 118d075..5a06d1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,13 @@ All notable changes to Multi-Debrid Downloader are documented in this file. - Group supported 1Fichier mirror domains under one hoster identity. - Display the full `1Fichier` hoster name consistently in the link collector and Downloads. +### DDownload imports + +- Resolve public DDownload filenames, exact sizes, and availability before links enter the download queue. +- Treat removed files as offline while keeping protected or inconclusive pages available for later retry. +- Preserve resolved DDownload filenames when a debrid provider later returns only `download.bin`. +- Normalize `ddownload.com` and `ddl.to` under one DDownload hoster identity. + ### Link collector - Rebuilt the link collector as a package-oriented preview with expandable file rows, resolved metadata, availability filters, stable selection, and selected or complete transfer to Downloads. diff --git a/src/main/collector-inspection.ts b/src/main/collector-inspection.ts index 3083a36..1c64290 100644 --- a/src/main/collector-inspection.ts +++ b/src/main/collector-inspection.ts @@ -4,11 +4,12 @@ import { serializeCollectorPackages } from "../shared/collector"; import type { CollectorInspectionRequest, CollectorInspectionResult, CollectorLink, CollectorPackage } from "../shared/collector"; import type { AppSettings, ParsedPackageInput } from "../shared/types"; import { extractHosterFromUrl } from "../shared/hoster"; -import { checkOneFichierLinks, checkRapidgatorOnline, DebridService, isOneFichierLink, type OneFichierCheckResult } from "./debrid"; +import { checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, DebridService, isDdownloadLink, isOneFichierLink, type OneFichierCheckResult } from "./debrid"; import { parseCollectorInput } from "./link-parser"; import { filenameFromUrl, isHttpLink, looksLikeOpaqueFilename, sanitizeFilename } from "./utils"; interface CollectorInspectionDependencies { + checkDdownload?: typeof checkDdownloadOnline; checkOneFichier?: (links: string[]) => Promise>; checkRapidgator?: typeof checkRapidgatorOnline; resolveFilenames?: (links: string[]) => Promise>; @@ -160,11 +161,13 @@ export async function inspectCollectorPackages( const oneFichierLinks = sourceLinks.map((link) => link.url).filter(isOneFichierLink); const rapidgatorLinks = sourceLinks.map((link) => link.url).filter((url) => extractHosterFromUrl(url) === "rapidgator"); + const ddownloadLinks = sourceLinks.map((link) => link.url).filter(isDdownloadLink); const genericLinks = sourceLinks .filter((source) => !source.explicitFileName && looksLikeOpaqueFilename(source.fileName)) .map((source) => source.url) - .filter((url) => !oneFichierLinks.includes(url) && !rapidgatorLinks.includes(url)); + .filter((url) => !oneFichierLinks.includes(url) && !rapidgatorLinks.includes(url) && !ddownloadLinks.includes(url)); + const checkDdownload = dependencies.checkDdownload ?? checkDdownloadOnline; const checkOneFichier = dependencies.checkOneFichier ?? checkOneFichierLinks; const checkRapidgator = dependencies.checkRapidgator ?? checkRapidgatorOnline; const resolveFilenames = dependencies.resolveFilenames ?? ((urls) => new DebridService(settings).resolveFilenames(urls)); @@ -179,9 +182,20 @@ export async function inspectCollectorPackages( if (result.fileName) link.fileName = sanitizeFilename(result.fileName); if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; }); - const genericPromise = resolveFilenames(genericLinks).catch(() => new Map()); + const ddownloadPromise = runWithConcurrency(ddownloadLinks, 4, async (url) => { + const result = await checkDdownload(url).catch(() => null); + const link = linksByUrl.get(url); + if (!link || !result) return; + link.availability = result.online ? "online" : "offline"; + link.status = result.online ? (result.fileName ? "ready" : "unknown") : "offline"; + if (result.fileName) link.fileName = sanitizeFilename(result.fileName); + if (result.fileSizeBytes !== null && result.fileSizeBytes >= 0) link.fileSizeBytes = result.fileSizeBytes; + }); + const genericPromise = genericLinks.length > 0 + ? resolveFilenames(genericLinks).catch(() => new Map()) + : Promise.resolve(new Map()); - const [oneFichierResults, genericResults] = await Promise.all([oneFichierPromise, genericPromise, rapidgatorPromise]).then(([one, generic]) => [one, generic] as const); + const [oneFichierResults, genericResults] = await Promise.all([oneFichierPromise, genericPromise, rapidgatorPromise, ddownloadPromise]).then(([one, generic]) => [one, generic] as const); for (const [url, result] of oneFichierResults) { const link = linksByUrl.get(url); if (!link) continue; diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 655cd29..a3f7253 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -3797,11 +3797,125 @@ class OneFichierClient { } } -const DDOWNLOAD_URL_RE = /^https?:\/\/(?:www\.)?(?:ddownload\.com|ddl\.to)\/([a-z0-9]+)/i; -const DDOWNLOAD_WEB_BASE = "https://ddownload.com"; -const DDOWNLOAD_WEB_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; - -class DdownloadClient { +const DDOWNLOAD_URL_RE = /^https?:\/\/(?:www\.)?(?:ddownload\.com|ddl\.to)\/([a-z0-9]{8,20})(?:\/|$)/i; +const DDOWNLOAD_WEB_BASE = "https://ddownload.com"; +const DDOWNLOAD_WEB_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; +const DDOWNLOAD_SCAN_MAX_BYTES = 512 * 1024; +const DDOWNLOAD_FILE_NOT_FOUND_RE = /(?:File Not Found|No such file|file was removed|file was banned|Unavailable for legal reasons)/i; + +export interface DdownloadCheckResult { + online: boolean; + fileName: string; + fileSizeBytes: number | null; +} + +export function isDdownloadLink(link: string): boolean { + return DDOWNLOAD_URL_RE.test(String(link || "").trim()); +} + +export function filenameFromDdownloadUrlPath(link: string): string { + if (!isDdownloadLink(link)) return ""; + try { + const parts = new URL(link).pathname.split("/").filter(Boolean); + for (let index = parts.length - 1; index >= 1; index -= 1) { + const normalized = normalizeResolvedFilename(safeDecode(parts[index]).replace(/\.html?$/i, "")); + if (normalized) return normalized; + } + } catch { + } + return ""; +} + +export function extractDdownloadFilenameFromHtml(html: string): string { + const patterns = [ + /class=["'][^"']*dk-dl-icon[^"']*["'][^>]*data-fn=["']([^"']+)["']/i, + /data-fn=["']([^"']+)["'][^>]*class=["'][^"']*dk-dl-icon[^"']*["']/i, + /]*class=["'][^"']*dk-dl-name[^"']*["'][^>]*>([^<]+)<\/h2>/i, + /class=["'][^"']*file-info-name[^"']*["'][^>]*>([^<]+) { + if (!isDdownloadLink(link)) return null; + const headers = { + "User-Agent": DDOWNLOAD_WEB_UA, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9,de;q=0.8" + }; + + for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) { + try { + if (signal?.aborted) throw new Error("aborted:debrid"); + const response = await fetch(link, { + method: "GET", + redirect: "follow", + headers, + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + if (response.status === 404) { + try { await response.body?.cancel(); } catch { } + return { online: false, fileName: "", fileSizeBytes: null }; + } + if (!response.ok) { + try { await response.body?.cancel(); } catch { } + if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) { + await sleepWithSignal(retryDelayForResponse(response, attempt), signal); + continue; + } + return null; + } + const contentType = String(response.headers.get("content-type") || "").toLowerCase(); + const contentLength = Number(response.headers.get("content-length") || NaN); + if (contentType + && !contentType.includes("text/html") + && !contentType.includes("application/xhtml") + && !contentType.includes("text/plain")) { + try { await response.body?.cancel(); } catch { } + return null; + } + if (!contentType && Number.isFinite(contentLength) && contentLength > DDOWNLOAD_SCAN_MAX_BYTES) { + try { await response.body?.cancel(); } catch { } + return null; + } + const html = await readResponseTextLimited(response, DDOWNLOAD_SCAN_MAX_BYTES, signal); + if (DDOWNLOAD_FILE_NOT_FOUND_RE.test(html)) { + return { online: false, fileName: "", fileSizeBytes: null }; + } + const pageName = extractDdownloadFilenameFromHtml(html); + const hasFilePage = Boolean(pageName) + || /\bdk-dl-(?:icon|name|size)\b/i.test(html) + || /Your file is ready to download|Regular Download via DDownload/i.test(html); + if (!hasFilePage) return null; + const sizeMatch = html.match(/class=["'][^"']*dk-dl-size[^"']*["'][^>]*>([^<]+)]*>([^<]+) REQUEST_RETRIES || !isRetryableErrorText(errorText)) return null; + } + if (attempt <= REQUEST_RETRIES) await sleepWithSignal(retryDelay(attempt), signal); + } + return null; +} + +class DdownloadClient { private login: string; private password: string; private cookies: string = ""; @@ -3899,8 +4013,7 @@ class DdownloadClient { const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode; const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || ""; - const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+) logger.warn(`recoverRetryableItems Fehler (startup): ${compactErrorText(err)}`)); this.recoverPostProcessingOnStartup(); this.checkExistingRapidgatorLinks(); + this.checkExistingDdownloadLinks(); this.checkExistingOneFichierLinks(); void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`)); setRotationEventListener(() => { @@ -3234,6 +3235,7 @@ export class DownloadManager extends EventEmitter { } if (newItemIds.length > 0) { void this.checkRapidgatorLinks(newItemIds).catch((err) => logger.warn(`checkRapidgatorLinks Fehler: ${compactErrorText(err)}`)); + void this.checkDdownloadItems(newItemIds).catch((err) => logger.warn(`checkDdownloadItems Fehler: ${compactErrorText(err)}`)); void this.checkOneFichierItems(newItemIds).catch((err) => logger.warn(`checkOneFichierItems Fehler: ${compactErrorText(err)}`)); } return { addedPackages, addedLinks }; @@ -3597,6 +3599,78 @@ export class DownloadManager extends EventEmitter { } } + private async checkDdownloadItems(itemIds: string[]): Promise { + const itemsToCheck: Array<{ itemId: string; url: string }> = []; + for (const itemId of itemIds) { + const item = this.session.items[itemId]; + if (!item || !isDdownloadLink(item.url)) continue; + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + item.onlineStatus = "checking"; + itemsToCheck.push({ itemId, url: item.url }); + } + if (itemsToCheck.length === 0) return; + this.emitState(); + const checkedUrls = new Map>(); + await runWithLimitedConcurrency(itemsToCheck, 4, async ({ itemId, url }) => { + let pending = checkedUrls.get(url); + if (!pending) { + pending = checkDdownloadOnline(url); + checkedUrls.set(url, pending); + } + let result: DdownloadCheckResult | null = null; + try { + result = await pending; + } catch (error) { + logger.warn(`DDownload-Linkprüfung fehlgeschlagen: ${compactErrorText(error)}`); + } + const item = this.session.items[itemId]; + if (item) this.applyDdownloadCheckResult(item, result); + this.persistSoon(); + this.emitState(); + }); + this.persistSoon(); + } + + private applyDdownloadCheckResult(item: DownloadItem, result: DdownloadCheckResult | null): void { + if (!result) { + if (item.onlineStatus === "checking") item.onlineStatus = undefined; + return; + } + const canUpdateMetadata = item.status === "queued" + || item.status === "reconnect_wait" + || (item.status === "validating" && item.downloadedBytes === 0 && (!item.targetPath || !fs.existsSync(item.targetPath))); + if (item.status !== "queued" && item.status !== "reconnect_wait" && item.status !== "validating") { + item.onlineStatus = result.online ? "online" : "offline"; + item.updatedAt = nowMs(); + return; + } + if (item.status === "validating" && !result.online) { + item.onlineStatus = "offline"; + item.updatedAt = nowMs(); + return; + } + if (!result.online) { + item.status = "failed"; + item.fullStatus = "Offline"; + item.lastError = "Datei nicht gefunden auf DDownload"; + item.onlineStatus = "offline"; + item.updatedAt = nowMs(); + if (this.runItemIds.has(item.id)) this.recordRunOutcome(item.id, "failed"); + const pkg = this.session.packages[item.packageId]; + if (pkg) this.refreshPackageStatus(pkg); + return; + } + const unresolvedFileName = looksLikeOpaqueFilename(item.fileName) + || (!filenameFromDdownloadUrlPath(item.url) && item.fileName === filenameFromUrl(item.url)); + if (canUpdateMetadata && result.fileName && unresolvedFileName) { + item.fileName = sanitizeFilename(result.fileName); + this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, item.fileName)); + } + if (canUpdateMetadata && result.fileSizeBytes !== null && result.fileSizeBytes > 0) item.totalBytes = result.fileSizeBytes; + item.onlineStatus = "online"; + item.updatedAt = nowMs(); + } + private async checkOneFichierItems(itemIds: string[]): Promise { const itemIdsByUrl = new Map(); for (const itemId of itemIds) { @@ -3701,6 +3775,21 @@ export class DownloadManager extends EventEmitter { } } + private checkExistingDdownloadLinks(): void { + const uncheckedIds: string[] = []; + for (const item of Object.values(this.session.items)) { + if (item.status !== "queued" && item.status !== "reconnect_wait") continue; + if (!isDdownloadLink(item.url) || item.onlineStatus === "offline") continue; + const unresolvedFileName = looksLikeOpaqueFilename(item.fileName) + || (!filenameFromDdownloadUrlPath(item.url) && item.fileName === filenameFromUrl(item.url)); + if (item.onlineStatus === "online" && item.totalBytes !== null && item.totalBytes > 0 && !unresolvedFileName) continue; + uncheckedIds.push(item.id); + } + if (uncheckedIds.length > 0) { + void this.checkDdownloadItems(uncheckedIds).catch((err) => logger.warn(`checkDdownloadItems Fehler (startup): ${compactErrorText(err)}`)); + } + } + private checkExistingOneFichierLinks(): void { const uncheckedIds: string[] = []; for (const item of Object.values(this.session.items)) { diff --git a/src/shared/hoster.ts b/src/shared/hoster.ts index 12f62e4..8da8c44 100644 --- a/src/shared/hoster.ts +++ b/src/shared/hoster.ts @@ -2,6 +2,8 @@ const DOMAIN_ALIASES: Readonly> = Object.freeze({ "rapidgator.net": "rapidgator", "rapidgator.asia": "rapidgator", "rg.to": "rapidgator", + "ddownload.com": "ddownload", + "ddl.to": "ddownload", "1fichier.com": "1fichier", "alterupload.com": "1fichier", "cjoint.net": "1fichier", diff --git a/tests/collector-inspection.test.ts b/tests/collector-inspection.test.ts index 6ea7069..bda717f 100644 --- a/tests/collector-inspection.test.ts +++ b/tests/collector-inspection.test.ts @@ -62,11 +62,11 @@ describe("collector inspection", () => { "https://example.com/a", "https://example.com/a", "# Package: Staffel B", - "https://example.com/b" + "https://example.com/abcdef1234567890abcdef12" ].join("\n"); const result = await inspectCollectorText({ rawText, addedAt: 2000 }, defaultSettings(), { - resolveFilenames: async () => new Map([["https://example.com/b", "episode.part02.rar"]]) + resolveFilenames: async () => new Map([["https://example.com/abcdef1234567890abcdef12", "episode.part02.rar"]]) }); expect(result.packages.map((pkg) => pkg.name)).toEqual(["Staffel A", "Staffel B"]); @@ -100,6 +100,34 @@ describe("collector inspection", () => { })); }); + it("resolves DDownload metadata before grouping without using a debrid account", async () => { + const link = "https://ddownload.com/ntwscdw62gyb"; + let genericResolverCalls = 0; + const result = await inspectCollectorText({ rawText: link, addedAt: 3500 }, defaultSettings(), { + checkDdownload: async () => ({ + online: true, + fileName: "SBS14HD.part02.rar", + fileSizeBytes: 526_385_152 + }), + resolveFilenames: async () => { + genericResolverCalls += 1; + return new Map(); + } + }); + + expect(genericResolverCalls).toBe(0); + expect(result.packages).toHaveLength(1); + expect(result.packages[0].name).toBe("SBS14HD"); + expect(result.packages[0].links).toEqual([expect.objectContaining({ + url: link, + fileName: "SBS14HD.part02.rar", + fileSizeBytes: 526_385_152, + hoster: "ddownload", + availability: "online", + status: "ready" + })]); + }); + it("infers archive package names and serializes inspected metadata for the existing queue parser", () => { expect(inferCollectorPackageName("SBS14HD.part01.rar", "1fichier")).toBe("SBS14HD"); expect(inferCollectorPackageName("Archive.7z.001", "1fichier")).toBe("Archive"); diff --git a/tests/debrid.test.ts b/tests/debrid.test.ts index ddd13ef..49a7264 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -5,7 +5,7 @@ import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; -import { checkOneFichierLinks, checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getAvailableRealDebridAccounts, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, isOneFichierLink, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; +import { checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractDdownloadFilenameFromHtml, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromDdownloadUrlPath, filenameFromRapidgatorUrlPath, getAvailableRealDebridAccounts, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, isDdownloadLink, isOneFichierLink, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseDdownloadFileSize, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; import { getAccountRuntimeSessionStats } from "../src/main/account-runtime"; const originalFetch = globalThis.fetch; @@ -2831,6 +2831,59 @@ describe("checkRapidgatorOnline", () => { }); }); +describe("DDownload public metadata", () => { + it("recognizes canonical and short domains without treating unrelated pages as files", () => { + expect(isDdownloadLink("https://ddownload.com/ntwscdw62gyb")).toBe(true); + expect(isDdownloadLink("https://ddl.to/ntwscdw62gyb/Archive.part02.rar")).toBe(true); + expect(isDdownloadLink("https://ddownload.com/login.html")).toBe(false); + expect(isDdownloadLink("https://example.com/ntwscdw62gyb")).toBe(false); + }); + + it("uses a real filename from the URL but rejects a bare file code", () => { + expect(filenameFromDdownloadUrlPath("https://ddownload.com/ntwscdw62gyb/Archive.part02.rar")).toBe("Archive.part02.rar"); + expect(filenameFromDdownloadUrlPath("https://ddownload.com/ntwscdw62gyb")).toBe(""); + }); + + it("parses the current public page name and binary size", () => { + const html = [ + '
', + '

Show.S01E02.German.DL.part02.rar

', + '

502.00 MB

' + ].join(""); + + expect(extractDdownloadFilenameFromHtml(html)).toBe("Show.S01E02.German.DL.part02.rar"); + expect(parseDdownloadFileSize("502.00 MB")).toBe(526_385_152); + }); + + it("returns exact metadata for an online file and explicit offline state for a removed file", async () => { + const responses = [ + new Response('

Show.S01E02.mkv

840.02 MB

', { status: 200, headers: { "Content-Type": "text/html" } }), + new Response('

File Not Found

', { status: 200, headers: { "Content-Type": "text/html" } }) + ]; + globalThis.fetch = vi.fn(async () => responses.shift() || new Response("", { status: 500 })) as typeof fetch; + + await expect(checkDdownloadOnline("https://ddownload.com/3nq8wruijuh4")).resolves.toEqual({ + online: true, + fileName: "Show.S01E02.mkv", + fileSizeBytes: 880_824_812 + }); + await expect(checkDdownloadOnline("https://ddownload.com/missing1234")).resolves.toEqual({ + online: false, + fileName: "", + fileSizeBytes: null + }); + }); + + it("keeps challenge and malformed pages unknown instead of marking them online", async () => { + globalThis.fetch = vi.fn(async () => new Response("Just a moment...", { + status: 200, + headers: { "Content-Type": "text/html" } + })) as typeof fetch; + + await expect(checkDdownloadOnline("https://ddownload.com/unknown1234")).resolves.toBeNull(); + }); +}); + describe("checkOneFichierLinks", () => { it("checks at most 100 links per request and maps exact metadata", async () => { const links = Array.from({ length: 101 }, (_unused, index) => `https://1fichier.com/?id${String(index).padStart(5, "0")}`); diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index 037e4f2..8e98375 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -959,6 +959,156 @@ describe("download manager", () => { expect(item.onlineStatus).toBe("online"); }); + it("resolves DDownload names, sizes and availability after import", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-ddownload-metadata-")); + tempDirs.push(root); + const link = "https://ddownload.com/ntwscdw62gyb"; + let checkCalls = 0; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === link) { + checkCalls += 1; + return new Response('

Show.S01E02.German.DL.part02.rar

502.00 MB

', { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "ddownload", links: [link] }]); + + await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.onlineStatus === "online", 2_000); + const downloadedItem = Object.values(manager.getSnapshot().session.items)[0]; + + expect(checkCalls).toBe(1); + expect(downloadedItem.fileName).toBe("Show.S01E02.German.DL.part02.rar"); + expect(downloadedItem.totalBytes).toBe(526_385_152); + expect(path.basename(downloadedItem.targetPath)).toBe("Show.S01E02.German.DL.part02.rar"); + }); + + it("rechecks unresolved DDownload metadata when a queued session is restored", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-ddownload-startup-")); + tempDirs.push(root); + const link = "https://ddownload.com/startup1234"; + const session = emptySession(); + const paths = createStoragePaths(path.join(root, "state")); + let page = "Just a moment..."; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === link) return new Response(page, { status: 200, headers: { "Content-Type": "text/html" } }); + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const firstManager = new DownloadManager(defaultSettings(), session, paths); + firstManager.addPackages([{ name: "startup", links: [link] }]); + await waitFor(() => Object.values(session.items)[0]?.onlineStatus !== "checking", 2_000); + const restoredItem = Object.values(session.items)[0]; + restoredItem.fileName = "download.bin"; + restoredItem.targetPath = path.join(session.packages[restoredItem.packageId].outputDir, restoredItem.fileName); + restoredItem.totalBytes = null; + restoredItem.onlineStatus = undefined; + page = '

Restored.Show.S01E01.mkv

1.50 GB

'; + + const restoredManager = new DownloadManager(defaultSettings(), session, paths); + await waitFor(() => Object.values(restoredManager.getSnapshot().session.items)[0]?.onlineStatus === "online", 2_000); + const checkedItem = Object.values(restoredManager.getSnapshot().session.items)[0]; + + expect(checkedItem.fileName).toBe("Restored.Show.S01E01.mkv"); + expect(checkedItem.totalBytes).toBe(1_610_612_736); + expect(path.basename(checkedItem.targetPath)).toBe("Restored.Show.S01E01.mkv"); + }); + + it("fails removed DDownload files while keeping protected pages queued as unknown", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-ddownload-status-")); + tempDirs.push(root); + const missing = "https://ddownload.com/missing1234"; + const protectedLink = "https://ddownload.com/protect1234"; + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === missing) return new Response("

File Not Found

", { status: 200, headers: { "Content-Type": "text/html" } }); + if (url === protectedLink) return new Response("Just a moment...", { status: 200, headers: { "Content-Type": "text/html" } }); + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "ddownload-status", links: [missing, protectedLink] }]); + await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.onlineStatus !== "checking"), 2_000); + const items = Object.values(manager.getSnapshot().session.items); + + expect(items[0]).toEqual(expect.objectContaining({ + status: "failed", + fullStatus: "Offline", + onlineStatus: "offline", + lastError: "Datei nicht gefunden auf DDownload" + })); + expect(items[1]).toEqual(expect.objectContaining({ + status: "queued", + onlineStatus: undefined, + totalBytes: null + })); + }); + + it("keeps DDownload metadata when a debrid response later returns download.bin", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-ddownload-preserve-")); + tempDirs.push(root); + const link = "https://ddownload.com/keep1234567"; + const expectedName = "Series.S02E03.German.DL.part01.rar"; + const binary = Buffer.alloc(192 * 1024, 31); + const server = http.createServer((_req, res) => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + 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 directUrl = `http://127.0.0.1:${address.port}/download`; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === link) { + return new Response(`

${expectedName}

192 KB

`, { + status: 200, + headers: { "Content-Type": "text/html" } + }); + } + if (url.includes("api.real-debrid.com/rest/1.0/unrestrict/link")) { + return new Response(JSON.stringify({ download: directUrl, filename: "download.bin", filesize: binary.length }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + return originalFetch(input, init); + }) as typeof fetch; + + try { + const manager = new DownloadManager({ + ...defaultSettings(), + token: "rd-token", + outputDir: path.join(root, "downloads"), + extractDir: path.join(root, "extract"), + autoExtract: false + }, emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "ddownload-preserve", links: [link] }]); + await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.fileName === expectedName, 2_000); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 15_000); + const downloadedItem = Object.values(manager.getSnapshot().session.items)[0]; + + expect(downloadedItem.status).toBe("completed"); + expect(downloadedItem.fileName).toBe(expectedName); + expect(path.basename(downloadedItem.targetPath)).toBe(expectedName); + } finally { + server.close(); + await once(server, "close"); + } + }, 20_000); + it("resolves 1Fichier names, sizes and availability in one batch after import", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-metadata-")); tempDirs.push(root); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index e8ef877..2243bad 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -633,6 +633,16 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => { expect(formatHosterLabel(hosters[1])).toEqual({ compact: "1Fichier", title: "1Fichier", iconSrc: "./provider-icons/onefichier.png" }); }); + it("normalizes DDownload and ddl.to to one hoster identity", () => { + const hosters = [ + extractHoster("https://ddownload.com/ntwscdw62gyb"), + extractHoster("https://ddl.to/ntwscdw62gyb/Archive.part02.rar") + ]; + + expect(hosters).toEqual(["ddownload", "ddownload"]); + expect(formatHosterLabel(hosters[1])).toEqual({ compact: "DD", title: "DDownload", iconSrc: "./provider-icons/ddownload.ico" }); + }); + it("removes duplicated access-mode wording from service labels", () => { expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)"); expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)");