From 036cd3e06681d1e81bb8c8ea8158ed753e6d7db0 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Thu, 5 Mar 2026 02:05:16 +0100 Subject: [PATCH] Add DDownload provider, post-processing status labels, and update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DDownload (ddownload.com/ddl.to) as new hoster with web login - Post-processing labels: Entpacken/Renaming/Aufräumen/MKVs - Release notes shown in update confirmation dialog Co-Authored-By: Claude Opus 4.6 --- src/main/app-controller.ts | 5 +- src/main/constants.ts | 2 + src/main/debrid.ts | 196 ++++++++++++++++++++++++++++++++++- src/main/download-manager.ts | 14 +++ src/main/storage.ts | 13 ++- src/main/update.ts | 5 +- src/renderer/App.tsx | 20 +++- src/shared/types.ts | 6 +- 8 files changed, 248 insertions(+), 13 deletions(-) diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index b90634f..d3092e4 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -104,6 +104,7 @@ export class AppController { || (settings.megaLogin.trim() && settings.megaPassword.trim()) || settings.bestToken.trim() || settings.allDebridToken.trim() + || (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()) ); } @@ -284,7 +285,7 @@ export class AppController { public exportBackup(): string { const settings = { ...this.settings }; - const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaPassword", "bestToken", "allDebridToken"]; + const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaPassword", "bestToken", "allDebridToken", "ddownloadPassword"]; for (const key of SENSITIVE_KEYS) { const val = settings[key]; if (typeof val === "string" && val.length > 0) { @@ -306,7 +307,7 @@ export class AppController { return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" }; } const importedSettings = parsed.settings as AppSettings; - const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaPassword", "bestToken", "allDebridToken"]; + const SENSITIVE_KEYS: (keyof AppSettings)[] = ["token", "megaPassword", "bestToken", "allDebridToken", "ddownloadPassword"]; for (const key of SENSITIVE_KEYS) { const val = (importedSettings as Record)[key]; if (typeof val === "string" && val.startsWith("***")) { diff --git a/src/main/constants.ts b/src/main/constants.ts index 3d90e06..c401517 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -45,6 +45,8 @@ export function defaultSettings(): AppSettings { megaPassword: "", bestToken: "", allDebridToken: "", + ddownloadLogin: "", + ddownloadPassword: "", archivePasswordList: "", rememberToken: true, providerPrimary: "realdebrid", diff --git a/src/main/debrid.ts b/src/main/debrid.ts index 4141d94..c9dd0ee 100644 --- a/src/main/debrid.ts +++ b/src/main/debrid.ts @@ -15,7 +15,8 @@ const PROVIDER_LABELS: Record = { realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", - alldebrid: "AllDebrid" + alldebrid: "AllDebrid", + ddownload: "DDownload" }; interface ProviderUnrestrictedLink extends UnrestrictedLink { @@ -958,6 +959,193 @@ class AllDebridClient { } } +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 { + private login: string; + private password: string; + private cookies: string = ""; + + public constructor(login: string, password: string) { + this.login = login; + this.password = password; + } + + private async webLogin(signal?: AbortSignal): Promise { + // Step 1: GET login page to extract form token + const loginPageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/login.html`, { + headers: { "User-Agent": DDOWNLOAD_WEB_UA }, + redirect: "manual", + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + const loginPageHtml = await loginPageRes.text(); + const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/); + const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; "); + + // Step 2: POST login + const body = new URLSearchParams({ + op: "login", + token: tokenMatch?.[1] || "", + rand: "", + redirect: "", + login: this.login, + password: this.password + }); + const loginRes = await fetch(`${DDOWNLOAD_WEB_BASE}/`, { + method: "POST", + headers: { + "User-Agent": DDOWNLOAD_WEB_UA, + "Content-Type": "application/x-www-form-urlencoded", + ...(pageCookies ? { Cookie: pageCookies } : {}) + }, + body: body.toString(), + redirect: "manual", + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + // Drain body + try { await loginRes.text(); } catch { /* ignore */ } + + const setCookies = loginRes.headers.getSetCookie?.() || []; + const xfss = setCookies.find((c: string) => c.startsWith("xfss=")); + const loginCookie = setCookies.find((c: string) => c.startsWith("login=")); + if (!xfss) { + throw new Error("DDownload Login fehlgeschlagen (kein Session-Cookie)"); + } + this.cookies = [loginCookie, xfss].filter(Boolean).map((c: string) => c.split(";")[0]).join("; "); + } + + public async unrestrictLink(link: string, signal?: AbortSignal): Promise { + const match = link.match(DDOWNLOAD_URL_RE); + if (!match) { + throw new Error("Kein DDownload-Link"); + } + const fileCode = match[1]; + let lastError = ""; + + for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) { + try { + if (signal?.aborted) throw new Error("aborted:debrid"); + + // Login if no session yet + if (!this.cookies) { + await this.webLogin(signal); + } + + // Step 1: GET file page to extract form fields + const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, { + headers: { + "User-Agent": DDOWNLOAD_WEB_UA, + Cookie: this.cookies + }, + redirect: "manual", + signal: withTimeoutSignal(signal, API_TIMEOUT_MS) + }); + + // Premium with direct downloads enabled → redirect immediately + if (filePageRes.status >= 300 && filePageRes.status < 400) { + const directUrl = filePageRes.headers.get("location") || ""; + try { await filePageRes.text(); } catch { /* drain */ } + if (directUrl) { + return { + fileName: filenameFromUrl(directUrl) || filenameFromUrl(link), + directUrl, + fileSize: null, + retriesUsed: attempt - 1 + }; + } + } + + const html = await filePageRes.text(); + + // Check for file not found + if (/File Not Found|file was removed|file was banned/i.test(html)) { + throw new Error("DDownload: Datei nicht gefunden"); + } + + // Extract form fields + 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"[^>]*>([^<]+)= 300 && dlRes.status < 400) { + const directUrl = dlRes.headers.get("location") || ""; + try { await dlRes.text(); } catch { /* drain */ } + if (directUrl) { + return { + fileName: fileName || filenameFromUrl(directUrl), + directUrl, + fileSize: null, + retriesUsed: attempt - 1 + }; + } + } + + const dlHtml = await dlRes.text(); + // Try to find direct URL in response HTML + const directMatch = dlHtml.match(/https?:\/\/[a-z0-9]+\.(?:dstorage\.org|ddownload\.com|ddl\.to|ucdn\.to)[^\s"'<>]+/i); + if (directMatch) { + return { + fileName, + directUrl: directMatch[0], + fileSize: null, + retriesUsed: attempt - 1 + }; + } + + // Check for error messages + const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)= REQUEST_RETRIES || !isRetryableErrorText(lastError)) { + break; + } + await sleepWithSignal(retryDelay(attempt), signal); + } + } + + throw new Error(String(lastError || "DDownload Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, "")); + } +} + export class DebridService { private settings: AppSettings; @@ -1109,6 +1297,9 @@ export class DebridService { if (provider === "alldebrid") { return Boolean(settings.allDebridToken.trim()); } + if (provider === "ddownload") { + return Boolean(settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()); + } return Boolean(settings.bestToken.trim()); } @@ -1122,6 +1313,9 @@ export class DebridService { if (provider === "alldebrid") { return new AllDebridClient(settings.allDebridToken).unrestrictLink(link, signal); } + if (provider === "ddownload") { + return new DdownloadClient(settings.ddownloadLogin, settings.ddownloadPassword).unrestrictLink(link, signal); + } return new BestDebridClient(settings.bestToken).unrestrictLink(link, signal); } } diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index f2e5e2b..d1c56e8 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -6439,6 +6439,8 @@ export class DownloadManager extends EventEmitter { logger.info(`Hybrid-Extract Ende: pkg=${pkg.name}, extracted=${result.extracted}, failed=${result.failed}`); if (result.extracted > 0) { + pkg.postProcessLabel = "Renaming..."; + this.emitState(); await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg); } if (result.failed > 0) { @@ -6551,8 +6553,10 @@ export class DownloadManager extends EventEmitter { const allDone = success + failed + cancelled >= items.length; if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) { + pkg.postProcessLabel = "Entpacken..."; await this.runHybridExtraction(packageId, pkg, items, signal); if (signal?.aborted) { + pkg.postProcessLabel = undefined; pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "queued" : "paused"; pkg.updatedAt = nowMs(); return; @@ -6566,6 +6570,7 @@ export class DownloadManager extends EventEmitter { if (!this.session.packages[packageId]) { return; // Package was fully cleaned up } + pkg.postProcessLabel = undefined; pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; pkg.updatedAt = nowMs(); this.emitState(); @@ -6573,6 +6578,7 @@ export class DownloadManager extends EventEmitter { } if (!allDone) { + pkg.postProcessLabel = undefined; pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "downloading" : "queued"; logger.info(`Post-Processing verschoben: pkg=${pkg.name}, noch offene items`); return; @@ -6582,6 +6588,7 @@ export class DownloadManager extends EventEmitter { const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus)); if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { + pkg.postProcessLabel = "Entpacken..."; pkg.status = "extracting"; this.emitState(); const extractionStartMs = nowMs(); @@ -6732,6 +6739,8 @@ export class DownloadManager extends EventEmitter { // Auto-rename even when some archives failed — successfully extracted files still need renaming if (result.extracted > 0) { + pkg.postProcessLabel = "Renaming..."; + this.emitState(); await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg); } @@ -6835,6 +6844,8 @@ export class DownloadManager extends EventEmitter { } if (this.settings.autoExtract && alreadyMarkedExtracted && failed === 0 && success > 0 && this.settings.cleanupMode !== "none") { + pkg.postProcessLabel = "Aufräumen..."; + this.emitState(); const removedArchives = await this.cleanupRemainingArchiveArtifacts(pkg.outputDir); if (removedArchives > 0) { logger.info(`Hybrid-Post-Cleanup entfernte Archive: pkg=${pkg.name}, entfernt=${removedArchives}`); @@ -6842,6 +6853,8 @@ export class DownloadManager extends EventEmitter { } if (success > 0 && (pkg.status === "completed" || pkg.status === "failed")) { + pkg.postProcessLabel = "Verschiebe MKVs..."; + this.emitState(); await this.collectMkvFilesToLibrary(packageId, pkg); } if (this.runPackageIds.has(packageId)) { @@ -6851,6 +6864,7 @@ export class DownloadManager extends EventEmitter { this.runCompletedPackages.delete(packageId); } } + pkg.postProcessLabel = undefined; pkg.updatedAt = nowMs(); logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status}`); diff --git a/src/main/storage.ts b/src/main/storage.ts index 43e2ad1..9f7fbf5 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -5,8 +5,8 @@ import { AppSettings, BandwidthScheduleEntry, DebridProvider, DownloadItem, Down import { defaultSettings } from "./constants"; import { logger } from "./logger"; -const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]); -const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid"]); +const VALID_PRIMARY_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload"]); +const VALID_FALLBACK_PROVIDERS = new Set(["none", "realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload"]); const VALID_CLEANUP_MODES = new Set(["none", "trash", "delete"]); const VALID_CONFLICT_MODES = new Set(["overwrite", "skip", "rename", "ask"]); const VALID_FINISHED_POLICIES = new Set(["never", "immediate", "on_start", "package_done"]); @@ -17,7 +17,7 @@ const VALID_PACKAGE_PRIORITIES = new Set(["high", "normal", "low"]); const VALID_DOWNLOAD_STATUSES = new Set([ "queued", "validating", "downloading", "paused", "reconnect_wait", "extracting", "integrity_check", "completed", "failed", "cancelled" ]); -const VALID_ITEM_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid"]); +const VALID_ITEM_PROVIDERS = new Set(["realdebrid", "megadebrid", "bestdebrid", "alldebrid", "ddownload"]); const VALID_ONLINE_STATUSES = new Set(["online", "offline", "checking"]); function asText(value: unknown): string { @@ -111,6 +111,8 @@ export function normalizeSettings(settings: AppSettings): AppSettings { megaPassword: asText(settings.megaPassword), bestToken: asText(settings.bestToken), allDebridToken: asText(settings.allDebridToken), + ddownloadLogin: asText(settings.ddownloadLogin), + ddownloadPassword: asText(settings.ddownloadPassword), archivePasswordList: String(settings.archivePasswordList ?? "").replace(/\r\n/g, "\n"), rememberToken: Boolean(settings.rememberToken), providerPrimary: settings.providerPrimary, @@ -200,7 +202,9 @@ function sanitizeCredentialPersistence(settings: AppSettings): AppSettings { megaLogin: "", megaPassword: "", bestToken: "", - allDebridToken: "" + allDebridToken: "", + ddownloadLogin: "", + ddownloadPassword: "" }; } @@ -442,6 +446,7 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se if (ACTIVE_PKG_STATUSES.has(pkg.status)) { pkg.status = "queued"; } + pkg.postProcessLabel = undefined; } // Clear stale session-level running/paused flags diff --git a/src/main/update.ts b/src/main/update.ts index 84a9b09..60d55ee 100644 --- a/src/main/update.ts +++ b/src/main/update.ts @@ -336,6 +336,8 @@ function parseReleasePayload(payload: Record, fallback: UpdateC const releaseUrl = String(payload.html_url || fallback.releaseUrl); const setup = pickSetupAsset(readReleaseAssets(payload)); + const body = typeof payload.body === "string" ? payload.body.trim() : ""; + return { updateAvailable: isRemoteNewer(APP_VERSION, latestVersion), currentVersion: APP_VERSION, @@ -344,7 +346,8 @@ function parseReleasePayload(payload: Record, fallback: UpdateC releaseUrl, setupAssetUrl: setup?.browser_download_url || "", setupAssetName: setup?.name || "", - setupAssetDigest: setup?.digest || "" + setupAssetDigest: setup?.digest || "", + releaseNotes: body || undefined }; } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 6a3125c..4140878 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -93,7 +93,7 @@ const cleanupLabels: Record = { const AUTO_RENDER_PACKAGE_LIMIT = 260; const providerLabels: Record = { - realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid" + realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid", ddownload: "DDownload" }; function formatDateTime(ts: number): string { @@ -928,8 +928,11 @@ export function App(): ReactElement { if (settingsDraft.allDebridToken.trim()) { list.push("alldebrid"); } + if (settingsDraft.ddownloadLogin.trim() && settingsDraft.ddownloadPassword.trim()) { + list.push("ddownload"); + } return list; - }, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]); + }, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken, settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]); const primaryProviderValue: DebridProvider = useMemo(() => { if (configuredProviders.includes(settingsDraft.providerPrimary)) { @@ -990,9 +993,14 @@ export function App(): ReactElement { if (source === "manual") { showToast(`Kein Update verfügbar (v${result.currentVersion})`, 2000); } return; } + let changelogBlock = ""; + if (result.releaseNotes) { + const notes = result.releaseNotes.length > 500 ? `${result.releaseNotes.slice(0, 500)}…` : result.releaseNotes; + changelogBlock = `\n\n--- Changelog ---\n${notes}`; + } const approved = await askConfirmPrompt({ title: "Update verfügbar", - message: `${result.latestTag} (aktuell v${result.currentVersion})\n\nJetzt automatisch herunterladen und installieren?`, + message: `${result.latestTag} (aktuell v${result.currentVersion})${changelogBlock}\n\nJetzt automatisch herunterladen und installieren?`, confirmLabel: "Jetzt installieren" }); if (!mountedRef.current) { @@ -2711,6 +2719,10 @@ export function App(): ReactElement { setText("bestToken", e.target.value)} /> setText("allDebridToken", e.target.value)} /> + + setText("ddownloadLogin", e.target.value)} /> + + setText("ddownloadPassword", e.target.value)} /> {configuredProviders.length === 0 && (
Füge mindestens einen Account hinzu, dann erscheint die Hoster-Auswahl.
)} @@ -3344,7 +3356,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs {pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""} ); case "status": return ( - [{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}] + [{done}/{total}{done === total && total > 0 ? " - Done" : ""}{failed > 0 ? ` · ${failed} Fehler` : ""}{cancelled > 0 ? ` · ${cancelled} abgebr.` : ""}]{pkg.postProcessLabel ? ` ${pkg.postProcessLabel}` : ""} ); case "speed": return ( {packageSpeed > 0 ? formatSpeedMbps(packageSpeed) : ""} diff --git a/src/shared/types.ts b/src/shared/types.ts index c368547..407b083 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -14,7 +14,7 @@ export type CleanupMode = "none" | "trash" | "delete"; export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; export type SpeedMode = "global" | "per_download"; export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done"; -export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid"; +export type DebridProvider = "realdebrid" | "megadebrid" | "bestdebrid" | "alldebrid" | "ddownload"; export type DebridFallbackProvider = DebridProvider | "none"; export type AppTheme = "dark" | "light"; export type PackagePriority = "high" | "normal" | "low"; @@ -42,6 +42,8 @@ export interface AppSettings { megaPassword: string; bestToken: string; allDebridToken: string; + ddownloadLogin: string; + ddownloadPassword: string; archivePasswordList: string; rememberToken: boolean; providerPrimary: DebridProvider; @@ -119,6 +121,7 @@ export interface PackageEntry { cancelled: boolean; enabled: boolean; priority: PackagePriority; + postProcessLabel?: string; createdAt: number; updatedAt: number; } @@ -219,6 +222,7 @@ export interface UpdateCheckResult { setupAssetUrl?: string; setupAssetName?: string; setupAssetDigest?: string; + releaseNotes?: string; error?: string; }