feat(ddownload): resolve metadata before queueing

Inspect public ddownload.com and ddl.to links before download startup to resolve canonical filenames, exact sizes and availability without consuming a debrid account. Keep removed files offline, protected pages retryable, restored queues recheckable and previously resolved names safe from later download.bin responses. Reuse the metadata path in the link collector, queue import and direct DDownload login flow.
This commit is contained in:
Sucukdeluxe
2026-08-22 02:38:29 +02:00
parent 40428a69b4
commit b9a4f97681
9 changed files with 481 additions and 15 deletions
+7
View File
@@ -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.
+18 -4
View File
@@ -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<Map<string, OneFichierCheckResult>>;
checkRapidgator?: typeof checkRapidgatorOnline;
resolveFilenames?: (links: string[]) => Promise<Map<string, string>>;
@@ -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<string, string>());
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<string, string>())
: Promise.resolve(new Map<string, string>());
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;
+120 -7
View File
@@ -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,
/<h2[^>]*class=["'][^"']*dk-dl-name[^"']*["'][^>]*>([^<]+)<\/h2>/i,
/class=["'][^"']*file-info-name[^"']*["'][^>]*>([^<]+)</i
];
for (const pattern of patterns) {
const normalized = normalizeResolvedFilename(html.match(pattern)?.[1] || "");
if (normalized) return normalized;
}
return "";
}
export function parseDdownloadFileSize(value: string | null | undefined): number | null {
return parseRapidgatorFileSize(value);
}
export async function checkDdownloadOnline(
link: string,
signal?: AbortSignal
): Promise<DdownloadCheckResult | null> {
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[^"']*["'][^>]*>([^<]+)</i)
|| html.match(/(?:File\s*size|Dateigröße)\s*[:\-]?\s*<[^>]*>([^<]+)</i);
return {
online: true,
fileName: pageName || filenameFromDdownloadUrlPath(link),
fileSizeBytes: parseDdownloadFileSize(sizeMatch?.[1])
};
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
if (attempt > 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"[^>]*>([^<]+)</);
const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link);
const fileName = extractDdownloadFilenameFromHtml(html) || filenameFromDdownloadUrlPath(link) || filenameFromUrl(link);
const dlBody = new URLSearchParams({
op: "download2",
+90 -1
View File
@@ -58,7 +58,7 @@ function releaseTlsSkip(): void {
}
import { cleanupCancelledPackageArtifactsAsync, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
import { planDownloadCompletion, reconcileFinalizedSize, validateDownloadedFileCompletion } from "./download-completion";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type OneFichierCheckResult } from "./debrid";
import { AllDebridWebUnrestrictor, BestDebridWebUnrestrictor, DebridService, MegaWebUnrestrictor, RealDebridWebUnrestrictor, checkDdownloadOnline, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, filenameFromDdownloadUrlPath, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isDdownloadLink, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, type DdownloadCheckResult, type OneFichierCheckResult } from "./debrid";
import { cleanupArchives, clearExtractResumeState, collectArchiveCleanupTargets, detectArchiveSignature, extractPackageArchives, findArchiveCandidates, hasAnyFilesRecursive, removeEmptyDirectoryTree, resetExtractorCachesForPasswordChange, type ExtractArchiveFailureInfo } from "./extractor";
import { validateFileAgainstManifest } from "./integrity";
import { classifyDiskError } from "./fs-error";
@@ -2009,6 +2009,7 @@ export class DownloadManager extends EventEmitter {
void this.recoverRetryableItems("startup").catch((err) => 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<void> {
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<string, Promise<DdownloadCheckResult | null>>();
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<void> {
const itemIdsByUrl = new Map<string, string[]>();
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)) {
+2
View File
@@ -2,6 +2,8 @@ const DOMAIN_ALIASES: Readonly<Record<string, string>> = 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",
+30 -2
View File
@@ -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");
+54 -1
View File
@@ -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 = [
'<div class="dk-dl-icon" data-fn="Show.S01E02.German.DL.part02.rar">',
'<h2 class="dk-dl-name">Show.S01E02.German.DL.part02.rar</h2>',
'<p class="dk-dl-size">502.00 MB</p>'
].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('<div class="dk-dl-icon" data-fn="Show.S01E02.mkv"><h2 class="dk-dl-name">Show.S01E02.mkv</h2><p class="dk-dl-size">840.02 MB</p>', { status: 200, headers: { "Content-Type": "text/html" } }),
new Response('<main><h1>File Not Found</h1></main>', { 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("<html><title>Just a moment...</title></html>", {
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")}`);
+150
View File
@@ -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<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === link) {
checkCalls += 1;
return new Response('<div class="dk-dl-icon" data-fn="Show.S01E02.German.DL.part02.rar"><h2 class="dk-dl-name">Show.S01E02.German.DL.part02.rar</h2><p class="dk-dl-size">502.00 MB</p>', {
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 = "<html><title>Just a moment...</title></html>";
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
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 = '<h2 class="dk-dl-name">Restored.Show.S01E01.mkv</h2><p class="dk-dl-size">1.50 GB</p>';
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<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === missing) return new Response("<h1>File Not Found</h1>", { status: 200, headers: { "Content-Type": "text/html" } });
if (url === protectedLink) return new Response("<title>Just a moment...</title>", { 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<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === link) {
return new Response(`<h2 class="dk-dl-name">${expectedName}</h2><p class="dk-dl-size">192 KB</p>`, {
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);
+10
View File
@@ -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)");