Refactor: Extractor in 18 Sektionen reorganisiert
This commit is contained in:
@@ -0,0 +1,183 @@
|
|||||||
|
# Intensive Analyse: Pausen zwischen Pack-Entpackungen (10–15 Sekunden)
|
||||||
|
|
||||||
|
**Nur Analyse – keine Code-Änderungen.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem
|
||||||
|
|
||||||
|
Nach dem Entpacken eines Packs (z.B. 3 Parts einer Serie) passiert ca. 10–15 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 10–15 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 2208–2284)
|
||||||
|
|
||||||
|
- 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 2286–2328)
|
||||||
|
|
||||||
|
- 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 (10–15 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 10–15 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. 3761–3804).
|
||||||
|
- Post-Processing-Task: `runPackagePostProcessing` → `handlePackagePostProcessing` (ca. 3806–3854, 6544–6916).
|
||||||
|
- Hybrid: `runHybridExtraction` (ca. 6374–6542), inkl. `await autoRenameExtractedVideoFiles` (6490).
|
||||||
|
- Final: `handlePackagePostProcessing` nach `extractPackageArchives` (6697–6916): `recordPackageHistory`, `void runDeferredPostExtraction`, dann return.
|
||||||
|
- Extractor: `extractPackageArchives` (extractor.ts, ca. 1880–2353), Nested 2208–2284, Post-Cleanup 2286–2328.
|
||||||
|
- Rename: `autoRenameExtractedVideoFiles` (download-manager.ts, 2173–2312), nutzt `collectVideoFiles` (rekursiv).
|
||||||
|
- MKV/Cleanup: `collectMkvFilesToLibrary` (2448), `cleanupRemainingArchiveArtifacts` (2353), `runDeferredPostExtraction` (6922–6965).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+18
-18
@@ -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)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
+298
-298
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
-97
@@ -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: {},
|
||||||
|
|||||||
+2239
-2239
File diff suppressed because it is too large
Load Diff
+10674
-10674
File diff suppressed because it is too large
Load Diff
+2396
-2425
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+577
-577
File diff suppressed because it is too large
Load Diff
+424
-424
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+1058
-1058
File diff suppressed because it is too large
Load Diff
+800
-865
File diff suppressed because it is too large
Load Diff
+63
-63
@@ -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);
|
||||||
|
|||||||
+2671
-2671
File diff suppressed because it is too large
Load Diff
+35
-35
@@ -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;
|
||||||
|
|||||||
+34
-34
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+307
-307
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-86
@@ -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("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+109
-109
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+998
-998
File diff suppressed because it is too large
Load Diff
+5447
-5447
File diff suppressed because it is too large
Load Diff
+491
-491
File diff suppressed because it is too large
Load Diff
+546
-546
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user