diff --git a/src/main/debrid.ts b/src/main/debrid.ts index ed6e452..e7ff58b 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -25,8 +25,11 @@ 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_API_BASE = "https://api.1fichier.com/v1"; +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"]); @@ -1675,7 +1678,7 @@ function looksLikeFileName(value: string): boolean { return /\.(?:part\d+\.rar|r\d{2}|rar|zip|7z|tar|gz|bz2|xz|iso|mkv|mp4|avi|mov|wmv|m4v|m2ts|ts|webm|mp3|flac|aac|srt|ass|sub)$/i.test(value); } -export function normalizeResolvedFilename(value: string): string { +export function normalizeResolvedFilename(value: string): string { const candidate = decodeHtmlEntities(String(value || "")) .replace(/<[^>]*>/g, " ") .replace(/\s+/g, " ") @@ -1686,8 +1689,20 @@ export function normalizeResolvedFilename(value: string): string { if (!candidate || candidate.length > 260 || !looksLikeFileName(candidate) || looksLikeOpaqueFilename(candidate)) { return ""; } - return candidate; -} + 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 { @@ -1901,7 +1916,7 @@ export function parseRapidgatorFileSize(value: string | null | undefined): numbe return Number.isSafeInteger(bytes) ? bytes : null; } -export async function checkRapidgatorOnline( +export async function checkRapidgatorOnline( link: string, signal?: AbortSignal ): Promise { @@ -1975,10 +1990,106 @@ export async function checkRapidgatorOnline( } } - return null; -} - -function buildBestDebridRequests(link: string, token: string): BestDebridRequest[] { + 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): Map { + const results = new Map(); + 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> { + const supportedLinks = Array.from(new Set(links.map((link) => String(link || "").trim()).filter(isOneFichierLink))); + const results = new Map(); + + 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(); + 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(); const useAuthHeader = Boolean(safeToken); @@ -3672,11 +3783,132 @@ 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()); +} + +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, + /]*class=["'][^"']*dk-dl-name[^"']*["'][^>]*>([^<]+)<\/h2>/i, + /class=["'][^"']*file-info-name[^"']*["'][^>]*>([^<]+) { + 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[^"']*["'][^>]*>([^<]+)]*>([^<]+) 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 = ""; @@ -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"[^>]*>([^<]+) logger.warn(`recoverRetryableItems Fehler (startup): ${compactErrorText(err)}`)); - this.recoverPostProcessingOnStartup(); - this.checkExistingRapidgatorLinks(); - void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${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) { return; @@ -2021,7 +2024,9 @@ export class DownloadManager extends EventEmitter { }); } - private rotationListenerActive = true; + private rotationListenerActive = true; + + private metadataChecksActive = true; public getPackageLogPath(packageId: string): string | null { const pkg = this.session.packages[packageId]; @@ -3231,9 +3236,11 @@ export class DownloadManager extends EventEmitter { if (unresolvedByLink.size > 0) { void this.resolveQueuedFilenames(unresolvedByLink).catch((err) => logger.warn(`resolveQueuedFilenames Fehler (addPackages): ${compactErrorText(err)}`)); } - if (newItemIds.length > 0) { - void this.checkRapidgatorLinks(newItemIds).catch((err) => logger.warn(`checkRapidgatorLinks Fehler: ${compactErrorText(err)}`)); - } + 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 }; } @@ -3558,7 +3565,7 @@ export class DownloadManager extends EventEmitter { } } - private applyRapidgatorCheckResult(item: DownloadItem, result: Awaited>): void { + private applyRapidgatorCheckResult(item: DownloadItem, result: Awaited>): void { if (!result) { if (item.onlineStatus === "checking") { item.onlineStatus = undefined; @@ -3592,10 +3599,227 @@ export class DownloadManager extends EventEmitter { } item.onlineStatus = "online"; item.updatedAt = nowMs(); - } - } - - private checkExistingRapidgatorLinks(): void { + } + } + + 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" && item.status !== "completed") 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)}`); + } + 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 { + const itemIdsByUrl = new Map(); + 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; + 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)) { if (item.status !== "queued") continue; @@ -6109,7 +6333,8 @@ export class DownloadManager extends EventEmitter { public prepareForShutdown(): void { logger.info(`Shutdown-Vorbereitung gestartet: active=${this.activeTasks.size}, running=${this.session.running}, paused=${this.session.paused}`); this.updateStatisticsActivity(nowMs()); - this.rotationListenerActive = false; + this.rotationListenerActive = false; + this.metadataChecksActive = false; this.clearPersistTimer(); if (this.stateEmitTimer) { clearTimeout(this.stateEmitTimer); @@ -6121,9 +6346,13 @@ export class DownloadManager extends EventEmitter { this.session.reconnectReason = ""; this.lastGlobalProgressBytes = this.session.totalDownloadedBytes; this.lastGlobalProgressAt = nowMs(); - this.abortPostProcessing("shutdown"); - - let requeuedItems = 0; + 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]; if (item && !isFinishedStatus(item.status)) { @@ -8351,6 +8580,34 @@ export class DownloadManager extends EventEmitter { throw new Error("Kein aktiver Download-Account verfügbar"); } } + + 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) { @@ -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; @@ -9584,13 +9844,18 @@ export class DownloadManager extends EventEmitter { done = true; } - if (active.abortController.signal.aborted) { - throw new Error(`aborted:${active.abortReason}`); - } - - const completedAt = nowMs(); - item.status = "completed"; - item.fullStatus = this.settings.autoExtract + if (active.abortController.signal.aborted) { + 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)})`; item.progressPercent = 100; diff --git a/src/renderer/download-format.ts b/src/renderer/download-format.ts index b49424c..250def9 100644 --- a/src/renderer/download-format.ts +++ b/src/renderer/download-format.ts @@ -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 }; } diff --git a/src/shared/hoster.ts b/src/shared/hoster.ts index 2694171..8da8c44 100644 --- a/src/shared/hoster.ts +++ b/src/shared/hoster.ts @@ -1,7 +1,21 @@ const DOMAIN_ALIASES: Readonly> = 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 { diff --git a/src/shared/types.ts b/src/shared/types.ts index 0073c74..2310ab2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -436,9 +436,10 @@ export interface DownloadItem { downloadedBytes: number; totalBytes: number | null; progressPercent: number; - fileName: string; - targetPath: string; - resumable: boolean; + fileName: string; + targetPath: string; + metadataRenameTargetPath?: string; + resumable: boolean; attempts: number; lastError: string; fullStatus: string; diff --git a/tests/download-hoster-metadata.test.ts b/tests/download-hoster-metadata.test.ts new file mode 100644 index 0000000..c0b01aa --- /dev/null +++ b/tests/download-hoster-metadata.test.ts @@ -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 { + 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 => { + 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('

Show.S01E01.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(), + 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((resolve) => { + resolveMetadata = resolve; + }); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + 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((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((resolve) => { + resolveMetadata = resolve; + }); + + 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 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(`

${expectedName}

192 KB

`, { 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((resolve) => { + resolveMetadata = resolve; + }); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + 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((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((resolve) => { + resolveMetadata = resolve; + }); + + 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 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("

File Not Found

", { 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("Just a moment...", { 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("Just a moment...", { 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("Just a moment...", { 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 => { + 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(`

${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: "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); +}); diff --git a/tests/hoster-metadata.test.ts b/tests/hoster-metadata.test.ts new file mode 100644 index 0000000..44cc7a4 --- /dev/null +++ b/tests/hoster-metadata.test.ts @@ -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&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 = '

Show.S01E02.German.DL.part02.rar

502.00 MB

'; + + 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('

Release.Notes.pdf

')).toBe("Release.Notes.pdf"); + }); + + it("returns online and offline metadata while keeping challenge pages unknown", 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" } }), + new Response("Just a moment...", { 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('

Internal.Secret.txt

1 KB

', { 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('

Wrong.Target.rar

1 MB

', { 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([ + '

Still.Online.mkv

', + '

10 MB

', + '' + ].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 + }); + }); +});