Refactor: Extractor in 18 Sektionen reorganisiert

This commit is contained in:
Sucukdeluxe 2026-03-10 23:47:02 +01:00
parent b49de16534
commit d9170f4167
25 changed files with 114170 additions and 30695 deletions

View File

@ -0,0 +1,183 @@
# Intensive Analyse: Pausen zwischen Pack-Entpackungen (1015 Sekunden)
**Nur Analyse keine Code-Änderungen.**
---
## 1. Problem
Nach dem Entpacken eines Packs (z.B. 3 Parts einer Serie) passiert ca. 1015 Sekunden lang scheinbar nichts, bevor das nächste Pack mit dem Entpacken beginnt.
---
## 2. Steuerungslogik: Ein Slot für alle Packs
- **Nur ein Pack** darf gleichzeitig Post-Processing (inkl. Entpacken) machen.
- Steuerung: `acquirePostProcessSlot(packageId)` / `releasePostProcessSlot()` in `download-manager.ts`.
- Weitere Packs warten in `packagePostProcessWaiters` und kommen erst dran, wenn der aktive Task im **finally**-Block `releasePostProcessSlot()` aufruft.
```3761:3804:src/main/download-manager.ts
private async acquirePostProcessSlot(packageId: string): Promise<void> {
const maxConcurrent = 1;
// ...
}
private releasePostProcessSlot(): void {
// ...
}
```
- Der Slot wird **erst** freigegeben, wenn die gesamte `runPackagePostProcessing`-Task-Funktion durch ist genauer: wenn ihr **finally**-Block läuft (dort `releasePostProcessSlot()`). Alles, was vorher im gleichen Task **synchron** (await) läuft, blockiert den Slot und damit das nächste Pack.
---
## 3. Zwei relevante Code-Pfade
### 3.1 Pfad A: Hybrid-Extract (Pack noch nicht fertig)
- Bedingung: `!allDone && settings.hybridExtract && autoExtract && failed === 0 && success > 0`.
- Es werden nur die **bereits fertigen** Archive des Packs entpackt (`onlyArchives: readyArchives`), mit `skipPostCleanup: true` (kein Post-Cleanup im Extractor).
- Ablauf:
1. `handlePackagePostProcessing``runHybridExtraction`.
2. `await extractPackageArchives(..., onlyArchives, skipPostCleanup: true)`.
3. **Direkt danach (im gleichen Callstack, vor Rückkehr):**
`await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg)` (Zeile 6490).
4. Dann return aus `handlePackagePostProcessing`**finally**`releasePostProcessSlot()`.
**Folge:** Im Hybrid-Pfad blockiert **Auto-Rename** den Slot. Solange Rename läuft (rekursives Scannen + Umbenennen), kann das nächste Pack nicht starten. Das kann gut 1015 Sekunden ausmachen.
---
### 3.2 Pfad B: Finales Post-Processing (Pack komplett, alle Items fertig)
- Bedingung: `allDone` (alle Items completed/failed/cancelled).
- Es wird das **gesamte** Pack entpackt (`extractPackageArchives` ohne `onlyArchives`), **ohne** `skipPostCleanup`.
- Ablauf:
1. `await extractPackageArchives(...)` **inklusive allem, was der Extractor danach noch macht** (siehe Abschnitt 4).
2. Status-Updates, `recordPackageHistory(...)` (synchron, schnell).
3. `void this.runDeferredPostExtraction(...)` wird **nicht** awaitet; Rename, MKV-Sammlung, Cleanup laufen im Hintergrund.
4. `handlePackagePostProcessing` kehrt zurück → **finally**`releasePostProcessSlot()`.
**Folge:** Im Final-Pfad blockieren **nicht** mehr Rename/MKV/Cleanup im Download-Manager den Slot die sind in `runDeferredPostExtraction` ausgelagert. Was den Slot aber **noch** blockiert, ist alles, was **innerhalb** von `extractPackageArchives` **nach** dem eigentlichen Entpacken passiert (Post-Cleanup und ggf. Nested-Extraction im Extractor).
---
## 4. Was passiert INNERHALB von `extractPackageArchives` (Extractor) und blockiert
Nach dem Durchlauf über alle Kandidaten-Archive folgt im Extractor (`extractor.ts`) noch:
### 4.1 Nested-Extraction (Zeilen 22082284)
- Wenn `extracted > 0 && !skipPostCleanup && !onlyArchives`: Es werden Archive **im Zielordner** gesucht (`findArchiveCandidates(options.targetDir)`) und nacheinander entpackt.
- Pro nested-Archiv: Entpacken, ggf. `cleanupArchives([nestedArchive], ...)`.
- Kann bei vielen/vollen Archiven deutlich Zeit kosten und den Slot blockieren.
### 4.2 Post-Cleanup (Zeilen 22862328)
- Nur wenn `!options.skipPostCleanup`:
- **cleanupArchives(cleanupSources, cleanupMode):** Entfernen/Trash der entpackten Quell-Archive (readdir pro Verzeichnis, ggf. viele `rm`/rename).
- **removeDownloadLinkArtifacts(targetDir):** Link-Artefakte im Zielordner entfernen.
- **removeSampleArtifacts(targetDir):** Rekursives Durchlaufen des kompletten Extract-Ordners, Erkennung von Sample-Dateien/Ordnern, Löschen.
- **removeEmptyDirectoryTree(packageDir):** Rekursives Auflisten aller Unterordner, dann sortiert leere Ordner von tief nach flach löschen.
All das läuft **vor** dem Return von `extractPackageArchives`. Erst danach kommt im Download-Manager noch `recordPackageHistory` und `void runDeferredPostExtraction`. Der Slot wird also erst nach dem **gesamten** `extractPackageArchives`-Lauf (inkl. Nested + Post-Cleanup) freigegeben.
**Typische Zeitfresser (1015 s):**
- `cleanupArchives`: viele Dateien/Archive → viele I/O-Ops.
- `removeSampleArtifacts`: vollständiger rekursiver Scan des Extract-Ordners.
- `removeEmptyDirectoryTree`: rekursives readdir über die ganze Verzeichnisstruktur.
- Nested-Extraction: zusätzliches Entpacken und ggf. weiteres Cleanup.
---
## 5. Was im Download-Manager NACH dem Extractor noch passiert (Final-Pfad)
- **recordPackageHistory:** synchron, in-memory + Callback vernachlässigbar.
- **runDeferredPostExtraction:** wird mit `void` gestartet, blockiert den Slot **nicht**. Darin laufen (im Hintergrund):
- `autoRenameExtractedVideoFiles`
- `cleanupRemainingArchiveArtifacts` (bei Hybrid-Szenario/cleanupMode)
- `collectMkvFilesToLibrary`
- `applyPackageDoneCleanup`
Diese Schritte verursachen **keine** Pause mehr zwischen zwei Packs im Final-Pfad, weil der Slot schon vorher freigegeben wird.
---
## 6. Zusammenfassung: Wo entstehen die 1015 Sekunden Pause?
| Szenario | Was blockiert den Slot (Pause bis zum nächsten Pack)? |
|----------|--------------------------------------------------------|
| **Hybrid-Extract** (Pack hat noch offene Items) | `await autoRenameExtractedVideoFiles` **direkt nach** `extractPackageArchives` in `runHybridExtraction` (Zeile 6490). Rekursives Scannen + Umbenennen aller Video-Dateien. |
| **Finales Post-Processing** (Pack fertig) | Alles **innerhalb** von `extractPackageArchives`: Nested-Extraction (falls vorhanden) + Post-Cleanup (`cleanupArchives`, `removeDownloadLinkArtifacts`, `removeSampleArtifacts`, `removeEmptyDirectoryTree`). Rekursive Scans und viele I/O-Ops. |
In beiden Fällen ist die Pause also die Zeit **vor** `releasePostProcessSlot()` einmal durch Rename im Manager (Hybrid), einmal durch Post-Cleanup und Nested-Extraction im Extractor (Final).
---
## 7. Mögliche Verbesserungen (nur Konzept, keine Änderung)
- **Hybrid-Pfad:**
`autoRenameExtractedVideoFiles` nach dem Hybrid-Extract **nicht** mehr awaiten, sondern (analog zu `runDeferredPostExtraction`) im Hintergrund starten und sofort aus `runHybridExtraction` zurückkehren. Dann wird der Slot direkt nach `extractPackageArchives` freigegeben; Rename läuft parallel.
- **Final-Pfad / Extractor:**
Post-Cleanup (und ggf. Nested-Extraction) **nicht** mehr synchron am Ende von `extractPackageArchives` ausführen, sondern:
- Entweder: Extractor gibt nach dem letzten „eigentlichen“ Entpacken sofort zurück und eine andere Komponente (z.B. Download-Manager oder eine Queue) übernimmt Cleanup/Nested im Hintergrund; oder
- Extractor bekommt eine Option (z.B. `deferPostCleanup: true`), liefert die nötigen Daten (z.B. Liste der zu löschenden Archive) zurück, und der Aufrufer führt Cleanup/Nested asynchron aus.
- **Slot-Logik unverändert:**
Ein Slot bleibt sinnvoll, um I/O und CPU beim Entpacken zu bündeln. Durch die Entkopplung der „teuren“ Schritte (Rename, Cleanup, Nested) von der Slot-Holding-Zeit verkürzt sich die Pause zwischen zwei Packs ohne Parallel-Entpacken mehrerer Packs.
---
## 8. Relevante Stellen im Code (Orientierung)
- Slot: `acquirePostProcessSlot` / `releasePostProcessSlot` (download-manager.ts, ca. 37613804).
- Post-Processing-Task: `runPackagePostProcessing``handlePackagePostProcessing` (ca. 38063854, 65446916).
- Hybrid: `runHybridExtraction` (ca. 63746542), inkl. `await autoRenameExtractedVideoFiles` (6490).
- Final: `handlePackagePostProcessing` nach `extractPackageArchives` (66976916): `recordPackageHistory`, `void runDeferredPostExtraction`, dann return.
- Extractor: `extractPackageArchives` (extractor.ts, ca. 18802353), Nested 22082284, Post-Cleanup 22862328.
- Rename: `autoRenameExtractedVideoFiles` (download-manager.ts, 21732312), nutzt `collectVideoFiles` (rekursiv).
- MKV/Cleanup: `collectMkvFilesToLibrary` (2448), `cleanupRemainingArchiveArtifacts` (2353), `runDeferredPostExtraction` (69226965).
---
## 9. Vergleich: JDownloader (jdownloader-source)
Im JDownloader-Quellcode (z.B. `C:\Users\ploet\Desktop\jdownloader-source`) ist das Entpacken so aufgebaut, dass **pro Pack (3 Parts = 1 Folge) kaum schwere Arbeit nach dem eigentlichen Entpacken** im gleichen Queue-Job läuft deshalb wirkt es „ohne Pause“.
### Ablauf bei JDownloader
- **Ein Archiv = ein Pack** (z.B. 3 RAR-Parts = 1 Archive mit `archive.getArchiveFiles()`).
- **Eine Queue** (`ExtractionQueue`), ein Job pro Archiv (`ExtractionController` extends `QueueAction`).
- Pro Job passiert in `ExtractionController.run()`:
1. `extractor.extract(this)` reines Entpacken.
2. `extractor.close()`.
3. Je nach Exit-Code: `fireEvent(ExtractionEvent.Type.FINISHED)` (inkl. `FileCreationEvent(NEW_FILES, files)` die Dateiliste kommt vom Extractor, **kein** rekursives Scannen).
4. Im **finally**: `fireEvent(Type.CLEANUP)``archive.onCleanUp()`.
5. Listener bei `CLEANUP`: `controller.removeArchiveFiles()`.
### Was `removeArchiveFiles()` bei JDownloader macht
- Holt die **bereits bekannten** Archive-Dateien: `archive.getArchiveFiles()` (die 3 Parts sind dem Archiv von Anfang an zugeordnet).
- Löscht nur diese Dateien (z.B. `link.deleteFile(remove)` pro Part).
- **Kein** rekursives Durchsuchen von Ordnern, **kein** `findArchiveCandidates`, **kein** Scannen des Extract-Ordners.
- Aufwand: O(Anzahl Parts) Datei-Löschungen, typisch sehr schnell.
### Was JDownloader in diesem Pfad nicht macht
- **Kein** Auto-Rename der entpackten Dateien im Extraction-Queue-Job (LinknameCleaner wird an anderer Stelle für Pfadsegmente genutzt, nicht als Blockierung nach Extract).
- **Kein** „Collect MKV to Library“ (rekursives Scannen + Verschieben) im gleichen Job.
- **Kein** `removeSampleArtifacts` (rekursiver Scan des Extract-Ordners).
- **Kein** `removeEmptyDirectoryTree` (rekursives Auflisten aller Unterordner).
- Nested-Archive (Deep-Extraction) werden als **neue** Archive in die Queue gestellt (`addToQueue(..., newArchive, false)`), also **separate Jobs**, die nacheinander laufen der aktuelle Job ist sofort fertig.
### Warum es sich „flawless“ anfühlt
- Der kritische Pfad pro Pack ist: **Entpacken → Event FINISHED → Event CLEANUP → nur die bekannten Archive-Dateien löschen → `run()` endet**.
- Keine rechen- oder I/O-intensiven Schritte (keine rekursiven Scans, kein Rename, keine MKV-Sammlung) im gleichen Queue-Job.
- Das nächste Pack (nächster `ExtractionController` in der Queue) startet direkt nach `run()` return die spürbare Pause entfällt.
### Übertrag auf unser Projekt
- Um ein ähnlich „flüssiges“ Verhalten zu erreichen, sollten **alle** zeitaufwändigen Schritte (Rename, MKV-Sammlung, Sample-Cleanup, leere Ordner entfernen, ggf. Post-Cleanup im Extractor) **nicht** den Post-Process-Slot blockieren.
- Konkret: Sie entweder **nach** `releasePostProcessSlot()` im Hintergrund ausführen (wie beim Final-Pfad bereits für Rename/MKV/Cleanup im Manager) **oder** den Extractor so auslegen, dass er direkt nach dem letzten eigentlichen Entpacken zurückkehrt und Cleanup/Nested in einem separaten, asynchronen Schritt erledigt wird (siehe Abschnitt 7).

