Revert to v1.5.49 base + fix "Ausgewählte Downloads starten"
- Restore all source files from v1.5.49 (proven stable on both servers) - Add startPackages() IPC method that starts only specified packages - Fix context menu "Ausgewählte Downloads starten" to use startPackages() instead of start() which was starting ALL enabled packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
ca4392fa8b
commit
2ef3983049
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -11,7 +11,6 @@ import net.sf.sevenzipjbinding.IInStream;
|
||||
import net.sf.sevenzipjbinding.ISequentialOutStream;
|
||||
import net.sf.sevenzipjbinding.ICryptoGetTextPassword;
|
||||
import net.sf.sevenzipjbinding.PropID;
|
||||
import net.sf.sevenzipjbinding.ArchiveFormat;
|
||||
import net.sf.sevenzipjbinding.SevenZip;
|
||||
import net.sf.sevenzipjbinding.SevenZipException;
|
||||
import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream;
|
||||
@ -43,8 +42,6 @@ public final class JBindExtractorMain {
|
||||
private static final Pattern NUMBERED_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.zip\\.\\d{3}$");
|
||||
private static final Pattern OLD_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.z\\d{2,3}$");
|
||||
private static final Pattern SEVEN_ZIP_SPLIT_RE = Pattern.compile("(?i).*\\.7z\\.001$");
|
||||
private static final Pattern RAR_MULTIPART_RE = Pattern.compile("(?i).*\\.part\\d+\\.rar$");
|
||||
private static final Pattern RAR_OLDSPLIT_RE = Pattern.compile("(?i).*\\.r\\d{2,3}$");
|
||||
private static volatile boolean sevenZipInitialized = false;
|
||||
|
||||
private JBindExtractorMain() {
|
||||
@ -329,79 +326,18 @@ public final class JBindExtractorMain {
|
||||
String effectivePassword = password == null ? "" : password;
|
||||
SevenZipVolumeCallback callback = new SevenZipVolumeCallback(archiveFile, effectivePassword);
|
||||
|
||||
// VolumedArchiveInStream is ONLY for .7z.001 split archives.
|
||||
// It internally checks for the ".7z.001" suffix and rejects everything else.
|
||||
if (SEVEN_ZIP_SPLIT_RE.matcher(nameLower).matches()) {
|
||||
VolumedArchiveInStream volumed = new VolumedArchiveInStream(archiveFile.getName(), callback);
|
||||
IInArchive archive = SevenZip.openInArchive(null, volumed, callback);
|
||||
return new SevenZipArchiveContext(archive, null, volumed, callback);
|
||||
}
|
||||
|
||||
// Multi-part RAR (.part1.rar, .part2.rar or old-style .rar/.r01/.r02):
|
||||
// The first stream MUST be obtained via the callback so the volume name
|
||||
// tracker is properly initialized. 7z-JBinding uses getProperty(NAME)
|
||||
// to compute subsequent volume filenames.
|
||||
boolean isMultiPartRar = RAR_MULTIPART_RE.matcher(nameLower).matches()
|
||||
|| hasOldStyleRarSplits(archiveFile);
|
||||
|
||||
if (isMultiPartRar) {
|
||||
IInStream inStream = callback.getStream(archiveFile.getAbsolutePath());
|
||||
if (inStream == null) {
|
||||
throw new IOException("Archiv konnte nicht geoeffnet werden: " + archiveFile.getAbsolutePath());
|
||||
}
|
||||
// Try RAR5 first (modern), then RAR4, then auto-detect
|
||||
Exception lastError = null;
|
||||
ArchiveFormat[] rarFormats = { ArchiveFormat.RAR5, ArchiveFormat.RAR, null };
|
||||
for (ArchiveFormat fmt : rarFormats) {
|
||||
try {
|
||||
inStream.seek(0L, 0);
|
||||
IInArchive archive = SevenZip.openInArchive(fmt, inStream, callback);
|
||||
return new SevenZipArchiveContext(archive, null, null, callback);
|
||||
} catch (Exception e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
callback.close();
|
||||
throw lastError != null ? lastError : new IOException("Archiv konnte nicht geoeffnet werden");
|
||||
}
|
||||
|
||||
// Single-file archives: open directly with auto-detection
|
||||
RandomAccessFile raf = new RandomAccessFile(archiveFile, "r");
|
||||
RandomAccessFileInStream stream = new RandomAccessFileInStream(raf);
|
||||
IInArchive archive = SevenZip.openInArchive(null, stream, callback);
|
||||
return new SevenZipArchiveContext(archive, stream, null, callback);
|
||||
}
|
||||
|
||||
private static boolean hasOldStyleRarSplits(File archiveFile) {
|
||||
// Old-style RAR splits: main.rar + main.r01, main.r02, ...
|
||||
String name = archiveFile.getName();
|
||||
if (!name.toLowerCase(Locale.ROOT).endsWith(".rar")) {
|
||||
return false;
|
||||
}
|
||||
File parent = archiveFile.getParentFile();
|
||||
if (parent == null || !parent.exists()) {
|
||||
return false;
|
||||
}
|
||||
File[] siblings = parent.listFiles();
|
||||
if (siblings == null) {
|
||||
return false;
|
||||
}
|
||||
String stem = name.substring(0, name.length() - 4);
|
||||
for (File sibling : siblings) {
|
||||
if (!sibling.isFile()) {
|
||||
continue;
|
||||
}
|
||||
String sibName = sibling.getName();
|
||||
if (sibName.length() > stem.length() + 1 && sibName.substring(0, stem.length()).equalsIgnoreCase(stem)) {
|
||||
String suffix = sibName.substring(stem.length());
|
||||
if (RAR_OLDSPLIT_RE.matcher(suffix).matches() || suffix.toLowerCase(Locale.ROOT).matches("\\.r\\d{2,3}")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean isWrongPassword(ZipException error, boolean encrypted) {
|
||||
if (error == null) {
|
||||
return false;
|
||||
@ -420,10 +356,7 @@ public final class JBindExtractorMain {
|
||||
if (!encrypted || result == null) {
|
||||
return false;
|
||||
}
|
||||
// Only DATAERROR reliably indicates wrong password. CRCERROR can also mean
|
||||
// a genuinely corrupt or incomplete archive, and 7z-JBinding sometimes
|
||||
// falsely reports encrypted=true for non-encrypted RAR files.
|
||||
return result == ExtractOperationResult.DATAERROR;
|
||||
return result == ExtractOperationResult.CRCERROR || result == ExtractOperationResult.DATAERROR;
|
||||
}
|
||||
|
||||
private static boolean looksLikeWrongPassword(Throwable error, boolean encrypted) {
|
||||
@ -434,9 +367,7 @@ public final class JBindExtractorMain {
|
||||
if (text.contains("wrong password") || text.contains("falsches passwort")) {
|
||||
return true;
|
||||
}
|
||||
// Only "data error" suggests wrong password. CRC errors can also mean
|
||||
// corrupt/incomplete archives, so we don't treat them as password failures.
|
||||
return encrypted && text.contains("data error");
|
||||
return encrypted && (text.contains("crc") || text.contains("data error") || text.contains("checksum"));
|
||||
}
|
||||
|
||||
private static boolean shouldUseZip4j(File archiveFile) {
|
||||
@ -840,22 +771,20 @@ public final class JBindExtractorMain {
|
||||
|
||||
private static final class SevenZipVolumeCallback implements IArchiveOpenCallback, IArchiveOpenVolumeCallback, ICryptoGetTextPassword, Closeable {
|
||||
private final File archiveDir;
|
||||
private final String firstFileName;
|
||||
private final String password;
|
||||
private final Map<String, RandomAccessFile> openRafs = new HashMap<String, RandomAccessFile>();
|
||||
// Must track the LAST opened volume name — 7z-JBinding queries this via
|
||||
// getProperty(NAME) to compute the next volume filename.
|
||||
private volatile String currentVolumeName;
|
||||
|
||||
SevenZipVolumeCallback(File archiveFile, String password) {
|
||||
this.archiveDir = archiveFile.getAbsoluteFile().getParentFile();
|
||||
this.currentVolumeName = archiveFile.getName();
|
||||
this.firstFileName = archiveFile.getName();
|
||||
this.password = password == null ? "" : password;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getProperty(PropID propID) {
|
||||
if (propID == PropID.NAME) {
|
||||
return currentVolumeName;
|
||||
return firstFileName;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@ -874,9 +803,6 @@ public final class JBindExtractorMain {
|
||||
openRafs.put(key, raf);
|
||||
}
|
||||
raf.seek(0L);
|
||||
// Update current volume name so getProperty(NAME) returns the
|
||||
// correct value when 7z-JBinding computes the next volume.
|
||||
currentVolumeName = filename;
|
||||
return new RandomAccessFileInStream(raf);
|
||||
} catch (IOException error) {
|
||||
throw new SevenZipException("Volume konnte nicht geoffnet werden: " + filename, error);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { app } from "electron";
|
||||
import {
|
||||
@ -7,7 +6,6 @@ import {
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
ParsedPackageInput,
|
||||
ProviderAccountInfo,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
@ -25,8 +23,6 @@ import { MegaWebFallback } from "./mega-web-fallback";
|
||||
import { addHistoryEntry, clearHistory, createStoragePaths, loadHistory, loadSession, loadSettings, normalizeSettings, removeHistoryEntry, saveSession, saveSettings } from "./storage";
|
||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||
import { startDebugServer, stopDebugServer } from "./debug-server";
|
||||
import { decryptCredentials, encryptCredentials, SENSITIVE_KEYS } from "./backup-crypto";
|
||||
import { compactErrorText } from "./utils";
|
||||
|
||||
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
|
||||
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
|
||||
@ -208,6 +204,10 @@ export class AppController {
|
||||
await this.manager.start();
|
||||
}
|
||||
|
||||
public async startPackages(packageIds: string[]): Promise<void> {
|
||||
await this.manager.startPackages(packageIds);
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.manager.stop();
|
||||
}
|
||||
@ -257,16 +257,9 @@ export class AppController {
|
||||
}
|
||||
|
||||
public exportBackup(): string {
|
||||
const settingsCopy = { ...this.settings } as Record<string, unknown>;
|
||||
const sensitiveFields: Record<string, string> = {};
|
||||
for (const key of SENSITIVE_KEYS) {
|
||||
sensitiveFields[key] = String(settingsCopy[key] ?? "");
|
||||
delete settingsCopy[key];
|
||||
}
|
||||
const username = os.userInfo().username;
|
||||
const credentials = encryptCredentials(sensitiveFields, username);
|
||||
const settings = this.settings;
|
||||
const session = this.manager.getSession();
|
||||
return JSON.stringify({ version: 2, settings: settingsCopy, credentials, session }, null, 2);
|
||||
return JSON.stringify({ version: 1, settings, session }, null, 2);
|
||||
}
|
||||
|
||||
public importBackup(json: string): { restored: boolean; message: string } {
|
||||
@ -279,28 +272,7 @@ export class AppController {
|
||||
if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) {
|
||||
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" };
|
||||
}
|
||||
|
||||
const version = typeof parsed.version === "number" ? parsed.version : 1;
|
||||
let settingsObj = parsed.settings as Record<string, unknown>;
|
||||
|
||||
if (version >= 2) {
|
||||
const creds = parsed.credentials as { salt: string; iv: string; tag: string; data: string } | undefined;
|
||||
if (!creds || !creds.salt || !creds.iv || !creds.tag || !creds.data) {
|
||||
return { restored: false, message: "Backup v2: Verschlüsselte Zugangsdaten fehlen" };
|
||||
}
|
||||
try {
|
||||
const username = os.userInfo().username;
|
||||
const decrypted = decryptCredentials(creds, username);
|
||||
settingsObj = { ...settingsObj, ...decrypted };
|
||||
} catch {
|
||||
return {
|
||||
restored: false,
|
||||
message: "Entschlüsselung fehlgeschlagen. Das Backup wurde mit einem anderen Benutzer erstellt."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const restoredSettings = normalizeSettings(settingsObj as AppSettings);
|
||||
const restoredSettings = normalizeSettings(parsed.settings as AppSettings);
|
||||
this.settings = restoredSettings;
|
||||
saveSettings(this.storagePaths, this.settings);
|
||||
this.manager.setSettings(this.settings);
|
||||
@ -329,62 +301,6 @@ export class AppController {
|
||||
removeHistoryEntry(this.storagePaths, entryId);
|
||||
}
|
||||
|
||||
public async checkMegaAccount(): Promise<ProviderAccountInfo> {
|
||||
return this.megaWebFallback.getAccountInfo();
|
||||
}
|
||||
|
||||
public async checkRealDebridAccount(): Promise<ProviderAccountInfo> {
|
||||
try {
|
||||
const response = await fetch("https://api.real-debrid.com/rest/1.0/user", {
|
||||
headers: { Authorization: `Bearer ${this.settings.token}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return { provider: "realdebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: `HTTP ${response.status}: ${compactErrorText(text)}` };
|
||||
}
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
const username = String(data.username ?? "");
|
||||
const type = String(data.type ?? "");
|
||||
const expiration = data.expiration ? new Date(String(data.expiration)) : null;
|
||||
const daysRemaining = expiration ? Math.max(0, Math.round((expiration.getTime() - Date.now()) / 86400000)) : null;
|
||||
const points = typeof data.points === "number" ? data.points : null;
|
||||
return { provider: "realdebrid", username, accountType: type === "premium" ? "Premium" : type, daysRemaining, loyaltyPoints: points as number | null };
|
||||
} catch (err) {
|
||||
return { provider: "realdebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: compactErrorText(err) };
|
||||
}
|
||||
}
|
||||
|
||||
public async checkAllDebridAccount(): Promise<ProviderAccountInfo> {
|
||||
try {
|
||||
const response = await fetch("https://api.alldebrid.com/v4/user", {
|
||||
headers: { Authorization: `Bearer ${this.settings.allDebridToken}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => "");
|
||||
return { provider: "alldebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: `HTTP ${response.status}: ${compactErrorText(text)}` };
|
||||
}
|
||||
const data = await response.json() as Record<string, unknown>;
|
||||
const userData = (data.data as Record<string, unknown> | undefined)?.user as Record<string, unknown> | undefined;
|
||||
if (!userData) {
|
||||
return { provider: "alldebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Ungültige API-Antwort" };
|
||||
}
|
||||
const username = String(userData.username ?? "");
|
||||
const isPremium = Boolean(userData.isPremium);
|
||||
const premiumUntil = typeof userData.premiumUntil === "number" ? userData.premiumUntil : 0;
|
||||
const daysRemaining = premiumUntil > 0 ? Math.max(0, Math.round((premiumUntil * 1000 - Date.now()) / 86400000)) : null;
|
||||
return { provider: "alldebrid", username, accountType: isPremium ? "Premium" : "Free", daysRemaining, loyaltyPoints: null };
|
||||
} catch (err) {
|
||||
return { provider: "alldebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: compactErrorText(err) };
|
||||
}
|
||||
}
|
||||
|
||||
public async checkBestDebridAccount(): Promise<ProviderAccountInfo> {
|
||||
if (!this.settings.bestToken.trim()) {
|
||||
return { provider: "bestdebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Kein Token konfiguriert" };
|
||||
}
|
||||
return { provider: "bestdebrid", username: "(Token konfiguriert)", accountType: "Konfiguriert", daysRemaining: null, loyaltyPoints: null };
|
||||
}
|
||||
|
||||
public addToHistory(entry: HistoryEntry): void {
|
||||
addHistoryEntry(this.storagePaths, entry);
|
||||
}
|
||||
|
||||
@ -218,35 +218,6 @@ function isArchiveLikePath(filePath: string): boolean {
|
||||
return /\.(?:part\d+\.rar|rar|r\d{2,3}|zip(?:\.\d+)?|z\d{1,3}|7z(?:\.\d+)?)$/i.test(lower);
|
||||
}
|
||||
|
||||
const ITEM_RECOVERY_MIN_BYTES = 10 * 1024;
|
||||
const ARCHIVE_RECOVERY_MIN_RATIO = 0.995;
|
||||
const ARCHIVE_RECOVERY_MAX_SLACK_BYTES = 4 * 1024 * 1024;
|
||||
const FILE_RECOVERY_MIN_RATIO = 0.98;
|
||||
const FILE_RECOVERY_MAX_SLACK_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
function recoveryExpectedMinSize(filePath: string, totalBytes: number | null | undefined): number {
|
||||
const knownTotal = Number(totalBytes || 0);
|
||||
if (!Number.isFinite(knownTotal) || knownTotal <= 0) {
|
||||
return ITEM_RECOVERY_MIN_BYTES;
|
||||
}
|
||||
|
||||
const archiveLike = isArchiveLikePath(filePath);
|
||||
const minRatio = archiveLike ? ARCHIVE_RECOVERY_MIN_RATIO : FILE_RECOVERY_MIN_RATIO;
|
||||
const maxSlack = archiveLike ? ARCHIVE_RECOVERY_MAX_SLACK_BYTES : FILE_RECOVERY_MAX_SLACK_BYTES;
|
||||
const ratioBased = Math.floor(knownTotal * minRatio);
|
||||
const slackBased = Math.max(0, Math.floor(knownTotal) - maxSlack);
|
||||
return Math.max(ITEM_RECOVERY_MIN_BYTES, Math.max(ratioBased, slackBased));
|
||||
}
|
||||
|
||||
function isRecoveredFileSizeSufficient(item: Pick<DownloadItem, "targetPath" | "fileName" | "totalBytes">, fileSize: number): boolean {
|
||||
if (!Number.isFinite(fileSize) || fileSize <= 0) {
|
||||
return false;
|
||||
}
|
||||
const candidatePath = String(item.targetPath || item.fileName || "");
|
||||
const minSize = recoveryExpectedMinSize(candidatePath, item.totalBytes);
|
||||
return fileSize >= minSize;
|
||||
}
|
||||
|
||||
function isFetchFailure(errorText: string): boolean {
|
||||
const text = String(errorText || "").toLowerCase();
|
||||
return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error");
|
||||
@ -2308,6 +2279,92 @@ export class DownloadManager extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
public async startPackages(packageIds: string[]): Promise<void> {
|
||||
const targetSet = new Set(packageIds);
|
||||
|
||||
// Enable specified packages if disabled
|
||||
for (const pkgId of targetSet) {
|
||||
const pkg = this.session.packages[pkgId];
|
||||
if (pkg && !pkg.enabled) {
|
||||
pkg.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Recover stopped items in specified packages
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (!targetSet.has(item.packageId)) continue;
|
||||
if (item.status === "cancelled" && item.fullStatus === "Gestoppt") {
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
if (pkg && !pkg.cancelled && pkg.enabled) {
|
||||
item.status = "queued";
|
||||
item.fullStatus = "Wartet";
|
||||
item.lastError = "";
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If already running, the scheduler will pick up newly enabled items
|
||||
if (this.session.running) {
|
||||
// Add new items to runItemIds so the scheduler processes them
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (!targetSet.has(item.packageId)) continue;
|
||||
if (item.status === "queued" || item.status === "reconnect_wait") {
|
||||
this.runItemIds.add(item.id);
|
||||
this.runPackageIds.add(item.packageId);
|
||||
}
|
||||
}
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not running: start with only items from specified packages
|
||||
const runItems = Object.values(this.session.items)
|
||||
.filter((item) => {
|
||||
if (!targetSet.has(item.packageId)) return false;
|
||||
if (item.status !== "queued" && item.status !== "reconnect_wait") return false;
|
||||
const pkg = this.session.packages[item.packageId];
|
||||
return Boolean(pkg && !pkg.cancelled && pkg.enabled);
|
||||
});
|
||||
if (runItems.length === 0) {
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
return;
|
||||
}
|
||||
this.runItemIds = new Set(runItems.map((item) => item.id));
|
||||
this.runPackageIds = new Set(runItems.map((item) => item.packageId));
|
||||
this.runOutcomes.clear();
|
||||
this.runCompletedPackages.clear();
|
||||
this.retryAfterByItem.clear();
|
||||
this.session.running = true;
|
||||
this.session.paused = false;
|
||||
this.session.runStartedAt = nowMs();
|
||||
this.session.totalDownloadedBytes = 0;
|
||||
this.session.summaryText = "";
|
||||
this.session.reconnectUntil = 0;
|
||||
this.session.reconnectReason = "";
|
||||
this.speedEvents = [];
|
||||
this.speedBytesLastWindow = 0;
|
||||
this.speedBytesPerPackage.clear();
|
||||
this.speedEventsHead = 0;
|
||||
this.lastGlobalProgressBytes = 0;
|
||||
this.lastGlobalProgressAt = nowMs();
|
||||
this.summary = null;
|
||||
this.nonResumableActive = 0;
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
logger.info(`Start (nur Pakete: ${packageIds.length}): ${runItems.length} Items`);
|
||||
void this.ensureScheduler().catch((error) => {
|
||||
logger.error(`Scheduler abgestürzt: ${compactErrorText(error)}`);
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
this.persistSoon();
|
||||
this.emitState(true);
|
||||
});
|
||||
}
|
||||
|
||||
public async start(): Promise<void> {
|
||||
if (this.session.running) {
|
||||
return;
|
||||
@ -4978,7 +5035,6 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
|
||||
const completedPaths = new Set<string>();
|
||||
const completedItemsByPath = new Map<string, DownloadItem>();
|
||||
const pendingPaths = new Set<string>();
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
@ -4986,9 +5042,7 @@ export class DownloadManager extends EventEmitter {
|
||||
continue;
|
||||
}
|
||||
if (item.status === "completed" && item.targetPath) {
|
||||
const key = pathKey(item.targetPath);
|
||||
completedPaths.add(key);
|
||||
completedItemsByPath.set(key, item);
|
||||
completedPaths.add(pathKey(item.targetPath));
|
||||
} else if (item.targetPath) {
|
||||
pendingPaths.add(pathKey(item.targetPath));
|
||||
}
|
||||
@ -5024,82 +5078,12 @@ export class DownloadManager extends EventEmitter {
|
||||
const partsOnDisk = collectArchiveCleanupTargets(candidate, dirFiles);
|
||||
const allPartsCompleted = partsOnDisk.every((part) => completedPaths.has(pathKey(part)));
|
||||
if (allPartsCompleted) {
|
||||
let allPartsLikelyComplete = true;
|
||||
for (const part of partsOnDisk) {
|
||||
const completedItem = completedItemsByPath.get(pathKey(part));
|
||||
if (!completedItem) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(part);
|
||||
if (isRecoveredFileSizeSufficient(completedItem, stat.size)) {
|
||||
continue;
|
||||
}
|
||||
const minSize = recoveryExpectedMinSize(completedItem.targetPath || completedItem.fileName, completedItem.totalBytes);
|
||||
logger.info(`Hybrid-Extract: ${path.basename(candidate)} übersprungen – ${path.basename(part)} zu klein (${humanSize(stat.size)}, erwartet mind. ${humanSize(minSize)})`);
|
||||
allPartsLikelyComplete = false;
|
||||
break;
|
||||
} catch {
|
||||
allPartsLikelyComplete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!allPartsLikelyComplete) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateBase = path.basename(candidate).toLowerCase();
|
||||
|
||||
// For multi-part archives (.part1.rar), check if parts of THIS SPECIFIC archive
|
||||
// are still pending. We match by archive prefix so E01 parts don't block E02.
|
||||
const multiMatch = candidateBase.match(/^(.*)\.part0*1\.rar$/i);
|
||||
if (multiMatch) {
|
||||
const prefix = multiMatch[1].toLowerCase();
|
||||
const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const partPattern = new RegExp(`^${escapedPrefix}\\.part\\d+\\.rar$`, "i");
|
||||
const hasRelatedPending = pkg.itemIds.some((itemId) => {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item || item.status === "completed" || item.status === "failed" || item.status === "cancelled") {
|
||||
return false;
|
||||
}
|
||||
// Check fileName (set early from link URL)
|
||||
if (item.fileName && partPattern.test(item.fileName)) {
|
||||
return true;
|
||||
}
|
||||
// Check targetPath basename (set when download starts)
|
||||
if (item.targetPath && partPattern.test(path.basename(item.targetPath))) {
|
||||
return true;
|
||||
}
|
||||
// Item has no identity at all — might be an unresolved part, be conservative
|
||||
if (!item.fileName && !item.targetPath) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (hasRelatedPending) {
|
||||
logger.info(`Hybrid-Extract: ${path.basename(candidate)} übersprungen – zugehörige Parts noch ausstehend`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const hasUnstartedParts = [...pendingPaths].some((pendingPath) => {
|
||||
const pendingName = path.basename(pendingPath).toLowerCase();
|
||||
return this.looksLikeArchivePart(pendingName, candidateBase);
|
||||
const candidateStem = path.basename(candidate).toLowerCase();
|
||||
return this.looksLikeArchivePart(pendingName, candidateStem);
|
||||
});
|
||||
// Also check items without targetPath (queued items that only have fileName)
|
||||
const hasMatchingPendingItems = pkg.itemIds.some((itemId) => {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item || item.status === "completed" || item.status === "failed" || item.status === "cancelled") {
|
||||
return false;
|
||||
}
|
||||
if (item.fileName && !item.targetPath) {
|
||||
if (this.looksLikeArchivePart(item.fileName.toLowerCase(), candidateBase)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (hasUnstartedParts || hasMatchingPendingItems) {
|
||||
if (hasUnstartedParts) {
|
||||
continue;
|
||||
}
|
||||
ready.add(pathKey(candidate));
|
||||
@ -5109,11 +5093,6 @@ export class DownloadManager extends EventEmitter {
|
||||
// Disk-fallback: if all parts exist on disk but some items lack "completed" status,
|
||||
// allow extraction if none of those parts are actively downloading/validating.
|
||||
// This handles items that finished downloading but whose status was not updated.
|
||||
// Skip disk-fallback entirely for multi-part archives — only allPartsCompleted should handle those.
|
||||
const isMultiPart = /\.part0*1\.rar$/i.test(path.basename(candidate));
|
||||
if (isMultiPart) {
|
||||
continue;
|
||||
}
|
||||
const missingParts = partsOnDisk.filter((part) => !completedPaths.has(pathKey(part)));
|
||||
let allMissingExistOnDisk = true;
|
||||
for (const part of missingParts) {
|
||||
@ -5138,22 +5117,6 @@ export class DownloadManager extends EventEmitter {
|
||||
if (anyActivelyProcessing) {
|
||||
continue;
|
||||
}
|
||||
// Also check fileName for items without targetPath (queued/downloading items)
|
||||
const candidateBaseFb = path.basename(candidate).toLowerCase();
|
||||
const hasMatchingPendingFb = pkg.itemIds.some((itemId) => {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item || item.status === "completed" || item.status === "failed" || item.status === "cancelled") {
|
||||
return false;
|
||||
}
|
||||
const nameToCheck = item.fileName?.toLowerCase() || (item.targetPath ? path.basename(item.targetPath).toLowerCase() : "");
|
||||
if (!nameToCheck) {
|
||||
return false;
|
||||
}
|
||||
return nameToCheck === candidateBaseFb || this.looksLikeArchivePart(nameToCheck, candidateBaseFb);
|
||||
});
|
||||
if (hasMatchingPendingFb) {
|
||||
continue;
|
||||
}
|
||||
logger.info(`Hybrid-Extract Disk-Fallback: ${path.basename(candidate)} (${missingParts.length} Part(s) auf Disk ohne completed-Status)`);
|
||||
ready.add(pathKey(candidate));
|
||||
}
|
||||
@ -5281,9 +5244,17 @@ export class DownloadManager extends EventEmitter {
|
||||
if (progress.phase === "done") {
|
||||
return;
|
||||
}
|
||||
// Track only currently active archive items; final statuses are set
|
||||
// after extraction result is known.
|
||||
// When a new archive starts, mark the previous archive's items as done
|
||||
if (progress.archiveName && progress.archiveName !== lastHybridArchiveName) {
|
||||
if (lastHybridArchiveName && currentArchiveItems.length > 0) {
|
||||
const doneAt = nowMs();
|
||||
for (const entry of currentArchiveItems) {
|
||||
if (!isExtractedLabel(entry.fullStatus)) {
|
||||
entry.fullStatus = "Entpackt - Done";
|
||||
entry.updatedAt = doneAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastHybridArchiveName = progress.archiveName;
|
||||
const resolved = resolveArchiveItems(progress.archiveName);
|
||||
currentArchiveItems = resolved;
|
||||
@ -5358,8 +5329,12 @@ export class DownloadManager extends EventEmitter {
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(item.targetPath);
|
||||
const minSize = recoveryExpectedMinSize(item.targetPath || item.fileName, item.totalBytes);
|
||||
if (isRecoveredFileSizeSufficient(item, stat.size)) {
|
||||
// Require file to be either ≥50% of expected size or at least 10 KB to avoid
|
||||
// recovering tiny error-response files (e.g. 9-byte "Forbidden" pages).
|
||||
const minSize = item.totalBytes && item.totalBytes > 0
|
||||
? Math.max(10240, Math.floor(item.totalBytes * 0.5))
|
||||
: 10240;
|
||||
if (stat.size >= minSize) {
|
||||
logger.info(`Item-Recovery: ${item.fileName} war "${item.status}" aber Datei existiert (${humanSize(stat.size)}), setze auf completed`);
|
||||
item.status = "completed";
|
||||
item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`;
|
||||
@ -5493,9 +5468,17 @@ export class DownloadManager extends EventEmitter {
|
||||
signal: extractAbortController.signal,
|
||||
packageId,
|
||||
onProgress: (progress) => {
|
||||
// Track only currently active archive items; final statuses are set
|
||||
// after extraction result is known.
|
||||
// When a new archive starts, mark the previous archive's items as done
|
||||
if (progress.archiveName && progress.archiveName !== lastExtractArchiveName) {
|
||||
if (lastExtractArchiveName && currentArchiveItems.length > 0) {
|
||||
const doneAt = nowMs();
|
||||
for (const entry of currentArchiveItems) {
|
||||
if (!isExtractedLabel(entry.fullStatus)) {
|
||||
entry.fullStatus = "Entpackt - Done";
|
||||
entry.updatedAt = doneAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
lastExtractArchiveName = progress.archiveName;
|
||||
currentArchiveItems = resolveArchiveItems(progress.archiveName);
|
||||
}
|
||||
|
||||
@ -499,7 +499,7 @@ function extractorThreadSwitch(hybridMode = false): string {
|
||||
return `-mt${threadCount}`;
|
||||
}
|
||||
|
||||
function lowerExtractProcessPriority(childPid: number | undefined, label = ""): void {
|
||||
function lowerExtractProcessPriority(childPid: number | undefined): void {
|
||||
if (process.platform !== "win32") {
|
||||
return;
|
||||
}
|
||||
@ -511,9 +511,6 @@ function lowerExtractProcessPriority(childPid: number | undefined, label = ""):
|
||||
// IDLE_PRIORITY_CLASS: lowers CPU scheduling priority so extraction
|
||||
// doesn't starve other processes. I/O priority stays Normal (like JDownloader 2).
|
||||
os.setPriority(pid, os.constants.priority.PRIORITY_LOW);
|
||||
if (label) {
|
||||
logger.info(`Prozess-Priorität: CPU=Idle, I/O=Normal (PID ${pid}, ${label})`);
|
||||
}
|
||||
} catch {
|
||||
// ignore: priority lowering is best-effort
|
||||
}
|
||||
@ -583,7 +580,7 @@ function runExtractCommand(
|
||||
let settled = false;
|
||||
let output = "";
|
||||
const child = spawn(command, args, { windowsHide: true });
|
||||
lowerExtractProcessPriority(child.pid, `legacy/${path.basename(command).replace(/\.exe$/i, "")}`);
|
||||
lowerExtractProcessPriority(child.pid);
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
let timedOutByWatchdog = false;
|
||||
let abortedBySignal = false;
|
||||
@ -900,7 +897,7 @@ function runJvmExtractCommand(
|
||||
let stderrBuffer = "";
|
||||
|
||||
const child = spawn(layout.javaCommand, args, { windowsHide: true });
|
||||
lowerExtractProcessPriority(child.pid, "7zjbinding/single-thread");
|
||||
lowerExtractProcessPriority(child.pid);
|
||||
|
||||
const flushLines = (rawChunk: string, fromStdErr = false): void => {
|
||||
if (!rawChunk) {
|
||||
@ -1169,17 +1166,7 @@ async function runExternalExtract(
|
||||
}
|
||||
logger.warn(`JVM-Extractor nicht verfügbar, nutze Legacy-Extractor: ${path.basename(archivePath)}`);
|
||||
} else {
|
||||
if (hybridMode) {
|
||||
try {
|
||||
const archiveStat = await fs.promises.stat(archivePath);
|
||||
logger.info(`Hybrid-Extract JVM: ${path.basename(archivePath)} (${(archiveStat.size / 1048576).toFixed(1)} MB)`);
|
||||
} catch (statErr) {
|
||||
logger.warn(`Hybrid-Extract JVM: Archiv nicht zugreifbar: ${path.basename(archivePath)} — ${String(statErr)}`);
|
||||
}
|
||||
}
|
||||
logger.info(`JVM-Extractor aktiv (${layout.rootDir}): ${path.basename(archivePath)}`);
|
||||
const maxJvmAttempts = hybridMode ? 2 : 1;
|
||||
for (let jvmAttempt = 1; jvmAttempt <= maxJvmAttempts; jvmAttempt++) {
|
||||
const jvmResult = await runJvmExtractCommand(
|
||||
layout,
|
||||
archivePath,
|
||||
@ -1192,10 +1179,7 @@ async function runExternalExtract(
|
||||
);
|
||||
|
||||
if (jvmResult.ok) {
|
||||
if (jvmAttempt > 1) {
|
||||
logger.info(`JVM-Extractor Retry #${jvmAttempt - 1} erfolgreich: ${path.basename(archivePath)}`);
|
||||
}
|
||||
logger.info(`Entpackt via ${jvmResult.backend || "jvm"} [CPU=Idle, I/O=Normal, single-thread]: ${path.basename(archivePath)}`);
|
||||
logger.info(`Entpackt via ${jvmResult.backend || "jvm"}: ${path.basename(archivePath)}`);
|
||||
return jvmResult.usedPassword;
|
||||
}
|
||||
if (jvmResult.aborted) {
|
||||
@ -1206,16 +1190,6 @@ async function runExternalExtract(
|
||||
}
|
||||
|
||||
jvmFailureReason = jvmResult.errorText || "JVM-Extractor fehlgeschlagen";
|
||||
|
||||
// In hybrid mode, retry once on "codecs" / "can't be opened" errors —
|
||||
// these can be caused by transient Windows file locks right after download completion.
|
||||
const isTransientOpen = jvmFailureReason.includes("codecs") || jvmFailureReason.includes("can't be opened");
|
||||
if (hybridMode && isTransientOpen && jvmAttempt < maxJvmAttempts) {
|
||||
logger.warn(`JVM-Extractor Hybrid-Retry: ${jvmFailureReason} — warte 3s vor Versuch #${jvmAttempt + 1}: ${path.basename(archivePath)}`);
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
continue;
|
||||
}
|
||||
|
||||
const isUnsupportedMethod = jvmFailureReason.includes("UNSUPPORTEDMETHOD");
|
||||
if (backendMode === "jvm" && !isUnsupportedMethod) {
|
||||
throw new Error(jvmFailureReason);
|
||||
@ -1225,8 +1199,6 @@ async function runExternalExtract(
|
||||
} else {
|
||||
logger.warn(`JVM-Extractor Fehler, fallback auf Legacy: ${jvmFailureReason}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1247,12 +1219,10 @@ async function runExternalExtract(
|
||||
hybridMode
|
||||
);
|
||||
const extractorName = path.basename(command).replace(/\.exe$/i, "");
|
||||
const threadInfo = extractorThreadSwitch(hybridMode);
|
||||
const modeLabel = hybridMode ? "hybrid" : "normal";
|
||||
if (jvmFailureReason) {
|
||||
logger.info(`Entpackt via legacy/${extractorName} [CPU=Idle, I/O=Normal, ${threadInfo}, ${modeLabel}] (nach JVM-Fehler): ${path.basename(archivePath)}`);
|
||||
logger.info(`Entpackt via legacy/${extractorName} (nach JVM-Fehler): ${path.basename(archivePath)}`);
|
||||
} else {
|
||||
logger.info(`Entpackt via legacy/${extractorName} [CPU=Idle, I/O=Normal, ${threadInfo}, ${modeLabel}]: ${path.basename(archivePath)}`);
|
||||
logger.info(`Entpackt via legacy/${extractorName}: ${path.basename(archivePath)}`);
|
||||
}
|
||||
return password;
|
||||
} finally {
|
||||
|
||||
@ -254,31 +254,9 @@ function registerIpcHandlers(): void {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, async (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
|
||||
const validated = validatePlainObject(partial ?? {}, "partial") as Partial<AppSettings>;
|
||||
const oldSettings = controller.getSettings();
|
||||
const dirKeys = ["outputDir", "extractDir", "mkvLibraryDir"] as const;
|
||||
for (const key of dirKeys) {
|
||||
const newVal = validated[key];
|
||||
if (typeof newVal === "string" && newVal.trim() && newVal !== oldSettings[key]) {
|
||||
if (!fs.existsSync(newVal)) {
|
||||
const msgOpts = {
|
||||
type: "question" as const,
|
||||
buttons: ["Ja", "Nein"],
|
||||
defaultId: 0,
|
||||
title: "Ordner erstellen?",
|
||||
message: `Der Ordner existiert nicht:\n${newVal}\n\nSoll er erstellt werden?`
|
||||
};
|
||||
const { response } = mainWindow
|
||||
? await dialog.showMessageBox(mainWindow, msgOpts)
|
||||
: await dialog.showMessageBox(msgOpts);
|
||||
if (response === 0) {
|
||||
fs.mkdirSync(newVal, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = controller.updateSettings(validated);
|
||||
ipcMain.handle(IPC_CHANNELS.UPDATE_SETTINGS, (_event: IpcMainInvokeEvent, partial: Partial<AppSettings>) => {
|
||||
const validated = validatePlainObject(partial ?? {}, "partial");
|
||||
const result = controller.updateSettings(validated as Partial<AppSettings>);
|
||||
updateClipboardWatcher();
|
||||
updateTray();
|
||||
return result;
|
||||
@ -310,6 +288,10 @@ function registerIpcHandlers(): void {
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
|
||||
ipcMain.handle(IPC_CHANNELS.START, () => controller.start());
|
||||
ipcMain.handle(IPC_CHANNELS.START_PACKAGES, (_event: IpcMainInvokeEvent, packageIds: string[]) => {
|
||||
if (!Array.isArray(packageIds)) throw new Error("packageIds muss ein Array sein");
|
||||
return controller.startPackages(packageIds);
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());
|
||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
|
||||
ipcMain.handle(IPC_CHANNELS.CANCEL_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
|
||||
@ -384,21 +366,6 @@ function registerIpcHandlers(): void {
|
||||
const result = mainWindow ? await dialog.showOpenDialog(mainWindow, options) : await dialog.showOpenDialog(options);
|
||||
return result.canceled ? [] : result.filePaths;
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.CHECK_ACCOUNT, async (_event: IpcMainInvokeEvent, provider: string) => {
|
||||
validateString(provider, "provider");
|
||||
switch (provider) {
|
||||
case "realdebrid":
|
||||
return controller.checkRealDebridAccount();
|
||||
case "megadebrid":
|
||||
return controller.checkMegaAccount();
|
||||
case "bestdebrid":
|
||||
return controller.checkBestDebridAccount();
|
||||
case "alldebrid":
|
||||
return controller.checkAllDebridAccount();
|
||||
default:
|
||||
return { provider, username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Nicht unterstützt" };
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.GET_SESSION_STATS, () => controller.getSessionStats());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.RESTART, () => {
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { ProviderAccountInfo } from "../shared/types";
|
||||
import { UnrestrictedLink } from "./realdebrid";
|
||||
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
||||
|
||||
@ -16,7 +15,6 @@ 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_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 PROFILE_URL = "https://www.mega-debrid.eu/index.php?page=profil";
|
||||
|
||||
function normalizeLink(link: string): string {
|
||||
return link.trim().toLowerCase();
|
||||
@ -266,51 +264,6 @@ export class MegaWebFallback {
|
||||
}, signal);
|
||||
}
|
||||
|
||||
public async getAccountInfo(): Promise<ProviderAccountInfo> {
|
||||
return this.runExclusive(async () => {
|
||||
const creds = this.getCredentials();
|
||||
if (!creds.login.trim() || !creds.password.trim()) {
|
||||
return { provider: "megadebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Login/Passwort nicht konfiguriert" };
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.cookie || Date.now() - this.cookieSetAt > 20 * 60 * 1000) {
|
||||
await this.login(creds.login, creds.password);
|
||||
}
|
||||
|
||||
const res = await fetch(PROFILE_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0",
|
||||
Cookie: this.cookie,
|
||||
Referer: DEBRID_REFERER
|
||||
},
|
||||
signal: AbortSignal.timeout(30000)
|
||||
});
|
||||
const html = await res.text();
|
||||
|
||||
const usernameMatch = html.match(/<a[^>]*id=["']user_link["'][^>]*><span>([^<]+)<\/span>/i);
|
||||
const username = usernameMatch?.[1]?.trim() || "";
|
||||
|
||||
const typeMatch = html.match(/(Premiumuser|Freeuser)\s*-\s*(\d+)\s*Tag/i);
|
||||
const accountType = typeMatch?.[1] || "Unbekannt";
|
||||
const daysRemaining = typeMatch?.[2] ? parseInt(typeMatch[2], 10) : null;
|
||||
|
||||
const pointsMatch = html.match(/(\d+)\s*Treuepunkte/i);
|
||||
const loyaltyPoints = pointsMatch?.[1] ? parseInt(pointsMatch[1], 10) : null;
|
||||
|
||||
if (!username && !typeMatch) {
|
||||
this.cookie = "";
|
||||
return { provider: "megadebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Profil konnte nicht gelesen werden (Session ungültig?)" };
|
||||
}
|
||||
|
||||
return { provider: "megadebrid", username, accountType, daysRemaining, loyaltyPoints };
|
||||
} catch (err) {
|
||||
return { provider: "megadebrid", username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: compactErrorText(err) };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public invalidateSession(): void {
|
||||
this.cookie = "";
|
||||
this.cookieSetAt = 0;
|
||||
|
||||
@ -4,7 +4,6 @@ import {
|
||||
AppSettings,
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
ProviderAccountInfo,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
@ -31,6 +30,7 @@ const api: ElectronApi = {
|
||||
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
|
||||
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
|
||||
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
|
||||
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
|
||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
||||
@ -54,7 +54,6 @@ const api: ElectronApi = {
|
||||
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
||||
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
||||
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
|
||||
checkAccount: (provider: string): Promise<ProviderAccountInfo> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_ACCOUNT, provider),
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||
|
||||
@ -10,7 +10,6 @@ import type {
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
PackageEntry,
|
||||
ProviderAccountInfo,
|
||||
StartConflictEntry,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
@ -93,15 +92,6 @@ const providerLabels: Record<DebridProvider, string> = {
|
||||
realdebrid: "Real-Debrid", megadebrid: "Mega-Debrid", bestdebrid: "BestDebrid", alldebrid: "AllDebrid"
|
||||
};
|
||||
|
||||
const allProviders: DebridProvider[] = ["realdebrid", "megadebrid", "bestdebrid", "alldebrid"];
|
||||
|
||||
const providerCredentialFields: Record<DebridProvider, { key: keyof AppSettings; label: string; type: string }[]> = {
|
||||
realdebrid: [{ key: "token", label: "API Token", type: "password" }],
|
||||
megadebrid: [{ key: "megaLogin", label: "Login", type: "text" }, { key: "megaPassword", label: "Passwort", type: "password" }],
|
||||
bestdebrid: [{ key: "bestToken", label: "API Token", type: "password" }],
|
||||
alldebrid: [{ key: "allDebridToken", label: "API Key", type: "password" }]
|
||||
};
|
||||
|
||||
function extractHoster(url: string): string {
|
||||
try {
|
||||
const host = new URL(url).hostname.replace(/^www\./, "");
|
||||
@ -452,11 +442,6 @@ export function App(): ReactElement {
|
||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [accountInfoMap, setAccountInfoMap] = useState<Record<string, ProviderAccountInfo>>({});
|
||||
const [accountCheckLoading, setAccountCheckLoading] = useState<Set<DebridProvider>>(new Set());
|
||||
const [showAddAccountModal, setShowAddAccountModal] = useState(false);
|
||||
const [addAccountProvider, setAddAccountProvider] = useState<DebridProvider | "">("");
|
||||
const [addAccountFields, setAddAccountFields] = useState<Record<string, string>>({});
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
|
||||
const [editingName, setEditingName] = useState("");
|
||||
@ -844,8 +829,6 @@ export function App(): ReactElement {
|
||||
return list;
|
||||
}, [settingsDraft.token, settingsDraft.megaLogin, settingsDraft.megaPassword, settingsDraft.bestToken, settingsDraft.allDebridToken]);
|
||||
|
||||
const unconfiguredProviders = useMemo(() => allProviders.filter((p) => !configuredProviders.includes(p)), [configuredProviders]);
|
||||
|
||||
const primaryProviderValue: DebridProvider = useMemo(() => {
|
||||
if (configuredProviders.includes(settingsDraft.providerPrimary)) {
|
||||
return settingsDraft.providerPrimary;
|
||||
@ -1240,42 +1223,6 @@ export function App(): ReactElement {
|
||||
setSettingsDraft((prev) => ({ ...prev, speedLimitKbps: Math.floor(mbps * 1024) }));
|
||||
};
|
||||
|
||||
const checkSingleAccount = (provider: DebridProvider): void => {
|
||||
setAccountCheckLoading((prev) => new Set(prev).add(provider));
|
||||
window.rd.checkAccount(provider).then((info) => {
|
||||
setAccountInfoMap((prev) => ({ ...prev, [provider]: info }));
|
||||
}).catch(() => {
|
||||
setAccountInfoMap((prev) => ({ ...prev, [provider]: { provider, username: "", accountType: "", daysRemaining: null, loyaltyPoints: null, error: "Verbindungsfehler" } }));
|
||||
}).finally(() => {
|
||||
setAccountCheckLoading((prev) => { const next = new Set(prev); next.delete(provider); return next; });
|
||||
});
|
||||
};
|
||||
|
||||
const checkAllAccounts = (): void => {
|
||||
for (const provider of configuredProviders) {
|
||||
checkSingleAccount(provider);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAccount = (provider: DebridProvider): void => {
|
||||
for (const field of providerCredentialFields[provider]) {
|
||||
setText(field.key, "");
|
||||
}
|
||||
setAccountInfoMap((prev) => { const next = { ...prev }; delete next[provider]; return next; });
|
||||
};
|
||||
|
||||
const saveAddAccountModal = (): void => {
|
||||
if (!addAccountProvider) {
|
||||
return;
|
||||
}
|
||||
for (const field of providerCredentialFields[addAccountProvider]) {
|
||||
setText(field.key, addAccountFields[field.key] || "");
|
||||
}
|
||||
setShowAddAccountModal(false);
|
||||
setAddAccountProvider("");
|
||||
setAddAccountFields({});
|
||||
};
|
||||
|
||||
const performQuickAction = async (
|
||||
action: () => Promise<unknown>,
|
||||
onError?: (error: unknown) => void
|
||||
@ -2437,62 +2384,19 @@ export function App(): ReactElement {
|
||||
{settingsSubTab === "accounts" && (
|
||||
<div className="settings-section card">
|
||||
<h3>Accounts</h3>
|
||||
{configuredProviders.length === 0 ? (
|
||||
<div className="empty">Keine Accounts konfiguriert</div>
|
||||
) : (
|
||||
<table className="account-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Hoster</th>
|
||||
<th>Status</th>
|
||||
<th>Benutzername</th>
|
||||
<th>Verfallsdatum</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{configuredProviders.map((provider) => {
|
||||
const info = accountInfoMap[provider];
|
||||
const loading = accountCheckLoading.has(provider);
|
||||
let statusClass = "account-status-unknown";
|
||||
let statusText = "Nicht geprüft";
|
||||
if (loading) {
|
||||
statusClass = "account-status-loading";
|
||||
statusText = "Prüfe…";
|
||||
} else if (info?.error) {
|
||||
statusClass = "account-status-error";
|
||||
statusText = "Fehler";
|
||||
} else if (info && (info.accountType === "Premium" || info.accountType === "premium")) {
|
||||
statusClass = "account-status-ok";
|
||||
statusText = "Premium";
|
||||
} else if (info && info.accountType) {
|
||||
statusClass = "account-status-configured";
|
||||
statusText = info.accountType;
|
||||
}
|
||||
return (
|
||||
<tr key={provider}>
|
||||
<td className="account-col-hoster">{providerLabels[provider]}</td>
|
||||
<td><span className={`account-status ${statusClass}`}>{statusText}</span>{info?.error ? <span style={{ marginLeft: 6, fontSize: 12, color: "var(--danger)" }}>{info.error}</span> : null}</td>
|
||||
<td>{info?.username || "—"}</td>
|
||||
<td>{info?.daysRemaining !== null && info?.daysRemaining !== undefined ? `${info.daysRemaining} Tage` : "—"}</td>
|
||||
<td className="account-actions-cell">
|
||||
<button className="btn" disabled={loading} onClick={() => checkSingleAccount(provider)}>Prüfen</button>
|
||||
<button className="btn danger" onClick={() => removeAccount(provider)}>Entfernen</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<label>Real-Debrid API Token</label>
|
||||
<input type="password" value={settingsDraft.token} onChange={(e) => setText("token", e.target.value)} />
|
||||
<label>Mega-Debrid Login</label>
|
||||
<input value={settingsDraft.megaLogin} onChange={(e) => setText("megaLogin", e.target.value)} />
|
||||
<label>Mega-Debrid Passwort</label>
|
||||
<input type="password" value={settingsDraft.megaPassword} onChange={(e) => setText("megaPassword", e.target.value)} />
|
||||
<label>BestDebrid API Token</label>
|
||||
<input type="password" value={settingsDraft.bestToken} onChange={(e) => setText("bestToken", e.target.value)} />
|
||||
<label>AllDebrid API Key</label>
|
||||
<input type="password" value={settingsDraft.allDebridToken} onChange={(e) => setText("allDebridToken", e.target.value)} />
|
||||
{configuredProviders.length === 0 && (
|
||||
<div className="hint">Füge mindestens einen Account hinzu, dann erscheint die Hoster-Auswahl.</div>
|
||||
)}
|
||||
<div className="account-toolbar">
|
||||
<button className="btn accent" disabled={unconfiguredProviders.length === 0} onClick={() => {
|
||||
setAddAccountProvider(unconfiguredProviders[0] || "");
|
||||
setAddAccountFields({});
|
||||
setShowAddAccountModal(true);
|
||||
}}>Hinzufügen</button>
|
||||
<button className="btn" disabled={configuredProviders.length === 0 || accountCheckLoading.size > 0} onClick={checkAllAccounts}>Alle aktualisieren</button>
|
||||
</div>
|
||||
{configuredProviders.length >= 1 && (
|
||||
<div><label>Hauptaccount</label><select value={primaryProviderValue} onChange={(e) => setText("providerPrimary", e.target.value)}>
|
||||
{configuredProviders.map((provider) => (<option key={provider} value={provider}>{providerLabels[provider]}</option>))}
|
||||
@ -2615,30 +2519,6 @@ export function App(): ReactElement {
|
||||
)}
|
||||
</main>
|
||||
|
||||
{showAddAccountModal && (
|
||||
<div className="modal-backdrop" onClick={() => setShowAddAccountModal(false)}>
|
||||
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<h3>Account hinzufügen</h3>
|
||||
<div>
|
||||
<label>Provider</label>
|
||||
<select value={addAccountProvider} onChange={(e) => { setAddAccountProvider(e.target.value as DebridProvider); setAddAccountFields({}); }}>
|
||||
{unconfiguredProviders.map((p) => (<option key={p} value={p}>{providerLabels[p]}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
{addAccountProvider && providerCredentialFields[addAccountProvider].map((field) => (
|
||||
<div key={field.key}>
|
||||
<label>{field.label}</label>
|
||||
<input type={field.type} value={addAccountFields[field.key] || ""} onChange={(e) => setAddAccountFields((prev) => ({ ...prev, [field.key]: e.target.value }))} />
|
||||
</div>
|
||||
))}
|
||||
<div className="modal-actions">
|
||||
<button className="btn" onClick={() => setShowAddAccountModal(false)}>Abbrechen</button>
|
||||
<button className="btn accent" disabled={!addAccountProvider || providerCredentialFields[addAccountProvider as DebridProvider]?.some((f) => !(addAccountFields[f.key] || "").trim())} onClick={saveAddAccountModal}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{confirmPrompt && (
|
||||
<div className="modal-backdrop" onClick={() => closeConfirmPrompt(false)}>
|
||||
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
@ -2764,8 +2644,8 @@ export function App(): ReactElement {
|
||||
<div className="ctx-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onClick={(e) => e.stopPropagation()}>
|
||||
{(!contextMenu.itemId || multi) && hasPackages && (
|
||||
<button className="ctx-menu-item" onClick={() => {
|
||||
for (const id of selectedIds) { const pkg = snapshot.session.packages[id]; if (pkg && !pkg.enabled) { void window.rd.togglePackage(id); } }
|
||||
void window.rd.start(); setContextMenu(null);
|
||||
const pkgIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
|
||||
void window.rd.startPackages(pkgIds); setContextMenu(null);
|
||||
}}>Ausgewählte Downloads starten{multi ? ` (${selectedIds.size})` : ""}</button>
|
||||
)}
|
||||
<button className="ctx-menu-item" onClick={() => { void window.rd.start(); setContextMenu(null); }}>Alle Downloads starten</button>
|
||||
|
||||
@ -1465,88 +1465,6 @@ td {
|
||||
}
|
||||
}
|
||||
|
||||
.account-info {
|
||||
font-size: 13px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--field);
|
||||
}
|
||||
|
||||
.account-info-success {
|
||||
color: #4ade80;
|
||||
border-color: rgba(74, 222, 128, 0.35);
|
||||
}
|
||||
|
||||
.account-info-error {
|
||||
color: var(--danger);
|
||||
border-color: rgba(244, 63, 94, 0.35);
|
||||
}
|
||||
|
||||
.account-table {
|
||||
table-layout: fixed;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.account-table th:first-child,
|
||||
.account-table td:first-child {
|
||||
width: 22%;
|
||||
}
|
||||
|
||||
.account-col-hoster {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.account-status {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.account-status-ok {
|
||||
background: rgba(74, 222, 128, 0.15);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.account-status-configured {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.account-status-error {
|
||||
background: rgba(244, 63, 94, 0.12);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.account-status-loading {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.account-status-unknown {
|
||||
background: color-mix(in srgb, var(--muted) 15%, transparent);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.account-toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.account-actions-cell {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.account-actions-cell .btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.schedule-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px auto 56px auto 92px auto auto auto;
|
||||
|
||||
@ -12,6 +12,7 @@ export const IPC_CHANNELS = {
|
||||
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
||||
CLEAR_ALL: "queue:clear-all",
|
||||
START: "queue:start",
|
||||
START_PACKAGES: "queue:start-packages",
|
||||
STOP: "queue:stop",
|
||||
TOGGLE_PAUSE: "queue:toggle-pause",
|
||||
CANCEL_PACKAGE: "queue:cancel-package",
|
||||
@ -36,6 +37,5 @@ export const IPC_CHANNELS = {
|
||||
EXTRACT_NOW: "queue:extract-now",
|
||||
GET_HISTORY: "history:get",
|
||||
CLEAR_HISTORY: "history:clear",
|
||||
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
||||
CHECK_ACCOUNT: "app:check-account"
|
||||
REMOVE_HISTORY_ENTRY: "history:remove-entry"
|
||||
} as const;
|
||||
|
||||
@ -3,7 +3,6 @@ import type {
|
||||
AppSettings,
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
ProviderAccountInfo,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
StartConflictResolutionResult,
|
||||
@ -26,6 +25,7 @@ export interface ElectronApi {
|
||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy) => Promise<StartConflictResolutionResult>;
|
||||
clearAll: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
startPackages: (packageIds: string[]) => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
togglePause: () => Promise<boolean>;
|
||||
cancelPackage: (packageId: string) => Promise<void>;
|
||||
@ -49,7 +49,6 @@ export interface ElectronApi {
|
||||
getHistory: () => Promise<HistoryEntry[]>;
|
||||
clearHistory: () => Promise<void>;
|
||||
removeHistoryEntry: (entryId: string) => Promise<void>;
|
||||
checkAccount: (provider: string) => Promise<ProviderAccountInfo>;
|
||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
|
||||
onClipboardDetected: (callback: (links: string[]) => void) => () => void;
|
||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;
|
||||
|
||||
@ -269,15 +269,6 @@ export interface HistoryEntry {
|
||||
outputDir: string;
|
||||
}
|
||||
|
||||
export interface ProviderAccountInfo {
|
||||
provider: DebridProvider;
|
||||
username: string;
|
||||
accountType: string;
|
||||
daysRemaining: number | null;
|
||||
loyaltyPoints: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface HistoryState {
|
||||
entries: HistoryEntry[];
|
||||
maxEntries: number;
|
||||
|
||||
@ -3636,242 +3636,6 @@ describe("download manager", () => {
|
||||
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpacken abgebrochen (wird fortgesetzt)");
|
||||
});
|
||||
|
||||
it("does not recover partial archive files as completed", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const outputDir = path.join(root, "downloads", "partial-recovery");
|
||||
const extractDir = path.join(root, "extract", "partial-recovery");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const archivePath = path.join(outputDir, "partial.repack.part1.rar");
|
||||
const totalBytes = 1_000_000;
|
||||
fs.writeFileSync(archivePath, Buffer.alloc(860_000, 1));
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = "partial-recovery-pkg";
|
||||
const itemId = "partial-recovery-item";
|
||||
const createdAt = Date.now() - 20_000;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "partial-recovery",
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "downloading",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://dummy/partial-recovery",
|
||||
provider: "megadebrid",
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes,
|
||||
progressPercent: 0,
|
||||
fileName: path.basename(archivePath),
|
||||
targetPath: archivePath,
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const internal = manager as unknown as {
|
||||
handlePackagePostProcessing: (packageId: string) => Promise<void>;
|
||||
};
|
||||
await internal.handlePackagePostProcessing(packageId);
|
||||
|
||||
const item = manager.getSnapshot().session.items[itemId];
|
||||
expect(item?.status).toBe("queued");
|
||||
expect(item?.fullStatus).toBe("Wartet");
|
||||
});
|
||||
|
||||
it("recovers near-complete archive files with known size", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const outputDir = path.join(root, "downloads", "near-complete-recovery");
|
||||
const extractDir = path.join(root, "extract", "near-complete-recovery");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const archivePath = path.join(outputDir, "near.complete.part1.rar");
|
||||
const totalBytes = 1_000_000;
|
||||
const fileSize = 996_000;
|
||||
fs.writeFileSync(archivePath, Buffer.alloc(fileSize, 2));
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = "near-complete-recovery-pkg";
|
||||
const itemId = "near-complete-recovery-item";
|
||||
const createdAt = Date.now() - 20_000;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "near-complete-recovery",
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "downloading",
|
||||
itemIds: [itemId],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items[itemId] = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: "https://dummy/near-complete-recovery",
|
||||
provider: "megadebrid",
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes,
|
||||
progressPercent: 0,
|
||||
fileName: path.basename(archivePath),
|
||||
targetPath: archivePath,
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: false
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const internal = manager as unknown as {
|
||||
handlePackagePostProcessing: (packageId: string) => Promise<void>;
|
||||
};
|
||||
await internal.handlePackagePostProcessing(packageId);
|
||||
|
||||
const item = manager.getSnapshot().session.items[itemId];
|
||||
expect(item?.status).toBe("completed");
|
||||
expect(item?.downloadedBytes).toBe(fileSize);
|
||||
});
|
||||
|
||||
it("skips hybrid-ready multipart archives when a completed part is still too small", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
const outputDir = path.join(root, "downloads", "hybrid-size-guard");
|
||||
const extractDir = path.join(root, "extract", "hybrid-size-guard");
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const part1 = path.join(outputDir, "show.s01e01.part1.rar");
|
||||
const part2 = path.join(outputDir, "show.s01e01.part2.rar");
|
||||
fs.writeFileSync(part1, Buffer.alloc(900_000, 3));
|
||||
fs.writeFileSync(part2, Buffer.alloc(700_000, 4));
|
||||
|
||||
const session = emptySession();
|
||||
const packageId = "hybrid-size-guard-pkg";
|
||||
const createdAt = Date.now() - 20_000;
|
||||
session.packageOrder = [packageId];
|
||||
session.packages[packageId] = {
|
||||
id: packageId,
|
||||
name: "hybrid-size-guard",
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "downloading",
|
||||
itemIds: ["hybrid-size-guard-item-1", "hybrid-size-guard-item-2"],
|
||||
cancelled: false,
|
||||
enabled: true,
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items["hybrid-size-guard-item-1"] = {
|
||||
id: "hybrid-size-guard-item-1",
|
||||
packageId,
|
||||
url: "https://dummy/hybrid-size-guard/1",
|
||||
provider: "megadebrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 900_000,
|
||||
totalBytes: 1_000_000,
|
||||
progressPercent: 100,
|
||||
fileName: path.basename(part1),
|
||||
targetPath: part1,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
session.items["hybrid-size-guard-item-2"] = {
|
||||
id: "hybrid-size-guard-item-2",
|
||||
packageId,
|
||||
url: "https://dummy/hybrid-size-guard/2",
|
||||
provider: "megadebrid",
|
||||
status: "completed",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 700_000,
|
||||
totalBytes: 700_000,
|
||||
progressPercent: 100,
|
||||
fileName: path.basename(part2),
|
||||
targetPath: part2,
|
||||
resumable: true,
|
||||
attempts: 1,
|
||||
lastError: "",
|
||||
fullStatus: "Entpacken - Ausstehend",
|
||||
createdAt,
|
||||
updatedAt: createdAt
|
||||
};
|
||||
|
||||
const manager = new DownloadManager(
|
||||
{
|
||||
...defaultSettings(),
|
||||
token: "rd-token",
|
||||
outputDir: path.join(root, "downloads"),
|
||||
extractDir: path.join(root, "extract"),
|
||||
autoExtract: true,
|
||||
hybridExtract: true
|
||||
},
|
||||
session,
|
||||
createStoragePaths(path.join(root, "state"))
|
||||
);
|
||||
|
||||
const internal = manager as unknown as {
|
||||
session: ReturnType<typeof emptySession>;
|
||||
findReadyArchiveSets: (pkg: ReturnType<typeof emptySession>["packages"][string]) => Promise<Set<string>>;
|
||||
};
|
||||
const ready = await internal.findReadyArchiveSets(internal.session.packages[packageId]);
|
||||
expect(ready.size).toBe(0);
|
||||
});
|
||||
|
||||
it("recovers pending extraction on startup for completed package", async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||
tempDirs.push(root);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user