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)");