79822
rd_downloader.log.old Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,18 @@
const path = require("path"); const path = require("path");
const { rcedit } = require("rcedit"); const { rcedit } = require("rcedit");
module.exports = async function afterPack(context) { module.exports = async function afterPack(context) {
const productFilename = context.packager?.appInfo?.productFilename; const productFilename = context.packager?.appInfo?.productFilename;
if (!productFilename) { if (!productFilename) {
console.warn(" • rcedit: skipped — productFilename not available"); console.warn(" • rcedit: skipped — productFilename not available");
return; return;
} }
const exePath = path.join(context.appOutDir, `${productFilename}.exe`); const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico"); const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
console.log(` • rcedit: patching icon → ${exePath}`); console.log(` • rcedit: patching icon → ${exePath}`);
try { try {
await rcedit(exePath, { icon: iconPath }); await rcedit(exePath, { icon: iconPath });
} catch (error) { } catch (error) {
console.warn(` • rcedit: failed — ${String(error)}`); console.warn(` • rcedit: failed — ${String(error)}`);
} }
}; };

View File

@ -1,29 +1,29 @@
import path from "node:path"; import path from "node:path";
import { app } from "electron"; import { app } from "electron";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { import {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
ParsedPackageInput, ParsedPackageInput,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
UiSnapshot, UiSnapshot,
UpdateCheckResult, UpdateCheckResult,
UpdateInstallProgress, UpdateInstallProgress,
UpdateInstallResult UpdateInstallResult
} from "../shared/types"; } from "../shared/types";
import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits"; import { resetDebridLinkApiKeyDailyUsage, resetProviderDailyUsage } from "../shared/provider-daily-limits";
import { importDlcContainers } from "./container"; import { importDlcContainers } from "./container";
import { APP_VERSION } from "./constants"; import { APP_VERSION } from "./constants";
import { DownloadManager } from "./download-manager"; import { DownloadManager } from "./download-manager";
import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid"; import { fetchAllDebridHostInfo, fetchDebridLinkHostLimits } from "./debrid";
import { parseCollectorInput } from "./link-parser"; import { parseCollectorInput } from "./link-parser";
import { configureLogger, getLogFilePath, logger } from "./logger"; import { configureLogger, getLogFilePath, logger } from "./logger";
import { AllDebridWebFallback } from "./all-debrid-web"; import { AllDebridWebFallback } from "./all-debrid-web";
import { BestDebridWebFallback } from "./bestdebrid-web"; import { BestDebridWebFallback } from "./bestdebrid-web";
@ -31,7 +31,7 @@ import { RealDebridWebFallback } from "./realdebrid-web";
import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log"; import { getItemLogPath, initItemLogs, shutdownItemLogs } from "./item-log";
import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log"; import { getPackageLogPath, initPackageLogs, shutdownPackageLogs } from "./package-log";
import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log"; import { initSessionLog, getSessionLogPath, shutdownSessionLog } from "./session-log";
import { MegaWebFallback } from "./mega-web-fallback"; import { MegaWebFallback } from "./mega-web-fallback";
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage"; import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
@ -44,40 +44,40 @@ import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types"; import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> { function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
return Object.fromEntries(entries) as Partial<AppSettings>; return Object.fromEntries(entries) as Partial<AppSettings>;
} }
function settingsFingerprint(settings: AppSettings): string { function settingsFingerprint(settings: AppSettings): string {
return JSON.stringify(normalizeSettings(settings)); return JSON.stringify(normalizeSettings(settings));
} }
export class AppController { export class AppController {
private settings: AppSettings; private settings: AppSettings;
private manager: DownloadManager; private manager: DownloadManager;
private megaWebFallback: MegaWebFallback; private megaWebFallback: MegaWebFallback;
private realDebridWebFallback: RealDebridWebFallback; private realDebridWebFallback: RealDebridWebFallback;
private allDebridWebFallback: AllDebridWebFallback; private allDebridWebFallback: AllDebridWebFallback;
private bestDebridWebFallback: BestDebridWebFallback; private bestDebridWebFallback: BestDebridWebFallback;
private lastUpdateCheck: UpdateCheckResult | null = null; private lastUpdateCheck: UpdateCheckResult | null = null;
private lastUpdateCheckAt = 0; private lastUpdateCheckAt = 0;
private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime")); private storagePaths = createStoragePaths(path.join(app.getPath("userData"), "runtime"));
private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null; private onStateHandler: ((snapshot: UiSnapshot) => void) | null = null;
private autoResumePending = false; private autoResumePending = false;
private runtimeStatsTimer: NodeJS.Timeout | null = null; private runtimeStatsTimer: NodeJS.Timeout | null = null;
public constructor() { public constructor() {
configureLogger(this.storagePaths.baseDir); configureLogger(this.storagePaths.baseDir);
initSessionLog(this.storagePaths.baseDir); initSessionLog(this.storagePaths.baseDir);
@ -89,15 +89,15 @@ export class AppController {
this.settings = loadSettings(this.storagePaths); this.settings = loadSettings(this.storagePaths);
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const session = loadSession(this.storagePaths); const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({ this.megaWebFallback = new MegaWebFallback(() => ({
login: this.settings.megaLogin, login: this.settings.megaLogin,
password: this.settings.megaPassword password: this.settings.megaPassword
})); }));
this.realDebridWebFallback = new RealDebridWebFallback(() => this.settings.rememberToken); this.realDebridWebFallback = new RealDebridWebFallback(() => this.settings.rememberToken);
this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken); this.allDebridWebFallback = new AllDebridWebFallback(() => this.settings.rememberToken);
this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken); this.bestDebridWebFallback = new BestDebridWebFallback(() => this.settings.rememberToken);
this.manager = new DownloadManager(this.settings, session, this.storagePaths, { this.manager = new DownloadManager(this.settings, session, this.storagePaths, {
megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal), megaWebUnrestrict: (link: string, signal?: AbortSignal) => this.megaWebFallback.unrestrict(link, signal),
allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal), allDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.allDebridWebFallback.unrestrict(link, signal),
realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal), realDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.realDebridWebFallback.unrestrict(link, signal),
bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal), bestDebridWebUnrestrict: (link: string, signal?: AbortSignal) => this.bestDebridWebFallback.unrestrict(link, signal),
@ -106,8 +106,8 @@ export class AppController {
addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry); addHistoryEntryForRetention(this.storagePaths, this.settings.historyRetentionMode, entry);
} }
}); });
this.manager.on("state", (snapshot: UiSnapshot) => { this.manager.on("state", (snapshot: UiSnapshot) => {
this.onStateHandler?.(snapshot); this.onStateHandler?.(snapshot);
}); });
logger.info(`App gestartet v${APP_VERSION}`); logger.info(`App gestartet v${APP_VERSION}`);
logger.info(`Log-Datei: ${getLogFilePath()}`); logger.info(`Log-Datei: ${getLogFilePath()}`);
@ -123,70 +123,70 @@ export class AppController {
this.runtimeStatsTimer.unref?.(); this.runtimeStatsTimer.unref?.();
if (this.settings.autoResumeOnStart) { if (this.settings.autoResumeOnStart) {
const snapshot = this.manager.getSnapshot(); const snapshot = this.manager.getSnapshot();
const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait"); const hasPending = Object.values(snapshot.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
if (hasPending) { if (hasPending) {
void this.manager.getStartConflicts().then((conflicts) => { void this.manager.getStartConflicts().then((conflicts) => {
const hasConflicts = conflicts.length > 0; const hasConflicts = conflicts.length > 0;
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) { if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
// If the onState handler is already set (renderer connected), start immediately. // If the onState handler is already set (renderer connected), start immediately.
// Otherwise mark as pending so the onState setter triggers the start. // Otherwise mark as pending so the onState setter triggers the start.
if (this.onStateHandler) { if (this.onStateHandler) {
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)"); logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`)); void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
} else { } else {
this.autoResumePending = true; this.autoResumePending = true;
logger.info("Auto-Resume beim Start vorgemerkt"); logger.info("Auto-Resume beim Start vorgemerkt");
} }
} else if (hasConflicts) { } else if (hasConflicts) {
logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt"); logger.info("Auto-Resume übersprungen: Start-Konflikte erkannt");
} }
}).catch((err) => logger.warn(`getStartConflicts Fehler (constructor): ${String(err)}`)); }).catch((err) => logger.warn(`getStartConflicts Fehler (constructor): ${String(err)}`));
} }
} }
} }
private hasAnyProviderToken(settings: AppSettings): boolean { private hasAnyProviderToken(settings: AppSettings): boolean {
return Boolean( return Boolean(
settings.token.trim() settings.token.trim()
|| settings.realDebridUseWebLogin || settings.realDebridUseWebLogin
|| (settings.megaLogin.trim() && settings.megaPassword.trim()) || (settings.megaLogin.trim() && settings.megaPassword.trim())
|| settings.bestToken.trim() || settings.bestToken.trim()
|| settings.bestDebridUseWebLogin || settings.bestDebridUseWebLogin
|| settings.allDebridUseWebLogin || settings.allDebridUseWebLogin
|| settings.allDebridToken.trim() || settings.allDebridToken.trim()
|| (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim()) || (settings.ddownloadLogin.trim() && settings.ddownloadPassword.trim())
|| settings.oneFichierApiKey.trim() || settings.oneFichierApiKey.trim()
); );
} }
public get onState(): ((snapshot: UiSnapshot) => void) | null { public get onState(): ((snapshot: UiSnapshot) => void) | null {
return this.onStateHandler; return this.onStateHandler;
} }
public set onState(handler: ((snapshot: UiSnapshot) => void) | null) { public set onState(handler: ((snapshot: UiSnapshot) => void) | null) {
this.onStateHandler = handler; this.onStateHandler = handler;
if (handler) { if (handler) {
handler(this.manager.getSnapshot()); handler(this.manager.getSnapshot());
if (this.autoResumePending) { if (this.autoResumePending) {
this.autoResumePending = false; this.autoResumePending = false;
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`)); void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
logger.info("Auto-Resume beim Start aktiviert"); logger.info("Auto-Resume beim Start aktiviert");
} else { } else {
// Trigger pending extractions without starting the session // Trigger pending extractions without starting the session
this.manager.triggerIdleExtractions(); this.manager.triggerIdleExtractions();
} }
} }
} }
public getSnapshot(): UiSnapshot { public getSnapshot(): UiSnapshot {
return this.manager.getSnapshot(); return this.manager.getSnapshot();
} }
public getVersion(): string { public getVersion(): string {
return APP_VERSION; return APP_VERSION;
} }
public getSettings(): AppSettings { public getSettings(): AppSettings {
return this.settings; return this.settings;
} }
@ -227,19 +227,19 @@ export class AppController {
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note }); this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
return next; return next;
} }
public updateSettings(partial: Partial<AppSettings>): AppSettings { public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial); const sanitizedPatch = sanitizeSettingsPatch(partial);
const previousSettings = this.settings; const previousSettings = this.settings;
const nextSettings = normalizeSettings({ const nextSettings = normalizeSettings({
...previousSettings, ...previousSettings,
...sanitizedPatch ...sanitizedPatch
}); });
if (settingsFingerprint(nextSettings) === settingsFingerprint(previousSettings)) { if (settingsFingerprint(nextSettings) === settingsFingerprint(previousSettings)) {
return previousSettings; return previousSettings;
} }
// Preserve the live all-time counters from the download manager // Preserve the live all-time counters from the download manager
const liveSettings = this.manager.getSettings(); const liveSettings = this.manager.getSettings();
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
@ -266,38 +266,38 @@ export class AppController {
accountChanges: diffAccountSummary(previousSettings, this.settings) accountChanges: diffAccountSummary(previousSettings, this.settings)
}); });
if (previousSettings.rememberToken && !this.settings.rememberToken) { if (previousSettings.rememberToken && !this.settings.rememberToken) {
void this.realDebridWebFallback.clearSessions().catch((error) => { void this.realDebridWebFallback.clearSessions().catch((error) => {
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
}); });
void this.allDebridWebFallback.clearSessions().catch((error) => { void this.allDebridWebFallback.clearSessions().catch((error) => {
logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); logger.warn(`AllDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
}); });
void this.bestDebridWebFallback.clearSessions().catch((error) => { void this.bestDebridWebFallback.clearSessions().catch((error) => {
logger.warn(`BestDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`); logger.warn(`BestDebrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
}); });
} }
return this.settings; return this.settings;
} }
public resetProviderDailyUsage(provider: DebridProvider): AppSettings { public resetProviderDailyUsage(provider: DebridProvider): AppSettings {
const liveSettings = this.manager.getSettings(); const liveSettings = this.manager.getSettings();
const nextSettings = normalizeSettings({ const nextSettings = normalizeSettings({
...liveSettings, ...liveSettings,
...resetProviderDailyUsage(liveSettings, provider) ...resetProviderDailyUsage(liveSettings, provider)
}); });
this.settings = nextSettings; this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider }); this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider });
return this.settings; return this.settings;
} }
public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings { public resetDebridLinkApiKeyDailyUsage(keyId: string): AppSettings {
const liveSettings = this.manager.getSettings(); const liveSettings = this.manager.getSettings();
const nextSettings = normalizeSettings({ const nextSettings = normalizeSettings({
...liveSettings, ...liveSettings,
...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId) ...resetDebridLinkApiKeyDailyUsage(liveSettings, keyId)
}); });
this.settings = nextSettings; this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
@ -323,50 +323,50 @@ export class AppController {
}); });
return imported; return imported;
} }
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> { public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
if (this.settings.allDebridUseWebLogin) { if (this.settings.allDebridUseWebLogin) {
return this.allDebridWebFallback.getHostInfo(host); return this.allDebridWebFallback.getHostInfo(host);
} }
const token = this.settings.allDebridToken.trim(); const token = this.settings.allDebridToken.trim();
if (!token) { if (!token) {
throw new Error("AllDebrid ist nicht konfiguriert"); throw new Error("AllDebrid ist nicht konfiguriert");
} }
return fetchAllDebridHostInfo(token, host); return fetchAllDebridHostInfo(token, host);
} }
public async getDebridLinkHostLimits(host = "rapidgator") { public async getDebridLinkHostLimits(host = "rapidgator") {
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host); return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
} }
public async checkUpdates(): Promise<UpdateCheckResult> { public async checkUpdates(): Promise<UpdateCheckResult> {
const result = await checkGitHubUpdate(this.settings.updateRepo); const result = await checkGitHubUpdate(this.settings.updateRepo);
if (!result.error) { if (!result.error) {
this.lastUpdateCheck = result; this.lastUpdateCheck = result;
this.lastUpdateCheckAt = Date.now(); this.lastUpdateCheckAt = Date.now();
} }
return result; return result;
} }
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> { public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
// Stop active downloads before installing. Extractions may continue briefly // Stop active downloads before installing. Extractions may continue briefly
// until prepareForShutdown() is called during app quit. // until prepareForShutdown() is called during app quit.
if (this.manager.isSessionRunning()) { if (this.manager.isSessionRunning()) {
this.manager.stop(); this.manager.stop();
} }
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt; const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000 const cached = this.lastUpdateCheck && !this.lastUpdateCheck.error && cacheAgeMs <= 10 * 60 * 1000
? this.lastUpdateCheck ? this.lastUpdateCheck
: undefined; : undefined;
const result = await installLatestUpdate(this.settings.updateRepo, cached, onProgress); const result = await installLatestUpdate(this.settings.updateRepo, cached, onProgress);
if (result.started) { if (result.started) {
this.lastUpdateCheck = null; this.lastUpdateCheck = null;
this.lastUpdateCheckAt = 0; this.lastUpdateCheckAt = 0;
} }
return result; return result;
} }
public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } { public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {
const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName); const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName);
if (parsed.length === 0) { if (parsed.length === 0) {
@ -383,9 +383,9 @@ export class AppController {
}); });
return { ...result, invalidCount: 0 }; return { ...result, invalidCount: 0 };
} }
public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> { public async addContainers(filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> {
const packages = await importDlcContainers(filePaths); const packages = await importDlcContainers(filePaths);
const merged: ParsedPackageInput[] = packages.map((pkg) => ({ const merged: ParsedPackageInput[] = packages.map((pkg) => ({
name: pkg.name, name: pkg.name,
links: pkg.links, links: pkg.links,
@ -399,15 +399,15 @@ export class AppController {
}); });
return result; return result;
} }
public async getStartConflicts(): Promise<StartConflictEntry[]> { public async getStartConflicts(): Promise<StartConflictEntry[]> {
return this.manager.getStartConflicts(); return this.manager.getStartConflicts();
} }
public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> { public async resolveStartConflict(packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> {
return this.manager.resolveStartConflict(packageId, policy); return this.manager.resolveStartConflict(packageId, policy);
} }
public clearAll(): void { public clearAll(): void {
this.audit("WARN", "Queue komplett geleert"); this.audit("WARN", "Queue komplett geleert");
this.manager.clearAll(); this.manager.clearAll();
@ -473,7 +473,7 @@ export class AppController {
this.audit("WARN", "Item entfernt", { itemId }); this.audit("WARN", "Item entfernt", { itemId });
this.manager.removeItem(itemId); this.manager.removeItem(itemId);
} }
public togglePackage(packageId: string): void { public togglePackage(packageId: string): void {
this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId }); this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId });
this.manager.togglePackage(packageId); this.manager.togglePackage(packageId);
@ -518,7 +518,7 @@ export class AppController {
this.audit("INFO", "Import-Datei verarbeitet", result); this.audit("INFO", "Import-Datei verarbeitet", result);
return result; return result;
} }
public getSessionStats(): SessionStats { public getSessionStats(): SessionStats {
return this.manager.getSessionStats(); return this.manager.getSessionStats();
} }
@ -533,15 +533,15 @@ export class AppController {
this.settings = this.manager.getSettings(); this.settings = this.manager.getSettings();
this.audit("INFO", "Download-Statistik zurückgesetzt"); this.audit("INFO", "Download-Statistik zurückgesetzt");
} }
public exportBackup(): Buffer { public exportBackup(): Buffer {
const settings = { ...this.settings }; const settings = { ...this.settings };
const session = this.manager.getSession(); const session = this.manager.getSession();
const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const payload = JSON.stringify({ const payload = JSON.stringify({
version: 2, version: 2,
appVersion: APP_VERSION, appVersion: APP_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
settings, settings,
session, session,
history history
@ -565,75 +565,75 @@ export class AppController {
defaultFileName: getSupportBundleDefaultFileName() defaultFileName: getSupportBundleDefaultFileName()
}; };
} }
public importBackup(data: Buffer): { restored: boolean; message: string } { public importBackup(data: Buffer): { restored: boolean; message: string } {
let parsed: Record<string, unknown>; let parsed: Record<string, unknown>;
try { try {
// Try encrypted MDD format first // Try encrypted MDD format first
const json = decryptBackup(data); const json = decryptBackup(data);
parsed = JSON.parse(json) as Record<string, unknown>; parsed = JSON.parse(json) as Record<string, unknown>;
} catch { } catch {
// Fallback: try legacy plaintext JSON (old backups) // Fallback: try legacy plaintext JSON (old backups)
try { try {
const json = data.toString("utf8"); const json = data.toString("utf8");
parsed = JSON.parse(json) as Record<string, unknown>; parsed = JSON.parse(json) as Record<string, unknown>;
} catch { } catch {
return { restored: false, message: "Backup-Datei konnte nicht entschlüsselt werden" }; return { restored: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
} }
} }
if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) { if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) {
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" }; return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" };
} }
// Restore settings — ALL credentials are included (no more masking) // Restore settings — ALL credentials are included (no more masking)
const importedSettings = parsed.settings as AppSettings; const importedSettings = parsed.settings as AppSettings;
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>; const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>; const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
// Legacy backup compatibility: if credentials were masked with ***, keep current values // Legacy backup compatibility: if credentials were masked with ***, keep current values
const SENSITIVE_KEYS: (keyof AppSettings)[] = [ const SENSITIVE_KEYS: (keyof AppSettings)[] = [
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
"debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword" "debridLinkApiKeys", "linkSnappyLogin", "linkSnappyPassword"
]; ];
for (const key of SENSITIVE_KEYS) { for (const key of SENSITIVE_KEYS) {
const val = importedSettingsRecord[key]; const val = importedSettingsRecord[key];
if (typeof val === "string" && val.startsWith("***")) { if (typeof val === "string" && val.startsWith("***")) {
importedSettingsRecord[key] = currentSettingsRecord[key]; importedSettingsRecord[key] = currentSettingsRecord[key];
} }
} }
const restoredSettings = normalizeSettings(importedSettings); const restoredSettings = normalizeSettings(importedSettings);
this.settings = restoredSettings; this.settings = restoredSettings;
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
// Full stop including extraction abort // Full stop including extraction abort
this.manager.stop(); this.manager.stop();
this.manager.abortAllPostProcessing(); this.manager.abortAllPostProcessing();
this.manager.clearPersistTimer(); this.manager.clearPersistTimer();
cancelPendingAsyncSaves(); cancelPendingAsyncSaves();
// Restore session // Restore session
const restoredSession = normalizeLoadedSessionTransientFields( const restoredSession = normalizeLoadedSessionTransientFields(
normalizeLoadedSession(parsed.session) normalizeLoadedSession(parsed.session)
); );
saveSession(this.storagePaths, restoredSession); saveSession(this.storagePaths, restoredSession);
// Restore history (if present in backup) // Restore history (if present in backup)
if (Array.isArray(parsed.history) && parsed.history.length > 0) { if (Array.isArray(parsed.history) && parsed.history.length > 0) {
const normalizedHistory = (parsed.history as unknown[]) const normalizedHistory = (parsed.history as unknown[])
.map((raw, idx) => normalizeHistoryEntry(raw, idx)) .map((raw, idx) => normalizeHistoryEntry(raw, idx))
.filter((entry): entry is HistoryEntry => entry !== null); .filter((entry): entry is HistoryEntry => entry !== null);
if (normalizedHistory.length > 0) { if (normalizedHistory.length > 0) {
saveHistory(this.storagePaths, normalizedHistory); saveHistory(this.storagePaths, normalizedHistory);
logger.info(`Backup: ${normalizedHistory.length} History-Einträge wiederhergestellt`); logger.info(`Backup: ${normalizedHistory.length} History-Einträge wiederhergestellt`);
} }
} }
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
// Prevent prepareForShutdown from overwriting the restored data // Prevent prepareForShutdown from overwriting the restored data
this.manager.skipShutdownPersist = true; this.manager.skipShutdownPersist = true;
this.manager.blockAllPersistence = true; this.manager.blockAllPersistence = true;
logger.info("Backup wiederhergestellt (verschlüsseltes Format)"); logger.info("Backup wiederhergestellt (verschlüsseltes Format)");
this.audit("WARN", "Backup importiert", { this.audit("WARN", "Backup importiert", {
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
@ -641,7 +641,7 @@ export class AppController {
}); });
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." }; return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." };
} }
public getSessionLogPath(): string | null { public getSessionLogPath(): string | null {
return getSessionLogPath(); return getSessionLogPath();
} }
@ -660,10 +660,10 @@ export class AppController {
this.runtimeStatsTimer = null; this.runtimeStatsTimer = null;
} }
stopDebugServer(); stopDebugServer();
abortActiveUpdateDownload(); abortActiveUpdateDownload();
this.manager.prepareForShutdown(); this.manager.prepareForShutdown();
this.megaWebFallback.dispose(); this.megaWebFallback.dispose();
this.realDebridWebFallback.dispose(); this.realDebridWebFallback.dispose();
this.allDebridWebFallback.dispose(); this.allDebridWebFallback.dispose();
this.bestDebridWebFallback.dispose(); this.bestDebridWebFallback.dispose();
shutdownSessionLog(); shutdownSessionLog();
@ -682,7 +682,7 @@ export class AppController {
public getHistory(): HistoryEntry[] { public getHistory(): HistoryEntry[] {
return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); return loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
} }
public clearHistory(): void { public clearHistory(): void {
this.audit("WARN", "Verlauf geleert"); this.audit("WARN", "Verlauf geleert");
clearHistory(this.storagePaths); clearHistory(this.storagePaths);
@ -718,4 +718,4 @@ export class AppController {
}); });
addHistoryEntry(this.storagePaths, entry); addHistoryEntry(this.storagePaths, entry);
} }
} }

View File

@ -1,98 +1,98 @@
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
import { AppSettings } from "../shared/types"; import { AppSettings } from "../shared/types";
import { getProviderUsageDayKey } from "../shared/provider-daily-limits"; import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
import packageJson from "../../package.json"; import packageJson from "../../package.json";
export const APP_NAME = "Multi Debrid Downloader"; export const APP_NAME = "Multi Debrid Downloader";
export const APP_VERSION: string = packageJson.version; export const APP_VERSION: string = packageJson.version;
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0"; export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload"; export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste"; export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste";
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}"; export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8"); export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8"); export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
export const REQUEST_RETRIES = 3; export const REQUEST_RETRIES = 3;
export const CHUNK_SIZE = 512 * 1024; export const CHUNK_SIZE = 512 * 1024;
export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDownloader: 500 KB) export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDownloader: 500 KB)
export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout
export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment
export const STREAM_HIGH_WATER_MARK = 512 * 1024; // 512 KB stream buffer — lower than before (2 MB) so backpressure triggers sooner when disk is slow export const STREAM_HIGH_WATER_MARK = 512 * 1024; // 512 KB stream buffer — lower than before (2 MB) so backpressure triggers sooner when disk is slow
export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]); export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]); export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]); export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i; export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]); export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]);
export const RAR_SPLIT_RE = /\.r\d{2,3}$/i; export const RAR_SPLIT_RE = /\.r\d{2,3}$/i;
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024; export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024; export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
export const SPEED_WINDOW_SECONDS = 1; export const SPEED_WINDOW_SECONDS = 1;
export const CLIPBOARD_POLL_INTERVAL_MS = 2000; export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader"; export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader";
export function defaultSettings(): AppSettings { export function defaultSettings(): AppSettings {
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
return { return {
token: "", token: "",
realDebridUseWebLogin: false, realDebridUseWebLogin: false,
megaLogin: "", megaLogin: "",
megaPassword: "", megaPassword: "",
megaDebridApiEnabled: false, megaDebridApiEnabled: false,
megaDebridWebEnabled: false, megaDebridWebEnabled: false,
megaDebridPreferApi: true, megaDebridPreferApi: true,
bestToken: "", bestToken: "",
bestDebridUseWebLogin: false, bestDebridUseWebLogin: false,
allDebridToken: "", allDebridToken: "",
allDebridUseWebLogin: false, allDebridUseWebLogin: false,
ddownloadLogin: "", ddownloadLogin: "",
ddownloadPassword: "", ddownloadPassword: "",
oneFichierApiKey: "", oneFichierApiKey: "",
debridLinkApiKeys: "", debridLinkApiKeys: "",
debridLinkDisabledKeyIds: [], debridLinkDisabledKeyIds: [],
linkSnappyLogin: "", linkSnappyLogin: "",
linkSnappyPassword: "", linkSnappyPassword: "",
archivePasswordList: "", archivePasswordList: "",
rememberToken: true, rememberToken: true,
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"], providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
providerPrimary: "realdebrid", providerPrimary: "realdebrid",
providerSecondary: "megadebrid-api", providerSecondary: "megadebrid-api",
providerTertiary: "bestdebrid", providerTertiary: "bestdebrid",
autoProviderFallback: true, autoProviderFallback: true,
outputDir: baseDir, outputDir: baseDir,
packageName: "", packageName: "",
autoExtract: true, autoExtract: true,
autoRename4sf4sj: false, autoRename4sf4sj: false,
extractDir: path.join(baseDir, "_entpackt"), extractDir: path.join(baseDir, "_entpackt"),
collectMkvToLibrary: false, collectMkvToLibrary: false,
mkvLibraryDir: path.join(baseDir, "_mkv"), mkvLibraryDir: path.join(baseDir, "_mkv"),
createExtractSubfolder: true, createExtractSubfolder: true,
hybridExtract: true, hybridExtract: true,
cleanupMode: "none", cleanupMode: "none",
extractConflictMode: "overwrite", extractConflictMode: "overwrite",
removeLinkFilesAfterExtract: false, removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false, removeSamplesAfterExtract: false,
enableIntegrityCheck: true, enableIntegrityCheck: true,
autoResumeOnStart: true, autoResumeOnStart: true,
autoReconnect: false, autoReconnect: false,
reconnectWaitSeconds: 45, reconnectWaitSeconds: 45,
completedCleanupPolicy: "never", completedCleanupPolicy: "never",
maxParallel: 4, maxParallel: 4,
maxParallelExtract: 2, maxParallelExtract: 2,
retryLimit: 0, retryLimit: 0,
speedLimitEnabled: false, speedLimitEnabled: false,
speedLimitKbps: 0, speedLimitKbps: 0,
speedLimitMode: "global", speedLimitMode: "global",
updateRepo: DEFAULT_UPDATE_REPO, updateRepo: DEFAULT_UPDATE_REPO,
autoUpdateCheck: true, autoUpdateCheck: true,
clipboardWatch: false, clipboardWatch: false,
minimizeToTray: false, minimizeToTray: false,
theme: "dark" as const, theme: "dark" as const,
@ -107,10 +107,10 @@ export function defaultSettings(): AppSettings {
totalCompletedFilesAllTime: 0, totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0, totalRuntimeAllTimeMs: 0,
bandwidthSchedules: [], bandwidthSchedules: [],
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
extractCpuPriority: "high", extractCpuPriority: "high",
autoExtractWhenStopped: true, autoExtractWhenStopped: true,
disabledProviders: [], disabledProviders: [],
hosterRouting: {}, hosterRouting: {},
providerDailyLimitBytes: {}, providerDailyLimitBytes: {},
providerDailyUsageBytes: {}, providerDailyUsageBytes: {},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

3564
src/main/extractor.ts.bak Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,424 +1,424 @@
import { UnrestrictedLink } from "./realdebrid"; import { UnrestrictedLink } from "./realdebrid";
import { compactErrorText, filenameFromUrl, sleep } from "./utils"; import { compactErrorText, filenameFromUrl, sleep } from "./utils";
type MegaCredentials = { type MegaCredentials = {
login: string; login: string;
password: string; password: string;
}; };
type CodeEntry = { type CodeEntry = {
code: string; code: string;
linkHint: string; linkHint: string;
}; };
const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login"; const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login";
const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid"; const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid";
const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json"; const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json";
const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"; const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de";
function normalizeLink(link: string): string { function normalizeLink(link: string): string {
return link.trim().toLowerCase(); return link.trim().toLowerCase();
} }
function parseSetCookieFromHeaders(headers: Headers): string { function parseSetCookieFromHeaders(headers: Headers): string {
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie; const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
if (typeof getSetCookie === "function") { if (typeof getSetCookie === "function") {
const values = getSetCookie.call(headers) const values = getSetCookie.call(headers)
.map((entry) => entry.split(";")[0].trim()) .map((entry) => entry.split(";")[0].trim())
.filter(Boolean); .filter(Boolean);
if (values.length > 0) { if (values.length > 0) {
return values.join("; "); return values.join("; ");
} }
} }
const raw = headers.get("set-cookie") || ""; const raw = headers.get("set-cookie") || "";
if (!raw) { if (!raw) {
return ""; return "";
} }
return raw return raw
.split(/,(?=[^;=]+?=)/g) .split(/,(?=[^;=]+?=)/g)
.map((chunk) => chunk.split(";")[0].trim()) .map((chunk) => chunk.split(";")[0].trim())
.filter(Boolean) .filter(Boolean)
.join("; "); .join("; ");
} }
const PERMANENT_HOSTER_ERRORS = [ const PERMANENT_HOSTER_ERRORS = [
"hosternotavailable", "hosternotavailable",
"filenotfound", "filenotfound",
"file_unavailable", "file_unavailable",
"file not found", "file not found",
"link is dead", "link is dead",
"file has been removed", "file has been removed",
"file has been deleted", "file has been deleted",
"file was deleted", "file was deleted",
"file was removed", "file was removed",
"not available", "not available",
"file is no longer available" "file is no longer available"
]; ];
function parsePageErrors(html: string): string[] { function parsePageErrors(html: string): string[] {
const errors: string[] = []; const errors: string[] = [];
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi; const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = errorRegex.exec(html)) !== null) { while ((m = errorRegex.exec(html)) !== null) {
const text = m[1].replace(/^Fehler:\s*/i, "").trim(); const text = m[1].replace(/^Fehler:\s*/i, "").trim();
if (text) { if (text) {
errors.push(text); errors.push(text);
} }
} }
return errors; return errors;
} }
function isPermanentHosterError(errors: string[]): string | null { function isPermanentHosterError(errors: string[]): string | null {
for (const err of errors) { for (const err of errors) {
const lower = err.toLowerCase(); const lower = err.toLowerCase();
for (const pattern of PERMANENT_HOSTER_ERRORS) { for (const pattern of PERMANENT_HOSTER_ERRORS) {
if (lower.includes(pattern)) { if (lower.includes(pattern)) {
return err; return err;
} }
} }
} }
return null; return null;
} }
function parseCodes(html: string): CodeEntry[] { function parseCodes(html: string): CodeEntry[] {
const entries: CodeEntry[] = []; const entries: CodeEntry[] = [];
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi; const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
let cardMatch: RegExpExecArray | null; let cardMatch: RegExpExecArray | null;
while ((cardMatch = cardRegex.exec(html)) !== null) { while ((cardMatch = cardRegex.exec(html)) !== null) {
const block = cardMatch[0]; const block = cardMatch[0];
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim(); const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || ""; const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
if (!code) { if (!code) {
continue; continue;
} }
entries.push({ code, linkHint: normalizeLink(linkTitle) }); entries.push({ code, linkHint: normalizeLink(linkTitle) });
} }
if (entries.length === 0) { if (entries.length === 0) {
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi; const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
while ((m = fallbackRegex.exec(html)) !== null) { while ((m = fallbackRegex.exec(html)) !== null) {
entries.push({ code: m[1], linkHint: "" }); entries.push({ code: m[1], linkHint: "" });
} }
} }
return entries; return entries;
} }
function pickCode(entries: CodeEntry[], link: string): string { function pickCode(entries: CodeEntry[], link: string): string {
if (entries.length === 0) { if (entries.length === 0) {
return ""; return "";
} }
const target = normalizeLink(link); const target = normalizeLink(link);
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target)); const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
return (match?.code || entries[0].code || "").trim(); return (match?.code || entries[0].code || "").trim();
} }
function parseDebridJson(text: string): { link: string; text: string } | null { function parseDebridJson(text: string): { link: string; text: string } | null {
try { try {
const parsed = JSON.parse(text) as { link?: string; text?: string }; const parsed = JSON.parse(text) as { link?: string; text?: string };
return { return {
link: String(parsed.link || ""), link: String(parsed.link || ""),
text: String(parsed.text || "") text: String(parsed.text || "")
}; };
} catch { } catch {
return null; return null;
} }
} }
function abortError(): Error { function abortError(): Error {
return new Error("aborted:mega-web"); return new Error("aborted:mega-web");
} }
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal { function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
const timeoutSignal = AbortSignal.timeout(timeoutMs); const timeoutSignal = AbortSignal.timeout(timeoutMs);
if (!signal) { if (!signal) {
return timeoutSignal; return timeoutSignal;
} }
return AbortSignal.any([signal, timeoutSignal]); return AbortSignal.any([signal, timeoutSignal]);
} }
function throwIfAborted(signal?: AbortSignal): void { function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) { if (signal?.aborted) {
throw abortError(); throw abortError();
} }
} }
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> { async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) { if (!signal) {
await sleep(ms); await sleep(ms);
return; return;
} }
if (signal.aborted) { if (signal.aborted) {
throw abortError(); throw abortError();
} }
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
let timer: NodeJS.Timeout | null = setTimeout(() => { let timer: NodeJS.Timeout | null = setTimeout(() => {
timer = null; timer = null;
signal.removeEventListener("abort", onAbort); signal.removeEventListener("abort", onAbort);
resolve(); resolve();
}, Math.max(0, ms)); }, Math.max(0, ms));
const onAbort = (): void => { const onAbort = (): void => {
if (timer) { if (timer) {
clearTimeout(timer); clearTimeout(timer);
timer = null; timer = null;
} }
signal.removeEventListener("abort", onAbort); signal.removeEventListener("abort", onAbort);
reject(abortError()); reject(abortError());
}; };
signal.addEventListener("abort", onAbort, { once: true }); signal.addEventListener("abort", onAbort, { once: true });
}); });
} }
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> { async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) { if (!signal) {
return promise; return promise;
} }
if (signal.aborted) { if (signal.aborted) {
throw abortError(); throw abortError();
} }
return new Promise<T>((resolve, reject) => { return new Promise<T>((resolve, reject) => {
let settled = false; let settled = false;
const onAbort = (): void => { const onAbort = (): void => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
signal.removeEventListener("abort", onAbort); signal.removeEventListener("abort", onAbort);
reject(abortError()); reject(abortError());
}; };
signal.addEventListener("abort", onAbort, { once: true }); signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => { promise.then((value) => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
signal.removeEventListener("abort", onAbort); signal.removeEventListener("abort", onAbort);
resolve(value); resolve(value);
}, (error) => { }, (error) => {
if (settled) { if (settled) {
return; return;
} }
settled = true; settled = true;
signal.removeEventListener("abort", onAbort); signal.removeEventListener("abort", onAbort);
reject(error); reject(error);
}); });
}); });
} }
export class MegaWebFallback { export class MegaWebFallback {
private queue: Promise<unknown> = Promise.resolve(); private queue: Promise<unknown> = Promise.resolve();
private getCredentials: () => MegaCredentials; private getCredentials: () => MegaCredentials;
private cookie = ""; private cookie = "";
private cookieSetAt = 0; private cookieSetAt = 0;
public constructor(getCredentials: () => MegaCredentials) { public constructor(getCredentials: () => MegaCredentials) {
this.getCredentials = getCredentials; this.getCredentials = getCredentials;
} }
public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> { public async unrestrict(link: string, signal?: AbortSignal): Promise<UnrestrictedLink | null> {
const overallSignal = withTimeoutSignal(signal, 180000); const overallSignal = withTimeoutSignal(signal, 180000);
return this.runExclusive(async () => { return this.runExclusive(async () => {
throwIfAborted(overallSignal); throwIfAborted(overallSignal);
const creds = this.getCredentials(); const creds = this.getCredentials();
if (!creds.login.trim() || !creds.password.trim()) { if (!creds.login.trim() || !creds.password.trim()) {
return null; return null;
} }
if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) { if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) {
await this.login(creds.login, creds.password, overallSignal); await this.login(creds.login, creds.password, overallSignal);
} }
const generated = await this.generate(link, overallSignal); const generated = await this.generate(link, overallSignal);
if (!generated) { if (!generated) {
this.cookie = ""; this.cookie = "";
await this.login(creds.login, creds.password, overallSignal); await this.login(creds.login, creds.password, overallSignal);
const retry = await this.generate(link, overallSignal); const retry = await this.generate(link, overallSignal);
if (!retry) { if (!retry) {
return null; return null;
} }
return { return {
directUrl: retry.directUrl, directUrl: retry.directUrl,
fileName: retry.fileName || filenameFromUrl(link), fileName: retry.fileName || filenameFromUrl(link),
fileSize: null, fileSize: null,
retriesUsed: 0 retriesUsed: 0
}; };
} }
return { return {
directUrl: generated.directUrl, directUrl: generated.directUrl,
fileName: generated.fileName || filenameFromUrl(link), fileName: generated.fileName || filenameFromUrl(link),
fileSize: null, fileSize: null,
retriesUsed: 0 retriesUsed: 0
}; };
}, overallSignal); }, overallSignal);
} }
public invalidateSession(): void { public invalidateSession(): void {
this.cookie = ""; this.cookie = "";
this.cookieSetAt = 0; this.cookieSetAt = 0;
} }
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> { private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now(); const queuedAt = Date.now();
const QUEUE_WAIT_TIMEOUT_MS = 90000; const QUEUE_WAIT_TIMEOUT_MS = 90000;
const guardedJob = async (): Promise<T> => { const guardedJob = async (): Promise<T> => {
throwIfAborted(signal); throwIfAborted(signal);
const waited = Date.now() - queuedAt; const waited = Date.now() - queuedAt;
if (waited > QUEUE_WAIT_TIMEOUT_MS) { if (waited > QUEUE_WAIT_TIMEOUT_MS) {
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
} }
return job(); return job();
}; };
const run = this.queue.then(guardedJob, guardedJob); const run = this.queue.then(guardedJob, guardedJob);
this.queue = run.then(() => undefined, () => undefined); this.queue = run.then(() => undefined, () => undefined);
return raceWithAbort(run, signal); return raceWithAbort(run, signal);
} }
private async login(login: string, password: string, signal?: AbortSignal): Promise<void> { private async login(login: string, password: string, signal?: AbortSignal): Promise<void> {
throwIfAborted(signal); throwIfAborted(signal);
const response = await fetch(LOGIN_URL, { const response = await fetch(LOGIN_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0" "User-Agent": "Mozilla/5.0"
}, },
body: new URLSearchParams({ body: new URLSearchParams({
login, login,
password, password,
remember: "on" remember: "on"
}), }),
redirect: "manual", redirect: "manual",
signal: withTimeoutSignal(signal, 30000) signal: withTimeoutSignal(signal, 30000)
}); });
const cookie = parseSetCookieFromHeaders(response.headers); const cookie = parseSetCookieFromHeaders(response.headers);
if (!cookie) { if (!cookie) {
throw new Error("Mega-Web Login liefert kein Session-Cookie"); throw new Error("Mega-Web Login liefert kein Session-Cookie");
} }
const verify = await fetch(DEBRID_REFERER, { const verify = await fetch(DEBRID_REFERER, {
method: "GET", method: "GET",
headers: { headers: {
"User-Agent": "Mozilla/5.0", "User-Agent": "Mozilla/5.0",
Cookie: cookie, Cookie: cookie,
Referer: DEBRID_REFERER Referer: DEBRID_REFERER
}, },
signal: withTimeoutSignal(signal, 30000) signal: withTimeoutSignal(signal, 30000)
}); });
const verifyHtml = await verify.text(); const verifyHtml = await verify.text();
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml); const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
if (!hasDebridForm) { if (!hasDebridForm) {
throw new Error("Mega-Web Login ungültig oder Session blockiert"); throw new Error("Mega-Web Login ungültig oder Session blockiert");
} }
this.cookie = cookie; this.cookie = cookie;
this.cookieSetAt = Date.now(); this.cookieSetAt = Date.now();
} }
private async generate(link: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> { private async generate(link: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
throwIfAborted(signal); throwIfAborted(signal);
const page = await fetch(DEBRID_URL, { const page = await fetch(DEBRID_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0", "User-Agent": "Mozilla/5.0",
Cookie: this.cookie, Cookie: this.cookie,
Referer: DEBRID_REFERER Referer: DEBRID_REFERER
}, },
body: new URLSearchParams({ body: new URLSearchParams({
links: link, links: link,
password: "", password: "",
showLinks: "1" showLinks: "1"
}), }),
signal: withTimeoutSignal(signal, 30000) signal: withTimeoutSignal(signal, 30000)
}); });
const html = await page.text(); const html = await page.text();
// Check for permanent hoster errors before looking for debrid codes // Check for permanent hoster errors before looking for debrid codes
const pageErrors = parsePageErrors(html); const pageErrors = parsePageErrors(html);
const permanentError = isPermanentHosterError(pageErrors); const permanentError = isPermanentHosterError(pageErrors);
if (permanentError) { if (permanentError) {
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`); throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
} }
const code = pickCode(parseCodes(html), link); const code = pickCode(parseCodes(html), link);
if (!code) { if (!code) {
return null; return null;
} }
for (let attempt = 1; attempt <= 60; attempt += 1) { for (let attempt = 1; attempt <= 60; attempt += 1) {
throwIfAborted(signal); throwIfAborted(signal);
const res = await fetch(DEBRID_AJAX_URL, { const res = await fetch(DEBRID_AJAX_URL, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/x-www-form-urlencoded", "Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "Mozilla/5.0", "User-Agent": "Mozilla/5.0",
Cookie: this.cookie, Cookie: this.cookie,
Referer: DEBRID_REFERER Referer: DEBRID_REFERER
}, },
body: new URLSearchParams({ body: new URLSearchParams({
code, code,
autodl: "0" autodl: "0"
}), }),
signal: withTimeoutSignal(signal, 15000) signal: withTimeoutSignal(signal, 15000)
}); });
const text = (await res.text()).trim(); const text = (await res.text()).trim();
if (text === "reload") { if (text === "reload") {
await sleepWithSignal(650, signal); await sleepWithSignal(650, signal);
continue; continue;
} }
if (text === "false") { if (text === "false") {
return null; return null;
} }
const parsed = parseDebridJson(text); const parsed = parseDebridJson(text);
if (!parsed) { if (!parsed) {
return null; return null;
} }
if (!parsed.link) { if (!parsed.link) {
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) { if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
await sleepWithSignal(1200, signal); await sleepWithSignal(1200, signal);
continue; continue;
} }
return null; return null;
} }
const fromText = parsed.text const fromText = parsed.text
.replace(/<[^>]*>/g, " ") .replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ") .replace(/\s+/g, " ")
.trim(); .trim();
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i); const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim(); const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
return { return {
directUrl: parsed.link, directUrl: parsed.link,
fileName fileName
}; };
} }
return null; return null;
} }
public dispose(): void { public dispose(): void {
this.cookie = ""; this.cookie = "";
} }
} }
export function compactMegaWebError(error: unknown): string { export function compactMegaWebError(error: unknown): string {
return compactErrorText(error); return compactErrorText(error);
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { contextBridge, ipcRenderer } from "electron"; import { contextBridge, ipcRenderer } from "electron";
import { import {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
@ -6,22 +6,22 @@ import {
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
UiSnapshot, UiSnapshot,
UpdateCheckResult, UpdateCheckResult,
UpdateInstallProgress UpdateInstallProgress
} from "../shared/types"; } from "../shared/types";
import { IPC_CHANNELS } from "../shared/ipc"; import { IPC_CHANNELS } from "../shared/ipc";
import { ElectronApi } from "../shared/preload-api"; import { ElectronApi } from "../shared/preload-api";
const api: ElectronApi = { const api: ElectronApi = {
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT), getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION), getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES), checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE), installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url), openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings), updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
@ -29,17 +29,17 @@ const api: ElectronApi = {
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId), resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> => addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload), ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> => addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths), ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS), getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> => resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy), ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL), clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START), start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds), startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP), stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE), togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId), cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName), renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds), reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId), removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
@ -55,7 +55,7 @@ const api: ElectronApi = {
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS), resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS), resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART), restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT), quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
@ -71,41 +71,41 @@ const api: ElectronApi = {
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes), setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN), rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN), openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN), openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES), importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO), getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS), getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId), retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId), extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId), resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY), getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY), clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId), removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority), setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds), resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds), startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
return () => { return () => {
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener); ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
}; };
}, },
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => { onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
const listener = (_event: unknown, links: string[]): void => callback(links); const listener = (_event: unknown, links: string[]): void => callback(links);
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener); ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
return () => { return () => {
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener); ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
}; };
}, },
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => { onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress); const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener); ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
return () => { return () => {
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener); ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
}; };
} }
}; };
contextBridge.exposeInMainWorld("rd", api); contextBridge.exposeInMainWorld("rd", api);

