From 5f391a35d6842ec2059c0d8e79612f837dc54d0f Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Sat, 22 Aug 2026 01:27:47 +0200 Subject: [PATCH] fix(onefichier): resolve metadata during import Batch 1Fichier link checks before downloads start, populate original filenames, exact sizes, and availability, preserve resolved names across generic debrid responses, and normalize all supported mirror domains under one hoster identity. Pace batches to the hoster's documented safe interval and cover offline, private, delayed, malformed, alias, and 100-link boundary cases. --- CHANGELOG.md | 6 + src/main/debrid.ts | 143 ++++++++++++++++++++++-- src/main/download-manager.ts | 147 ++++++++++++++++++++++--- src/renderer/download-format.ts | 1 + src/shared/hoster.ts | 14 ++- tests/debrid.test.ts | 106 +++++++++++++++++- tests/download-manager.test.ts | 187 +++++++++++++++++++++++++++++++- tests/downloads-view.test.tsx | 13 +++ 8 files changed, 586 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a33bab..6727055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Multi-Debrid Downloader are documented in this file. ## [Unreleased] +### 1Fichier imports + +- Resolve original 1Fichier filenames, exact sizes, and availability in batches before downloads start. +- Keep resolved filenames when a debrid provider returns only a generic `download.bin` name. +- Group supported 1Fichier mirror domains under one hoster identity. + ## [2.0.54] - 2026-08-21 ### Update validation diff --git a/src/main/debrid.ts b/src/main/debrid.ts index ed6e452..655cd29 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"]); @@ -1901,7 +1904,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 +1978,132 @@ 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 || requestedLinks.length === 0) { + 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 (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); @@ -3622,7 +3747,7 @@ class OneFichierClient { } public async unrestrictLink(link: string, signal?: AbortSignal): Promise { - if (!ONEFICHIER_URL_RE.test(link)) { + if (!isOneFichierLink(link)) { throw new Error("Kein 1Fichier-Link"); } @@ -4110,7 +4235,7 @@ export class DebridService { } } - if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) { + if (isOneFichierLink(link) && this.isProviderSelectableFor(settings, "onefichier")) { try { const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal); return { diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index dbc88be..2c87bcc 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -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, checkOneFichierLinks, checkRapidgatorOnline, fetchAllDebridHostInfo, getAvailableDebridLinkApiKeys, getAvailableMegaDebridAccounts, getAvailableRealDebridAccounts, getMegaDebridAccountCooldownState, getMegaDebridInFlightCountForMode, getRealDebridAccountAttemptTimeoutMs, isOneFichierLink, pruneExpiredDebridLinkRuntimeState, pruneExpiredMegaDebridRuntimeState, pruneExpiredRealDebridRuntimeState, releaseRealDebridAccountCooldown, 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"; @@ -2007,9 +2007,10 @@ export class DownloadManager extends EventEmitter { this.resolveExistingQueuedOpaqueFilenames(); this.revalidateCompletedItems(); void this.recoverRetryableItems("startup").catch((err) => 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.checkExistingOneFichierLinks(); + void this.cleanupExistingExtractedArchives().catch((err) => logger.warn(`cleanupExistingExtractedArchives Fehler (constructor): ${compactErrorText(err)}`)); setRotationEventListener(() => { if (this.rotationListenerActive === false) { return; @@ -3231,9 +3232,10 @@ 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.checkOneFichierItems(newItemIds).catch((err) => logger.warn(`checkOneFichierItems Fehler: ${compactErrorText(err)}`)); + } return { addedPackages, addedLinks }; } @@ -3558,7 +3560,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; @@ -3591,11 +3593,98 @@ export class DownloadManager extends EventEmitter { item.totalBytes = result.fileSizeBytes; } item.onlineStatus = "online"; - item.updatedAt = nowMs(); - } - } - - private checkExistingRapidgatorLinks(): void { + item.updatedAt = nowMs(); + } + } + + 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") { + 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(); + } + + 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; + } + const canUpdateMetadata = item.status === "queued" + || item.status === "reconnect_wait" + || (item.status === "validating" && item.downloadedBytes === 0 && (!item.targetPath || !fs.existsSync(item.targetPath))); + if (item.status !== "queued" && item.status !== "reconnect_wait" && item.status !== "validating") { + item.onlineStatus = result.online ? "online" : "offline"; + item.updatedAt = nowMs(); + return; + } + if (item.status === "validating" && !result.online) { + item.onlineStatus = "offline"; + item.updatedAt = nowMs(); + return; + } + if (!result.online) { + item.status = "failed"; + item.fullStatus = "Offline"; + item.lastError = "Datei nicht gefunden auf 1Fichier"; + item.onlineStatus = "offline"; + item.updatedAt = nowMs(); + if (this.runItemIds.has(item.id)) { + this.recordRunOutcome(item.id, "failed"); + } + const pkg = this.session.packages[item.packageId]; + if (pkg) { + this.refreshPackageStatus(pkg); + } + return; + } + if (canUpdateMetadata && result.fileName && looksLikeOpaqueFilename(item.fileName)) { + item.fileName = sanitizeFilename(result.fileName); + this.assignItemTargetPath(item, path.join(this.session.packages[item.packageId]?.outputDir || this.settings.outputDir, item.fileName)); + } + if (canUpdateMetadata && result.fileSizeBytes !== null && result.fileSizeBytes > 0) { + item.totalBytes = result.fileSizeBytes; + } + item.onlineStatus = "online"; + item.updatedAt = nowMs(); + } + + private checkExistingRapidgatorLinks(): void { const uncheckedIds: string[] = []; for (const item of Object.values(this.session.items)) { if (item.status !== "queued") continue; @@ -3607,10 +3696,29 @@ export class DownloadManager extends EventEmitter { } catch { continue; } uncheckedIds.push(item.id); } - if (uncheckedIds.length > 0) { - void this.checkRapidgatorLinks(uncheckedIds).catch((err) => logger.warn(`checkRapidgatorLinks Fehler (startup): ${compactErrorText(err)}`)); - } - } + if (uncheckedIds.length > 0) { + void this.checkRapidgatorLinks(uncheckedIds).catch((err) => logger.warn(`checkRapidgatorLinks 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") { + 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 async cleanupExistingExtractedArchives(): Promise { if (this.settings.cleanupMode === "none") { @@ -9412,7 +9520,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; 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..12f62e4 100644 --- a/src/shared/hoster.ts +++ b/src/shared/hoster.ts @@ -1,7 +1,19 @@ const DOMAIN_ALIASES: Readonly> = Object.freeze({ "rapidgator.net": "rapidgator", "rapidgator.asia": "rapidgator", - "rg.to": "rapidgator" + "rg.to": "rapidgator", + "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/tests/debrid.test.ts b/tests/debrid.test.ts index 63c24d5..ddd13ef 100644 --- a/tests/debrid.test.ts +++ b/tests/debrid.test.ts @@ -5,7 +5,7 @@ import { getMegaDebridAccountId } from "../src/shared/mega-debrid-accounts"; import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accounts"; import { getProviderUsageDayKey } from "../src/shared/provider-daily-limits"; import { isMegaDebridTransientResolveFailure } from "../src/shared/mega-debrid-errors"; -import { checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getAvailableRealDebridAccounts, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; +import { checkOneFichierLinks, checkRapidgatorOnline, classifyMegaDebridAccountFailureForTests, clearMegaDebridEmptyResponseStreak, DebridService, extractRapidgatorFilenameFromHtml, fetchAllDebridHostInfo, fetchDebridLinkHostLimits, filenameFromRapidgatorUrlPath, getAvailableRealDebridAccounts, getDebridLinkKeyCooldownStateForTests, getDebridLinkKeyRuntimeStateForTests, getMegaDebridAccountCooldownState, getProviderRuntimeSnapshot, isOneFichierLink, leadProviderChainWith, MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART, MEGA_DEBRID_STICKY_LINKS, normalizeResolvedFilename, parseRapidgatorFileSize, primeMegaDebridRuntimeCooldownForTests, primeMegaDebridUntilRestartForTests, recordMegaDebridEmptyResponseStreak, resetDebridLinkRuntimeStateForTests, resetMegaDebridRuntimeStateForTests, resetRealDebridRuntimeStateForTests } from "../src/main/debrid"; import { getAccountRuntimeSessionStats } from "../src/main/account-runtime"; const originalFetch = globalThis.fetch; @@ -2830,7 +2830,109 @@ describe("checkRapidgatorOnline", () => { }); }); }); - + +describe("checkOneFichierLinks", () => { + it("checks at most 100 links per request and maps exact metadata", async () => { + const links = Array.from({ length: 101 }, (_unused, index) => `https://1fichier.com/?id${String(index).padStart(5, "0")}`); + const batchSizes: number[] = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + 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("\r\n"), { + status: 200, + headers: { "Content-Type": "text/plain; charset=utf-8" } + }); + }) as typeof fetch; + + const results = await checkOneFichierLinks(links); + + expect(batchSizes).toEqual([100, 1]); + expect(results.size).toBe(101); + 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 + }); + }); + + it("paces consecutive 1Fichier batches to avoid hoster request blocks", async () => { + vi.useFakeTimers(); + try { + const links = Array.from({ length: 101 }, (_unused, index) => `https://1fichier.com/?pc${String(index).padStart(5, "0")}`); + let calls = 0; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise => { + calls += 1; + const body = init?.body instanceof URLSearchParams ? init.body : new URLSearchParams(String(init?.body || "")); + return new Response(body.getAll("links[]").map((link) => `${link};Paced.${calls}.rar;1024`).join("\n"), { status: 200 }); + }) as typeof fetch; + + const pending = checkOneFichierLinks(links); + await vi.advanceTimersByTimeAsync(0); + expect(calls).toBe(1); + + await vi.advanceTimersByTimeAsync(999); + expect(calls).toBe(1); + + await vi.advanceTimersByTimeAsync(1); + await pending; + expect(calls).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("maps offline, private and HTML-encoded file responses without inventing metadata", async () => { + const online = "https://1fichier.com/?online123"; + const offline = "https://1fichier.com/?gone12345"; + const privateLink = "https://1fichier.com/?priv12345"; + globalThis.fetch = (async (): Promise => new Response([ + `${online};Movie&Bonus.mkv;734003200`, + `${offline};;;NOT FOUND`, + `${privateLink};;;PRIVATE` + ].join("\n"), { status: 200 })) as typeof fetch; + + const results = await checkOneFichierLinks([online, offline, privateLink]); + + expect(results.get(online)).toEqual({ + online: true, + fileName: "Movie&Bonus.mkv", + fileSizeBytes: 734003200, + accessRestricted: false + }); + expect(results.get(offline)).toEqual({ + online: false, + fileName: "", + fileSizeBytes: null, + accessRestricted: false + }); + expect(results.get(privateLink)).toEqual({ + online: true, + fileName: "", + fileSizeBytes: null, + accessRestricted: true + }); + }); + + it("recognizes current aliases and ignores unrelated links without a request", async () => { + const fetchSpy = vi.fn(async (): Promise => new Response("", { status: 200 })); + globalThis.fetch = fetchSpy as typeof fetch; + + expect(isOneFichierLink("https://desfichiers.net/?abc12345")).toBe(true); + expect(isOneFichierLink("https://piecejointe.net/?abc12345")).toBe(true); + expect(isOneFichierLink("https://example.com/?abc12345")).toBe(false); + expect(await checkOneFichierLinks(["https://example.com/?abc12345"])).toEqual(new Map()); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + describe("Real-Debrid account rotation", () => { const accountSettings = (accounts: Array<{ id: string; token: string }>) => ({ ...defaultSettings(), diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index df60337..037e4f2 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -22,7 +22,7 @@ import { serializeRealDebridApiAccounts } from "../src/shared/real-debrid-accoun import { getRenameLogPath, initRenameLog, shutdownRenameLog } from "../src/main/rename-log"; import { UnrestrictedLink } from "../src/main/realdebrid"; import { resetVideoToolingCache } from "../src/main/video-processor"; -import type { AppSettings, HistoryEntry, PackageEntry } from "../src/shared/types"; +import type { AppSettings, DownloadItem, HistoryEntry, PackageEntry } from "../src/shared/types"; const tempDirs: string[] = []; const originalFetch = globalThis.fetch; @@ -959,6 +959,191 @@ describe("download manager", () => { expect(item.onlineStatus).toBe("online"); }); + it("resolves 1Fichier names, sizes and availability in one batch after import", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-metadata-")); + tempDirs.push(root); + const linkA = "https://1fichier.com/?abc12345"; + const linkB = "https://desfichiers.net/?def67890"; + let checkCalls = 0; + + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://1fichier.com/check_links.pl") { + checkCalls += 1; + return new Response([ + `${linkA};Show.S01E01.German.DL.part01.rar;1073741824`, + `${linkB};Show.S01E01.German.DL.part02.rar;2147483648` + ].join("\n"), { + status: 200, + headers: { "Content-Type": "text/plain; charset=utf-8" } + }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "onefichier", links: [linkA, linkB] }]); + + await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.onlineStatus === "online"), 2_000); + const items = Object.values(manager.getSnapshot().session.items); + + expect(checkCalls).toBe(1); + expect(items.map((item) => item.fileName)).toEqual([ + "Show.S01E01.German.DL.part01.rar", + "Show.S01E01.German.DL.part02.rar" + ]); + expect(items.map((item) => item.totalBytes)).toEqual([1_073_741_824, 2_147_483_648]); + expect(items.map((item) => path.basename(item.targetPath))).toEqual([ + "Show.S01E01.German.DL.part01.rar", + "Show.S01E01.German.DL.part02.rar" + ]); + }); + + it("keeps resolved 1Fichier metadata when a debrid response only returns download.bin", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-preserve-")); + tempDirs.push(root); + const link = "https://1fichier.com/?keep12345"; + const expectedName = "Series.S02E03.German.DL.part01.rar"; + const binary = Buffer.alloc(192 * 1024, 23); + const server = http.createServer((_req, res) => { + res.statusCode = 200; + res.setHeader("Accept-Ranges", "bytes"); + res.setHeader("Content-Length", String(binary.length)); + res.end(binary); + }); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("server address unavailable"); + } + const directUrl = `http://127.0.0.1:${address.port}/download`; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url === "https://1fichier.com/check_links.pl") { + return new Response(`${link};${expectedName};${binary.length}`, { status: 200 }); + } + 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: "onefichier-preserve", links: [link] }]); + await waitFor(() => Object.values(manager.getSnapshot().session.items)[0]?.fileName === expectedName, 2_000); + + await manager.start(); + await waitFor(() => !manager.getSnapshot().session.running, 15_000); + const 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); + + it("finishes a delayed 1Fichier availability check after the download has started", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-delayed-")); + tempDirs.push(root); + const link = "https://1fichier.com/?delay1234"; + let resolveCheck: (response: Response) => void = () => undefined; + const delayedCheck = new Promise((resolve) => { + resolveCheck = 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 delayedCheck; + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "onefichier-delayed", links: [link] }]); + const item = Object.values((manager as any).session.items)[0] as DownloadItem; + expect(item.onlineStatus).toBe("checking"); + item.status = "validating"; + item.fullStatus = "Link wird umgewandelt"; + + resolveCheck(new Response(`${link};Delayed.Episode.mkv;524288000`, { status: 200 })); + await waitFor(() => item.onlineStatus === "online", 2_000); + + expect(item.status).toBe("validating"); + expect(item.fullStatus).toBe("Link wird umgewandelt"); + expect(item.fileName).toBe("Delayed.Episode.mkv"); + expect(path.basename(item.targetPath)).toBe("Delayed.Episode.mkv"); + expect(item.totalBytes).toBe(524_288_000); + }); + + it("marks missing 1Fichier files offline while keeping private files queued", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-status-")); + tempDirs.push(root); + const missing = "https://1fichier.com/?gone12345"; + const privateLink = "https://1fichier.com/?priv12345"; + 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(`${missing};;;NOT FOUND\n${privateLink};;;PRIVATE`, { status: 200 }); + } + return new Response("not-found", { status: 404 }); + }) as typeof fetch; + + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "onefichier-status", links: [missing, privateLink] }]); + await waitFor(() => Object.values(manager.getSnapshot().session.items).every((item) => item.onlineStatus !== "checking"), 2_000); + const items = Object.values(manager.getSnapshot().session.items); + + expect(items[0]).toEqual(expect.objectContaining({ + status: "failed", + fullStatus: "Offline", + onlineStatus: "offline", + lastError: "Datei nicht gefunden auf 1Fichier" + })); + expect(items[1]).toEqual(expect.objectContaining({ + status: "queued", + onlineStatus: "online", + fileName: "download.bin", + totalBytes: null + })); + }); + + it("leaves 1Fichier availability unknown when the checker returns no usable result", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-onefichier-unknown-")); + tempDirs.push(root); + globalThis.fetch = (async (input: RequestInfo | URL): Promise => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + return new Response("invalid response", { status: url === "https://1fichier.com/check_links.pl" ? 200 : 404 }); + }) as typeof fetch; + + const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state"))); + manager.addPackages([{ name: "onefichier-unknown", links: ["https://1fichier.com/?unknown12"] }]); + const item = Object.values((manager as any).session.items)[0] as DownloadItem; + await waitFor(() => item.onlineStatus !== "checking", 2_000); + + expect(item.status).toBe("queued"); + expect(item.onlineStatus).toBeUndefined(); + expect(item.fileName).toBe("download.bin"); + }); + it("applies an imported settings snapshot without touching queued items or filesystem workflows", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-settings-import-")); tempDirs.push(root); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index e525581..f2fadc1 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -620,6 +620,19 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => { expect(formatHosterLabel(hosters[1])).toEqual(expect.objectContaining({ compact: "RG", title: "RapidGator", iconSrc: expect.any(String) })); }); + it("normalizes every supported 1Fichier domain to one hoster identity", () => { + const hosters = [ + extractHoster("https://1fichier.com/?abc12345"), + extractHoster("https://desfichiers.net/?def67890"), + extractHoster("https://piecejointe.net/?ghi12345"), + extractHoster("https://dl4free.com/?jkl67890") + ]; + + expect(hosters).toEqual(["1fichier", "1fichier", "1fichier", "1fichier"]); + expect(new Set(hosters).size).toBe(1); + expect(formatHosterLabel(hosters[1])).toEqual({ compact: "1F", title: "1Fichier" }); + }); + it("removes duplicated access-mode wording from service labels", () => { expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid (Web)"); expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid (API)");