feat(hosters): resolve 1Fichier and DDownload metadata

Resolve public filenames, exact sizes, availability, aliases, and offline states for 1Fichier and DDownload without replacing the existing link collector. Preserve resolved names when debrid responses return generic filenames, protect partial-download resume paths, validate redirect identity, and journal final metadata renames for crash-safe recovery.
This commit is contained in:
Sucukdeluxe
2026-08-23 19:37:33 +02:00
parent 09bbff1887
commit d3b0ca7b15
7 changed files with 1139 additions and 49 deletions
+235 -4
View File
@@ -26,7 +26,10 @@ const ALL_DEBRID_API_BASE_V41 = "https://api.alldebrid.com/v4.1";
const MEGA_DEBRID_API_BASE = "https://www.mega-debrid.eu/api.php";
const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
const ONEFICHIER_CHECK_URL = "https://1fichier.com/check_links.pl";
const ONEFICHIER_CHECK_BATCH_SIZE = 100;
const ONEFICHIER_CHECK_BATCH_DELAY_MS = 1000;
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.(?:com|net)|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
@@ -1689,6 +1692,18 @@ export function normalizeResolvedFilename(value: string): string {
return candidate;
}
function normalizePublicHosterFilename(value: string): string {
const candidate = decodeHtmlEntities(String(value || ""))
.replace(/<[^>]*>/g, " ")
.replace(/[\u0000-\u001f\u007f]/g, "")
.replace(/\s+/g, " ")
.replace(/^['"]+|['"]+$/g, "")
.trim();
const fileName = candidate.split(/[\\/]/).pop()?.trim() || "";
if (!fileName || fileName === "." || fileName === ".." || fileName.length > 260 || looksLikeOpaqueFilename(fileName)) return "";
return fileName;
}
export function filenameFromRapidgatorUrlPath(link: string): string {
try {
const parsed = new URL(link);
@@ -1978,6 +1993,102 @@ export async function checkRapidgatorOnline(
return null;
}
export interface OneFichierCheckResult {
online: boolean;
fileName: string;
fileSizeBytes: number | null;
accessRestricted: boolean;
}
export function isOneFichierLink(link: string): boolean {
return ONEFICHIER_URL_RE.test(String(link || "").trim());
}
function getOneFichierLinkId(link: string): string {
return String(link || "").trim().match(ONEFICHIER_URL_RE)?.[1]?.toLowerCase() || "";
}
function parseOneFichierCheckResponse(responseText: string, linksById: Map<string, string[]>): Map<string, OneFichierCheckResult> {
const results = new Map<string, OneFichierCheckResult>();
for (const rawLine of String(responseText || "").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line) continue;
const id = line.match(/\?([a-z0-9]{5,20})(?:;|$)/i)?.[1]?.toLowerCase() || "";
const requestedLinks = linksById.get(id);
if (!requestedLinks?.length) continue;
let result: OneFichierCheckResult | null = null;
if (/;;;(?:NOT FOUND|BAD LINK)\s*$/i.test(line)) {
result = { online: false, fileName: "", fileSizeBytes: null, accessRestricted: false };
} else if (/;;;PRIVATE\s*$/i.test(line)) {
result = { online: true, fileName: "", fileSizeBytes: null, accessRestricted: true };
} else {
const firstSeparator = line.indexOf(";");
const lastSeparator = line.lastIndexOf(";");
const fileName = decodeHtmlEntities(firstSeparator >= 0 && lastSeparator > firstSeparator ? line.slice(firstSeparator + 1, lastSeparator) : "").trim();
const fileSizeBytes = Number(lastSeparator >= 0 ? line.slice(lastSeparator + 1).trim() : NaN);
if (fileName && Number.isSafeInteger(fileSizeBytes) && fileSizeBytes >= 0) {
result = { online: true, fileName, fileSizeBytes, accessRestricted: false };
}
}
if (result) {
for (const link of requestedLinks) results.set(link, result);
}
}
return results;
}
export async function checkOneFichierLinks(links: string[], signal?: AbortSignal): Promise<Map<string, OneFichierCheckResult>> {
const supportedLinks = Array.from(new Set(links.map((link) => String(link || "").trim()).filter(isOneFichierLink)));
const results = new Map<string, OneFichierCheckResult>();
for (let offset = 0; offset < supportedLinks.length; offset += ONEFICHIER_CHECK_BATCH_SIZE) {
if (offset > 0) await sleepWithSignal(ONEFICHIER_CHECK_BATCH_DELAY_MS, signal);
const batch = supportedLinks.slice(offset, offset + ONEFICHIER_CHECK_BATCH_SIZE);
const linksById = new Map<string, string[]>();
const body = new URLSearchParams();
for (const link of batch) {
body.append("links[]", link);
const id = getOneFichierLinkId(link);
const existing = linksById.get(id) ?? [];
existing.push(link);
linksById.set(id, existing);
}
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
const response = await fetch(ONEFICHIER_CHECK_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
if (!response.ok) {
try { await response.body?.cancel(); } catch { }
if (response.status === 429) break;
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
break;
}
const parsed = parseOneFichierCheckResponse(await readResponseTextLimited(response, RAPIDGATOR_SCAN_MAX_BYTES, signal), linksById);
for (const [link, result] of parsed) results.set(link, result);
break;
} 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)) break;
await sleepWithSignal(retryDelay(attempt), signal);
}
}
}
return results;
}
function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] {
const linkParam = encodeURIComponent(link);
const safeToken = String(token || "").trim();
@@ -3672,9 +3783,130 @@ class OneFichierClient {
}
}
const DDOWNLOAD_URL_RE = /^https?:\/\/(?:www\.)?(?:ddownload\.com|ddl\.to)\/([a-z0-9]+)/i;
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());
}
function getDdownloadFileCode(link: string): string {
return String(link || "").trim().match(DDOWNLOAD_URL_RE)?.[1]?.toLowerCase() || "";
}
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 = normalizePublicHosterFilename(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 = normalizePublicHosterFilename(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 originalFileCode = getDdownloadFileCode(link);
const originalProtocol = new URL(link).protocol.toLowerCase();
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"
};
let requestUrl = link;
let redirectCount = 0;
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
const response = await fetch(requestUrl, { method: "GET", redirect: "manual", headers, signal: withTimeoutSignal(signal, API_TIMEOUT_MS) });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
try { await response.body?.cancel(); } catch { }
if (!location || redirectCount >= 3) return null;
let nextUrl = "";
try {
nextUrl = new URL(location, requestUrl).toString();
} catch {
return null;
}
if (!isDdownloadLink(nextUrl) || getDdownloadFileCode(nextUrl) !== originalFileCode) return null;
if (originalProtocol === "https:" && new URL(nextUrl).protocol.toLowerCase() !== "https:") return null;
requestUrl = nextUrl;
redirectCount += 1;
attempt -= 1;
continue;
}
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 (response.status === 429) return null;
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);
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 && DDOWNLOAD_FILE_NOT_FOUND_RE.test(html)) return { online: false, fileName: "", fileSizeBytes: null };
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;
@@ -3774,8 +4006,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",
+267 -2
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, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown } 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";
@@ -2004,11 +2004,14 @@ export class DownloadManager extends EventEmitter {
this.applyOnStartCleanupPolicy();
this.normalizeSessionStatuses();
this.restoreTargetPathReservations();
this.finalizeExistingResolvedMetadataTargets();
this.resolveExistingQueuedOpaqueFilenames();
this.revalidateCompletedItems();
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(() => {
if (this.rotationListenerActive === false) {
@@ -2023,6 +2026,8 @@ export class DownloadManager extends EventEmitter {
private rotationListenerActive = true;
private metadataChecksActive = true;
public getPackageLogPath(packageId: string): string | null {
const pkg = this.session.packages[packageId];
if (pkg) {
@@ -3233,6 +3238,8 @@ 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 };
}
@@ -3595,6 +3602,223 @@ 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" && item.status !== "completed") 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)}`);
}
if (!this.metadataChecksActive) return;
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;
}
if (!result.online) {
item.onlineStatus = "offline";
item.updatedAt = nowMs();
if (item.status === "queued" || item.status === "reconnect_wait") {
item.status = "failed";
item.fullStatus = "Offline";
item.lastError = "Datei nicht gefunden auf DDownload";
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 (result.fileName && unresolvedFileName) this.applyResolvedMetadataName(item, result.fileName);
if (result.fileSizeBytes !== null && result.fileSizeBytes > 0 && (!item.totalBytes || item.totalBytes <= 0)) item.totalBytes = result.fileSizeBytes;
item.onlineStatus = "online";
item.updatedAt = nowMs();
if (item.status === "completed") this.finalizeResolvedMetadataTargetPath(item);
}
private async checkOneFichierItems(itemIds: string[]): Promise<void> {
const itemIdsByUrl = new Map<string, string[]>();
for (const itemId of itemIds) {
const item = this.session.items[itemId];
if (!item || !isOneFichierLink(item.url)) continue;
if (item.status !== "queued" && item.status !== "reconnect_wait" && item.status !== "completed") continue;
item.onlineStatus = "checking";
const existing = itemIdsByUrl.get(item.url) ?? [];
existing.push(itemId);
itemIdsByUrl.set(item.url, existing);
}
if (itemIdsByUrl.size === 0) return;
this.emitState();
let results: Map<string, OneFichierCheckResult>;
try {
results = await checkOneFichierLinks(Array.from(itemIdsByUrl.keys()));
} catch (error) {
logger.warn(`1Fichier-Linkprüfung fehlgeschlagen: ${compactErrorText(error)}`);
results = new Map();
}
if (!this.metadataChecksActive) return;
for (const [url, ids] of itemIdsByUrl) {
const result = results.get(url) ?? null;
for (const itemId of ids) {
const item = this.session.items[itemId];
if (item) this.applyOneFichierCheckResult(item, result);
}
}
this.persistSoon();
this.emitState();
}
private applyOneFichierCheckResult(item: DownloadItem, result: OneFichierCheckResult | null): void {
if (!result) {
if (item.onlineStatus === "checking") item.onlineStatus = undefined;
return;
}
if (!result.online) {
item.onlineStatus = "offline";
item.updatedAt = nowMs();
if (item.status === "queued" || item.status === "reconnect_wait") {
item.status = "failed";
item.fullStatus = "Offline";
item.lastError = "Datei nicht gefunden auf 1Fichier";
if (this.runItemIds.has(item.id)) this.recordRunOutcome(item.id, "failed");
const pkg = this.session.packages[item.packageId];
if (pkg) this.refreshPackageStatus(pkg);
}
return;
}
if (result.fileName && looksLikeOpaqueFilename(item.fileName)) this.applyResolvedMetadataName(item, result.fileName);
if (result.fileSizeBytes !== null && result.fileSizeBytes > 0 && (!item.totalBytes || item.totalBytes <= 0)) item.totalBytes = result.fileSizeBytes;
item.onlineStatus = "online";
item.updatedAt = nowMs();
if (item.status === "completed") this.finalizeResolvedMetadataTargetPath(item);
}
private applyResolvedMetadataName(item: DownloadItem, resolvedFileName: string): void {
const normalized = sanitizeFilename(resolvedFileName);
if (!normalized || looksLikeOpaqueFilename(normalized)) return;
item.fileName = normalized;
const targetPath = String(item.targetPath || "").trim();
const hasExistingData = item.downloadedBytes > 0 || Boolean(targetPath && fs.existsSync(targetPath));
const active = this.activeTasks.has(item.id)
|| item.status === "validating"
|| item.status === "downloading"
|| item.status === "integrity_check";
if (!hasExistingData && !active) {
this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, normalized));
}
}
private finalizeResolvedMetadataTargetPath(item: DownloadItem): void {
const pkg = this.session.packages[item.packageId];
if (pkg && this.recoverPendingMetadataRename(item, pkg)) return;
const currentPath = String(item.targetPath || "").trim();
const fileName = sanitizeFilename(item.fileName || "");
if (!pkg || !currentPath || !fileName || looksLikeOpaqueFilename(fileName)) return;
const preferredPath = path.join(pkg.outputDir, fileName);
if (pathKey(currentPath) === pathKey(preferredPath)) return;
if (!fs.existsSync(currentPath)) return;
const nextPath = this.claimTargetPath(item.id, preferredPath);
item.metadataRenameTargetPath = nextPath;
try {
saveSession(this.storagePaths, this.session);
} catch (error) {
delete item.metadataRenameTargetPath;
this.releaseTargetPath(item.id);
item.targetPath = this.claimTargetPath(item.id, currentPath, true);
logger.warn(`Metadaten-Umbenennung nicht vorgemerkt ${currentPath}: ${compactErrorText(error)}`);
return;
}
try {
fs.renameSync(currentPath, nextPath);
} catch (error) {
this.releaseTargetPath(item.id);
item.targetPath = this.claimTargetPath(item.id, currentPath, true);
delete item.metadataRenameTargetPath;
try { saveSession(this.storagePaths, this.session); } catch { }
logger.warn(`Metadaten-Umbenennung fehlgeschlagen ${currentPath}: ${compactErrorText(error)}`);
return;
}
item.targetPath = nextPath;
delete item.metadataRenameTargetPath;
try {
saveSession(this.storagePaths, this.session);
} catch (error) {
item.metadataRenameTargetPath = nextPath;
logger.warn(`Metadaten-Umbenennung gespeichert, Session-Abschluss fehlgeschlagen ${nextPath}: ${compactErrorText(error)}`);
}
}
private recoverPendingMetadataRename(item: DownloadItem, pkg: PackageEntry): boolean {
const pendingPath = String(item.metadataRenameTargetPath || "").trim();
if (!pendingPath || !isPathInsideDir(pendingPath, pkg.outputDir)) return false;
const currentPath = String(item.targetPath || "").trim();
const pendingOwner = this.reservedTargetPaths.get(pathKey(pendingPath));
if (pendingOwner && pendingOwner !== item.id) return false;
try {
const currentExists = Boolean(currentPath && fs.existsSync(currentPath));
const pendingExists = fs.existsSync(pendingPath);
if (currentExists && pendingExists && pathKey(currentPath) !== pathKey(pendingPath)) {
delete item.metadataRenameTargetPath;
saveSession(this.storagePaths, this.session);
return false;
}
if (!pendingExists) {
if (!currentExists) return false;
this.releaseTargetPath(item.id);
const claimedPath = this.claimTargetPath(item.id, pendingPath);
if (pathKey(claimedPath) !== pathKey(pendingPath)) return false;
fs.renameSync(currentPath, pendingPath);
} else {
this.releaseTargetPath(item.id);
const claimedPath = this.claimTargetPath(item.id, pendingPath, true);
if (pathKey(claimedPath) !== pathKey(pendingPath)) return false;
}
item.targetPath = pendingPath;
delete item.metadataRenameTargetPath;
saveSession(this.storagePaths, this.session);
return true;
} catch (error) {
logger.warn(`Metadaten-Umbenennung konnte nicht rekonstruiert werden ${pendingPath}: ${compactErrorText(error)}`);
return false;
}
}
private finalizeExistingResolvedMetadataTargets(): void {
for (const item of Object.values(this.session.items)) {
if (item.status !== "completed") continue;
if (!isOneFichierLink(item.url) && !isDdownloadLink(item.url)) continue;
this.finalizeResolvedMetadataTargetPath(item);
}
}
private checkExistingRapidgatorLinks(): void {
const uncheckedIds: string[] = [];
for (const item of Object.values(this.session.items)) {
@@ -6110,6 +6334,7 @@ export class DownloadManager extends EventEmitter {
logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`);
this.updateStatisticsActivity(nowMs());
this.rotationListenerActive = false;
this.metadataChecksActive = false;
this.clearPersistTimer();
if (this.stateEmitTimer) {
clearTimeout(this.stateEmitTimer);
@@ -6123,6 +6348,10 @@ export class DownloadManager extends EventEmitter {
this.lastGlobalProgressAt = nowMs();
this.abortPostProcessing("shutdown");
for (const item of Object.values(this.session.items)) {
if (item.onlineStatus === "checking") item.onlineStatus = undefined;
}
let requeuedItems = 0;
for (const active of this.activeTasks.values()) {
const item = this.session.items[active.itemId];
@@ -8352,6 +8581,34 @@ 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" && item.status !== "completed") 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)) {
if (item.status !== "queued" && item.status !== "reconnect_wait" && item.status !== "completed") continue;
if (!isOneFichierLink(item.url) || item.onlineStatus === "offline") continue;
if (item.onlineStatus === "online" && !looksLikeOpaqueFilename(item.fileName) && item.totalBytes !== null && item.totalBytes > 0) continue;
uncheckedIds.push(item.id);
}
if (uncheckedIds.length > 0) {
void this.checkOneFichierItems(uncheckedIds).catch((err) => logger.warn(`checkOneFichierItems Fehler (startup): ${compactErrorText(err)}`));
}
}
private getProviderOrder(): DebridProvider[] {
if (this.settings.providerOrder && this.settings.providerOrder.length > 0) {
return [...this.settings.providerOrder];
@@ -9412,7 +9669,10 @@ export class DownloadManager extends EventEmitter {
item.providerAccountId = unrestricted.sourceAccountId;
item.providerAccountLabel = unrestricted.sourceAccountLabel;
item.retries += unrestricted.retriesUsed;
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
const unrestrictedFileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
if (!looksLikeOpaqueFilename(unrestrictedFileName) || looksLikeOpaqueFilename(item.fileName)) {
item.fileName = unrestrictedFileName;
}
let directHost = "";
try {
directHost = new URL(unrestricted.directUrl).host;
@@ -9588,8 +9848,13 @@ export class DownloadManager extends EventEmitter {
throw new Error(`aborted:${active.abortReason}`);
}
if (isOneFichierLink(item.url) || isDdownloadLink(item.url)) {
this.finalizeResolvedMetadataTargetPath(item);
}
const completedAt = nowMs();
item.status = "completed";
if (isOneFichierLink(item.url) || isDdownloadLink(item.url)) item.onlineStatus = "online";
item.fullStatus = this.settings.autoExtract
? "Entpacken - Ausstehend"
: `Fertig (${humanSize(item.downloadedBytes)})`;
+1
View File
@@ -56,6 +56,7 @@ export function formatHosterLabel(hoster: string): { compact: string; title: str
const normalized = hoster.trim().toLowerCase();
if (normalized === "rapidgator") return { compact: "RG", title: "RapidGator", iconSrc: hosterIconSources.rapidgator };
if (normalized === "ddownload") return { compact: "DD", title: "DDownload", iconSrc: hosterIconSources.ddownload };
if (normalized === "1fichier") return { compact: "1F", title: "1Fichier" };
return { compact: hoster, title: hoster };
}
+15 -1
View File
@@ -1,7 +1,21 @@
const DOMAIN_ALIASES: Readonly<Record<string, string>> = Object.freeze({
"rapidgator.net": "rapidgator",
"rapidgator.asia": "rapidgator",
"rg.to": "rapidgator"
"rg.to": "rapidgator",
"ddownload.com": "ddownload",
"ddl.to": "ddownload",
"1fichier.com": "1fichier",
"alterupload.com": "1fichier",
"cjoint.net": "1fichier",
"desfichiers.com": "1fichier",
"desfichiers.net": "1fichier",
"dfichiers.com": "1fichier",
"megadl.fr": "1fichier",
"mesfichiers.org": "1fichier",
"piecejointe.net": "1fichier",
"pjointe.com": "1fichier",
"tenvoi.com": "1fichier",
"dl4free.com": "1fichier"
});
export function normalizeHosterHostname(hostname: string): string {
+1
View File
@@ -438,6 +438,7 @@ export interface DownloadItem {
progressPercent: number;
fileName: string;
targetPath: string;
metadataRenameTargetPath?: string;
resumable: boolean;
attempts: number;
lastError: string;
+449
View File
@@ -0,0 +1,449 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it, vi } from "vitest";
import { defaultSettings } from "../src/main/constants";
import { DownloadManager } from "../src/main/download-manager";
import { createStoragePaths, emptySession } from "../src/main/storage";
import * as storageModule from "../src/main/storage";
const originalFetch = globalThis.fetch;
const tempDirs: string[] = [];
async function waitFor(predicate: () => boolean, timeoutMs = 15_000): Promise<void> {
const startedAt = Date.now();
while (!predicate()) {
if (Date.now() - startedAt > timeoutMs) throw new Error("waitFor timeout");
await new Promise((resolve) => setTimeout(resolve, 40));
}
}
afterEach(() => {
vi.restoreAllMocks();
globalThis.fetch = originalFetch;
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
});
describe("download hoster metadata", () => {
it("resolves 1Fichier and DDownload names, sizes and availability after import", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-metadata-"));
tempDirs.push(root);
const oneFichier = "https://1fichier.com/?abc12345";
const ddownload = "https://ddownload.com/ntwscdw62gyb";
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://1fichier.com/check_links.pl") {
return new Response(`${oneFichier};Show.S01E01.part01.rar;1073741824`, { status: 200 });
}
if (url === ddownload) {
return new Response('<h2 class="dk-dl-name">Show.S01E01.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(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
}, emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "metadata", links: [oneFichier, ddownload] }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.onlineStatus === "online"), 3_000);
const items = Object.values(manager.getSnapshot().session.items);
expect(items.map((item) => item.fileName)).toEqual(["Show.S01E01.part01.rar", "Show.S01E01.part02.rar"]);
expect(items.map((item) => item.totalBytes)).toEqual([1_073_741_824, 526_385_152]);
expect(items.map((item) => path.basename(item.targetPath))).toEqual(["Show.S01E01.part01.rar", "Show.S01E01.part02.rar"]);
});
it("keeps an existing partial file attached while applying a resolved 1Fichier name", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-partial-"));
tempDirs.push(root);
const link = "https://1fichier.com/?partial12";
let resolveMetadata: (response: Response) => void = () => undefined;
const metadata = new Promise<Response>((resolve) => {
resolveMetadata = resolve;
});
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://1fichier.com/check_links.pl") return metadata;
return new Response("not found", { status: 404 });
}) as typeof fetch;
const manager = new DownloadManager({
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
}, emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "partial", links: [link] }]);
const item = Object.values(manager.getSnapshot().session.items)[0];
const partialPath = item.targetPath;
fs.mkdirSync(path.dirname(partialPath), { recursive: true });
fs.writeFileSync(partialPath, Buffer.alloc(64 * 1024, 9));
item.downloadedBytes = 64 * 1024;
resolveMetadata(new Response(`${link};Resolved.Partial.rar;1048576`, { status: 200 }));
await waitFor(() => item.onlineStatus === "online", 3_000);
expect(item.fileName).toBe("Resolved.Partial.rar");
expect(item.targetPath).toBe(partialPath);
expect(fs.existsSync(partialPath)).toBe(true);
expect(fs.statSync(partialPath).size).toBe(64 * 1024);
});
it("applies delayed DDownload metadata after the download already started", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-race-"));
tempDirs.push(root);
const link = "https://ddownload.com/race1234567";
const expectedName = "Resolved.Race.part01.rar";
const binary = Buffer.alloc(192 * 1024, 17);
let releaseBody: () => void = () => undefined;
const bodyGate = new Promise<void>((resolve) => {
releaseBody = resolve;
});
const server = http.createServer(async (_request, response) => {
await bodyGate;
response.statusCode = 200;
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Length", String(binary.length));
response.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`;
let resolveMetadata: (response: Response) => void = () => undefined;
const metadata = new Promise<Response>((resolve) => {
resolveMetadata = resolve;
});
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 metadata;
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: "race", links: [link] }]);
await manager.start();
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.status === "downloading", 8_000);
resolveMetadata(new Response(`<h2 class="dk-dl-name">${expectedName}</h2><p class="dk-dl-size">192 KB</p>`, { status: 200, headers: { "Content-Type": "text/html" } }));
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.onlineStatus === "online", 3_000);
releaseBody();
await waitFor(() => !manager.getSnapshot().session.running, 15_000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item.status).toBe("completed");
expect(item.fileName).toBe(expectedName);
expect(path.basename(item.targetPath)).toBe(expectedName);
expect(fs.existsSync(item.targetPath)).toBe(true);
} finally {
releaseBody();
server.close();
await once(server, "close");
}
}, 25_000);
it("discards late metadata after shutdown preparation", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-shutdown-"));
tempDirs.push(root);
const link = "https://1fichier.com/?shutdown1";
let resolveMetadata: (response: Response) => void = () => undefined;
const metadata = new Promise<Response>((resolve) => {
resolveMetadata = resolve;
});
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://1fichier.com/check_links.pl") return metadata;
return new Response("not found", { status: 404 });
}) as typeof fetch;
const manager = new DownloadManager({
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
}, emptySession(), createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "shutdown", links: [link] }]);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item.onlineStatus).toBe("checking");
manager.prepareForShutdown();
resolveMetadata(new Response(`${link};Must.Not.Apply.rar;1048576`, { status: 200 }));
await new Promise((resolve) => setTimeout(resolve, 80));
expect(item.onlineStatus).toBeUndefined();
expect(item.fileName).toBe("download.bin");
expect(item.totalBytes).toBeNull();
});
it("marks a successfully completed download online after a late offline check", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-late-offline-"));
tempDirs.push(root);
const link = "https://ddownload.com/offline1234";
const binary = Buffer.alloc(192 * 1024, 19);
let releaseBody: () => void = () => undefined;
const bodyGate = new Promise<void>((resolve) => {
releaseBody = resolve;
});
const server = http.createServer(async (_request, response) => {
await bodyGate;
response.statusCode = 200;
response.setHeader("Content-Length", String(binary.length));
response.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`;
let resolveMetadata: (response: Response) => void = () => undefined;
const metadata = new Promise<Response>((resolve) => {
resolveMetadata = resolve;
});
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 metadata;
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: "late-offline", links: [link] }]);
await manager.start();
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.status === "downloading", 8_000);
resolveMetadata(new Response("<h1>File Not Found</h1>", { status: 200, headers: { "Content-Type": "text/html" } }));
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.onlineStatus === "offline", 3_000);
releaseBody();
await waitFor(() => !manager.getSnapshot().session.running, 15_000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item.status).toBe("completed");
expect(item.onlineStatus).toBe("online");
} finally {
releaseBody();
server.close();
await once(server, "close");
}
}, 25_000);
it("reconstructs a completed metadata rename after a crash window", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-crash-rename-"));
tempDirs.push(root);
const link = "https://ddownload.com/crash12345";
globalThis.fetch = (async () => new Response("<title>Just a moment...</title>", { status: 200, headers: { "Content-Type": "text/html" } })) as typeof fetch;
const settings = {
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
};
const session = emptySession();
const paths = createStoragePaths(path.join(root, "state"));
const manager = new DownloadManager(settings, session, paths);
manager.addPackages([{ name: "crash-rename", links: [link] }]);
const item = Object.values(session.items)[0];
const pkg = session.packages[item.packageId];
const stalePath = item.targetPath;
const expectedName = "Recovered.After.Crash.rar";
const recoveredPath = path.join(pkg.outputDir, expectedName);
fs.mkdirSync(pkg.outputDir, { recursive: true });
fs.writeFileSync(recoveredPath, Buffer.alloc(128 * 1024, 21));
item.status = "completed";
item.fileName = expectedName;
item.targetPath = stalePath;
item.downloadedBytes = 128 * 1024;
item.totalBytes = 128 * 1024;
item.progressPercent = 100;
item.onlineStatus = "online";
(item as typeof item & { metadataRenameTargetPath?: string }).metadataRenameTargetPath = recoveredPath;
new DownloadManager(settings, session, paths);
expect(item.targetPath).toBe(recoveredPath);
expect(fs.existsSync(item.targetPath)).toBe(true);
});
it("does not adopt an unrelated same-name file without a rename journal", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-foreign-collision-"));
tempDirs.push(root);
const link = "https://ddownload.com/foreign1234";
globalThis.fetch = (async () => new Response("<title>Just a moment...</title>", { status: 200, headers: { "Content-Type": "text/html" } })) as typeof fetch;
const settings = {
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
};
const session = emptySession();
const paths = createStoragePaths(path.join(root, "state"));
const manager = new DownloadManager(settings, session, paths);
manager.addPackages([{ name: "foreign-collision", links: [link] }]);
const item = Object.values(session.items)[0];
const pkg = session.packages[item.packageId];
const stalePath = item.targetPath;
const expectedName = "Existing.Foreign.File.rar";
const foreignPath = path.join(pkg.outputDir, expectedName);
fs.mkdirSync(pkg.outputDir, { recursive: true });
fs.writeFileSync(foreignPath, Buffer.alloc(128 * 1024, 33));
item.status = "completed";
item.fileName = expectedName;
item.targetPath = stalePath;
item.downloadedBytes = 128 * 1024;
item.totalBytes = 128 * 1024;
item.progressPercent = 100;
item.onlineStatus = "online";
new DownloadManager(settings, session, paths);
expect(item.targetPath).toBe(stalePath);
expect(fs.existsSync(foreignPath)).toBe(true);
});
it("keeps the renamed file attached when the post-rename session save fails", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-rename-save-"));
tempDirs.push(root);
const link = "https://1fichier.com/?savefail1";
globalThis.fetch = (async () => new Response("invalid", { status: 200 })) as typeof fetch;
const settings = {
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
};
const session = emptySession();
const manager = new DownloadManager(settings, session, createStoragePaths(path.join(root, "state")));
manager.addPackages([{ name: "rename-save", links: [link] }]);
const item = Object.values(session.items)[0];
const pkg = session.packages[item.packageId];
const currentPath = item.targetPath;
const expectedName = "Rename.Save.Failure.rar";
const expectedPath = path.join(pkg.outputDir, expectedName);
fs.mkdirSync(pkg.outputDir, { recursive: true });
fs.writeFileSync(currentPath, Buffer.alloc(64 * 1024, 41));
item.fileName = expectedName;
vi.spyOn(storageModule, "saveSession")
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => {
throw new Error("simulated save failure");
});
(manager as unknown as { finalizeResolvedMetadataTargetPath: (target: typeof item) => void }).finalizeResolvedMetadataTargetPath(item);
expect(item.targetPath).toBe(expectedPath);
expect(fs.existsSync(expectedPath)).toBe(true);
expect(fs.existsSync(currentPath)).toBe(false);
});
it("keeps a foreign journal target and renames the real source to a free collision path", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-journal-collision-"));
tempDirs.push(root);
const link = "https://ddownload.com/journal123";
globalThis.fetch = (async () => new Response("<title>Just a moment...</title>", { status: 200, headers: { "Content-Type": "text/html" } })) as typeof fetch;
const settings = {
...defaultSettings(),
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract")
};
const session = emptySession();
const paths = createStoragePaths(path.join(root, "state"));
const manager = new DownloadManager(settings, session, paths);
manager.addPackages([{ name: "journal-collision", links: [link] }]);
const item = Object.values(session.items)[0];
const pkg = session.packages[item.packageId];
const sourcePath = item.targetPath;
const expectedName = "Journal.Collision.rar";
const foreignPath = path.join(pkg.outputDir, expectedName);
fs.mkdirSync(pkg.outputDir, { recursive: true });
fs.writeFileSync(sourcePath, Buffer.alloc(64 * 1024, 51));
fs.writeFileSync(foreignPath, Buffer.alloc(64 * 1024, 52));
item.status = "completed";
item.fileName = expectedName;
item.downloadedBytes = 64 * 1024;
item.totalBytes = 64 * 1024;
item.progressPercent = 100;
item.onlineStatus = "online";
item.metadataRenameTargetPath = foreignPath;
new DownloadManager(settings, session, paths);
expect(item.targetPath).toBe(path.join(pkg.outputDir, "Journal.Collision (1).rar"));
expect(fs.readFileSync(foreignPath).equals(Buffer.alloc(64 * 1024, 52))).toBe(true);
expect(fs.readFileSync(item.targetPath).equals(Buffer.alloc(64 * 1024, 51))).toBe(true);
});
it.each([
{ hoster: "1Fichier", link: "https://1fichier.com/?keep12345" },
{ hoster: "DDownload", link: "https://ddownload.com/keep1234567" }
])("keeps resolved $hoster metadata when Real-Debrid returns download.bin", async ({ hoster, link }) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "mdd-hoster-preserve-"));
tempDirs.push(root);
const expectedName = `${hoster}.Series.S02E03.part01.rar`;
const binary = Buffer.alloc(192 * 1024, 31);
const server = http.createServer((_request, response) => {
response.statusCode = 200;
response.setHeader("Accept-Ranges", "bytes");
response.setHeader("Content-Length", String(binary.length));
response.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 === "https://1fichier.com/check_links.pl") return new Response(`${link};${expectedName};${binary.length}`, { status: 200 });
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: "preserve", links: [link] }]);
await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.fileName === expectedName, 3_000);
await manager.start();
await waitFor(() => !manager.getSnapshot().session.running, 15_000);
const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item.status).toBe("completed");
expect(item.fileName).toBe(expectedName);
expect(path.basename(item.targetPath)).toBe(expectedName);
} finally {
server.close();
await once(server, "close");
}
}, 20_000);
});
+129
View File
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
checkDdownloadOnline,
checkOneFichierLinks,
extractDdownloadFilenameFromHtml,
filenameFromDdownloadUrlPath,
isDdownloadLink,
isOneFichierLink,
parseDdownloadFileSize
} from "../src/main/debrid";
import { extractHosterFromUrl } from "../src/shared/hoster";
import { formatHosterLabel } from "../src/renderer/download-format";
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
describe("1Fichier public metadata", () => {
it("normalizes supported domains and resolves exact metadata in batches of 100", async () => {
vi.useFakeTimers();
const links = Array.from({ length: 101 }, (_unused, index) => `https://1fichier.com/?id${String(index).padStart(5, "0")}`);
const batchSizes: number[] = [];
globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body || ""));
const requested = body.getAll("links[]");
batchSizes.push(requested.length);
return new Response(requested.map((link, index) => `${link};Archive.${batchSizes.length}.${index}.7z;${1000 + index}`).join("\n"), { status: 200 });
}) as typeof fetch;
const pending = checkOneFichierLinks(links);
await vi.advanceTimersByTimeAsync(1000);
const results = await pending;
expect(batchSizes).toEqual([100, 1]);
expect(results.get(links[0])).toEqual({ online: true, fileName: "Archive.1.0.7z", fileSizeBytes: 1000, accessRestricted: false });
expect(results.get(links[100])).toEqual({ online: true, fileName: "Archive.2.0.7z", fileSizeBytes: 1000, accessRestricted: false });
expect(isOneFichierLink("https://desfichiers.net/?abc12345")).toBe(true);
expect(isOneFichierLink("https://piecejointe.net/?abc12345")).toBe(true);
expect(extractHosterFromUrl("https://dl4free.com/?abc12345")).toBe("1fichier");
expect(formatHosterLabel("1fichier")).toEqual({ compact: "1F", title: "1Fichier" });
});
it("distinguishes online, missing and private links without inventing metadata", async () => {
const online = "https://1fichier.com/?online123";
const missing = "https://1fichier.com/?gone12345";
const privateLink = "https://1fichier.com/?priv12345";
globalThis.fetch = vi.fn(async () => new Response([
`${online};Movie&amp;Bonus.mkv;734003200`,
`${missing};;;NOT FOUND`,
`${privateLink};;;PRIVATE`
].join("\n"), { status: 200 })) as typeof fetch;
const results = await checkOneFichierLinks([online, missing, privateLink]);
expect(results.get(online)).toEqual({ online: true, fileName: "Movie&Bonus.mkv", fileSizeBytes: 734003200, accessRestricted: false });
expect(results.get(missing)).toEqual({ online: false, fileName: "", fileSizeBytes: null, accessRestricted: false });
expect(results.get(privateLink)).toEqual({ online: true, fileName: "", fileSizeBytes: null, accessRestricted: true });
});
});
describe("DDownload public metadata", () => {
it("normalizes domains and parses public filename and 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>';
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(filenameFromDdownloadUrlPath("https://ddl.to/ntwscdw62gyb/Archive.part02.rar")).toBe("Archive.part02.rar");
expect(filenameFromDdownloadUrlPath("https://ddownload.com/ntwscdw62gyb")).toBe("");
expect(extractDdownloadFilenameFromHtml(html)).toBe("Show.S01E02.German.DL.part02.rar");
expect(parseDdownloadFileSize("502.00 MB")).toBe(526_385_152);
expect(extractHosterFromUrl("https://ddl.to/ntwscdw62gyb/Archive.part02.rar")).toBe("ddownload");
expect(formatHosterLabel("ddownload")).toEqual(expect.objectContaining({ compact: "DD", title: "DDownload" }));
expect(extractDdownloadFilenameFromHtml('<h2 class="dk-dl-name">Release.Notes.pdf</h2>')).toBe("Release.Notes.pdf");
});
it("returns online and offline metadata while keeping challenge pages unknown", async () => {
const responses = [
new Response('<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("<h1>File Not Found</h1>", { status: 200, headers: { "Content-Type": "text/html" } }),
new Response("<title>Just a moment...</title>", { 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/online1234")).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 });
await expect(checkDdownloadOnline("https://ddownload.com/unknown1234")).resolves.toBeNull();
});
it("does not follow redirects outside the supported DDownload domains", async () => {
globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.redirect === "follow") {
return new Response('<h2 class="dk-dl-name">Internal.Secret.txt</h2><p class="dk-dl-size">1 KB</p>', { status: 200, headers: { "Content-Type": "text/html" } });
}
return new Response(null, { status: 302, headers: { Location: "http://127.0.0.1/private" } });
}) as typeof fetch;
await expect(checkDdownloadOnline("https://ddownload.com/redirect123")).resolves.toBeNull();
});
it("rejects redirects that change the file code or downgrade HTTPS", async () => {
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url === "https://ddownload.com/original123") return new Response(null, { status: 302, headers: { Location: "https://ddl.to/different456/Other.rar" } });
if (url === "https://ddownload.com/secure123") return new Response(null, { status: 302, headers: { Location: "http://ddownload.com/secure123/File.rar" } });
return new Response('<h2 class="dk-dl-name">Wrong.Target.rar</h2><p class="dk-dl-size">1 MB</p>', { status: 200, headers: { "Content-Type": "text/html" } });
}) as typeof fetch;
await expect(checkDdownloadOnline("https://ddownload.com/original123")).resolves.toBeNull();
await expect(checkDdownloadOnline("https://ddownload.com/secure123")).resolves.toBeNull();
});
it("prefers concrete file markers over unrelated offline text in a valid page", async () => {
globalThis.fetch = vi.fn(async () => new Response([
'<h2 class="dk-dl-name">Still.Online.mkv</h2>',
'<p class="dk-dl-size">10 MB</p>',
'<script>const translatedFallback = "File Not Found";</script>'
].join(""), { status: 200, headers: { "Content-Type": "text/html" } })) as typeof fetch;
await expect(checkDdownloadOnline("https://ddownload.com/online5678")).resolves.toEqual({
online: true,
fileName: "Still.Online.mkv",
fileSizeBytes: 10_485_760
});
});
});