File diff suppressed because it is too large Load Diff

View File

@ -1,42 +1,42 @@
export const IPC_CHANNELS = { export const IPC_CHANNELS = {
GET_SNAPSHOT: "app:get-snapshot", GET_SNAPSHOT: "app:get-snapshot",
GET_VERSION: "app:get-version", GET_VERSION: "app:get-version",
CHECK_UPDATES: "app:check-updates", CHECK_UPDATES: "app:check-updates",
INSTALL_UPDATE: "app:install-update", INSTALL_UPDATE: "app:install-update",
UPDATE_INSTALL_PROGRESS: "app:update-install-progress", UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
OPEN_EXTERNAL: "app:open-external", OPEN_EXTERNAL: "app:open-external",
UPDATE_SETTINGS: "app:update-settings", UPDATE_SETTINGS: "app:update-settings",
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage", RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage", RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
ADD_LINKS: "queue:add-links", ADD_LINKS: "queue:add-links",
ADD_CONTAINERS: "queue:add-containers", ADD_CONTAINERS: "queue:add-containers",
GET_START_CONFLICTS: "queue:get-start-conflicts", GET_START_CONFLICTS: "queue:get-start-conflicts",
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict", RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
CLEAR_ALL: "queue:clear-all", CLEAR_ALL: "queue:clear-all",
START: "queue:start", START: "queue:start",
START_PACKAGES: "queue:start-packages", START_PACKAGES: "queue:start-packages",
STOP: "queue:stop", STOP: "queue:stop",
TOGGLE_PAUSE: "queue:toggle-pause", TOGGLE_PAUSE: "queue:toggle-pause",
CANCEL_PACKAGE: "queue:cancel-package", CANCEL_PACKAGE: "queue:cancel-package",
RENAME_PACKAGE: "queue:rename-package", RENAME_PACKAGE: "queue:rename-package",
REORDER_PACKAGES: "queue:reorder-packages", REORDER_PACKAGES: "queue:reorder-packages",
REMOVE_ITEM: "queue:remove-item", REMOVE_ITEM: "queue:remove-item",
TOGGLE_PACKAGE: "queue:toggle-package", TOGGLE_PACKAGE: "queue:toggle-package",
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection", EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
EXPORT_ITEM_SELECTION: "queue:export-item-selection", EXPORT_ITEM_SELECTION: "queue:export-item-selection",
EXPORT_QUEUE: "queue:export", EXPORT_QUEUE: "queue:export",
IMPORT_QUEUE: "queue:import", IMPORT_QUEUE: "queue:import",
PICK_FOLDER: "dialog:pick-folder", PICK_FOLDER: "dialog:pick-folder",
PICK_CONTAINERS: "dialog:pick-containers", PICK_CONTAINERS: "dialog:pick-containers",
STATE_UPDATE: "state:update", STATE_UPDATE: "state:update",
CLIPBOARD_DETECTED: "clipboard:detected", CLIPBOARD_DETECTED: "clipboard:detected",
TOGGLE_CLIPBOARD: "clipboard:toggle", TOGGLE_CLIPBOARD: "clipboard:toggle",
GET_SESSION_STATS: "stats:get-session-stats", GET_SESSION_STATS: "stats:get-session-stats",
RESET_SESSION_STATS: "stats:reset-session", RESET_SESSION_STATS: "stats:reset-session",
RESET_DOWNLOAD_STATS: "stats:reset-download", RESET_DOWNLOAD_STATS: "stats:reset-download",
RESTART: "app:restart", RESTART: "app:restart",
QUIT: "app:quit", QUIT: "app:quit",
EXPORT_BACKUP: "app:export-backup", EXPORT_BACKUP: "app:export-backup",
IMPORT_BACKUP: "app:import-backup", IMPORT_BACKUP: "app:import-backup",
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle", EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
OPEN_LOG: "app:open-log", OPEN_LOG: "app:open-log",
@ -51,18 +51,18 @@ export const IPC_CHANNELS = {
SET_TRACE_ENABLED: "app:set-trace-enabled", SET_TRACE_ENABLED: "app:set-trace-enabled",
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token", ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login", OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login", OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies", IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info", GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits", GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
RETRY_EXTRACTION: "queue:retry-extraction", RETRY_EXTRACTION: "queue:retry-extraction",
EXTRACT_NOW: "queue:extract-now", EXTRACT_NOW: "queue:extract-now",
RESET_PACKAGE: "queue:reset-package", RESET_PACKAGE: "queue:reset-package",
GET_HISTORY: "history:get", GET_HISTORY: "history:get",
CLEAR_HISTORY: "history:clear", CLEAR_HISTORY: "history:clear",
REMOVE_HISTORY_ENTRY: "history:remove-entry", REMOVE_HISTORY_ENTRY: "history:remove-entry",
SET_PACKAGE_PRIORITY: "queue:set-package-priority", SET_PACKAGE_PRIORITY: "queue:set-package-priority",
SKIP_ITEMS: "queue:skip-items", SKIP_ITEMS: "queue:skip-items",
RESET_ITEMS: "queue:reset-items", RESET_ITEMS: "queue:reset-items",
START_ITEMS: "queue:start-items" START_ITEMS: "queue:start-items"
} as const; } as const;

View File

@ -1,4 +1,4 @@
import type { import type {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
@ -6,7 +6,7 @@ import type {
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
@ -15,30 +15,30 @@ import type {
UiSnapshot, UiSnapshot,
UpdateCheckResult, UpdateCheckResult,
UpdateInstallProgress, UpdateInstallProgress,
UpdateInstallResult UpdateInstallResult
} from "./types"; } from "./types";
export interface ElectronApi { export interface ElectronApi {
getSnapshot: () => Promise<UiSnapshot>; getSnapshot: () => Promise<UiSnapshot>;
getVersion: () => Promise<string>; getVersion: () => Promise<string>;
checkUpdates: () => Promise<UpdateCheckResult>; checkUpdates: () => Promise<UpdateCheckResult>;
installUpdate: () => Promise<UpdateInstallResult>; installUpdate: () => Promise<UpdateInstallResult>;
openExternal: (url: string) => Promise<boolean>; openExternal: (url: string) => Promise<boolean>;
updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>; updateSettings: (settings: Partial<AppSettings>) => Promise<AppSettings>;
resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>; resetProviderDailyUsage: (provider: DebridProvider) => Promise<AppSettings>;
resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>; resetDebridLinkApiKeyDailyUsage: (keyId: string) => Promise<AppSettings>;
addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>; addLinks: (payload: AddLinksPayload) => Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }>;
addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>; addContainers: (filePaths: string[]) => Promise<{ addedPackages: number; addedLinks: number }>;
getStartConflicts: () => Promise<StartConflictEntry[]>; getStartConflicts: () => Promise<StartConflictEntry[]>;
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>; resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
clearAll: () => Promise<void>; clearAll: () => Promise<void>;
start: () => Promise<void>; start: () => Promise<void>;
startPackages: (packageIds: string[]) => Promise<void>; startPackages: (packageIds: string[]) => Promise<void>;
stop: () => Promise<void>; stop: () => Promise<void>;
togglePause: () => Promise<boolean>; togglePause: () => Promise<boolean>;
cancelPackage: (packageId: string) => Promise<void>; cancelPackage: (packageId: string) => Promise<void>;
renamePackage: (packageId: string, newName: string) => Promise<void>; renamePackage: (packageId: string, newName: string) => Promise<void>;
reorderPackages: (packageIds: string[]) => Promise<void>; reorderPackages: (packageIds: string[]) => Promise<void>;
removeItem: (itemId: string) => Promise<void>; removeItem: (itemId: string) => Promise<void>;
togglePackage: (packageId: string) => Promise<void>; togglePackage: (packageId: string) => Promise<void>;
exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>; exportPackageSelection: (packageIds: string[]) => Promise<{ saved: boolean; packageCount: number; linkCount: number; filePath?: string }>;
@ -52,7 +52,7 @@ export interface ElectronApi {
resetSessionStats: () => Promise<void>; resetSessionStats: () => Promise<void>;
resetDownloadStats: () => Promise<void>; resetDownloadStats: () => Promise<void>;
restart: () => Promise<void>; restart: () => Promise<void>;
quit: () => Promise<void>; quit: () => Promise<void>;
exportBackup: () => Promise<{ saved: boolean }>; exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; message: string }>; importBackup: () => Promise<{ restored: boolean; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
@ -68,21 +68,21 @@ export interface ElectronApi {
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>; setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
rotateDebugToken: () => Promise<{ path: string }>; rotateDebugToken: () => Promise<{ path: string }>;
openRealDebridLogin: () => Promise<void>; openRealDebridLogin: () => Promise<void>;
openAllDebridLogin: () => Promise<void>; openAllDebridLogin: () => Promise<void>;
importBestDebridCookies: () => Promise<number>; importBestDebridCookies: () => Promise<number>;
getAllDebridHostInfo: () => Promise<AllDebridHostInfo>; getAllDebridHostInfo: () => Promise<AllDebridHostInfo>;
getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>; getDebridLinkHostLimits: () => Promise<DebridLinkHostLimitInfo[]>;
retryExtraction: (packageId: string) => Promise<void>; retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>; extractNow: (packageId: string) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>; resetPackage: (packageId: string) => Promise<void>;
getHistory: () => Promise<HistoryEntry[]>; getHistory: () => Promise<HistoryEntry[]>;
clearHistory: () => Promise<void>; clearHistory: () => Promise<void>;
removeHistoryEntry: (entryId: string) => Promise<void>; removeHistoryEntry: (entryId: string) => Promise<void>;
setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>; setPackagePriority: (packageId: string, priority: PackagePriority) => Promise<void>;
skipItems: (itemIds: string[]) => Promise<void>; skipItems: (itemIds: string[]) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>; resetItems: (itemIds: string[]) => Promise<void>;
startItems: (itemIds: string[]) => Promise<void>; startItems: (itemIds: string[]) => Promise<void>;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
} }

View File

@ -1,44 +1,44 @@
export type DownloadStatus = export type DownloadStatus =
| "queued" | "queued"
| "validating" | "validating"
| "downloading" | "downloading"
| "paused" | "paused"
| "reconnect_wait" | "reconnect_wait"
| "extracting" | "extracting"
| "integrity_check" | "integrity_check"
| "completed" | "completed"
| "failed" | "failed"
| "cancelled"; | "cancelled";
export type CleanupMode = "none" | "trash" | "delete"; export type CleanupMode = "none" | "trash" | "delete";
export type ConflictMode = "overwrite" | "skip" | "rename" | "ask"; export type ConflictMode = "overwrite" | "skip" | "rename" | "ask";
export type SpeedMode = "global" | "per_download"; export type SpeedMode = "global" | "per_download";
export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done"; export type FinishedCleanupPolicy = "never" | "immediate" | "on_start" | "package_done";
export type DebridProvider = export type DebridProvider =
| "realdebrid" | "realdebrid"
| "megadebrid" | "megadebrid"
| "megadebrid-api" | "megadebrid-api"
| "megadebrid-web" | "megadebrid-web"
| "bestdebrid" | "bestdebrid"
| "alldebrid" | "alldebrid"
| "ddownload" | "ddownload"
| "onefichier" | "onefichier"
| "debridlink" | "debridlink"
| "linksnappy"; | "linksnappy";
export type DebridFallbackProvider = DebridProvider | "none"; export type DebridFallbackProvider = DebridProvider | "none";
export type AppTheme = "dark" | "light"; export type AppTheme = "dark" | "light";
export type PackagePriority = "high" | "normal" | "low"; export type PackagePriority = "high" | "normal" | "low";
export type ExtractCpuPriority = "high" | "middle" | "low"; export type ExtractCpuPriority = "high" | "middle" | "low";
export type HistoryRetentionMode = "never" | "session" | "permanent"; export type HistoryRetentionMode = "never" | "session" | "permanent";
export interface BandwidthScheduleEntry { export interface BandwidthScheduleEntry {
id: string; id: string;
startHour: number; startHour: number;
endHour: number; endHour: number;
speedLimitKbps: number; speedLimitKbps: number;
enabled: boolean; enabled: boolean;
} }
export interface DownloadStats { export interface DownloadStats {
totalDownloaded: number; totalDownloaded: number;
totalDownloadedAllTime: number; totalDownloadedAllTime: number;
@ -52,67 +52,67 @@ export interface DownloadStats {
totalRuntimeMs: number; totalRuntimeMs: number;
runtimeMeasuredAt: number; runtimeMeasuredAt: number;
} }
export interface AppSettings { export interface AppSettings {
token: string; token: string;
realDebridUseWebLogin: boolean; realDebridUseWebLogin: boolean;
megaLogin: string; megaLogin: string;
megaPassword: string; megaPassword: string;
megaDebridApiEnabled: boolean; megaDebridApiEnabled: boolean;
megaDebridWebEnabled: boolean; megaDebridWebEnabled: boolean;
megaDebridPreferApi: boolean; megaDebridPreferApi: boolean;
bestToken: string; bestToken: string;
bestDebridUseWebLogin: boolean; bestDebridUseWebLogin: boolean;
allDebridToken: string; allDebridToken: string;
allDebridUseWebLogin: boolean; allDebridUseWebLogin: boolean;
ddownloadLogin: string; ddownloadLogin: string;
ddownloadPassword: string; ddownloadPassword: string;
oneFichierApiKey: string; oneFichierApiKey: string;
debridLinkApiKeys: string; debridLinkApiKeys: string;
debridLinkDisabledKeyIds: string[]; debridLinkDisabledKeyIds: string[];
linkSnappyLogin: string; linkSnappyLogin: string;
linkSnappyPassword: string; linkSnappyPassword: string;
archivePasswordList: string; archivePasswordList: string;
rememberToken: boolean; rememberToken: boolean;
providerOrder: readonly DebridProvider[]; providerOrder: readonly DebridProvider[];
providerPrimary: DebridProvider; providerPrimary: DebridProvider;
providerSecondary: DebridFallbackProvider; providerSecondary: DebridFallbackProvider;
providerTertiary: DebridFallbackProvider; providerTertiary: DebridFallbackProvider;
autoProviderFallback: boolean; autoProviderFallback: boolean;
outputDir: string; outputDir: string;
packageName: string; packageName: string;
autoExtract: boolean; autoExtract: boolean;
autoRename4sf4sj: boolean; autoRename4sf4sj: boolean;
extractDir: string; extractDir: string;
collectMkvToLibrary: boolean; collectMkvToLibrary: boolean;
mkvLibraryDir: string; mkvLibraryDir: string;
createExtractSubfolder: boolean; createExtractSubfolder: boolean;
hybridExtract: boolean; hybridExtract: boolean;
cleanupMode: CleanupMode; cleanupMode: CleanupMode;
extractConflictMode: ConflictMode; extractConflictMode: ConflictMode;
removeLinkFilesAfterExtract: boolean; removeLinkFilesAfterExtract: boolean;
removeSamplesAfterExtract: boolean; removeSamplesAfterExtract: boolean;
enableIntegrityCheck: boolean; enableIntegrityCheck: boolean;
autoResumeOnStart: boolean; autoResumeOnStart: boolean;
autoReconnect: boolean; autoReconnect: boolean;
reconnectWaitSeconds: number; reconnectWaitSeconds: number;
completedCleanupPolicy: FinishedCleanupPolicy; completedCleanupPolicy: FinishedCleanupPolicy;
maxParallel: number; maxParallel: number;
maxParallelExtract: number; maxParallelExtract: number;
retryLimit: number; retryLimit: number;
speedLimitEnabled: boolean; speedLimitEnabled: boolean;
speedLimitKbps: number; speedLimitKbps: number;
speedLimitMode: SpeedMode; speedLimitMode: SpeedMode;
updateRepo: string; updateRepo: string;
autoUpdateCheck: boolean; autoUpdateCheck: boolean;
clipboardWatch: boolean; clipboardWatch: boolean;
minimizeToTray: boolean; minimizeToTray: boolean;
theme: AppTheme; theme: AppTheme;
collapseNewPackages: boolean; collapseNewPackages: boolean;
historyRetentionMode: HistoryRetentionMode; historyRetentionMode: HistoryRetentionMode;
accountListShowDetailedDebridLinkKeys: boolean; accountListShowDetailedDebridLinkKeys: boolean;
autoSortPackagesByProgress: boolean; autoSortPackagesByProgress: boolean;
autoSkipExtracted: boolean; autoSkipExtracted: boolean;
hideExtractedItems: boolean; hideExtractedItems: boolean;
confirmDeleteSelection: boolean; confirmDeleteSelection: boolean;
totalDownloadedAllTime: number; totalDownloadedAllTime: number;
@ -121,8 +121,8 @@ export interface AppSettings {
bandwidthSchedules: BandwidthScheduleEntry[]; bandwidthSchedules: BandwidthScheduleEntry[];
columnOrder: string[]; columnOrder: string[];
extractCpuPriority: ExtractCpuPriority; extractCpuPriority: ExtractCpuPriority;
autoExtractWhenStopped: boolean; autoExtractWhenStopped: boolean;
disabledProviders: DebridProvider[]; disabledProviders: DebridProvider[];
hosterRouting: Record<string, DebridProvider>; hosterRouting: Record<string, DebridProvider>;
providerDailyLimitBytes: Partial<Record<DebridProvider, number>>; providerDailyLimitBytes: Partial<Record<DebridProvider, number>>;
providerDailyUsageBytes: Partial<Record<DebridProvider, number>>; providerDailyUsageBytes: Partial<Record<DebridProvider, number>>;
@ -133,40 +133,40 @@ export interface AppSettings {
providerDailyUsageDay: string; providerDailyUsageDay: string;
scheduledStartEpochMs: number; scheduledStartEpochMs: number;
} }
export interface DownloadItem { export interface DownloadItem {
id: string; id: string;
packageId: string; packageId: string;
url: string; url: string;
provider: DebridProvider | null; provider: DebridProvider | null;
providerLabel?: string; providerLabel?: string;
providerAccountId?: string; providerAccountId?: string;
providerAccountLabel?: string; providerAccountLabel?: string;
status: DownloadStatus; status: DownloadStatus;
retries: number; retries: number;
speedBps: number; speedBps: number;
downloadedBytes: number; downloadedBytes: number;
totalBytes: number | null; totalBytes: number | null;
progressPercent: number; progressPercent: number;
fileName: string; fileName: string;
targetPath: string; targetPath: string;
resumable: boolean; resumable: boolean;
attempts: number; attempts: number;
lastError: string; lastError: string;
fullStatus: string; fullStatus: string;
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
onlineStatus?: "online" | "offline" | "checking"; onlineStatus?: "online" | "offline" | "checking";
} }
export interface PackageEntry { export interface PackageEntry {
id: string; id: string;
name: string; name: string;
outputDir: string; outputDir: string;
extractDir: string; extractDir: string;
status: DownloadStatus; status: DownloadStatus;
itemIds: string[]; itemIds: string[];
cancelled: boolean; cancelled: boolean;
enabled: boolean; enabled: boolean;
priority?: PackagePriority; priority?: PackagePriority;
postProcessLabel?: string; postProcessLabel?: string;
@ -175,169 +175,169 @@ export interface PackageEntry {
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
} }
export interface SessionState { export interface SessionState {
version: number; version: number;
packageOrder: string[]; packageOrder: string[];
packages: Record<string, PackageEntry>; packages: Record<string, PackageEntry>;
items: Record<string, DownloadItem>; items: Record<string, DownloadItem>;
runStartedAt: number; runStartedAt: number;
totalDownloadedBytes: number; totalDownloadedBytes: number;
summaryText: string; summaryText: string;
reconnectUntil: number; reconnectUntil: number;
reconnectReason: string; reconnectReason: string;
paused: boolean; paused: boolean;
running: boolean; running: boolean;
updatedAt: number; updatedAt: number;
} }
export interface DownloadSummary { export interface DownloadSummary {
total: number; total: number;
success: number; success: number;
failed: number; failed: number;
cancelled: number; cancelled: number;
extracted: number; extracted: number;
durationSeconds: number; durationSeconds: number;
averageSpeedBps: number; averageSpeedBps: number;
} }
export interface ParsedPackageInput { export interface ParsedPackageInput {
name: string; name: string;
links: string[]; links: string[];
fileNames?: string[]; fileNames?: string[];
} }
export interface ContainerImportResult { export interface ContainerImportResult {
packages: ParsedPackageInput[]; packages: ParsedPackageInput[];
source: "dlc"; source: "dlc";
} }
export interface UiSnapshot { export interface UiSnapshot {
settings: AppSettings; settings: AppSettings;
session: SessionState; session: SessionState;
summary: DownloadSummary | null; summary: DownloadSummary | null;
stats: DownloadStats; stats: DownloadStats;
speedText: string; speedText: string;
etaText: string; etaText: string;
canStart: boolean; canStart: boolean;
canStop: boolean; canStop: boolean;
canPause: boolean; canPause: boolean;
clipboardActive: boolean; clipboardActive: boolean;
reconnectSeconds: number; reconnectSeconds: number;
packageSpeedBps: Record<string, number>; packageSpeedBps: Record<string, number>;
} }
export interface AddLinksPayload { export interface AddLinksPayload {
rawText: string; rawText: string;
packageName?: string; packageName?: string;
duplicatePolicy?: DuplicatePolicy; duplicatePolicy?: DuplicatePolicy;
} }
export interface AddContainerPayload { export interface AddContainerPayload {
filePaths: string[]; filePaths: string[];
} }
export type DuplicatePolicy = "keep" | "skip" | "overwrite"; export type DuplicatePolicy = "keep" | "skip" | "overwrite";
export interface QueueAddResult { export interface QueueAddResult {
addedPackages: number; addedPackages: number;
addedLinks: number; addedLinks: number;
skippedExistingPackages: string[]; skippedExistingPackages: string[];
overwrittenPackages: string[]; overwrittenPackages: string[];
} }
export interface ContainerConflictResult { export interface ContainerConflictResult {
conflicts: string[]; conflicts: string[];
packageCount: number; packageCount: number;
linkCount: number; linkCount: number;
} }
export interface StartConflictEntry { export interface StartConflictEntry {
packageId: string; packageId: string;
packageName: string; packageName: string;
extractDir: string; extractDir: string;
} }
export interface StartConflictResolutionResult { export interface StartConflictResolutionResult {
skipped: boolean; skipped: boolean;
overwritten: boolean; overwritten: boolean;
} }
export interface UpdateCheckResult { export interface UpdateCheckResult {
updateAvailable: boolean; updateAvailable: boolean;
currentVersion: string; currentVersion: string;
latestVersion: string; latestVersion: string;
latestTag: string; latestTag: string;
releaseUrl: string; releaseUrl: string;
setupAssetUrl?: string; setupAssetUrl?: string;
setupAssetName?: string; setupAssetName?: string;
setupAssetDigest?: string; setupAssetDigest?: string;
releaseNotes?: string; releaseNotes?: string;
error?: string; error?: string;
} }
export interface UpdateInstallResult { export interface UpdateInstallResult {
started: boolean; started: boolean;
message: string; message: string;
} }
export interface UpdateInstallProgress { export interface UpdateInstallProgress {
stage: "starting" | "downloading" | "verifying" | "launching" | "done" | "error"; stage: "starting" | "downloading" | "verifying" | "launching" | "done" | "error";
percent: number | null; percent: number | null;
downloadedBytes: number; downloadedBytes: number;
totalBytes: number | null; totalBytes: number | null;
message: string; message: string;
} }
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown"; export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
export type AllDebridHostInfoSource = "api" | "web"; export type AllDebridHostInfoSource = "api" | "web";
export interface AllDebridHostInfo { export interface AllDebridHostInfo {
host: string; host: string;
source: AllDebridHostInfoSource; source: AllDebridHostInfoSource;
state: AllDebridHostState; state: AllDebridHostState;
statusLabel: string; statusLabel: string;
fetchedAt: number; fetchedAt: number;
lastCheckedAt: number | null; lastCheckedAt: number | null;
quota: number | null; quota: number | null;
quotaMax: number | null; quotaMax: number | null;
quotaType: string; quotaType: string;
limitSimuDl: number | null; limitSimuDl: number | null;
note: string; note: string;
} }
export interface DebridLinkHostLimitInfo { export interface DebridLinkHostLimitInfo {
keyId: string; keyId: string;
keyLabel: string; keyLabel: string;
host: string; host: string;
fetchedAt: number; fetchedAt: number;
trafficCurrentBytes: number | null; trafficCurrentBytes: number | null;
trafficMaxBytes: number | null; trafficMaxBytes: number | null;
linksCurrent: number | null; linksCurrent: number | null;
linksMax: number | null; linksMax: number | null;
note: string; note: string;
} }
export interface ParsedHashEntry { export interface ParsedHashEntry {
fileName: string; fileName: string;
algorithm: "crc32" | "md5" | "sha1"; algorithm: "crc32" | "md5" | "sha1";
digest: string; digest: string;
} }
export interface BandwidthSample { export interface BandwidthSample {
timestamp: number; timestamp: number;
speedBps: number; speedBps: number;
} }
export interface BandwidthStats { export interface BandwidthStats {
samples: BandwidthSample[]; samples: BandwidthSample[];
currentSpeedBps: number; currentSpeedBps: number;
averageSpeedBps: number; averageSpeedBps: number;
maxSpeedBps: number; maxSpeedBps: number;
totalBytesSession: number; totalBytesSession: number;
sessionDurationSeconds: number; sessionDurationSeconds: number;
} }
export interface SessionStats { export interface SessionStats {
bandwidth: BandwidthStats; bandwidth: BandwidthStats;
totalDownloads: number; totalDownloads: number;
@ -436,18 +436,18 @@ export interface DebugSetupCheckResult {
export interface HistoryEntry { export interface HistoryEntry {
id: string; id: string;
name: string; name: string;
totalBytes: number; totalBytes: number;
downloadedBytes: number; downloadedBytes: number;
fileCount: number; fileCount: number;
provider: DebridProvider | null; provider: DebridProvider | null;
completedAt: number; completedAt: number;
durationSeconds: number; durationSeconds: number;
status: "completed" | "deleted"; status: "completed" | "deleted";
outputDir: string; outputDir: string;
urls?: string[]; urls?: string[];
} }
export interface HistoryState { export interface HistoryState {
entries: HistoryEntry[]; entries: HistoryEntry[];
maxEntries: number; maxEntries: number;
} }

View File

@ -1,86 +1,86 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto"; import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
describe("backup-crypto", () => { describe("backup-crypto", () => {
it("encrypts and decrypts a round-trip correctly", () => { it("encrypts and decrypts a round-trip correctly", () => {
const original = JSON.stringify({ const original = JSON.stringify({
version: 2, version: 2,
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" }, settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
session: { packages: {}, items: {} }, session: { packages: {}, items: {} },
history: [{ id: "h1", name: "Test" }] history: [{ id: "h1", name: "Test" }]
}); });
const encrypted = encryptBackup(original); const encrypted = encryptBackup(original);
const decrypted = decryptBackup(encrypted); const decrypted = decryptBackup(encrypted);
expect(decrypted).toBe(original); expect(decrypted).toBe(original);
}); });
it("produces binary output that is not plaintext readable", () => { it("produces binary output that is not plaintext readable", () => {
const secret = "super-secret-token-12345"; const secret = "super-secret-token-12345";
const plaintext = JSON.stringify({ settings: { token: secret } }); const plaintext = JSON.stringify({ settings: { token: secret } });
const encrypted = encryptBackup(plaintext); const encrypted = encryptBackup(plaintext);
// The encrypted buffer should NOT contain the secret in plaintext // The encrypted buffer should NOT contain the secret in plaintext
expect(encrypted.toString("utf8")).not.toContain(secret); expect(encrypted.toString("utf8")).not.toContain(secret);
expect(encrypted.toString("latin1")).not.toContain(secret); expect(encrypted.toString("latin1")).not.toContain(secret);
}); });
it("starts with the MDD1 magic bytes", () => { it("starts with the MDD1 magic bytes", () => {
const encrypted = encryptBackup("test"); const encrypted = encryptBackup("test");
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1"); expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
}); });
it("produces different ciphertext for the same input (random IV)", () => { it("produces different ciphertext for the same input (random IV)", () => {
const plaintext = "same input data"; const plaintext = "same input data";
const a = encryptBackup(plaintext); const a = encryptBackup(plaintext);
const b = encryptBackup(plaintext); const b = encryptBackup(plaintext);
// IVs are different, so full buffers must differ // IVs are different, so full buffers must differ
expect(a.equals(b)).toBe(false); expect(a.equals(b)).toBe(false);
// But both decrypt to the same plaintext // But both decrypt to the same plaintext
expect(decryptBackup(a)).toBe(plaintext); expect(decryptBackup(a)).toBe(plaintext);
expect(decryptBackup(b)).toBe(plaintext); expect(decryptBackup(b)).toBe(plaintext);
}); });
it("throws on truncated data", () => { it("throws on truncated data", () => {
const encrypted = encryptBackup("test data"); const encrypted = encryptBackup("test data");
const truncated = encrypted.subarray(0, 10); const truncated = encrypted.subarray(0, 10);
expect(() => decryptBackup(truncated)).toThrow(); expect(() => decryptBackup(truncated)).toThrow();
}); });
it("throws on corrupted ciphertext", () => { it("throws on corrupted ciphertext", () => {
const encrypted = encryptBackup("test data"); const encrypted = encryptBackup("test data");
// Flip a byte in the ciphertext area // Flip a byte in the ciphertext area
const corrupted = Buffer.from(encrypted); const corrupted = Buffer.from(encrypted);
corrupted[corrupted.length - 1] ^= 0xff; corrupted[corrupted.length - 1] ^= 0xff;
expect(() => decryptBackup(corrupted)).toThrow(); expect(() => decryptBackup(corrupted)).toThrow();
}); });
it("throws on wrong magic bytes", () => { it("throws on wrong magic bytes", () => {
const encrypted = encryptBackup("test data"); const encrypted = encryptBackup("test data");
const wrongMagic = Buffer.from(encrypted); const wrongMagic = Buffer.from(encrypted);
wrongMagic[0] = 0x00; wrongMagic[0] = 0x00;
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/); expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
}); });
it("throws on empty buffer", () => { it("throws on empty buffer", () => {
expect(() => decryptBackup(Buffer.alloc(0))).toThrow(); expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
}); });
it("handles large payloads", () => { it("handles large payloads", () => {
const large = JSON.stringify({ data: "x".repeat(1_000_000) }); const large = JSON.stringify({ data: "x".repeat(1_000_000) });
const encrypted = encryptBackup(large); const encrypted = encryptBackup(large);
const decrypted = decryptBackup(encrypted); const decrypted = decryptBackup(encrypted);
expect(decrypted).toBe(large); expect(decrypted).toBe(large);
}); });
it("handles unicode content", () => { it("handles unicode content", () => {
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" }); const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
const encrypted = encryptBackup(unicode); const encrypted = encryptBackup(unicode);
expect(decryptBackup(encrypted)).toBe(unicode); expect(decryptBackup(encrypted)).toBe(unicode);
}); });
it("handles empty string round-trip", () => { it("handles empty string round-trip", () => {
const encrypted = encryptBackup(""); const encrypted = encryptBackup("");
expect(decryptBackup(encrypted)).toBe(""); expect(decryptBackup(encrypted)).toBe("");
}); });
}); });

View File

@ -1,109 +1,109 @@
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup"; import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
const tempDirs: string[] = []; const tempDirs: string[] = [];
afterEach(() => { afterEach(() => {
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} }
}); });
describe("cleanup", () => { describe("cleanup", () => {
it("removes archive artifacts but keeps media", () => { it("removes archive artifacts but keeps media", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir); tempDirs.push(dir);
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x"); fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
fs.writeFileSync(path.join(dir, "movie.mkv"), "x"); fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
const removed = cleanupCancelledPackageArtifacts(dir); const removed = cleanupCancelledPackageArtifacts(dir);
expect(removed).toBeGreaterThan(0); expect(removed).toBeGreaterThan(0);
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false); expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true); expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
}); });
it("removes sample artifacts and link files", async () => { it("removes sample artifacts and link files", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir); tempDirs.push(dir);
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true }); fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x"); fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n"); fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
const links = await removeDownloadLinkArtifacts(dir); const links = await removeDownloadLinkArtifacts(dir);
const samples = await removeSampleArtifacts(dir); const samples = await removeSampleArtifacts(dir);
expect(links).toBeGreaterThan(0); expect(links).toBeGreaterThan(0);
expect(samples.files + samples.dirs).toBeGreaterThan(0); expect(samples.files + samples.dirs).toBeGreaterThan(0);
}); });
it("cleans up archive files in nested directories", () => { it("cleans up archive files in nested directories", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir); tempDirs.push(dir);
// Create nested directory structure with archive files // Create nested directory structure with archive files
const sub1 = path.join(dir, "season1"); const sub1 = path.join(dir, "season1");
const sub2 = path.join(dir, "season1", "extras"); const sub2 = path.join(dir, "season1", "extras");
fs.mkdirSync(sub2, { recursive: true }); fs.mkdirSync(sub2, { recursive: true });
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x"); fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x"); fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x"); fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x"); fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
// Non-archive files should be kept // Non-archive files should be kept
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content"); fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content"); fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
const removed = cleanupCancelledPackageArtifacts(dir); const removed = cleanupCancelledPackageArtifacts(dir);
expect(removed).toBe(4); // 2 rar parts + zip + 7z expect(removed).toBe(4); // 2 rar parts + zip + 7z
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false); expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false); expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false); expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false); expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
// Non-archives kept // Non-archives kept
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true); expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true); expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
}); });
it("detects link artifacts by URL content in text files", async () => { it("detects link artifacts by URL content in text files", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir); tempDirs.push(dir);
// File with link-like name containing URLs should be removed // File with link-like name containing URLs should be removed
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n"); fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
// File with link-like name but no URLs should be kept // File with link-like name but no URLs should be kept
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs"); fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
// Regular text file that doesn't match the link pattern should be kept // Regular text file that doesn't match the link pattern should be kept
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com"); fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
// .url files should always be removed // .url files should always be removed
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com"); fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
// .dlc files should always be removed // .dlc files should always be removed
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data"); fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
const removed = await removeDownloadLinkArtifacts(dir); const removed = await removeDownloadLinkArtifacts(dir);
expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false); expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false); expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false); expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
// Non-matching files should be kept // Non-matching files should be kept
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true); expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
}); });
it("does not recurse into sample symlink or junction targets", async () => { it("does not recurse into sample symlink or junction targets", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-")); const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
tempDirs.push(dir, external); tempDirs.push(dir, external);
const outsideFile = path.join(external, "outside-sample.mkv"); const outsideFile = path.join(external, "outside-sample.mkv");
fs.writeFileSync(outsideFile, "keep", "utf8"); fs.writeFileSync(outsideFile, "keep", "utf8");
const linkedSampleDir = path.join(dir, "sample"); const linkedSampleDir = path.join(dir, "sample");
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir"; const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
fs.symlinkSync(external, linkedSampleDir, linkType); fs.symlinkSync(external, linkedSampleDir, linkType);
const result = await removeSampleArtifacts(dir); const result = await removeSampleArtifacts(dir);
expect(result.files).toBe(0); expect(result.files).toBe(0);
expect(fs.existsSync(outsideFile)).toBe(true); expect(fs.existsSync(outsideFile)).toBe(true);
}); });
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff