fix(downloads): restore actionable package states and extraction controls

Resolve child archive selections to complete multipart sets, support bulk package extraction, refresh extraction passwords at execution time, and keep archive failures scoped by their full paths.

Restore sortable download columns, preserve verified availability, classify package retry and extraction states, secure link copying through the preload bridge, and release cancelled provider work without stale cooldowns.
This commit is contained in:
Sucukdeluxe
2026-08-22 22:52:49 +02:00
parent a5920869a3
commit 55b8911e94
33 changed files with 2187 additions and 561 deletions
+4 -3
View File
@@ -3,6 +3,7 @@ import os from "node:os";
import v8 from "node:v8"; import v8 from "node:v8";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { app } from "electron"; import { app } from "electron";
import type { ExtractNowRequest } from "../shared/extract-now";
import { import {
AddLinksPayload, AddLinksPayload,
AccountCheckScope, AccountCheckScope,
@@ -1038,9 +1039,9 @@ export class AppController {
this.manager.retryExtraction(packageId); this.manager.retryExtraction(packageId);
} }
public extractNow(packageId: string): void { public extractNow(request: ExtractNowRequest): void {
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId }); this.audit("INFO", "Jetzt entpacken ausgelöst", { packageIds: request.packageIds, itemIds: request.itemIds });
this.manager.extractNow(packageId); this.manager.extractNow(request);
} }
public resetPackage(packageId: string): void { public resetPackage(packageId: string): void {
+15
View File
@@ -0,0 +1,15 @@
export const CLIPBOARD_WRITE_MAX_BYTES = 1024 * 1024;
export function validateClipboardWriteText(value: unknown): string {
if (typeof value !== "string") {
throw new Error("text muss ein String sein");
}
if (!value.trim()) {
throw new Error("text darf nicht leer sein");
}
const bytes = Buffer.byteLength(value, "utf8");
if (bytes > CLIPBOARD_WRITE_MAX_BYTES) {
throw new Error(`text ist zu groß (max ${CLIPBOARD_WRITE_MAX_BYTES} Bytes)`);
}
return value;
}
+23 -9
View File
@@ -1807,6 +1807,14 @@ function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number):
return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]); return AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]);
} }
function isCallerAbortSignal(signal?: AbortSignal): boolean {
if (!signal?.aborted) {
return false;
}
const reason = signal.reason;
return !(reason && typeof reason === "object" && "name" in reason && reason.name === "TimeoutError");
}
async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise<string> { async function readResponseTextLimited(response: Response, maxBytes: number, signal?: AbortSignal): Promise<string> {
const body = response.body; const body = response.body;
if (!body) { if (!body) {
@@ -2509,14 +2517,17 @@ class MegaDebridClient {
} catch (error) { } catch (error) {
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The if (isCallerAbortSignal(signal)) {
// account-wide cooldown exists ONLY to make the retry rotate to another traceConversionPhase({
// account — so it is set only when another usable account actually exists. phase: "mega-account",
// With no rotation target (single account / all others busy), cooling the provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
// sole account would freeze EVERY queued item while the account is healthy; account: rotationLabel,
// a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so workMs: elapsedMs,
// we park just this link (mega_debrid_slow_link) and leave the account free outcome: "aborted",
// for other items. A quick user-cancel (below the min run) parks nothing. detail: abortText
});
throw error;
}
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
const otherUsableAccounts = orderedEntries.reduce((count, candidate) => { const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
@@ -3259,9 +3270,12 @@ class DebridLinkClient {
sourceAccountLabel: apiKey.label sourceAccountLabel: apiKey.label
}; };
} catch (error) { } catch (error) {
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
const elapsedMs = Date.now() - testStartedAt; const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, ""); const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
if (isCallerAbortSignal(signal)) {
throw error;
}
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) { if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs(); const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
if (ranLongEnough) { if (ranLongEnough) {
+252 -150
View File
@@ -28,6 +28,7 @@ import {
StartConflictResolutionResult, StartConflictResolutionResult,
UiSnapshot, DebridAccountStatus } from "../shared/types"; UiSnapshot, DebridAccountStatus } from "../shared/types";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys"; import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import type { ExtractNowRequest } from "../shared/extract-now";
import { extractHosterFromUrl } from "../shared/hoster"; import { extractHosterFromUrl } from "../shared/hoster";
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors"; import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts"; import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
@@ -42,7 +43,8 @@ import {
addRealDebridAccountDailyUsageBytes, addRealDebridAccountDailyUsageBytes,
addRealDebridAccountTotalUsageBytes, addRealDebridAccountTotalUsageBytes,
getProviderUsageDayKey, getProviderUsageDayKey,
isProviderDailyLimitReached isProviderDailyLimitReached,
isRealDebridAccountDailyLimitReached
} from "../shared/provider-daily-limits"; } from "../shared/provider-daily-limits";
import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS, DISK_BUSY_STATUS_THRESHOLD_MS } from "./constants"; import { REQUEST_RETRIES, SAMPLE_VIDEO_EXTENSIONS, SPEED_WINDOW_SECONDS, WRITE_BUFFER_SIZE, WRITE_FLUSH_TIMEOUT_MS, ALLOCATION_UNIT_SIZE, STREAM_HIGH_WATER_MARK, DISK_BUSY_THRESHOLD_MS, DISK_BUSY_STATUS_THRESHOLD_MS } from "./constants";
import { parseCollectorInput } from "./link-parser"; import { parseCollectorInput } from "./link-parser";
@@ -1623,7 +1625,7 @@ export function decideAutoRenameBaseName(
return { kind: "rename", baseName: targetBaseName, note }; return { kind: "rename", baseName: targetBaseName, note };
} }
const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*1\.rar$/; const ARCHIVE_MULTIPART_RAR_RE = /^(.*)\.part0*\d+\.rar$/;
const ARCHIVE_RAR_RE = /^(.*)\.rar$/; const ARCHIVE_RAR_RE = /^(.*)\.rar$/;
const ARCHIVE_ZIP_SPLIT_RE = /^(.*)\.zip\.001$/; const ARCHIVE_ZIP_SPLIT_RE = /^(.*)\.zip\.001$/;
const ARCHIVE_7Z_SPLIT_RE = /^(.*)\.7z\.001$/; const ARCHIVE_7Z_SPLIT_RE = /^(.*)\.7z\.001$/;
@@ -1637,22 +1639,15 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download
const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase(); const entryLower = normalizeArchiveMatchName(archiveName).toLowerCase();
const normalizedArchivePath = String(archivePath || "").trim(); const normalizedArchivePath = String(archivePath || "").trim();
if (normalizedArchivePath) { const candidateItems = normalizedArchivePath
const archivePathKey = pathKey(path.join( ? items.filter((item) => {
path.dirname(path.resolve(normalizedArchivePath)),
normalizeArchiveMatchName(normalizedArchivePath)
));
const pathMatches = items.filter((item) => {
const targetPath = String(item.targetPath || "").trim(); const targetPath = String(item.targetPath || "").trim();
if (!targetPath) { if (!targetPath) {
return false; return true;
}
return pathKey(path.join(path.dirname(path.resolve(targetPath)), normalizeArchiveMatchName(targetPath))) === archivePathKey;
});
if (pathMatches.length > 0) {
return pathMatches;
}
} }
return pathKey(path.dirname(path.resolve(targetPath))) === pathKey(path.dirname(path.resolve(normalizedArchivePath)));
})
: items;
const itemBaseName = (item: DownloadItem): string => const itemBaseName = (item: DownloadItem): string =>
normalizeArchiveMatchName(item.targetPath || item.fileName || ""); normalizeArchiveMatchName(item.targetPath || item.fileName || "");
@@ -1693,11 +1688,11 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download
} }
if (pattern) { if (pattern) {
const matched = items.filter((item) => pattern!.test(itemBaseName(item))); const matched = candidateItems.filter((item) => pattern!.test(itemBaseName(item)));
if (matched.length > 0) return matched; if (matched.length > 0) return matched;
} }
const exactMatch = items.filter((item) => itemBaseName(item).toLowerCase() === entryLower); const exactMatch = candidateItems.filter((item) => itemBaseName(item).toLowerCase() === entryLower);
if (exactMatch.length > 0) return exactMatch; if (exactMatch.length > 0) return exactMatch;
const archiveStem = entryLower const archiveStem = entryLower
@@ -1708,23 +1703,64 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download
.replace(/\.\d{3}$/i, "") .replace(/\.\d{3}$/i, "")
.replace(/\.(zip|7z)$/i, ""); .replace(/\.(zip|7z)$/i, "");
if (archiveStem.length > 3) { if (archiveStem.length > 3) {
const stemMatch = items.filter((item) => { const stemMatch = candidateItems.filter((item) => {
const name = itemBaseName(item).toLowerCase(); const name = itemBaseName(item).toLowerCase();
return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name); return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name);
}); });
if (stemMatch.length > 0) return stemMatch; if (stemMatch.length > 0) return stemMatch;
} }
if (items.length === 1) { if (candidateItems.length === 1) {
const singleName = itemBaseName(items[0]).toLowerCase(); const singleName = itemBaseName(candidateItems[0]).toLowerCase();
if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) { if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) {
return items; return candidateItems;
} }
} }
return []; return [];
} }
export function resolveSelectedArchiveSetsFromCandidates(
candidatePaths: readonly string[],
items: DownloadItem[],
selectedItemIds: ReadonlySet<string>
): { archivePaths: Set<string>; itemIds: Set<string> } {
const archivePaths = new Set<string>();
const itemIds = new Set<string>();
for (const candidatePath of candidatePaths) {
const archiveItems = resolveArchiveItemsFromList(path.basename(candidatePath), items, candidatePath);
if (!archiveItems.some((item) => selectedItemIds.has(item.id))) {
continue;
}
archivePaths.add(candidatePath);
for (const item of archiveItems) {
itemIds.add(item.id);
}
}
return { archivePaths, itemIds };
}
export function markPlannedHybridArchiveItemsPending(
items: DownloadItem[],
plannedItemIds: ReadonlySet<string>,
updatedAt: number
): boolean {
let changed = false;
for (const item of items) {
if (
!plannedItemIds.has(item.id)
|| item.status !== "completed"
|| item.fullStatus !== "Entpacken - Warten auf Parts"
) {
continue;
}
item.fullStatus = "Entpacken - Ausstehend";
item.updatedAt = updatedAt;
changed = true;
}
return changed;
}
function stripDuplicateSuffixBeforeExtension(fileName: string): string { function stripDuplicateSuffixBeforeExtension(fileName: string): string {
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, ""); return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
} }
@@ -1970,6 +2006,10 @@ export class DownloadManager extends EventEmitter {
private hybridFailedArchives = new Map<string, Map<string, HybridFailedArchiveState>>(); private hybridFailedArchives = new Map<string, Map<string, HybridFailedArchiveState>>();
private manualExtractArchiveFilters = new Map<string, Set<string>>();
private manualExtractPackages = new Set<string>();
private autoRecoveredForRedownload = new Set<string>(); private autoRecoveredForRedownload = new Set<string>();
private reservedTargetPaths = new Map<string, string>(); private reservedTargetPaths = new Map<string, string>();
@@ -3022,6 +3062,8 @@ export class DownloadManager extends EventEmitter {
this.packageHybridPostProcessTasks.delete(packageId); this.packageHybridPostProcessTasks.delete(packageId);
this.hybridExtractRequeue.delete(packageId); this.hybridExtractRequeue.delete(packageId);
this.manualExtractArchiveFilters.delete(packageId);
this.manualExtractPackages.delete(packageId);
this.clearHybridArchiveState(packageId); this.clearHybridArchiveState(packageId);
return tasks; return tasks;
} }
@@ -3350,6 +3392,8 @@ export class DownloadManager extends EventEmitter {
this.packageDeferredPostProcessTasks.clear(); this.packageDeferredPostProcessTasks.clear();
this.packageHybridPostProcessControllers.clear(); this.packageHybridPostProcessControllers.clear();
this.packageHybridPostProcessTasks.clear(); this.packageHybridPostProcessTasks.clear();
this.manualExtractArchiveFilters.clear();
this.manualExtractPackages.clear();
this.hybridExtractRequeue.clear(); this.hybridExtractRequeue.clear();
this.hybridExtractedPaths.clear(); this.hybridExtractedPaths.clear();
this.hybridFailedArchives.clear(); this.hybridFailedArchives.clear();
@@ -6481,7 +6525,6 @@ export class DownloadManager extends EventEmitter {
item.targetPath = ""; item.targetPath = "";
item.provider = null; item.provider = null;
item.fullStatus = "Wartet"; item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
} }
@@ -6564,7 +6607,6 @@ export class DownloadManager extends EventEmitter {
item.targetPath = ""; item.targetPath = "";
item.provider = null; item.provider = null;
item.fullStatus = "Wartet"; item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
if (this.session.running) { if (this.session.running) {
@@ -8222,7 +8264,7 @@ export class DownloadManager extends EventEmitter {
const candidates = await findArchiveCandidates(pkg.outputDir); const candidates = await findArchiveCandidates(pkg.outputDir);
for (const candidate of candidates) { for (const candidate of candidates) {
const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), completedItems); const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), completedItems, candidate);
if (archiveItems.length === 0) { if (archiveItems.length === 0) {
continue; continue;
} }
@@ -8266,7 +8308,7 @@ export class DownloadManager extends EventEmitter {
private buildHybridArchiveRetryMarker(pkg: PackageEntry, items: DownloadItem[], archiveKey: string): string { private buildHybridArchiveRetryMarker(pkg: PackageEntry, items: DownloadItem[], archiveKey: string): string {
const archiveName = path.basename(archiveKey); const archiveName = path.basename(archiveKey);
const archiveItems = resolveArchiveItemsFromList(archiveName, items) const archiveItems = resolveArchiveItemsFromList(archiveName, items, archiveKey)
.slice() .slice()
.sort((left, right) => { .sort((left, right) => {
const leftName = (left.fileName || left.targetPath || left.id || "").toLowerCase(); const leftName = (left.fileName || left.targetPath || left.id || "").toLowerCase();
@@ -8303,7 +8345,7 @@ export class DownloadManager extends EventEmitter {
return 0; return 0;
} }
const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items) const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items, failure.archivePath)
.filter((item) => item.status === "completed"); .filter((item) => item.status === "completed");
if (archiveItems.length === 0) { if (archiveItems.length === 0) {
logger.warn(`Auto-Recovery (${scope}): Keine completed Items für ${failure.archiveName} gefunden, überspringe`); logger.warn(`Auto-Recovery (${scope}): Keine completed Items für ${failure.archiveName} gefunden, überspringe`);
@@ -8396,21 +8438,21 @@ export class DownloadManager extends EventEmitter {
private applyPackageExtractFailureStatuses( private applyPackageExtractFailureStatuses(
completedItems: DownloadItem[], completedItems: DownloadItem[],
resolveArchiveItems: (archiveName: string) => DownloadItem[], resolveArchiveItems: (archiveName: string, archivePath?: string) => DownloadItem[],
failedArchiveErrors: Map<string, string>, failedArchiveErrors: Map<string, { archiveName: string; archivePath: string; errorText: string }>,
fallbackReason: string, fallbackReason: string,
previousStatuses: Map<string, string>, previousStatuses: Map<string, string>,
appliedAt = nowMs() appliedAt = nowMs()
): void { ): void {
const affectedItemIds = new Set<string>(); const affectedItemIds = new Set<string>();
for (const [archiveName, errorText] of failedArchiveErrors.entries()) { for (const failure of failedArchiveErrors.values()) {
const reason = compactErrorText(errorText || fallbackReason || "Entpacken fehlgeschlagen"); const reason = compactErrorText(failure.errorText || fallbackReason || "Entpacken fehlgeschlagen");
for (const entry of resolveArchiveItems(archiveName)) { for (const entry of resolveArchiveItems(failure.archiveName, failure.archivePath)) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) { if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) {
continue; continue;
} }
entry.fullStatus = formatExtractFailureLabel(reason, archiveName); entry.fullStatus = formatExtractFailureLabel(reason, failure.archiveName);
entry.updatedAt = appliedAt; entry.updatedAt = appliedAt;
affectedItemIds.add(entry.id); affectedItemIds.add(entry.id);
} }
@@ -8890,6 +8932,8 @@ export class DownloadManager extends EventEmitter {
// orphan that newer task (uncancellable) and allow a duplicate concurrent run. // orphan that newer task (uncancellable) and allow a duplicate concurrent run.
if (this.packagePostProcessTasks.get(packageId) === handle.task) { if (this.packagePostProcessTasks.get(packageId) === handle.task) {
this.packagePostProcessTasks.delete(packageId); this.packagePostProcessTasks.delete(packageId);
this.manualExtractArchiveFilters.delete(packageId);
this.manualExtractPackages.delete(packageId);
} }
if (this.packagePostProcessAbortControllers.get(packageId) === abortController) { if (this.packagePostProcessAbortControllers.get(packageId) === abortController) {
this.packagePostProcessAbortControllers.delete(packageId); this.packagePostProcessAbortControllers.delete(packageId);
@@ -9133,23 +9177,35 @@ export class DownloadManager extends EventEmitter {
}); });
this.beginPackageResultGeneration(packageId, false, true); this.beginPackageResultGeneration(packageId, false, true);
this.reactivateStandalonePackageResult(packageId); this.reactivateStandalonePackageResult(packageId);
this.manualExtractPackages.add(packageId);
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`)); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (retryExtraction): ${compactErrorText(err)}`));
} }
public extractNow(packageId: string): void { private armExtractNowPackage(
packageId: string,
selectedItemIds?: ReadonlySet<string>,
archiveFilter?: ReadonlySet<string>
): boolean {
const pkg = this.session.packages[packageId]; const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled) return; if (!pkg || pkg.cancelled) return false;
if (this.packagePostProcessTasks.has(packageId)) return; if (this.packagePostProcessTasks.has(packageId)) return false;
this.clearHybridArchiveState(packageId); this.clearHybridArchiveState(packageId);
if (!pkg.enabled) { if (!pkg.enabled) {
pkg.enabled = true; pkg.enabled = true;
} }
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[]; const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
const completedItems = items.filter((item) => item.status === "completed"); const completedItems = items.filter((item) => item.status === "completed");
const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus)); const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id)));
if (targetItems.length === 0) return; if (targetItems.length === 0) {
this.manualExtractArchiveFilters.delete(packageId);
this.manualExtractPackages.delete(packageId);
return false;
}
if (archiveFilter) this.manualExtractArchiveFilters.set(packageId, new Set(archiveFilter));
else this.manualExtractArchiveFilters.delete(packageId);
this.manualExtractPackages.add(packageId);
pkg.status = "queued"; pkg.status = "queued";
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
for (const item of targetItems) { for (const item of targetItems) {
@@ -9166,6 +9222,55 @@ export class DownloadManager extends EventEmitter {
this.persistSoon(); this.persistSoon();
this.emitState(true); this.emitState(true);
void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`)); void this.runPackagePostProcessing(packageId).catch((err) => logger.warn(`runPackagePostProcessing Fehler (extractNow): ${compactErrorText(err)}`));
return true;
}
private async extractNowItems(itemIds: readonly string[], excludedPackageIds: ReadonlySet<string>): Promise<void> {
const selectedByPackage = new Map<string, Set<string>>();
for (const itemId of itemIds) {
const item = this.session.items[itemId];
if (!item || excludedPackageIds.has(item.packageId)) {
continue;
}
const selected = selectedByPackage.get(item.packageId) || new Set<string>();
selected.add(itemId);
selectedByPackage.set(item.packageId, selected);
}
for (const [packageId, selectedItemIds] of selectedByPackage) {
const pkg = this.session.packages[packageId];
if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(packageId)) {
continue;
}
const completedItems = pkg.itemIds
.map((itemId) => this.session.items[itemId])
.filter((item): item is DownloadItem => Boolean(item && item.status === "completed"));
const candidates = await findArchiveCandidates(pkg.outputDir);
const selection = resolveSelectedArchiveSetsFromCandidates(candidates, completedItems, selectedItemIds);
if (selection.archivePaths.size === 0 || selection.itemIds.size === 0) {
logger.warn(`Jetzt entpacken: Kein vollständiger Archivsatz für ${selectedItemIds.size} ausgewählte Datei(en) in pkg=${pkg.name}`);
continue;
}
this.armExtractNowPackage(
packageId,
selection.itemIds,
new Set([...selection.archivePaths].map((archivePath) => pathKey(archivePath)))
);
}
}
public extractNow(target: string | ExtractNowRequest): void {
if (typeof target === "string") {
this.armExtractNowPackage(target);
return;
}
const packageIds = [...new Set(target.packageIds)];
const packageSet = new Set(packageIds);
for (const packageId of packageIds) {
this.armExtractNowPackage(packageId);
}
void this.extractNowItems(target.itemIds, packageSet).catch((error) => {
logger.warn(`Jetzt entpacken für Dateiauswahl fehlgeschlagen: ${compactErrorText(error)}`);
});
} }
private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void { private notePackageDownloadStarted(pkg: PackageEntry, startedAt = nowMs()): void {
@@ -9447,7 +9552,7 @@ export class DownloadManager extends EventEmitter {
if (effectiveProvider === "realdebrid") { if (effectiveProvider === "realdebrid") {
const configuredAccounts = getRealDebridAccounts(this.settings); const configuredAccounts = getRealDebridAccounts(this.settings);
return configuredAccounts.length > 0 return configuredAccounts.length > 0
? getAvailableRealDebridAccounts(this.settings).length > 0 ? configuredAccounts.some((account) => account.enabled && !isRealDebridAccountDailyLimitReached(this.settings, account.id))
: Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim()); : Boolean(this.settings.realDebridUseWebLogin || this.settings.token.trim());
} }
if (effectiveProvider === "megadebrid-api") { if (effectiveProvider === "megadebrid-api") {
@@ -13353,7 +13458,7 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), packageItems); const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), packageItems, candidate);
if (archiveItems.length === 0) { if (archiveItems.length === 0) {
continue; continue;
} }
@@ -13465,6 +13570,14 @@ export class DownloadManager extends EventEmitter {
const findReadyStart = nowMs(); const findReadyStart = nowMs();
const readyArchives = await this.findReadyArchiveSets(pkg); const readyArchives = await this.findReadyArchiveSets(pkg);
const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId);
if (manualArchiveFilter) {
for (const archivePath of [...readyArchives]) {
if (!manualArchiveFilter.has(pathKey(archivePath))) {
readyArchives.delete(archivePath);
}
}
}
const findReadyMs = nowMs() - findReadyStart; const findReadyMs = nowMs() - findReadyStart;
if (findReadyMs > 200) { if (findReadyMs > 200) {
logger.info(`findReadyArchiveSets dauerte ${(findReadyMs / 1000).toFixed(1)}s: pkg=${pkg.name}, found=${readyArchives.size}`); logger.info(`findReadyArchiveSets dauerte ${(findReadyMs / 1000).toFixed(1)}s: pkg=${pkg.name}, found=${readyArchives.size}`);
@@ -13489,7 +13602,7 @@ export class DownloadManager extends EventEmitter {
continue; continue;
} }
const archiveItems = resolveArchiveItemsFromList(path.basename(archiveKey), completedItems); const archiveItems = resolveArchiveItemsFromList(path.basename(archiveKey), completedItems, archiveKey);
const allItemsStillInError = archiveItems.length > 0 && archiveItems.every((item) => isExtractErrorLabel(item.fullStatus)); const allItemsStillInError = archiveItems.length > 0 && archiveItems.every((item) => isExtractErrorLabel(item.fullStatus));
const retryMarker = this.buildHybridArchiveRetryMarker(pkg, items, archiveKey); const retryMarker = this.buildHybridArchiveRetryMarker(pkg, items, archiveKey);
if (!allItemsStillInError || previousFailure.marker !== retryMarker) { if (!allItemsStillInError || previousFailure.marker !== retryMarker) {
@@ -13514,48 +13627,19 @@ export class DownloadManager extends EventEmitter {
this.emitState(); this.emitState();
const hybridExtractStartMs = nowMs(); const hybridExtractStartMs = nowMs();
const hybridFileNames = new Set<string>(); const plannedHybridItemIds = new Set<string>();
let dirFiles: string[] | undefined;
try {
dirFiles = (await fs.promises.readdir(pkg.outputDir, { withFileTypes: true }))
.filter((entry) => entry.isFile())
.map((entry) => entry.name);
} catch { }
const archiveStems = new Set<string>();
for (const archiveKey of readyArchives) { for (const archiveKey of readyArchives) {
const parts = collectArchiveCleanupTargets(archiveKey, dirFiles); for (const item of resolveArchiveItemsFromList(path.basename(archiveKey), completedItems, archiveKey)) {
for (const part of parts) { plannedHybridItemIds.add(item.id);
const partName = path.basename(part).toLowerCase();
hybridFileNames.add(partName);
const stem = partName
.replace(/\.part\d+\.rar$/i, "")
.replace(/\.(rar|r\d{2,3}|zip|z\d{2,3}|7z|tar|gz|bz2|xz|tgz|tbz2|txz|rev)$/i, "")
.replace(/\.(zip|7z)\.\d{3}$/i, "")
.replace(/\.\d{3}$/i, "");
if (stem && stem !== partName) archiveStems.add(stem);
} }
hybridFileNames.add(path.basename(archiveKey).toLowerCase()); const cleanupTargetKeys = new Set(collectArchiveCleanupTargets(archiveKey).map((target) => pathKey(target)));
} for (const item of completedItems) {
if (dirFiles && archiveStems.size > 0) { if (item.targetPath && cleanupTargetKeys.has(pathKey(item.targetPath))) {
for (const fileName of dirFiles) { plannedHybridItemIds.add(item.id);
const lower = fileName.toLowerCase();
if (!KNOWN_SMALL_FILE_RE.test(lower)) continue;
const companionStem = lower.replace(/\.[^.]+$/, "");
if (archiveStems.has(companionStem)) {
hybridFileNames.add(lower);
} }
} }
} }
const isHybridItem = (item: DownloadItem): boolean => { const hybridItems = completedItems.filter((item) => plannedHybridItemIds.has(item.id));
if (item.targetPath && hybridFileNames.has(path.basename(item.targetPath).toLowerCase())) {
return true;
}
if (item.fileName && hybridFileNames.has(item.fileName.toLowerCase())) {
return true;
}
return false;
};
const hybridItems = completedItems.filter(isHybridItem);
if (hybridItems.length > 0 && hybridItems.every((item) => isExtractedLabel(item.fullStatus))) { if (hybridItems.length > 0 && hybridItems.every((item) => isExtractedLabel(item.fullStatus))) {
logger.info(`Hybrid-Extract: pkg=${pkg.name}, alle ${hybridItems.length} Items bereits entpackt, überspringe`); logger.info(`Hybrid-Extract: pkg=${pkg.name}, alle ${hybridItems.length} Items bereits entpackt, überspringe`);
@@ -13563,17 +13647,7 @@ export class DownloadManager extends EventEmitter {
} }
for (const archiveKey of [...readyArchives]) { for (const archiveKey of [...readyArchives]) {
const archiveParts = collectArchiveCleanupTargets(archiveKey, dirFiles); const archiveItems = resolveArchiveItemsFromList(path.basename(archiveKey), completedItems, archiveKey);
const archivePartNames = new Set<string>();
archivePartNames.add(path.basename(archiveKey).toLowerCase());
for (const part of archiveParts) {
archivePartNames.add(path.basename(part).toLowerCase());
}
const archiveItems = completedItems.filter((item) => {
const targetName = item.targetPath ? path.basename(item.targetPath).toLowerCase() : "";
const fileName = (item.fileName || "").toLowerCase();
return archivePartNames.has(targetName) || archivePartNames.has(fileName);
});
if (archiveItems.length > 0 && archiveItems.every((item) => isExtractedLabel(item.fullStatus))) { if (archiveItems.length > 0 && archiveItems.every((item) => isExtractedLabel(item.fullStatus))) {
readyArchives.delete(archiveKey); readyArchives.delete(archiveKey);
} }
@@ -13586,10 +13660,8 @@ export class DownloadManager extends EventEmitter {
const resolveArchiveItems = (archiveName: string, archivePath = ""): DownloadItem[] => const resolveArchiveItems = (archiveName: string, archivePath = ""): DownloadItem[] =>
resolveArchiveItemsFromList(archiveName, items, archivePath); resolveArchiveItemsFromList(archiveName, items, archivePath);
const readyArchiveKeyByName = new Map<string, string>();
const readyArchiveMarkers = new Map<string, string>(); const readyArchiveMarkers = new Map<string, string>();
for (const archiveKey of readyArchives) { for (const archiveKey of readyArchives) {
readyArchiveKeyByName.set(path.basename(archiveKey).toLowerCase(), archiveKey);
readyArchiveMarkers.set(archiveKey, this.buildHybridArchiveRetryMarker(pkg, items, archiveKey)); readyArchiveMarkers.set(archiveKey, this.buildHybridArchiveRetryMarker(pkg, items, archiveKey));
} }
@@ -13601,7 +13673,6 @@ export class DownloadManager extends EventEmitter {
let hybridLastEmitAt = 0; let hybridLastEmitAt = 0;
let hybridLastProgressCurrent: number | null = null; let hybridLastProgressCurrent: number | null = null;
const allDownloaded = completedItems.length >= items.length;
let labelsChanged = false; let labelsChanged = false;
for (const entry of completedItems) { for (const entry of completedItems) {
if (isExtractedLabel(entry.fullStatus)) { if (isExtractedLabel(entry.fullStatus)) {
@@ -13610,9 +13681,7 @@ export class DownloadManager extends EventEmitter {
if (isExtractErrorLabel(entry.fullStatus)) { if (isExtractErrorLabel(entry.fullStatus)) {
continue; continue;
} }
const belongsToReady = allDownloaded const belongsToReady = plannedHybridItemIds.has(entry.id);
|| hybridFileNames.has((entry.fileName || "").toLowerCase())
|| (entry.targetPath && hybridFileNames.has(path.basename(entry.targetPath).toLowerCase()));
const targetLabel = belongsToReady ? "Entpacken - Ausstehend" : "Entpacken - Warten auf Parts"; const targetLabel = belongsToReady ? "Entpacken - Ausstehend" : "Entpacken - Warten auf Parts";
if (entry.fullStatus !== targetLabel) { if (entry.fullStatus !== targetLabel) {
entry.fullStatus = targetLabel; entry.fullStatus = targetLabel;
@@ -13648,17 +13717,17 @@ export class DownloadManager extends EventEmitter {
onLog: (level, message) => this.logExtractionForItems(pkg, items, "Hybrid-Extractor", level, message), onLog: (level, message) => this.logExtractionForItems(pkg, items, "Hybrid-Extractor", level, message),
onOutput: (event) => scope.add(event), onOutput: (event) => scope.add(event),
onArchiveFailure: (failure) => { onArchiveFailure: (failure) => {
failedArchiveCategories.set(String(failure.archiveName || "").toLowerCase(), failure.category); const failedArchiveKey = pathKey(failure.archivePath);
const failedArchiveKey = readyArchiveKeyByName.get(String(failure.archiveName || "").toLowerCase()); failedArchiveCategories.set(failedArchiveKey, failure.category);
if (failedArchiveKey) { if (failedArchiveKey) {
failedArchiveErrors.set(failedArchiveKey, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen"); failedArchiveErrors.set(failedArchiveKey, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen");
} }
if (autoRecoveredArchives.has(failure.archiveName)) { if (autoRecoveredArchives.has(failedArchiveKey)) {
return; return;
} }
const changed = this.autoRecoverArchiveCrcFailure(pkg, items, failure, "hybrid"); const changed = this.autoRecoverArchiveCrcFailure(pkg, items, failure, "hybrid");
if (changed > 0) { if (changed > 0) {
autoRecoveredArchives.add(failure.archiveName); autoRecoveredArchives.add(failedArchiveKey);
} }
}, },
onProgress: (progress) => { onProgress: (progress) => {
@@ -13680,10 +13749,11 @@ export class DownloadManager extends EventEmitter {
hybridLastProgressCurrent = currentCount; hybridLastProgressCurrent = currentCount;
if (progress.archiveName) { if (progress.archiveName) {
if (!hybridResolvedItems.has(progress.archiveName)) { const progressKey = pathKey(progress.archivePath || progress.archiveName);
if (!hybridResolvedItems.has(progressKey)) {
const resolved = resolveArchiveItems(progress.archiveName, progress.archivePath); const resolved = resolveArchiveItems(progress.archiveName, progress.archivePath);
hybridResolvedItems.set(progress.archiveName, resolved); hybridResolvedItems.set(progressKey, resolved);
hybridStartTimes.set(progress.archiveName, nowMs()); hybridStartTimes.set(progressKey, nowMs());
if (resolved.length === 0) { if (resolved.length === 0) {
logger.warn(`resolveArchiveItems (hybrid): KEINE Items gefunden für archiveName="${progress.archiveName}", items.length=${items.length}, itemNames=[${items.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`); logger.warn(`resolveArchiveItems (hybrid): KEINE Items gefunden für archiveName="${progress.archiveName}", items.length=${items.length}, itemNames=[${items.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`);
} else { } else {
@@ -13703,15 +13773,15 @@ export class DownloadManager extends EventEmitter {
this.emitState(true); this.emitState(true);
} }
} }
const archItems = hybridResolvedItems.get(progress.archiveName) || []; const archItems = hybridResolvedItems.get(progressKey) || [];
if (archiveFinished) { if (archiveFinished) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt; const startedAt = hybridStartTimes.get(progressKey) || doneAt;
const doneLabel = progress.archiveSuccess === false const doneLabel = progress.archiveSuccess === false
? "Entpacken - Error" ? "Entpacken - Error"
: formatExtractDone(doneAt - startedAt); : formatExtractDone(doneAt - startedAt);
const archiveKey = readyArchiveKeyByName.get(progress.archiveName.toLowerCase()); const archiveKey = readyArchives.has(progressKey) ? progressKey : undefined;
if (archiveKey && progress.archiveSuccess !== false) { if (archiveKey && progress.archiveSuccess !== false) {
this.clearHybridArchiveState(packageId, archiveKey); this.clearHybridArchiveState(packageId, archiveKey);
} }
@@ -13719,15 +13789,15 @@ export class DownloadManager extends EventEmitter {
pkg, pkg,
progress, progress,
archItems, archItems,
failedArchiveCategories.get(progress.archiveName.toLowerCase()) || "" failedArchiveCategories.get(progressKey) || ""
); );
for (const entry of archItems) { for (const entry of archItems) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
entry.fullStatus = doneLabel; entry.fullStatus = doneLabel;
entry.updatedAt = doneAt; entry.updatedAt = doneAt;
} }
hybridResolvedItems.delete(progress.archiveName); hybridResolvedItems.delete(progressKey);
hybridStartTimes.delete(progress.archiveName); hybridStartTimes.delete(progressKey);
const done = currentCount; const done = currentCount;
if (done < progress.total) { if (done < progress.total) {
pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Nächstes Archiv...`; pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Nächstes Archiv...`;
@@ -13780,12 +13850,7 @@ export class DownloadManager extends EventEmitter {
const now = nowMs(); const now = nowMs();
if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) { if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) {
hybridLastEmitAt = now; hybridLastEmitAt = now;
for (const entry of items) { markPlannedHybridArchiveItemsPending(items, plannedHybridItemIds, now);
if (entry.status === "completed" && entry.fullStatus === "Entpacken - Warten auf Parts") {
entry.fullStatus = "Entpacken - Ausstehend";
entry.updatedAt = now;
}
}
this.emitState(); this.emitState();
} }
} }
@@ -14044,6 +14109,8 @@ export class DownloadManager extends EventEmitter {
}); });
const allDone = this.areAllPackageItemRefsFinished(pkg); const allDone = this.areAllPackageItemRefsFinished(pkg);
const manualExtraction = this.manualExtractPackages.has(packageId);
const shouldExtract = this.settings.autoExtract || manualExtraction;
if (!allDone && success + failed + cancelled >= items.length) { if (!allDone && success + failed + cancelled >= items.length) {
logger.warn( logger.warn(
`Post-Processing wartet trotz gefiltert fertiger Items: ` + `Post-Processing wartet trotz gefiltert fertiger Items: ` +
@@ -14052,7 +14119,7 @@ export class DownloadManager extends EventEmitter {
); );
} }
if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) { if (!allDone && this.settings.hybridExtract && shouldExtract && failed === 0 && success > 0) {
pkg.postProcessLabel = "Entpacken vorbereiten..."; pkg.postProcessLabel = "Entpacken vorbereiten...";
this.emitState(); this.emitState();
const hybridExtracted = await this.runHybridExtraction(packageId, pkg, items, signal); const hybridExtracted = await this.runHybridExtraction(packageId, pkg, items, signal);
@@ -14091,7 +14158,7 @@ export class DownloadManager extends EventEmitter {
const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus)); const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus));
let extractedCount = 0; let extractedCount = 0;
if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) { if (shouldExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
pkg.postProcessLabel = "Entpacken vorbereiten..."; pkg.postProcessLabel = "Entpacken vorbereiten...";
pkg.status = "extracting"; pkg.status = "extracting";
this.emitState(); this.emitState();
@@ -14142,9 +14209,10 @@ export class DownloadManager extends EventEmitter {
extractAbortController.abort("extract_timeout"); extractAbortController.abort("extract_timeout");
} }
}, extractTimeoutMs); }, extractTimeoutMs);
let fullExtractionItems = completedItems;
try { try {
const autoRecoveredArchives = new Set<string>(); const autoRecoveredArchives = new Set<string>();
const fullFailedArchiveErrors = new Map<string, string>(); const fullFailedArchiveErrors = new Map<string, { archiveName: string; archivePath: string; errorText: string }>();
const fullFailedArchiveCategories = new Map<string, string>(); const fullFailedArchiveCategories = new Map<string, string>();
const fullResolvedItems = new Map<string, DownloadItem[]>(); const fullResolvedItems = new Map<string, DownloadItem[]>();
const fullStartTimes = new Map<string, number>(); const fullStartTimes = new Map<string, number>();
@@ -14161,13 +14229,24 @@ export class DownloadManager extends EventEmitter {
} }
const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems); const fullArchiveSet = await this.findFullExtractArchiveSet(pkg, completedItems);
const manualArchiveFilter = this.manualExtractArchiveFilters.get(packageId);
if (manualArchiveFilter) {
for (const archivePath of [...fullArchiveSet]) {
if (!manualArchiveFilter.has(pathKey(archivePath))) {
fullArchiveSet.delete(archivePath);
}
}
}
const fullExtractItemIds = new Set<string>(); const fullExtractItemIds = new Set<string>();
for (const archivePath of fullArchiveSet) { for (const archivePath of fullArchiveSet) {
const archiveItems = resolveArchiveItems(path.basename(archivePath)); const archiveItems = resolveArchiveItems(path.basename(archivePath), archivePath);
for (const entry of archiveItems) { for (const entry of archiveItems) {
fullExtractItemIds.add(entry.id); fullExtractItemIds.add(entry.id);
} }
} }
fullExtractionItems = manualArchiveFilter
? completedItems.filter((entry) => fullExtractItemIds.has(entry.id))
: completedItems;
const pendingAt = nowMs(); const pendingAt = nowMs();
for (const entry of completedItems) { for (const entry of completedItems) {
if (!fullExtractItemIds.has(entry.id) || isExtractedLabel(entry.fullStatus)) { if (!fullExtractItemIds.has(entry.id) || isExtractedLabel(entry.fullStatus)) {
@@ -14197,20 +14276,25 @@ export class DownloadManager extends EventEmitter {
onLog: (level, message) => this.logExtractionForItems(pkg, completedItems, "Extractor", level, message), onLog: (level, message) => this.logExtractionForItems(pkg, completedItems, "Extractor", level, message),
onOutput: (event) => scope.add(event), onOutput: (event) => scope.add(event),
onArchiveFailure: (failure) => { onArchiveFailure: (failure) => {
fullFailedArchiveCategories.set(failure.archiveName.toLowerCase(), failure.category); const failureKey = pathKey(failure.archivePath);
if (autoRecoveredArchives.has(failure.archiveName)) { fullFailedArchiveCategories.set(failureKey, failure.category);
if (autoRecoveredArchives.has(failureKey)) {
return; return;
} }
const changed = this.autoRecoverArchiveCrcFailure(pkg, completedItems, failure, "full"); const changed = this.autoRecoverArchiveCrcFailure(pkg, completedItems, failure, "full");
if (changed > 0) { if (changed > 0) {
autoRecoveredArchives.add(failure.archiveName); autoRecoveredArchives.add(failureKey);
fullFailedArchiveErrors.delete(failure.archiveName); fullFailedArchiveErrors.delete(failureKey);
fullFailedArchiveCategories.delete(failure.archiveName.toLowerCase()); fullFailedArchiveCategories.delete(failureKey);
return; return;
} }
fullFailedArchiveErrors.set( fullFailedArchiveErrors.set(
failure.archiveName, failureKey,
failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen" {
archiveName: failure.archiveName,
archivePath: failure.archivePath,
errorText: failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen"
}
); );
}, },
onProgress: (progress) => { onProgress: (progress) => {
@@ -14233,10 +14317,11 @@ export class DownloadManager extends EventEmitter {
fullLastProgressCurrent = currentCount; fullLastProgressCurrent = currentCount;
if (progress.archiveName) { if (progress.archiveName) {
if (!fullResolvedItems.has(progress.archiveName)) { const progressKey = pathKey(progress.archivePath || progress.archiveName);
if (!fullResolvedItems.has(progressKey)) {
const resolved = resolveArchiveItems(progress.archiveName, progress.archivePath); const resolved = resolveArchiveItems(progress.archiveName, progress.archivePath);
fullResolvedItems.set(progress.archiveName, resolved); fullResolvedItems.set(progressKey, resolved);
fullStartTimes.set(progress.archiveName, nowMs()); fullStartTimes.set(progressKey, nowMs());
if (resolved.length === 0) { if (resolved.length === 0) {
logger.warn(`resolveArchiveItems (full): KEINE Items für archiveName="${progress.archiveName}", completedItems=${completedItems.length}, names=[${completedItems.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`); logger.warn(`resolveArchiveItems (full): KEINE Items für archiveName="${progress.archiveName}", completedItems=${completedItems.length}, names=[${completedItems.map((i) => path.basename(i.targetPath || i.fileName || "?")).join(", ")}]`);
} else { } else {
@@ -14251,11 +14336,11 @@ export class DownloadManager extends EventEmitter {
emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true); emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true);
} }
} }
const archiveItems = fullResolvedItems.get(progress.archiveName) || []; const archiveItems = fullResolvedItems.get(progressKey) || [];
if (archiveFinished) { if (archiveFinished) {
const doneAt = nowMs(); const doneAt = nowMs();
const startedAt = fullStartTimes.get(progress.archiveName) || doneAt; const startedAt = fullStartTimes.get(progressKey) || doneAt;
const doneLabel = progress.archiveSuccess === false const doneLabel = progress.archiveSuccess === false
? "Entpacken - Error" ? "Entpacken - Error"
: formatExtractDone(doneAt - startedAt); : formatExtractDone(doneAt - startedAt);
@@ -14263,15 +14348,15 @@ export class DownloadManager extends EventEmitter {
pkg, pkg,
progress, progress,
archiveItems, archiveItems,
fullFailedArchiveCategories.get(progress.archiveName.toLowerCase()) || "" fullFailedArchiveCategories.get(pathKey(progress.archivePath || progress.archiveName)) || ""
); );
for (const entry of archiveItems) { for (const entry of archiveItems) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue; if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
entry.fullStatus = doneLabel; entry.fullStatus = doneLabel;
entry.updatedAt = doneAt; entry.updatedAt = doneAt;
} }
fullResolvedItems.delete(progress.archiveName); fullResolvedItems.delete(progressKey);
fullStartTimes.delete(progress.archiveName); fullStartTimes.delete(progressKey);
const done = currentCount; const done = currentCount;
if (done < progress.total) { if (done < progress.total) {
emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true); emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true);
@@ -14328,7 +14413,7 @@ export class DownloadManager extends EventEmitter {
this.diskWaitEvents = [{ ...error.event, packageId }]; this.diskWaitEvents = [{ ...error.event, packageId }];
const retryAt = error.event.retryAt; const retryAt = error.event.retryAt;
this.packageDiskRetryAfterByPackage.set(packageId, retryAt); this.packageDiskRetryAfterByPackage.set(packageId, retryAt);
for (const entry of completedItems) { for (const entry of fullExtractionItems) {
entry.fullStatus = "Warte auf Festplatte"; entry.fullStatus = "Warte auf Festplatte";
entry.lastError = "Zu wenig Speicherplatz"; entry.lastError = "Zu wenig Speicherplatz";
entry.updatedAt = nowMs(); entry.updatedAt = nowMs();
@@ -14363,18 +14448,18 @@ export class DownloadManager extends EventEmitter {
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen"); const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
const failAt = nowMs(); const failAt = nowMs();
if (fullFailedArchiveErrors.size > 0) { if (fullFailedArchiveErrors.size > 0) {
const archiveSummaries = [...fullFailedArchiveErrors.entries()] const archiveSummaries = [...fullFailedArchiveErrors.values()]
.slice(0, 3) .slice(0, 3)
.map(([archiveName, errorText]) => `${archiveName}: ${summarizeExtractFailureReason(errorText)}`) .map((failure) => `${failure.archiveName}: ${summarizeExtractFailureReason(failure.errorText)}`)
.join(" | "); .join(" | ");
logger.warn(`Post-Processing Entpacken Fehlerdetails: pkg=${pkg.name}, archives=${archiveSummaries}`); logger.warn(`Post-Processing Entpacken Fehlerdetails: pkg=${pkg.name}, archives=${archiveSummaries}`);
this.logPackageForPackage(pkg, "WARN", "Post-Processing Entpacken Fehlerdetails", { this.logPackageForPackage(pkg, "WARN", "Post-Processing Entpacken Fehlerdetails", {
failedArchives: [...fullFailedArchiveErrors.keys()], failedArchives: [...fullFailedArchiveErrors.values()].map((failure) => failure.archivePath),
summary: archiveSummaries summary: archiveSummaries
}); });
} }
this.applyPackageExtractFailureStatuses( this.applyPackageExtractFailureStatuses(
completedItems, fullExtractionItems,
resolveArchiveItems, resolveArchiveItems,
fullFailedArchiveErrors, fullFailedArchiveErrors,
reason, reason,
@@ -14383,8 +14468,9 @@ export class DownloadManager extends EventEmitter {
); );
pkg.status = "failed"; pkg.status = "failed";
} else { } else {
const hasExtractedOutput = this.getPackageOutputScope(pkg).completeFiles() const hasExtractedOutput = this.getPackageOutputScope(pkg).records()
.some((filePath) => isPathInsideDir(filePath, pkg.extractDir)); .some((record) => isPathInsideDir(record.outputPath, pkg.extractDir)
&& (!manualArchiveFilter || fullArchiveSet.has(pathKey(record.archivePath))));
const sourceExists = await this.existsAsync(pkg.outputDir); const sourceExists = await this.existsAsync(pkg.outputDir);
let finalStatusText = ""; let finalStatusText = "";
@@ -14398,14 +14484,20 @@ export class DownloadManager extends EventEmitter {
} }
const finalAt = nowMs(); const finalAt = nowMs();
for (const entry of completedItems) { for (const entry of fullExtractionItems) {
if (!isExtractedLabel(entry.fullStatus)) { if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = finalStatusText; entry.fullStatus = finalStatusText;
entry.updatedAt = finalAt; entry.updatedAt = finalAt;
} }
} }
if (manualArchiveFilter) {
const hasRemainingExtractError = completedItems.some((entry) => isExtractErrorLabel(entry.fullStatus || ""));
const hasRemainingExtractWork = completedItems.some((entry) => !isExtractedLabel(entry.fullStatus || "") && /^Entpack/i.test(entry.fullStatus || ""));
pkg.status = hasRemainingExtractError ? "failed" : hasRemainingExtractWork ? "queued" : "completed";
} else {
pkg.status = "completed"; pkg.status = "completed";
} }
}
} catch (error) { } catch (error) {
const reasonRaw = String(error || ""); const reasonRaw = String(error || "");
const isExtractAbort = reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout"); const isExtractAbort = reasonRaw.includes("aborted:extract") || reasonRaw.includes("extract_timeout");
@@ -14414,7 +14506,7 @@ export class DownloadManager extends EventEmitter {
if (timedOut) { if (timedOut) {
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`; const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`); logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
for (const entry of completedItems) { for (const entry of fullExtractionItems) {
if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = formatExtractFailureLabel(timeoutReason); entry.fullStatus = formatExtractFailureLabel(timeoutReason);
entry.updatedAt = nowMs(); entry.updatedAt = nowMs();
@@ -14424,7 +14516,7 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
timeoutHandled = true; timeoutHandled = true;
} else { } else {
for (const entry of completedItems) { for (const entry of fullExtractionItems) {
if (/^Entpacken/i.test(entry.fullStatus || "") || /^Passwort/i.test(entry.fullStatus || "")) { if (/^Entpacken/i.test(entry.fullStatus || "") || /^Passwort/i.test(entry.fullStatus || "")) {
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)"; entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
entry.updatedAt = nowMs(); entry.updatedAt = nowMs();
@@ -14439,7 +14531,7 @@ export class DownloadManager extends EventEmitter {
if (!timeoutHandled) { if (!timeoutHandled) {
const reason = compactErrorText(error); const reason = compactErrorText(error);
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`); logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
for (const entry of completedItems) { for (const entry of fullExtractionItems) {
if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) { if (entry.status === "completed" && !isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = formatExtractFailureLabel(reason); entry.fullStatus = formatExtractFailureLabel(reason);
entry.updatedAt = nowMs(); entry.updatedAt = nowMs();
@@ -14481,7 +14573,15 @@ export class DownloadManager extends EventEmitter {
alreadyMarkedExtracted alreadyMarkedExtracted
}); });
void this.runDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount); void this.runDeferredPostExtraction(
packageId,
pkg,
success,
failed,
alreadyMarkedExtracted,
extractedCount,
manualExtraction
);
} }
private runDeferredPostExtraction( private runDeferredPostExtraction(
@@ -14490,10 +14590,11 @@ export class DownloadManager extends EventEmitter {
success: number, success: number,
failed: number, failed: number,
alreadyMarkedExtracted: boolean, alreadyMarkedExtracted: boolean,
extractedCount: number extractedCount: number,
manualSelection = false
): Promise<void> { ): Promise<void> {
this.trackPackagePostProcessResult(packageId); this.trackPackagePostProcessResult(packageId);
const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount) const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount, manualSelection)
.finally(() => { .finally(() => {
const tasks = this.packageDeferredPostProcessTasks.get(packageId); const tasks = this.packageDeferredPostProcessTasks.get(packageId);
tasks?.delete(task); tasks?.delete(task);
@@ -14516,7 +14617,8 @@ export class DownloadManager extends EventEmitter {
success: number, success: number,
failed: number, failed: number,
alreadyMarkedExtracted: boolean, alreadyMarkedExtracted: boolean,
extractedCount: number extractedCount: number,
manualSelection: boolean
): Promise<void> { ): Promise<void> {
const replacedController = this.packageDeferredPostProcessAbortControllers.get(packageId); const replacedController = this.packageDeferredPostProcessAbortControllers.get(packageId);
if (replacedController && !replacedController.signal.aborted) { if (replacedController && !replacedController.signal.aborted) {
@@ -14537,7 +14639,7 @@ export class DownloadManager extends EventEmitter {
try { try {
throwIfAborted(); throwIfAborted();
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.autoExtract) { if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && (this.settings.autoExtract || manualSelection)) {
const nestedBlacklist = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i; const nestedBlacklist = /\.(iso|img|bin|dmg|vhd|vhdx|vmdk|wim)$/i;
const nestedCandidates = outputScope.archiveFiles() const nestedCandidates = outputScope.archiveFiles()
.filter((candidate) => isPathInsideDir(candidate, pkg.extractDir) && !nestedBlacklist.test(candidate)); .filter((candidate) => isPathInsideDir(candidate, pkg.extractDir) && !nestedBlacklist.test(candidate));
@@ -14604,7 +14706,7 @@ export class DownloadManager extends EventEmitter {
} }
} }
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none") { if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && this.settings.cleanupMode !== "none" && !manualSelection) {
pkg.postProcessLabel = "Aufräumen..."; pkg.postProcessLabel = "Aufräumen...";
this.emitState(); this.emitState();
throwIfAborted(); throwIfAborted();
@@ -14639,7 +14741,7 @@ export class DownloadManager extends EventEmitter {
} }
} }
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0) { if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && !manualSelection) {
throwIfAborted(); throwIfAborted();
await clearExtractResumeState(pkg.outputDir, packageId); await clearExtractResumeState(pkg.outputDir, packageId);
await clearExtractResumeState(pkg.outputDir); await clearExtractResumeState(pkg.outputDir);
+2
View File
@@ -85,6 +85,7 @@ export interface ExtractProgressUpdate {
export interface ExtractArchiveFailureInfo { export interface ExtractArchiveFailureInfo {
archiveName: string; archiveName: string;
archivePath: string;
errorText: string; errorText: string;
category: ExtractErrorCategory; category: ExtractErrorCategory;
suggestRedownload: boolean; suggestRedownload: boolean;
@@ -4195,6 +4196,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const hintedError = error as ExtractionErrorWithHints; const hintedError = error as ExtractionErrorWithHints;
options.onArchiveFailure?.({ options.onArchiveFailure?.({
archiveName, archiveName,
archivePath,
errorText, errorText,
category: errorCategory, category: errorCategory,
suggestRedownload: hintedError?.suggestRedownload === true, suggestRedownload: hintedError?.suggestRedownload === true,
+5 -11
View File
@@ -26,6 +26,8 @@ import { migrateProductUserDataDirectory } from "./storage";
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector"; import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler"; import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
import { forceDarkNativeTheme } from "./native-theme"; import { forceDarkNativeTheme } from "./native-theme";
import { normalizeExtractNowRequest } from "../shared/extract-now";
import { validateClipboardWriteText } from "./clipboard-write";
forceDarkNativeTheme(nativeTheme); forceDarkNativeTheme(nativeTheme);
@@ -44,7 +46,6 @@ function validatePlainObject(value: unknown, name: string): Record<string, unkno
} }
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024; const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
const CLIPBOARD_WRITE_MAX_BYTES = 4096;
const RENAME_PACKAGE_MAX_CHARS = 240; const RENAME_PACKAGE_MAX_CHARS = 240;
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([ const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
"realdebrid", "realdebrid",
@@ -645,9 +646,8 @@ function registerIpcHandlers(): void {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
return controller.retryExtraction(packageId); return controller.retryExtraction(packageId);
}); });
handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, packageId: string) => { handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, request: unknown) => {
validateString(packageId, "packageId"); return controller.extractNow(normalizeExtractNowRequest(request));
return controller.extractNow(packageId);
}); });
handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => { handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
validateString(packageId, "packageId"); validateString(packageId, "packageId");
@@ -714,14 +714,8 @@ function registerIpcHandlers(): void {
return next; return next;
}); });
handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => { handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => {
const text = validateString(rawText, "text"); const text = validateClipboardWriteText(rawText);
const bytes = Buffer.byteLength(text, "utf8"); const bytes = Buffer.byteLength(text, "utf8");
if (!text.trim()) {
throw new Error("text darf nicht leer sein");
}
if (bytes > CLIPBOARD_WRITE_MAX_BYTES) {
throw new Error(`text ist zu groß (max ${CLIPBOARD_WRITE_MAX_BYTES} Bytes)`);
}
try { try {
clipboard.writeText(text); clipboard.writeText(text);
return true; return true;
+135 -70
View File
@@ -177,52 +177,31 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
}); });
} }
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal, abortErrorFactory: () => Error = abortError): Promise<T> { type MegaWebQueueTask = {
if (!signal) { run: (canMutate: () => boolean) => Promise<unknown>;
return promise; resolve: (value: unknown) => void;
} reject: (reason?: unknown) => void;
if (signal.aborted) { signal?: AbortSignal;
throw abortErrorFactory(); queuedAt: number;
} workStartedAt: number | null;
owner: symbol;
return new Promise<T>((resolve, reject) => { settled: boolean;
let settled = false; onAbort: () => void;
const onAbort = (): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortErrorFactory());
}; };
signal.addEventListener("abort", onAbort, { once: true }); type MegaWebQueueState = {
active: MegaWebQueueTask | null;
promise.then((value) => { pending: MegaWebQueueTask[];
if (settled) { };
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
});
});
}
export class MegaWebFallback { export class MegaWebFallback {
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen // Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene // seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts // Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
// gleichzeitig umgewandelt werden statt global eine nach der anderen. // gleichzeitig umgewandelt werden statt global eine nach der anderen.
private queues = new Map<string, Promise<unknown>>(); private queues = new Map<string, MegaWebQueueState>();
private activeQueueOwners = new Map<string, symbol>();
private getCredentials: () => MegaCredentials; private getCredentials: () => MegaCredentials;
@@ -248,14 +227,16 @@ export class MegaWebFallback {
} }
const key = creds.login.trim().toLowerCase(); const key = creds.login.trim().toLowerCase();
const sessionGeneration = this.sessionGeneration; const sessionGeneration = this.sessionGeneration;
return this.runExclusive(async () => { return this.runExclusive(async (canMutate) => {
throwIfAborted(overallSignal); throwIfAborted(overallSignal);
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration, canMutate);
let generated = await this.generate(link, cookie, overallSignal); let generated = await this.generate(link, cookie, overallSignal);
if (!generated) { if (!generated) {
if (canMutate()) {
this.sessions.delete(key); this.sessions.delete(key);
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration); }
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration, canMutate);
generated = await this.generate(link, cookie, overallSignal); generated = await this.generate(link, cookie, overallSignal);
if (!generated) { if (!generated) {
return null; return null;
@@ -275,14 +256,15 @@ export class MegaWebFallback {
login: string, login: string,
password: string, password: string,
signal?: AbortSignal, signal?: AbortSignal,
generation = this.sessionGeneration generation = this.sessionGeneration,
canMutate: () => boolean = () => true
): Promise<string> { ): Promise<string> {
const existing = this.sessions.get(key); const existing = this.sessions.get(key);
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) { if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
return existing.cookie; return existing.cookie;
} }
const cookie = await this.login(login, password, signal); const cookie = await this.login(login, password, signal);
if (generation === this.sessionGeneration) { if (generation === this.sessionGeneration && canMutate()) {
this.sessions.set(key, { cookie, setAt: Date.now() }); this.sessions.set(key, { cookie, setAt: Date.now() });
} }
return cookie; return cookie;
@@ -293,36 +275,119 @@ export class MegaWebFallback {
this.sessions.clear(); this.sessions.clear();
} }
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> { private runExclusive<T>(job: (canMutate: () => boolean) => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now(); return new Promise<T>((resolve, reject) => {
const QUEUE_WAIT_TIMEOUT_MS = 90000; const state = this.queues.get(key) ?? { active: null, pending: [] };
let workStarted = false; let task: MegaWebQueueTask;
const guardedJob = async (): Promise<T> => { task = {
throwIfAborted(signal); run: job,
const waited = Date.now() - queuedAt; resolve: (value: unknown) => resolve(value as T),
if (waited > QUEUE_WAIT_TIMEOUT_MS) { reject,
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` }); signal,
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`); queuedAt: Date.now(),
} workStartedAt: null,
workStarted = true; owner: Symbol(key),
const workStartedAt = Date.now(); settled: false,
try { onAbort: () => this.abortQueueTask(key, state, task)
const result = await job();
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "ok" });
return result;
} catch (jobError) {
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - workStartedAt, outcome: "error", detail: compactErrorText(jobError).slice(0, 100) });
throw jobError;
}
}; };
const prev = this.queues.get(key) ?? Promise.resolve(); state.pending.push(task);
const run = prev.then(guardedJob, guardedJob); this.queues.set(key, state);
this.queues.set(key, run.then(() => undefined, () => undefined)); if (signal?.aborted) {
return raceWithAbort(run, signal, () => task.onAbort();
workStarted return;
}
signal?.addEventListener("abort", task.onAbort, { once: true });
this.startNextQueueTask(key, state);
});
}
private startNextQueueTask(key: string, state: MegaWebQueueState): void {
if (state.active) {
return;
}
const task = state.pending.shift();
if (!task) {
if (this.queues.get(key) === state) {
this.queues.delete(key);
}
return;
}
if (task.settled) {
this.startNextQueueTask(key, state);
return;
}
const waited = Date.now() - task.queuedAt;
if (waited > 90000) {
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` });
this.settleQueueTask(task, undefined, new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`));
this.startNextQueueTask(key, state);
return;
}
if (task.signal?.aborted) {
this.settleQueueTask(task, undefined, new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor(waited / 1000)}s Wartezeit, Account war belegt)`));
this.startNextQueueTask(key, state);
return;
}
state.active = task;
task.workStartedAt = Date.now();
this.activeQueueOwners.set(key, task.owner);
const canMutate = (): boolean => state.active === task && this.activeQueueOwners.get(key) === task.owner;
void task.run(canMutate).then((result) => {
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - (task.workStartedAt ?? Date.now()), outcome: "ok" });
this.settleQueueTask(task, result);
}, (error) => {
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, workMs: Date.now() - (task.workStartedAt ?? Date.now()), outcome: "error", detail: compactErrorText(error).slice(0, 100) });
this.settleQueueTask(task, undefined, error);
}).finally(() => {
if (state.active === task) {
state.active = null;
if (this.activeQueueOwners.get(key) === task.owner) {
this.activeQueueOwners.delete(key);
}
this.startNextQueueTask(key, state);
}
});
}
private abortQueueTask(key: string, state: MegaWebQueueState, task: MegaWebQueueTask): void {
if (task.settled) {
return;
}
const waited = Date.now() - task.queuedAt;
const wasActive = state.active === task;
if (!wasActive) {
const index = state.pending.indexOf(task);
if (index >= 0) {
state.pending.splice(index, 1);
}
}
this.settleQueueTask(
task,
undefined,
wasActive
? abortError() ? abortError()
: new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor((Date.now() - queuedAt) / 1000)}s Wartezeit, Account war belegt)`) : new Error(`Mega-Web Queue-Timeout (abgebrochen nach ${Math.floor(waited / 1000)}s Wartezeit, Account war belegt)`)
); );
if (wasActive) {
state.active = null;
if (this.activeQueueOwners.get(key) === task.owner) {
this.activeQueueOwners.delete(key);
}
}
this.startNextQueueTask(key, state);
}
private settleQueueTask(task: MegaWebQueueTask, value?: unknown, error?: unknown): void {
if (task.settled) {
return;
}
task.settled = true;
task.signal?.removeEventListener("abort", task.onAbort);
if (error !== undefined) {
task.reject(error);
} else {
task.resolve(value);
}
} }
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> { private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
+10 -3
View File
@@ -97,6 +97,7 @@ function getFailure(
remuxOperations: readonly RemuxOperationMetric[], remuxOperations: readonly RemuxOperationMetric[],
remuxFallbackFailures: number, remuxFallbackFailures: number,
archiveOperations: readonly ArchiveOperationMetric[], archiveOperations: readonly ArchiveOperationMetric[],
extractionErrors: readonly string[],
downloadErrors: readonly string[] downloadErrors: readonly string[]
): { failurePhase: FailurePhase; errorCategory: string } { ): { failurePhase: FailurePhase; errorCategory: string } {
if (cleanupErrorCategory) { if (cleanupErrorCategory) {
@@ -107,8 +108,8 @@ function getFailure(
return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) }; return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) };
} }
const failedArchive = archiveOperations.find((operation) => operation.status === "failed"); const failedArchive = archiveOperations.find((operation) => operation.status === "failed");
if (failedArchive) { if (failedArchive || extractionErrors.length > 0) {
return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive.errorCategory) }; return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive?.errorCategory || extractionErrors[0]) };
} }
const downloadError = downloadErrors.find(Boolean); const downloadError = downloadErrors.find(Boolean);
if (downloadErrors.length > 0) { if (downloadErrors.length > 0) {
@@ -126,8 +127,13 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul
const cleanedCompletedDownloads = Math.max(0, Math.floor(finiteNonNegative(packageEntry.cleanedCompletedItemCount))); const cleanedCompletedDownloads = Math.max(0, Math.floor(finiteNonNegative(packageEntry.cleanedCompletedItemCount)));
const completedDownloads = cleanedCompletedDownloads + telemetry.items.filter((item) => item.status === "completed").length; const completedDownloads = cleanedCompletedDownloads + telemetry.items.filter((item) => item.status === "completed").length;
const failedDownloads = telemetry.items.filter((item) => item.status === "failed"); const failedDownloads = telemetry.items.filter((item) => item.status === "failed");
const itemExtractionFailures = telemetry.items.filter((item) => item.status === "completed" && /^(?:Entpack-Fehler|Entpacken\s*-\s*(?:Fehler|Error))/i.test(item.fullStatus || ""));
const itemExtractionFailureCount = new Set(itemExtractionFailures.map((item) => {
const archiveName = String(item.fullStatus || "").match(/^Entpack-Fehler\s*\[([^\]]+)\]/i)?.[1];
return archiveName?.toLocaleLowerCase("de-DE") || String(item.fullStatus || "").toLocaleLowerCase("de-DE") || item.id;
})).size;
const cancelledDownloads = telemetry.items.filter((item) => item.status === "cancelled").length; const cancelledDownloads = telemetry.items.filter((item) => item.status === "cancelled").length;
const failedArchives = archiveOperations.filter((operation) => operation.status === "failed").length; const failedArchives = Math.max(archiveOperations.filter((operation) => operation.status === "failed").length, itemExtractionFailureCount);
const cancelledArchives = archiveOperations.filter((operation) => operation.status === "cancelled").length; const cancelledArchives = archiveOperations.filter((operation) => operation.status === "cancelled").length;
const failedRemuxOperations = remuxOperations.filter((operation) => operation.status === "failed").length; const failedRemuxOperations = remuxOperations.filter((operation) => operation.status === "failed").length;
const cancelledRemuxOperations = remuxOperations.filter((operation) => operation.status === "cancelled").length; const cancelledRemuxOperations = remuxOperations.filter((operation) => operation.status === "cancelled").length;
@@ -163,6 +169,7 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul
remuxOperations, remuxOperations,
audioStripFailures, audioStripFailures,
archiveOperations, archiveOperations,
itemExtractionFailures.map((item) => item.lastError || item.fullStatus),
failedDownloads.map((item) => item.lastError || item.fullStatus) failedDownloads.map((item) => item.lastError || item.fullStatus)
); );
+2 -1
View File
@@ -1,4 +1,5 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"; import { contextBridge, ipcRenderer, webUtils } from "electron";
import type { ExtractNowRequest } from "../shared/extract-now";
import { import {
AddLinksPayload, AddLinksPayload,
AccountCheckScope, AccountCheckScope,
@@ -118,7 +119,7 @@ const api: ElectronApi = {
revealAccountSecret: (input: AccountSecretRequest): Promise<AccountSecretResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, input), revealAccountSecret: (input: AccountSecretRequest): Promise<AccountSecretResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, input),
getArchivePasswordList: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST), getArchivePasswordList: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST),
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: (request: ExtractNowRequest): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, request),
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),
onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void): (() => void) => { onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void): (() => void) => {
+68 -47
View File
@@ -63,6 +63,7 @@ import { BackupPassphraseDialog } from "./ui/BackupPassphraseDialog";
import { Dialog } from "./ui/Dialog"; import { Dialog } from "./ui/Dialog";
import { Icon } from "./ui/Icon"; import { Icon } from "./ui/Icon";
import { Toast } from "./ui/Toast"; import { Toast } from "./ui/Toast";
import { LinkAddressesDialog } from "./ui/LinkAddressesDialog";
import { import {
buildCollectorTransferPackages, buildCollectorTransferPackages,
buildCollectorWorkspaceViewModel, buildCollectorWorkspaceViewModel,
@@ -106,6 +107,7 @@ import {
import { buildDownloadsViewModel, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model"; import { buildDownloadsViewModel, formatRemainingDownloadBytes, formatRemainingDownloadTooltip, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable"; import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog"; import { DeleteConfirmationDialog } from "./views/downloads/DeleteConfirmationDialog";
import { buildExtractNowContextAction } from "./views/downloads/extract-action";
import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag"; import { beginDownloadColumnDrag, clearDownloadColumnDrag, commitDownloadColumnDrag, createDownloadColumnOrderPersistence, DOWNLOAD_COLUMN_MOVE_DURATION_MS, updateDownloadColumnDrag, type DownloadColumnDragSession, type DownloadColumnOrderPersistence } from "./views/downloads/column-drag";
import { import {
DownloadsContent, DownloadsContent,
@@ -1469,6 +1471,29 @@ function formatUpdateInstallProgress(progress: UpdateInstallProgress): string {
return `Update-Fehler: ${progress.message}`; return `Update-Fehler: ${progress.message}`;
} }
export function sortPackageOrderByService(
order: string[],
packages: Record<string, PackageEntry>,
items: Record<string, DownloadItem>,
descending: boolean,
visibleItemsByPackage: Record<string, readonly DownloadItem[]> = {}
): string[] {
const sorted = [...order];
const itemsFor = (packageId: string): readonly DownloadItem[] => visibleItemsByPackage[packageId]
?? (packages[packageId]?.itemIds ?? []).map((id) => items[id]).filter((item): item is DownloadItem => Boolean(item));
sorted.sort((a, b) => {
const serviceA = [...new Set(itemsFor(a).map((item) => {
return item?.providerLabel || (item?.provider ? providerLabels[item.provider] : "");
}).filter(Boolean))].join(",").toLocaleLowerCase("de-DE");
const serviceB = [...new Set(itemsFor(b).map((item) => {
return item?.providerLabel || (item?.provider ? providerLabels[item.provider] : "");
}).filter(Boolean))].join(",").toLocaleLowerCase("de-DE");
const cmp = serviceA.localeCompare(serviceB, "de");
return descending ? -cmp : cmp;
});
return sorted;
}
export function shouldApplyUpdateCheckResult( export function shouldApplyUpdateCheckResult(
completedGeneration: number, completedGeneration: number,
currentGeneration: number currentGeneration: number
@@ -4426,7 +4451,7 @@ export function App(): ReactElement {
const onCopyOnlineBackupKey = async (): Promise<void> => { const onCopyOnlineBackupKey = async (): Promise<void> => {
if (!onlineBackupDialog?.key) return; if (!onlineBackupDialog?.key) return;
try { try {
await navigator.clipboard.writeText(onlineBackupDialog.key); if (!(await window.rd.writeClipboardText(onlineBackupDialog.key))) throw new Error("clipboard_write_rejected");
showToast("Online-Schlüssel kopiert", 2200); showToast("Online-Schlüssel kopiert", 2200);
} catch { } catch {
showToast("Schlüssel konnte nicht kopiert werden", 2600); showToast("Schlüssel konnte nicht kopiert werden", 2600);
@@ -4495,11 +4520,11 @@ export function App(): ReactElement {
detailsLabel: "Einträge anzeigen" detailsLabel: "Einträge anzeigen"
}); });
if (copy && entries.length > 0) { if (copy && entries.length > 0) {
await navigator.clipboard.writeText(details); if (!(await window.rd.writeClipboardText(details))) throw new Error("clipboard_write_rejected");
showToast("Fehlerliste kopiert", 2600); showToast("Fehlerliste kopiert", 2600);
} }
} catch (error) { } catch (error) {
showToast(`Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000); showToast(String(error).includes("clipboard_write_rejected") ? "Kopieren fehlgeschlagen" : `Fehler-Ansicht fehlgeschlagen: ${String(error)}`, 3000);
} }
}; };
@@ -4598,7 +4623,7 @@ export function App(): ReactElement {
return; return;
} }
try { try {
await navigator.clipboard.writeText(remoteDiag.code); if (!(await window.rd.writeClipboardText(remoteDiag.code))) throw new Error("clipboard_write_rejected");
showToast("Verbindungscode kopiert", 2200); showToast("Verbindungscode kopiert", 2200);
} catch { } catch {
showToast("Kopieren fehlgeschlagen", 2200); showToast("Kopieren fehlgeschlagen", 2200);
@@ -4720,6 +4745,14 @@ export function App(): ReactElement {
? sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) ? sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
: column === "hoster" : column === "hoster"
? sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) ? sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
: column === "service"
? sortPackageOrderByService(
baseOrder,
snapshot.session.packages,
snapshot.session.items,
nextDescending,
Object.fromEntries(downloadsViewCore.packageRows.map((row) => [row.package.id, row.items]))
)
: sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); : sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending);
pendingPackageOrderRef.current = [...sorted]; pendingPackageOrderRef.current = [...sorted];
pendingPackageOrderAtRef.current = Date.now(); pendingPackageOrderAtRef.current = Date.now();
@@ -4732,7 +4765,7 @@ export function App(): ReactElement {
setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: serverPackageOrderRef.current } })); setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: serverPackageOrderRef.current } }));
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
}); });
}, [downloadsSortColumn, downloadsSortDescending, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); }, [downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]);
const clearDownloadQueue = useCallback((): void => { const clearDownloadQueue = useCallback((): void => {
void performQuickAction(async () => { void performQuickAction(async () => {
@@ -5374,7 +5407,7 @@ export function App(): ReactElement {
}, },
onCopyIdentity: (label, value) => { onCopyIdentity: (label, value) => {
void window.rd.writeClipboardText(value) void window.rd.writeClipboardText(value)
.then(() => showToast(`${label} kopiert`)) .then((copied) => copied ? showToast(`${label} kopiert`) : showToast("Kopieren fehlgeschlagen"))
.catch(() => showToast("Kopieren fehlgeschlagen")); .catch(() => showToast("Kopieren fehlgeschlagen"));
}, },
onAdd: openCreateAccountDialog, onAdd: openCreateAccountDialog,
@@ -6416,18 +6449,27 @@ export function App(): ReactElement {
const startableStatuses = new Set(["queued", "cancelled", "reconnect_wait"]); const startableStatuses = new Set(["queued", "cancelled", "reconnect_wait"]);
const hasStartableItems = actionableSelectedIds.some((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); }); const hasStartableItems = actionableSelectedIds.some((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
const hasItems = selectedItemIds.length > 0; const hasItems = selectedItemIds.length > 0;
const extractAction = buildExtractNowContextAction({
contextItemId: contextMenu.itemId,
selectedPackageIds,
selectedItemIds,
packages: snapshot.session.packages,
items: snapshot.session.items
});
return ( return (
<ContextMenu ariaLabel="Downloadaktionen" onClose={() => setContextMenu(null)} open ref={ctxMenuRef} x={contextMenu.x} y={contextMenu.y}> <ContextMenu ariaLabel="Downloadaktionen" onClose={() => setContextMenu(null)} open ref={ctxMenuRef} x={contextMenu.x} y={contextMenu.y}>
{(hasPackages || hasStartableItems) && ( {(hasPackages || hasStartableItems) && (
<button className="ctx-menu-item" onClick={() => { <button className="ctx-menu-item" disabled={actionBusy || (!snapshot.canStart && !snapshot.session.running)} onClick={() => {
const pkgIds = selectedPackageIds; const pkgIds = selectedPackageIds;
const itemIds = selectedItemIds.filter((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); }); const itemIds = selectedItemIds.filter((id) => { const it = snapshot.session.items[id]; return it && startableStatuses.has(it.status); });
if (pkgIds.length > 0) void window.rd.startPackages(pkgIds).catch(() => {});
if (itemIds.length > 0) void window.rd.startItems(itemIds).catch(() => {});
setContextMenu(null); setContextMenu(null);
void performQuickAction(async () => {
if (pkgIds.length > 0) await window.rd.startPackages(pkgIds);
if (itemIds.length > 0) await window.rd.startItems(itemIds);
}, (error) => showToast(`Start fehlgeschlagen: ${String(error)}`, 2600));
}}>Ausgewählte Downloads starten{multi ? ` (${actionableSelectedIds.length})` : ""}</button> }}>Ausgewählte Downloads starten{multi ? ` (${actionableSelectedIds.length})` : ""}</button>
)} )}
<button className="ctx-menu-item" onClick={() => { downloadsActions.onStartDownloads(); setContextMenu(null); }}>Alle Downloads starten</button> <button className="ctx-menu-item" disabled={actionBusy || !snapshot.canStart} onClick={() => { downloadsActions.onStartDownloads(); setContextMenu(null); }}>Alle Downloads starten</button>
<div className="ctx-menu-sep" /> <div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => showLinksPopup(contextMenu.packageId, contextMenu.itemId)}>Linkadressen anzeigen</button> <button className="ctx-menu-item" onClick={() => showLinksPopup(contextMenu.packageId, contextMenu.itemId)}>Linkadressen anzeigen</button>
{hasPackages && !contextMenu.itemId && ( {hasPackages && !contextMenu.itemId && (
@@ -6497,16 +6539,13 @@ export function App(): ReactElement {
setContextMenu(null); setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button> }}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button>
)} )}
{hasPackages && !multi && (() => { {extractAction && (
const pkg = snapshot.session.packages[contextMenu.packageId]; <button className="ctx-menu-item" onClick={() => {
const items = pkg?.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean) || []; void window.rd.extractNow(extractAction.request)
const someCompleted = items.some((item) => item && item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || "")); .catch((error) => showToast(`Entpacken fehlgeschlagen: ${String(error)}`, 2600));
return (<> setContextMenu(null);
{someCompleted && ( }}>{extractAction.label}</button>
<button className="ctx-menu-item" onClick={() => { void window.rd.extractNow(contextMenu.packageId).catch(() => {}); setContextMenu(null); }}>Jetzt entpacken</button>
)} )}
</>);
})()}
{hasPackages && !contextMenu.itemId && (<> {hasPackages && !contextMenu.itemId && (<>
<div className="ctx-menu-sep" /> <div className="ctx-menu-sep" />
<div className="ctx-menu-sub"> <div className="ctx-menu-sub">
@@ -6687,8 +6726,8 @@ export function App(): ReactElement {
type="button" type="button"
title={`${key.masked}\nMaskierte Kennung kopieren`} title={`${key.masked}\nMaskierte Kennung kopieren`}
onClick={() => { onClick={() => {
void navigator.clipboard.writeText(key.masked) void window.rd.writeClipboardText(key.masked)
.then(() => showToast("Maskierte Kennung kopiert", 1800)) .then((copied) => copied ? showToast("Maskierte Kennung kopiert", 1800) : showToast("Kopieren fehlgeschlagen", 2200))
.catch(() => showToast("Kopieren fehlgeschlagen", 2200)); .catch(() => showToast("Kopieren fehlgeschlagen", 2200));
}} }}
> >
@@ -6739,32 +6778,14 @@ export function App(): ReactElement {
/> />
) : null} ) : null}
{linkPopup ? ( {linkPopup ? (
<Dialog actions={null} className="link-popup" onClose={() => setLinkPopup(null)} open size="wide" title="Linkadressen anzeigen"> <LinkAddressesDialog
<p>{linkPopup.title}</p> isPackage={linkPopup.isPackage}
<div className="link-popup-list"> links={linkPopup.links}
{linkPopup.links.map((link, i) => ( onClose={() => setLinkPopup(null)}
<div key={i} className="link-popup-row"> onToast={showToast}
<button aria-label={`${link.name} kopieren`} className="link-popup-name link-popup-click" type="button" title={`${link.name}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.name).then(() => showToast("Name kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.name}</button> title={linkPopup.title}
<button aria-label="Link kopieren" className="link-popup-url link-popup-click" type="button" title={`${link.url}\nKlicken zum Kopieren`} onClick={() => { void navigator.clipboard.writeText(link.url).then(() => showToast("Link kopiert")).catch(() => showToast("Kopieren fehlgeschlagen")); }}>{link.url}</button> writeClipboardText={window.rd.writeClipboardText}
</div> />
))}
</div>
<div className="modal-actions">
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.name).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Namen kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Namen kopieren</button>
)}
{linkPopup.isPackage && (
<button className="btn" onClick={() => {
const text = linkPopup.links.map((l) => l.url).join("\n");
void navigator.clipboard.writeText(text).then(() => showToast("Alle Links kopiert")).catch(() => showToast("Kopieren fehlgeschlagen"));
}}>Alle Links kopieren</button>
)}
<button className="btn" onClick={() => setLinkPopup(null)}>Schließen</button>
</div>
</Dialog>
) : null} ) : null}
</> </>
)} )}
+53 -1
View File
@@ -64,7 +64,7 @@ const pairs = [
["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"], ["Alle sichtbaren Einträge auswählen", "Select all visible entries"], ["Details anzeigen", "Show details"], ["Details ausblenden", "Hide details"],
["Sichtbar:", "Visible:"], ["pro Seite", "per page"], ["Sichtbar:", "Visible:"], ["pro Seite", "per page"],
["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"], ["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."], ["Verfügbarkeit", "Availability"], ["Hinzugefügt am", "Added on"], ["Ungeprüft", "Unchecked"], ["Paket gestoppt", "Package stopped"], ["Alle anzeigen", "Show all"], ["Planen", "Schedule"], ["Startzeit", "Start time"], ["Starttag", "Start day"], ["Ab heute", "Starting today"], ["Ab morgen", "Starting tomorrow"], ["Bitte eine gültige Startzeit auswählen.", "Select a valid start time."],
["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Keine Downloads", "No downloads"], ["Keine passenden Downloads", "No matching downloads"], ["Füge Links hinzu, um Downloads vorzubereiten.", "Add links to prepare downloads."], ["Passe Filter oder Suche an.", "Adjust the filter or search."], ["Warte auf Festplatte", "Waiting for disk"],
["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"], ["Keine Links gesammelt", "No links collected"], ["Keine passenden Links", "No matching links"], ["Füge Links oder Text ein, um sie zu sammeln.", "Paste links or text to collect them."], ["Links durchsuchen", "Search links"],
["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"], ["Datenmenge", "Data volume"], ["Sitzungszähler", "Session counter"], ["Sieben Tage", "Seven days"], ["30 Tage", "30 days"], ["Zeitraum", "Period"], ["Erfolgreich", "Successful"],
["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"], ["Sitzungszähler und Ergebnisse der aktuellen Queue werden angezeigt.", "Session counters and results for the current queue are shown."], ["Sitzung zurücksetzen", "Reset session"], ["Gesamt zurücksetzen", "Reset total"], ["Fehler zurücksetzen", "Reset errors"],
@@ -222,11 +222,41 @@ export function normalizeLanguage(value: unknown): AppLanguage {
return value === "de" ? "de" : "en"; return value === "de" ? "de" : "en";
} }
function translatePackageStatusParts(value: string, language: AppLanguage): string | null {
const parts = value.split(" · ");
if (parts.length < 2) return null;
const translated = parts.map((part): string | null => {
if (language === "en") {
const extractionError = part.match(/^(\d+) Entpackfehler$/);
if (extractionError) return `${extractionError[1]} extraction error${extractionError[1] === "1" ? "" : "s"}`;
const retry = part.match(/^(\d+) Wiederholung(?:en)?$/);
if (retry) return `${retry[1]} retr${retry[1] === "1" ? "y" : "ies"}`;
const error = part.match(/^(\d+) Fehler$/);
if (error) return `${error[1]} error${error[1] === "1" ? "" : "s"}`;
const cancelled = part.match(/^(\d+) abgebrochen$/);
if (cancelled) return `${cancelled[1]} cancelled`;
return null;
}
const extractionError = part.match(/^(\d+) extraction errors?$/);
if (extractionError) return `${extractionError[1]} Entpackfehler`;
const retry = part.match(/^(\d+) retr(?:y|ies)$/);
if (retry) return `${retry[1]} Wiederholung${retry[1] === "1" ? "" : "en"}`;
const error = part.match(/^(\d+) errors?$/);
if (error) return `${error[1]} Fehler`;
const cancelled = part.match(/^(\d+) cancelled$/);
if (cancelled) return `${cancelled[1]} abgebrochen`;
return null;
});
return translated.every((part): part is string => part !== null) ? translated.join(" · ") : null;
}
function translateDynamic(value: string, language: AppLanguage): string { function translateDynamic(value: string, language: AppLanguage): string {
for (const [german, english] of prefixedPairs) { for (const [german, english] of prefixedPairs) {
const source = language === "en" ? german : english; const source = language === "en" ? german : english;
if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`; if (value.startsWith(source)) return `${language === "en" ? english : german}${value.slice(source.length)}`;
} }
const packageStatus = translatePackageStatusParts(value, language);
if (packageStatus) return packageStatus;
if (language === "en") { if (language === "en") {
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/); const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
if (update) return `${update[1]} is available. Installed version: ${update[2]}.`; if (update) return `${update[1]} is available. Installed version: ${update[2]}.`;
@@ -274,6 +304,17 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (audio) return `Audio track: ${audio[1].replace(/ohne DE-Tag/g, "without DE tag").replace(/ffmpeg fehlt/g, "ffmpeg missing").replace(/(\d+) Fehler/g, "$1 errors")}`; if (audio) return `Audio track: ${audio[1].replace(/ohne DE-Tag/g, "without DE tag").replace(/ffmpeg fehlt/g, "ffmpeg missing").replace(/(\d+) Fehler/g, "$1 errors")}`;
const result = value.match(/^(\d+\/\d+) fertig(.*)$/); const result = value.match(/^(\d+\/\d+) fertig(.*)$/);
if (result) return `${result[1]} completed${result[2].replace(/(\d+) Fehler/g, "$1 errors").replace(/(\d+) abgebrochen/g, "$1 cancelled")}`; if (result) return `${result[1]} completed${result[2].replace(/(\d+) Fehler/g, "$1 errors").replace(/(\d+) abgebrochen/g, "$1 cancelled")}`;
const extractionErrorsAndRetries = value.match(/^(\d+) Entpackfehler · (\d+) Wiederholung(?:en)?$/);
if (extractionErrorsAndRetries) return `${extractionErrorsAndRetries[1]} extraction error${extractionErrorsAndRetries[1] === "1" ? "" : "s"} · ${extractionErrorsAndRetries[2]} retr${extractionErrorsAndRetries[2] === "1" ? "y" : "ies"}`;
const extractionErrors = value.match(/^(\d+) Entpackfehler$/);
if (extractionErrors) return `${extractionErrors[1]} extraction error${extractionErrors[1] === "1" ? "" : "s"}`;
const retries = value.match(/^(\d+) Wiederholung(?:en)?$/);
if (retries) return `${retries[1]} retr${retries[1] === "1" ? "y" : "ies"}`;
const downloadCompleteExtractionErrors = value.match(/^Download fertig · (\d+) Entpackfehler$/);
if (downloadCompleteExtractionErrors) return `Download complete · ${downloadCompleteExtractionErrors[1]} extraction error${downloadCompleteExtractionErrors[1] === "1" ? "" : "s"}`;
if (value === "Download fertig") return "Download complete";
const extractSelection = value.match(/^Jetzt entpacken \((\d+)\)$/);
if (extractSelection) return `Extract now (${extractSelection[1]})`;
const extracting = value.match(/^Entpacken (\d+%)$/); const extracting = value.match(/^Entpacken (\d+%)$/);
if (extracting) return `Extracting ${extracting[1]}`; if (extracting) return `Extracting ${extracting[1]}`;
const finalizing = value.match(/^Finalisieren - (\d+%)$/); const finalizing = value.match(/^Finalisieren - (\d+%)$/);
@@ -447,6 +488,17 @@ function translateDynamic(value: string, language: AppLanguage): string {
if (audio) return `Tonspur: ${audio[1].replace(/without DE tag/g, "ohne DE-Tag").replace(/ffmpeg missing/g, "ffmpeg fehlt").replace(/(\d+) errors/g, "$1 Fehler")}`; if (audio) return `Tonspur: ${audio[1].replace(/without DE tag/g, "ohne DE-Tag").replace(/ffmpeg missing/g, "ffmpeg fehlt").replace(/(\d+) errors/g, "$1 Fehler")}`;
const result = value.match(/^(\d+\/\d+) completed(.*)$/); const result = value.match(/^(\d+\/\d+) completed(.*)$/);
if (result) return `${result[1]} fertig${result[2].replace(/(\d+) errors/g, "$1 Fehler").replace(/(\d+) cancelled/g, "$1 abgebrochen")}`; if (result) return `${result[1]} fertig${result[2].replace(/(\d+) errors/g, "$1 Fehler").replace(/(\d+) cancelled/g, "$1 abgebrochen")}`;
const extractionErrorsAndRetries = value.match(/^(\d+) extraction errors? · (\d+) retr(?:y|ies)$/);
if (extractionErrorsAndRetries) return `${extractionErrorsAndRetries[1]} Entpackfehler · ${extractionErrorsAndRetries[2]} Wiederholung${extractionErrorsAndRetries[2] === "1" ? "" : "en"}`;
const extractionErrors = value.match(/^(\d+) extraction errors?$/);
if (extractionErrors) return `${extractionErrors[1]} Entpackfehler`;
const retries = value.match(/^(\d+) retr(?:y|ies)$/);
if (retries) return `${retries[1]} Wiederholung${retries[1] === "1" ? "" : "en"}`;
const downloadCompleteExtractionErrors = value.match(/^Download complete · (\d+) extraction errors?$/);
if (downloadCompleteExtractionErrors) return `Download fertig · ${downloadCompleteExtractionErrors[1]} Entpackfehler`;
if (value === "Download complete") return "Download fertig";
const extractSelection = value.match(/^Extract now \((\d+)\)$/);
if (extractSelection) return `Jetzt entpacken (${extractSelection[1]})`;
const extracting = value.match(/^Extracting (\d+%)$/); const extracting = value.match(/^Extracting (\d+%)$/);
if (extracting) return `Entpacken ${extracting[1]}`; if (extracting) return `Entpacken ${extracting[1]}`;
const finalizing = value.match(/^Finalizing - (\d+%)$/); const finalizing = value.match(/^Finalizing - (\d+%)$/);
+77
View File
@@ -0,0 +1,77 @@
import type { ReactElement } from "react";
import { Dialog } from "./Dialog";
export interface LinkAddress {
name: string;
url: string;
}
export interface LinkAddressesDialogProps {
title: string;
links: LinkAddress[];
isPackage: boolean;
onClose: () => void;
writeClipboardText: (text: string) => Promise<boolean>;
onToast: (message: string) => void;
}
export function LinkAddressesDialog({
title,
links,
isPackage,
onClose,
writeClipboardText,
onToast
}: LinkAddressesDialogProps): ReactElement {
const copy = async (text: string, successMessage: string): Promise<void> => {
try {
const copied = await writeClipboardText(text);
onToast(copied === true ? successMessage : "Kopieren fehlgeschlagen");
} catch {
onToast("Kopieren fehlgeschlagen");
}
};
return (
<Dialog actions={null} className="link-popup" onClose={onClose} open size="wide" title="Linkadressen anzeigen">
<p>{title}</p>
<div className="link-popup-list">
{links.map((link, index) => (
<div key={index} className="link-popup-row">
<button
aria-label={`${link.name} kopieren`}
className="link-popup-name link-popup-click"
onClick={() => copy(link.name, "Name kopiert")}
title={`${link.name}\nKlicken zum Kopieren`}
type="button"
>
{link.name}
</button>
<button
aria-label="Link kopieren"
className="link-popup-url link-popup-click"
onClick={() => copy(link.url, "Link kopiert")}
title={`${link.url}\nKlicken zum Kopieren`}
type="button"
>
{link.url}
</button>
</div>
))}
</div>
<div className="modal-actions">
{isPackage ? (
<button className="btn" onClick={() => copy(links.map((link) => link.name).join("\n"), "Alle Namen kopiert")} type="button">
Alle Namen kopieren
</button>
) : null}
{isPackage ? (
<button className="btn" onClick={() => copy(links.map((link) => link.url).join("\n"), "Alle Links kopiert")} type="button">
Alle Links kopieren
</button>
) : null}
<button className="btn" onClick={onClose} type="button">Schließen</button>
</div>
</Dialog>
);
}
+61 -50
View File
@@ -13,13 +13,24 @@ import {
providerLabels providerLabels
} from "../../download-format"; } from "../../download-format";
import type { DownloadPackageRow } from "./downloads-model"; import type { DownloadPackageRow } from "./downloads-model";
import { buildPackagePresentation } from "./package-presentation";
export type DownloadSortColumn = "name" | "size" | "hoster" | "progress"; export type DownloadSortColumn = "name" | "size" | "hoster" | "progress" | "service";
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px"; const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px"; const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
const DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX = 5;
const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter"; const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter";
interface DownloadColumnPointerGesture {
dragged: boolean;
pointerId: number;
sortColumn?: DownloadSortColumn;
startX: number;
}
const downloadColumnPointerGestures = new WeakMap<HTMLDivElement, DownloadColumnPointerGesture>();
type HosterLabel = ReturnType<typeof formatHosterLabel>; type HosterLabel = ReturnType<typeof formatHosterLabel>;
function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement { function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement {
@@ -54,7 +65,7 @@ export const downloadColumnDefinitions: Record<string, { label: string; width: s
size: { label: "Geladen / Größe", width: "minmax(var(--downloads-size-min, 140px), 1.1fr)", sortable: "size" }, size: { label: "Geladen / Größe", width: "minmax(var(--downloads-size-min, 140px), 1.1fr)", sortable: "size" },
progress: { label: "Fortschritt", width: "minmax(var(--downloads-progress-min, 105px), 0.85fr)", sortable: "progress" }, progress: { label: "Fortschritt", width: "minmax(var(--downloads-progress-min, 105px), 0.85fr)", sortable: "progress" },
hoster: { label: "Hoster", width: "minmax(var(--downloads-hoster-min, 90px), 0.85fr)", sortable: "hoster" }, hoster: { label: "Hoster", width: "minmax(var(--downloads-hoster-min, 90px), 0.85fr)", sortable: "hoster" },
account: { label: "Service", width: "minmax(var(--downloads-service-min, 90px), 0.85fr)" }, account: { label: "Service", width: "minmax(var(--downloads-service-min, 90px), 0.85fr)", sortable: "service" },
prio: { label: "Priorität", width: "minmax(var(--downloads-priority-min, 85px), 0.8fr)" }, prio: { label: "Priorität", width: "minmax(var(--downloads-priority-min, 85px), 0.8fr)" },
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" }, status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
speed: { label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" }, speed: { label: "Geschwindigkeit", width: "minmax(var(--downloads-speed-min, 120px), 1fr)" },
@@ -64,10 +75,16 @@ export const downloadColumnDefinitions: Record<string, { label: string; width: s
export type AvailabilityState = "online" | "partial" | "offline" | "checking"; export type AvailabilityState = "online" | "partial" | "offline" | "checking";
function effectiveItemOnlineStatus(item: DownloadItem): DownloadItem["onlineStatus"] {
return item.onlineStatus
?? (item.status === "downloading" || item.status === "integrity_check" || item.status === "completed" ? "online" : undefined);
}
export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } { export function getAvailabilitySummary(items: DownloadItem[]): { online: number; total: number; state: AvailabilityState } {
const total = items.length; const total = items.length;
const online = items.filter((item) => item.onlineStatus === "online").length; const availability = items.map(effectiveItemOnlineStatus);
const offline = items.filter((item) => item.onlineStatus === "offline").length; const online = availability.filter((status) => status === "online").length;
const offline = availability.filter((status) => status === "offline").length;
if (total > 0 && online === total) return { online, total, state: "online" }; if (total > 0 && online === total) return { online, total, state: "online" };
if (total > 0 && offline === total) return { online, total, state: "offline" }; if (total > 0 && offline === total) return { online, total, state: "offline" };
if (total > 0 && online + offline === total) return { online, total, state: "partial" }; if (total > 0 && online + offline === total) return { online, total, state: "partial" };
@@ -216,7 +233,7 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
? error && error !== displayStatus && !displayStatus.includes(error) ? `${displayStatus}${retrySuffix}\n${error}` : `${displayStatus}${retrySuffix}` ? error && error !== displayStatus && !displayStatus.includes(error) ? `${displayStatus}${retrySuffix}\n${error}` : `${displayStatus}${retrySuffix}`
: error; : error;
if (column === "name") { if (column === "name") {
return <span className="downloads-cell downloads-name-cell downloads-copyable" title={item.fileName}><span className={`downloads-link-state ${item.onlineStatus ?? "unknown"}`} />{item.fileName}</span>; return <span className="downloads-cell downloads-name-cell downloads-copyable" title={item.fileName}><span className={`downloads-link-state ${effectiveItemOnlineStatus(item) ?? "unknown"}`} />{item.fileName}</span>;
} }
if (column === "size") { if (column === "size") {
const total = item.totalBytes || item.downloadedBytes || 0; const total = item.totalBytes || item.downloadedBytes || 0;
@@ -241,7 +258,8 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
if (column === "status") return <DownloadStatusCell status={displayStatus} title={statusTitle} />; if (column === "status") return <DownloadStatusCell status={displayStatus} title={statusTitle} />;
if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>; if (column === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
if (column === "availability") { if (column === "availability") {
const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking"; const effectiveStatus = effectiveItemOnlineStatus(item);
const state = effectiveStatus === "online" ? "online" : effectiveStatus === "offline" ? "offline" : "checking";
const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft"; const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft";
return <Availability online={state === "online" ? 1 : 0} total={1} state={state} text={text} />; return <Availability online={state === "online" ? 1 : 0} total={1} state={state} text={text} />;
} }
@@ -318,37 +336,7 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps)
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual); export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } { export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)); return buildPackagePresentation(row).progress;
let failed = 0;
let cancelled = 0;
let extracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0));
let extracting = false;
let activeProgress = 0;
let extractingProgress = 0;
for (const item of row.allItems) {
if (item.status === "completed") done += 1;
else if (item.status === "failed") failed += 1;
else if (item.status === "cancelled") cancelled += 1;
const fullStatus = item.fullStatus || "";
if (fullStatus.startsWith("Entpackt")) {
extracted += 1;
} else if (fullStatus.startsWith("Entpacken")) {
extracting = true;
const match = fullStatus.match(/^Entpacken\s+(\d+)%/);
if (match) extractingProgress += Number(match[1]) / 100;
}
if (item.status === "downloading" || (item.status === "queued" && (item.progressPercent || 0) > 0)) {
activeProgress += (item.progressPercent || 0) / 100;
}
}
const total = Math.max(1, Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)) + row.allItems.length);
const allDownloaded = done + failed + cancelled >= total;
const allExtracted = extracted >= total;
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
const downloadProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
const extractionProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
const value = Math.min(100, useExtractSplit ? downloadProgress + extractionProgress : downloadProgress);
return { done, failed, cancelled, total, value };
} }
export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } { export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } {
@@ -361,7 +349,8 @@ export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: n
function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null { function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null {
const entry = row.package; const entry = row.package;
const stats = getPackageProgress(row); const presentation = buildPackagePresentation(row);
const stats = presentation.progress;
if (column === "name") { if (column === "name") {
return ( return (
<span className="downloads-cell downloads-name-cell"> <span className="downloads-cell downloads-name-cell">
@@ -403,16 +392,14 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel) const postProcessLabel = entry.status === "extracting" && compactPostProcessLabel === rawPostProcessLabel && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
? "Entpacken - Ausstehend" ? "Entpacken - Ausstehend"
: compactPostProcessLabel; : compactPostProcessLabel;
const extractFailure = row.allItems.find((item) => /^Entpack-Fehler\b/i.test(item.fullStatus || "")); const details = `${presentation.details}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
const waitsForDisk = row.allItems.some((item) => compactDownloadStatus(item.fullStatus || "") === "Warte auf Festplatte"); const status = presentation.extractFailureCount === 0
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${extractFailure ? " · Entpack-Fehler" : ""}${audio ? ` · ${audio.text}` : ""}`; && presentation.retryCount === 0
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating"); && postProcessLabel
const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting") && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
? postProcessLabel ? postProcessLabel
: extractFailure ? "Entpack-Fehler" : presentation.status;
: waitsForDisk ? "Warte auf Festplatte" const statusDetails = presentation.extractFailure ? `${details}\n${presentation.extractFailure.fullStatus}` : details;
: downloading ? "Download läuft" : details;
const statusDetails = extractFailure ? `${details}\n${extractFailure.fullStatus}` : details;
const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails; const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails;
return <DownloadStatusCell status={status} title={title} />; return <DownloadStatusCell status={status} title={title} />;
} }
@@ -533,6 +520,11 @@ function moveColumnWithPointerActions(column: string, direction: -1 | 1, element
actions.onColumnPointerUp(column, pointerEvent(clientX)); actions.onColumnPointerUp(column, pointerEvent(clientX));
} }
function isColumnSortPointerTarget(target: EventTarget | null): boolean {
const closest = (target as { closest?: (selector: string) => Element | null } | null)?.closest;
return typeof closest === "function" && closest.call(target, ".downloads-column-sort") !== null;
}
export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement { export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement {
const allSelected = visibleIds.length > 0 && selectedCount === visibleIds.length; const allSelected = visibleIds.length > 0 && selectedCount === visibleIds.length;
const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length; const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length;
@@ -550,22 +542,41 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC
data-download-column={column} data-download-column={column}
key={column} key={column}
onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }}
onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)} onPointerCancel={(event) => {
downloadColumnPointerGestures.delete(event.currentTarget);
actions.onColumnPointerCancel(column, event);
}}
onPointerDown={(event) => { onPointerDown={(event) => {
if (event.button !== 0 || !event.isPrimary) return; if (event.button !== 0 || !event.isPrimary) return;
if (event.currentTarget.closest<HTMLElement>(".downloads-table")?.classList.contains("is-column-drag-settling")) return; if (event.currentTarget.closest<HTMLElement>(".downloads-table")?.classList.contains("is-column-drag-settling")) return;
downloadColumnPointerGestures.set(event.currentTarget, {
dragged: false,
pointerId: event.pointerId,
sortColumn: definition.sortable && isColumnSortPointerTarget(event.target) ? definition.sortable : undefined,
startX: event.clientX
});
event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.setPointerCapture(event.pointerId);
actions.onColumnPointerDown(column, event); actions.onColumnPointerDown(column, event);
}} }}
onPointerMove={(event) => actions.onColumnPointerMove(column, event)} onPointerMove={(event) => {
const gesture = downloadColumnPointerGestures.get(event.currentTarget);
if (gesture?.pointerId === event.pointerId && Math.abs(event.clientX - gesture.startX) >= DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX) gesture.dragged = true;
actions.onColumnPointerMove(column, event);
}}
onPointerUp={(event) => { onPointerUp={(event) => {
const gesture = downloadColumnPointerGestures.get(event.currentTarget);
if (gesture?.pointerId === event.pointerId) {
if (Math.abs(event.clientX - gesture.startX) >= DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX) gesture.dragged = true;
downloadColumnPointerGestures.delete(event.currentTarget);
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
actions.onColumnPointerUp(column, event); actions.onColumnPointerUp(column, event);
if (gesture?.pointerId === event.pointerId && gesture.sortColumn && !gesture.dragged) actions.onSortColumn(gesture.sortColumn);
}} }}
role="columnheader" role="columnheader"
> >
{definition.sortable {definition.sortable
? <button className="downloads-column-sort" onClick={() => actions.onSortColumn(definition.sortable!)} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button> ? <button className="downloads-column-sort" onClick={(event) => { if (event.detail === 0) actions.onSortColumn(definition.sortable!); }} type="button">{definition.label}{sortColumn === definition.sortable ? sortDirection === "asc" ? " ↑" : " ↓" : ""}</button>
: <span className="downloads-column-label">{definition.label}</span>} : <span className="downloads-column-label">{definition.label}</span>}
<span aria-label={`${definition.label} verschieben`} className="downloads-column-move-controls" onPointerDown={(event) => event.stopPropagation()} role="group"> <span aria-label={`${definition.label} verschieben`} className="downloads-column-move-controls" onPointerDown={(event) => event.stopPropagation()} role="group">
{index > 0 ? <button aria-label={`${definition.label} nach links verschieben`} onClick={(event) => { event.stopPropagation(); const element = event.currentTarget.closest<HTMLDivElement>(".downloads-column-header"); if (element) moveColumnWithPointerActions(column, -1, element, actions); }} type="button"></button> : null} {index > 0 ? <button aria-label={`${definition.label} nach links verschieben`} onClick={(event) => { event.stopPropagation(); const element = event.currentTarget.closest<HTMLDivElement>(".downloads-column-header"); if (element) moveColumnWithPointerActions(column, -1, element, actions); }} type="button"></button> : null}
@@ -0,0 +1,46 @@
import type { DownloadItem, PackageEntry } from "../../../shared/types";
import type { ExtractNowRequest } from "../../../shared/extract-now";
export interface ExtractNowContextAction {
label: string;
request: ExtractNowRequest;
targetCount: number;
}
export interface ExtractNowContextInput {
contextItemId?: string;
selectedPackageIds: readonly string[];
selectedItemIds: readonly string[];
packages: Record<string, PackageEntry>;
items: Record<string, DownloadItem>;
}
function canExtractItem(item: DownloadItem | undefined): item is DownloadItem {
return Boolean(item && item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || ""));
}
export function buildExtractNowContextAction(input: ExtractNowContextInput): ExtractNowContextAction | null {
const packageIds = [...new Set(input.selectedPackageIds)].filter((packageId) => {
const entry = input.packages[packageId];
return Boolean(entry && !entry.cancelled && entry.itemIds.some((itemId) => canExtractItem(input.items[itemId])));
});
const packageSet = new Set(packageIds);
const selectedItemIds = input.selectedItemIds.length > 0
? input.selectedItemIds
: input.contextItemId
? [input.contextItemId]
: [];
const itemIds = [...new Set(selectedItemIds)].filter((itemId) => {
const item = input.items[itemId];
return canExtractItem(item) && !packageSet.has(item.packageId);
});
const targetCount = packageIds.length + itemIds.length;
if (targetCount === 0) {
return null;
}
return {
label: targetCount > 1 ? `Jetzt entpacken (${targetCount})` : "Jetzt entpacken",
request: { packageIds, itemIds },
targetCount
};
}
@@ -0,0 +1,140 @@
import type { DownloadItem } from "../../../shared/types";
import type { DownloadPackageRow } from "./downloads-model";
export interface PackageProgressPresentation {
done: number;
failed: number;
cancelled: number;
total: number;
value: number;
}
export interface PackagePresentation {
progress: PackageProgressPresentation;
status: string;
details: string;
extractFailure?: DownloadItem;
extractFailureCount: number;
retryCount: number;
waitDiskCount: number;
extractingCount: number;
}
function extractionPercent(fullStatus: string): number {
const match = fullStatus.match(/^Entpacken\s+(\d+)%/i);
return match ? Math.max(0, Math.min(100, Number(match[1]))) / 100 : 0;
}
function isExtractFailure(fullStatus: string): boolean {
return /^(?:Entpack-Fehler|Entpacken\s*-\s*(?:Fehler|Error))/i.test(fullStatus);
}
function isExtractionLifecycle(fullStatus: string): boolean {
return /^(?:Entpack|Passwort)/i.test(fullStatus);
}
function isArchiveItem(item: DownloadItem): boolean {
return /\.(?:rar|r\d{2,3}|zip|7z|tar|gz|bz2|xz|tgz|tbz2|txz|\d{3})$/i.test(item.fileName || item.targetPath || "");
}
function isRetrying(item: DownloadItem): boolean {
return /(?:Link-Umwandlung erneut|Wiederholung|Retry|erneut)/i.test(item.fullStatus || "")
|| (item.retries > 0 && (item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait"));
}
function downloadFraction(item: DownloadItem): number {
if (item.status === "completed") {
return 1;
}
if (item.totalBytes && item.totalBytes > 0) {
return Math.max(0, Math.min(1, item.downloadedBytes / item.totalBytes));
}
return Math.max(0, Math.min(1, (item.progressPercent || 0) / 100));
}
export function buildPackagePresentation(row: DownloadPackageRow): PackagePresentation {
const cleanedCompleted = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0));
const cleanedExtracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0));
let done = cleanedCompleted;
let failed = 0;
let cancelled = 0;
let downloadUnits = cleanedCompleted;
let extractionUnits = cleanedExtracted;
let extractionLifecycle = row.package.status === "extracting"
|| /^(?:Entpack|Passwort)/i.test(row.package.postProcessLabel || "")
|| (row.allItems.some(isArchiveItem) && !row.allItems.every((item) => /^Fertig\b/i.test(item.fullStatus || "")));
let extracting = 0;
let retrying = 0;
let waitsForDisk = 0;
const extractFailures: DownloadItem[] = [];
for (const item of row.allItems) {
if (item.status === "completed") done += 1;
else if (item.status === "failed") failed += 1;
else if (item.status === "cancelled") cancelled += 1;
downloadUnits += downloadFraction(item);
const fullStatus = item.fullStatus || "";
if (/^Entpackt\b/i.test(fullStatus)) {
extractionUnits += 1;
extractionLifecycle = true;
} else {
const progress = extractionPercent(fullStatus);
if (progress > 0 || /^Entpacken\b/i.test(fullStatus)) {
extracting += 1;
extractionUnits += progress;
}
if (isExtractFailure(fullStatus)) {
extractFailures.push(item);
}
if (isExtractionLifecycle(fullStatus)) {
extractionLifecycle = true;
}
}
if (isRetrying(item)) retrying += 1;
if (/Warte auf Festplatte/i.test(fullStatus)) waitsForDisk += 1;
}
const total = Math.max(1, cleanedCompleted + row.allItems.length);
const downloadValue = Math.floor(Math.min(1, downloadUnits / total) * (extractionLifecycle ? 90 : 100));
const extractionValue = extractionLifecycle ? Math.floor(Math.min(1, extractionUnits / total) * 10) : 0;
const allExtracted = extractionLifecycle && extractionUnits >= total;
const value = allExtracted ? 100 : Math.min(extractionLifecycle ? 99 : 100, downloadValue + extractionValue);
const parts: string[] = [];
if (extractFailures.length > 0) parts.push(`${extractFailures.length} Entpackfehler`);
if (retrying > 0) parts.push(`${retrying} Wiederholung${retrying === 1 ? "" : "en"}`);
if (failed > 0) parts.push(`${failed} Fehler`);
if (cancelled > 0) parts.push(`${cancelled} abgebrochen`);
const details = parts.length > 0 ? parts.join(" · ") : done >= total ? "Fertig" : `${done}/${total} fertig`;
const downloadsComplete = row.allItems.every((item) => downloadFraction(item) >= 1);
const packageExtractLabel = (row.package.postProcessLabel || "").trim();
const downloading = row.package.status === "downloading"
|| row.package.status === "validating"
|| row.allItems.some((item) => item.status === "downloading" || item.status === "validating");
let status = allExtracted ? "Entpackt" : details;
if (extractFailures.length > 0 && retrying > 0) {
status = `${extractFailures.length} Entpackfehler · ${retrying} Wiederholung${retrying === 1 ? "" : "en"}`;
} else if (extractFailures.length > 0) {
status = downloadsComplete ? `Download fertig · ${extractFailures.length} Entpackfehler` : `${extractFailures.length} Entpackfehler`;
} else if (waitsForDisk > 0) {
status = "Warte auf Festplatte";
} else if (extracting > 0 || row.package.status === "extracting") {
status = packageExtractLabel || "Entpacken";
} else if (retrying > 0) {
status = `${retrying} Wiederholung${retrying === 1 ? "" : "en"}`;
} else if (downloading) {
status = "Download läuft";
}
return {
progress: { done, failed, cancelled, total, value },
status,
details,
extractFailure: extractFailures[0],
extractFailureCount: extractFailures.length,
retryCount: retrying,
waitDiskCount: waitsForDisk,
extractingCount: extracting
};
}
+42
View File
@@ -0,0 +1,42 @@
export interface ExtractNowRequest {
packageIds: string[];
itemIds: string[];
}
const MAX_EXTRACT_NOW_TARGETS = 2000;
const MAX_EXTRACT_NOW_ID_LENGTH = 256;
function normalizeIds(value: unknown, name: string): string[] {
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.trim().length > 0)) {
throw new Error(`${name} muss ein Array nicht-leerer Strings sein`);
}
if (value.some((entry) => entry.trim().length > MAX_EXTRACT_NOW_ID_LENGTH)) {
throw new Error(`${name} enthält eine ID mit ungültiger Länge`);
}
return [...new Set(value.map((entry) => entry.trim()))];
}
export function normalizeExtractNowRequest(value: unknown): ExtractNowRequest {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("extractNow muss ein Objekt sein");
}
const record = value as Record<string, unknown>;
const unknownKeys = Object.keys(record).filter((key) => key !== "packageIds" && key !== "itemIds");
if (unknownKeys.length > 0) {
throw new Error(`extractNow enthält unbekannte Felder: ${unknownKeys.join(", ")}`);
}
const rawCount = (Array.isArray(record.packageIds) ? record.packageIds.length : 0)
+ (Array.isArray(record.itemIds) ? record.itemIds.length : 0);
if (rawCount > MAX_EXTRACT_NOW_TARGETS) {
throw new Error(`extractNow unterstützt höchstens ${MAX_EXTRACT_NOW_TARGETS} Ziele`);
}
const packageIds = normalizeIds(record.packageIds, "packageIds");
const itemIds = normalizeIds(record.itemIds, "itemIds");
if (packageIds.length + itemIds.length === 0) {
throw new Error("extractNow benötigt mindestens ein Ziel");
}
if (packageIds.length + itemIds.length > MAX_EXTRACT_NOW_TARGETS) {
throw new Error(`extractNow unterstützt höchstens ${MAX_EXTRACT_NOW_TARGETS} Ziele`);
}
return { packageIds, itemIds };
}
+2 -1
View File
@@ -33,6 +33,7 @@ import type {
UpdateInstallProgress, UpdateInstallProgress,
UpdateInstallResult UpdateInstallResult
} from "./types"; } from "./types";
import type { ExtractNowRequest } from "./extract-now";
import { isRealDebridWebAccountId } from "./real-debrid-accounts"; import { isRealDebridWebAccountId } from "./real-debrid-accounts";
import type { CollectorInspectionRequest, CollectorInspectionResult } from "./collector"; import type { CollectorInspectionRequest, CollectorInspectionResult } from "./collector";
@@ -143,7 +144,7 @@ export interface ElectronApi {
revealAccountSecret: (input: AccountSecretRequest) => Promise<AccountSecretResult>; revealAccountSecret: (input: AccountSecretRequest) => Promise<AccountSecretResult>;
getArchivePasswordList: () => Promise<ArchivePasswordListResult>; getArchivePasswordList: () => Promise<ArchivePasswordListResult>;
retryExtraction: (packageId: string) => Promise<void>; retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>; extractNow: (request: ExtractNowRequest) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>; resetPackage: (packageId: string) => Promise<void>;
getHistory: () => Promise<HistoryEntry[]>; getHistory: () => Promise<HistoryEntry[]>;
onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void) => () => void; onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void) => () => void;
+3 -2
View File
@@ -12,9 +12,10 @@ describe("desktop shell", () => {
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8"); const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/); expect(source).not.toMatch(/<span[^>]*className="[^"]*link-popup-click/);
expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(3); expect(source.match(/<button[^>]*className="[^"]*link-popup-click[^>]*type="button"/g)).toHaveLength(1);
expect(source).not.toContain("navigator.clipboard.writeText(key.token)"); expect(source).not.toContain("navigator.clipboard.writeText(key.token)");
expect(source).toContain("navigator.clipboard.writeText(key.masked)"); expect(source).not.toContain("navigator.clipboard.writeText");
expect(source).toContain("window.rd.writeClipboardText(key.masked)");
expect(source).toContain("Maskierte Kennung kopiert"); expect(source).toContain("Maskierte Kennung kopiert");
}); });
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { CLIPBOARD_WRITE_MAX_BYTES, validateClipboardWriteText } from "../src/main/clipboard-write";
describe("clipboard write validation", () => {
it("accepts complete large link packages up to one MiB", () => {
const text = "x".repeat(CLIPBOARD_WRITE_MAX_BYTES);
expect(validateClipboardWriteText(text)).toBe(text);
});
it("rejects empty, non-string and oversized payloads", () => {
expect(() => validateClipboardWriteText(" \n ")).toThrow(/leer/i);
expect(() => validateClipboardWriteText(4)).toThrow(/String/i);
expect(() => validateClipboardWriteText("x".repeat(CLIPBOARD_WRITE_MAX_BYTES + 1))).toThrow(/zu groß/i);
});
});
+88 -10
View File
@@ -656,8 +656,7 @@ describe("debrid service", () => {
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull(); expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
}); });
it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => { it("does not cool down a Debrid-Link key when the caller aborts after more than eight seconds", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@@ -674,10 +673,13 @@ describe("debrid service", () => {
autoProviderFallback: false autoProviderFallback: false
}; };
const controller = new AbortController(); const controller = new AbortController();
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => { globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/downloader/add")) { if (url.includes("/downloader/add")) {
controller.abort(); now += 9_000;
controller.abort("stop");
throw new Error("aborted"); throw new Error("aborted");
} }
return new Response("not-found", { status: 404 }); return new Response("not-found", { status: 404 });
@@ -689,8 +691,47 @@ describe("debrid service", () => {
service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal) service.unrestrictLink("https://rapidgator.net/file/dl-long-abort", controller.signal)
).rejects.toThrow(); ).rejects.toThrow();
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId); expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000); });
it("cools down a Debrid-Link key after an internal timeout", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "",
megaPassword: "",
megaCredentials: "",
debridLinkApiKeys: "dl-key-one",
providerOrder: ["debridlink"] as const,
providerPrimary: "debridlink" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const callerController = new AbortController();
const timeoutController = new AbortController();
const signal = AbortSignal.any([callerController.signal, timeoutController.signal]);
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
globalThis.fetch = (async (input: RequestInfo | URL): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/downloader/add")) {
now += 9_000;
timeoutController.abort(new DOMException("The operation timed out", "TimeoutError"));
throw new Error("aborted");
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
const keyId = parseDebridLinkApiKeys("dl-key-one")[0].id;
const service = new DebridService(settings);
await expect(
service.unrestrictLink("https://rapidgator.net/file/dl-internal-timeout", signal)
).rejects.toThrow();
expect(getDebridLinkKeyCooldownStateForTests(keyId)?.remainingMs ?? 0).toBeGreaterThan(60_000);
}); });
it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => { it("treats bad Debrid-Link file passwords as fatal and does not rotate keys", async () => {
@@ -2158,12 +2199,10 @@ describe("debrid service", () => {
}; };
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const controller = new AbortController();
let calls = 0; let calls = 0;
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => { const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
calls += 1; calls += 1;
if (calls === 1) { if (calls <= REQUEST_RETRIES) {
controller.abort("simulated-60s-timeout");
return Promise.reject(new Error("aborted")); return Promise.reject(new Error("aborted"));
} }
return Promise.resolve({ return Promise.resolve({
@@ -2176,7 +2215,7 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html", controller.signal).then(() => null, (e: unknown) => e); const err = await service.unrestrictLink("https://rapidgator.net/file/slow-link.rar.html").then(() => null, (e: unknown) => e);
expect(err).toBeTruthy(); expect(err).toBeTruthy();
expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i); expect(String(err)).toMatch(/mega_debrid_slow_link:\d+:/i);
@@ -2347,10 +2386,14 @@ describe("debrid service", () => {
}; };
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const callerController = new AbortController();
const timeoutController = new AbortController();
const signal = AbortSignal.any([callerController.signal, timeoutController.signal]);
const loginsSeen: Array<string | undefined> = []; const loginsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
loginsSeen.push(account?.login); loginsSeen.push(account?.login);
if (account?.login === "user1") { if (account?.login === "user1") {
timeoutController.abort(new DOMException("The operation timed out", "TimeoutError"));
throw new Error("aborted:debrid"); throw new Error("aborted:debrid");
} }
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 }; return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
@@ -2359,7 +2402,7 @@ describe("debrid service", () => {
const user1Key = `${getMegaDebridAccountId("user1")}:web`; const user1Key = `${getMegaDebridAccountId("user1")}:web`;
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down. // Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow(); await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1", signal)).rejects.toThrow();
expect(loginsSeen).toContain("user1"); expect(loginsSeen).toContain("user1");
expect(loginsSeen).not.toContain("user2"); expect(loginsSeen).not.toContain("user2");
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull(); expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
@@ -2399,6 +2442,41 @@ describe("debrid service", () => {
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull(); expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
}, 20000); }, 20000);
it("does not cool down a Mega-Web account when the caller aborts after more than eight seconds", async () => {
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
const controller = new AbortController();
let now = 1_000_000;
vi.spyOn(Date, "now").mockImplementation(() => now);
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => {
now += 9_000;
controller.abort("stop");
throw new Error("aborted:debrid");
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
await expect(
service.unrestrictLink("https://rapidgator.net/file/long-caller-cancel", controller.signal)
).rejects.toThrow(/aborted/i);
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
}, 20000);
it("respects provider selection and does not append hidden providers", async () => { it("respects provider selection and does not append hidden providers", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
+216 -11
View File
@@ -997,7 +997,7 @@ describe("deterministic stop and restart lifecycle", () => {
expect(internal.activeTasks.get(itemId)).toBe(newOwner); expect(internal.activeTasks.get(itemId)).toBe(newOwner);
}); });
it("emits an idle snapshot when the earliest provider cooldown expires", async () => { it("keeps Start available while a configured account is temporarily cooling down", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-22T08:00:00.000Z")); vi.setSystemTime(new Date("2026-08-22T08:00:00.000Z"));
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-cooldown-event-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-provider-cooldown-event-"));
@@ -1025,15 +1025,14 @@ describe("deterministic stop and restart lifecycle", () => {
const waiting = manager.getSnapshot(); const waiting = manager.getSnapshot();
expect(waiting).toMatchObject({ expect(waiting).toMatchObject({
canStart: false, canStart: true,
lifecycle: { lifecycle: {
phase: "waiting_provider", phase: "idle",
retryAt: Date.parse("2026-08-22T08:00:01.000Z") retryAt: Date.parse("2026-08-22T08:00:01.000Z")
} }
}); });
await vi.advanceTimersByTimeAsync(999); await vi.advanceTimersByTimeAsync(999);
expect(events.some((snapshot) => snapshot.canStart)).toBe(false);
await vi.advanceTimersByTimeAsync(1); await vi.advanceTimersByTimeAsync(1);
expect(events.at(-1)).toMatchObject({ expect(events.at(-1)).toMatchObject({
canStart: true, canStart: true,
@@ -2390,6 +2389,189 @@ describe("download manager", () => {
expect((manager as any).session.packages[packageId].status).toBe("queued"); expect((manager as any).session.packages[packageId].status).toBe("queued");
}); });
it("extractNow on one multipart child arms only its complete archive set", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-child-"));
tempDirs.push(root);
const session = emptySession();
const packageId = "extract-child-pkg";
const outputDir = path.join(root, "downloads", "Extract Child");
const extractDir = path.join(root, "extract", "Extract Child");
fs.mkdirSync(outputDir, { recursive: true });
const createdAt = Date.now();
const specs = [
["e01-1", "Episode.E01.part1.rar"],
["e01-2", "Episode.E01.part2.rar"],
["e02-1", "Episode.E02.part1.rar"],
["e02-2", "Episode.E02.part2.rar"]
] as const;
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Extract Child",
outputDir,
extractDir,
status: "failed",
itemIds: specs.map(([id]) => id),
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
for (const [id, fileName] of specs) {
const targetPath = path.join(outputDir, fileName);
fs.writeFileSync(targetPath, Buffer.alloc(128, 3));
session.items[id] = {
id,
packageId,
url: `https://example.invalid/${fileName}`,
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 128,
totalBytes: 128,
progressPercent: 100,
fileName,
targetPath,
resumable: true,
attempts: 1,
lastError: "Keine entpackten Dateien erkannt",
fullStatus: "Entpack-Fehler: Keine entpackten Dateien erkannt",
createdAt,
updatedAt: createdAt
};
}
const manager = new DownloadManager(
{ ...defaultSettings(), token: "rd-token", outputDir, extractDir, autoExtract: true, hybridExtract: true },
session,
createStoragePaths(path.join(root, "state"))
);
const postProcess = vi.fn(async () => {});
(manager as any).runPackagePostProcessing = postProcess;
manager.extractNow({ packageIds: [], itemIds: ["e01-2"] });
await waitFor(() => postProcess.mock.calls.length === 1);
expect((manager as any).session.items["e01-1"].fullStatus).toBe("Entpacken - Ausstehend");
expect((manager as any).session.items["e01-2"].fullStatus).toBe("Entpacken - Ausstehend");
expect((manager as any).session.items["e02-1"].fullStatus).toMatch(/^Entpack-Fehler/);
expect((manager as any).session.items["e02-2"].fullStatus).toMatch(/^Entpack-Fehler/);
const filter = (manager as any).manualExtractArchiveFilters.get(packageId) as Set<string>;
expect([...filter].map((filePath) => path.basename(filePath).toLowerCase())).toEqual(["episode.e01.part1.rar"]);
});
it("extractNow item selection runs only the selected archive through real post-processing", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-selected-real-"));
tempDirs.push(root);
const outputDir = path.join(root, "downloads", "Selected");
const extractDir = path.join(root, "extract", "Selected");
fs.mkdirSync(outputDir, { recursive: true });
const firstArchive = path.join(outputDir, "Episode.E01.zip");
const secondArchive = path.join(outputDir, "Episode.E02.zip");
const firstZip = new AdmZip();
firstZip.addFile("Episode.E01.mkv", Buffer.from("episode-one"));
firstZip.writeZip(firstArchive);
const secondZip = new AdmZip();
secondZip.addFile("Episode.E02.mkv", Buffer.from("episode-two"));
secondZip.writeZip(secondArchive);
const createdAt = Date.now();
const session = emptySession();
const packageId = "selected-real-package";
session.packageOrder = [packageId];
session.packages[packageId] = {
id: packageId,
name: "Selected",
outputDir,
extractDir,
status: "failed",
itemIds: ["selected-e01", "selected-e02"],
cancelled: false,
enabled: true,
createdAt,
updatedAt: createdAt
};
for (const [id, archivePath] of [["selected-e01", firstArchive], ["selected-e02", secondArchive]] as const) {
const size = fs.statSync(archivePath).size;
session.items[id] = {
id,
packageId,
url: `https://example.invalid/${path.basename(archivePath)}`,
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: size,
totalBytes: size,
progressPercent: 100,
fileName: path.basename(archivePath),
targetPath: archivePath,
resumable: true,
attempts: 1,
lastError: "Keine entpackten Dateien erkannt",
fullStatus: "Entpack-Fehler: Keine entpackten Dateien erkannt",
createdAt,
updatedAt: createdAt
};
}
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
outputDir,
extractDir,
autoExtract: false,
hybridExtract: true,
cleanupMode: "none",
removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false,
autoRename4sf4sj: false,
keepGermanAudioOnly: false
},
session,
createStoragePaths(path.join(root, "state"))
);
manager.extractNow({ packageIds: [], itemIds: ["selected-e01"] });
await waitFor(() => fs.existsSync(path.join(extractDir, "Episode.E01.mkv")), 10_000);
await waitFor(() => !(manager as any).packagePostProcessTasks.has(packageId), 10_000);
await waitFor(() => !(manager as any).packageDeferredPostProcessTasks.has(packageId), 10_000);
const snapshot = manager.getSnapshot().session;
expect(snapshot.items["selected-e01"].fullStatus).toMatch(/^Entpackt/);
expect(snapshot.items["selected-e02"].fullStatus).toMatch(/^Entpack-Fehler/);
expect(snapshot.packages[packageId].status).toBe("failed");
expect(fs.existsSync(path.join(extractDir, "Episode.E02.mkv"))).toBe(false);
}, 15_000);
it("assigns same-named archive failures only to the matching directory", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-failure-scope-"));
tempDirs.push(root);
const manager = new DownloadManager(defaultSettings(), emptySession(), createStoragePaths(path.join(root, "state")));
const firstPath = path.join(root, "Season 01", "release.part1.rar");
const secondPath = path.join(root, "Season 02", "release.part1.rar");
const items = [
{ id: "season-1", status: "completed", fullStatus: "Entpacken - Error", fileName: "release.part1.rar", targetPath: firstPath, downloadedBytes: 100 },
{ id: "season-2", status: "completed", fullStatus: "Entpack-Fehler: Previous", fileName: "release.part1.rar", targetPath: secondPath, downloadedBytes: 100 }
] as unknown as DownloadItem[];
const failures = new Map([[firstPath.toLowerCase(), {
archiveName: "release.part1.rar",
archivePath: firstPath,
errorText: "CRC failed"
}]]);
(manager as any).applyPackageExtractFailureStatuses(
items,
(archiveName: string, archivePath: string) => resolveArchiveItemsFromList(archiveName, items, archivePath),
failures,
"Entpacken fehlgeschlagen",
new Map(items.map((item) => [item.id, item.fullStatus])),
Date.now()
);
expect(items[0].fullStatus).toMatch(/^Entpack-Fehler/);
expect(items[1].fullStatus).toBe("Entpack-Fehler: Previous");
});
it("merges duplicate-suffixed completed startup items back into the canonical queued item", () => { it("merges duplicate-suffixed completed startup items back into the canonical queued item", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-merge-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-merge-"));
tempDirs.push(root); tempDirs.push(root);
@@ -6833,6 +7015,7 @@ describe("download manager", () => {
itemIds.map((itemId) => session.items[itemId]!), itemIds.map((itemId) => session.items[itemId]!),
{ {
archiveName: "show.s01e01.part1.rar", archiveName: "show.s01e01.part1.rar",
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file", errorText: "Checksum error in the encrypted file",
category: "crc_error", category: "crc_error",
suggestRedownload: true, suggestRedownload: true,
@@ -6926,6 +7109,7 @@ describe("download manager", () => {
itemIds.map((itemId) => session.items[itemId]!), itemIds.map((itemId) => session.items[itemId]!),
{ {
archiveName: "show.s01e01.part1.rar", archiveName: "show.s01e01.part1.rar",
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file", errorText: "Checksum error in the encrypted file",
category: "crc_error", category: "crc_error",
suggestRedownload: true, suggestRedownload: true,
@@ -7019,6 +7203,7 @@ describe("download manager", () => {
itemIds.map((itemId) => session.items[itemId]!), itemIds.map((itemId) => session.items[itemId]!),
{ {
archiveName: "show.s01e01.part1.rar", archiveName: "show.s01e01.part1.rar",
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file", errorText: "Checksum error in the encrypted file",
category: "crc_error", category: "crc_error",
suggestRedownload: true, suggestRedownload: true,
@@ -7358,7 +7543,7 @@ describe("download manager", () => {
} }
completedItems[0].fullStatus = "Entpacken - Error"; completedItems[0].fullStatus = "Entpacken - Error";
completedItems[1].fullStatus = "Entpacken - Error"; completedItems[1].fullStatus = "Entpacken - Error";
const resolveArchiveItems = (archiveName: string) => { const resolveArchiveItems = (archiveName: string, _archivePath?: string) => {
const base = archiveName.replace(/\.part0*1\.rar$/i, ""); const base = archiveName.replace(/\.part0*1\.rar$/i, "");
return completedItems.filter((item: any) => String(item.fileName || "").toLowerCase().startsWith(`${base}.part`)); return completedItems.filter((item: any) => String(item.fileName || "").toLowerCase().startsWith(`${base}.part`));
}; };
@@ -7367,7 +7552,11 @@ describe("download manager", () => {
{}, {},
completedItems, completedItems,
resolveArchiveItems, resolveArchiveItems,
new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]), new Map([["show.s01e01.part1.rar", {
archiveName: "show.s01e01.part1.rar",
archivePath: path.resolve("show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file"
}]]),
"Checksum error in the encrypted file", "Checksum error in the encrypted file",
previousStatuses, previousStatuses,
createdAt + 5_000 createdAt + 5_000
@@ -7415,8 +7604,12 @@ describe("download manager", () => {
(DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call( (DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call(
{}, {},
completedItems, completedItems,
(archiveName: string) => resolveArchiveItemsFromList(archiveName, completedItems), (archiveName: string, archivePath: string) => resolveArchiveItemsFromList(archiveName, completedItems, archivePath),
new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]), new Map([["show.s01e01.part1.rar", {
archiveName: "show.s01e01.part1.rar",
archivePath: path.resolve("show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file"
}]]),
"Checksum error in the encrypted file", "Checksum error in the encrypted file",
previousStatuses, previousStatuses,
createdAt + 5_000 createdAt + 5_000
@@ -8231,6 +8424,8 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
(manager as any).manualExtractArchiveFilters.set(packageId, new Set([targetPath]));
(manager as any).manualExtractPackages.add(packageId);
manager.clearAll(); manager.clearAll();
const snapshot = manager.getSnapshot(); const snapshot = manager.getSnapshot();
expect(snapshot.stats.totalPackages).toBe(0); expect(snapshot.stats.totalPackages).toBe(0);
@@ -8238,6 +8433,8 @@ describe("download manager", () => {
expect(snapshot.stats.totalDownloaded).toBe(0); expect(snapshot.stats.totalDownloaded).toBe(0);
expect(snapshot.session.totalDownloadedBytes).toBe(0); expect(snapshot.session.totalDownloadedBytes).toBe(0);
expect(snapshot.session.runStartedAt).toBe(0); expect(snapshot.session.runStartedAt).toBe(0);
expect((manager as any).manualExtractArchiveFilters.size).toBe(0);
expect((manager as any).manualExtractPackages.size).toBe(0);
}); });
it("keeps cumulative session totals when completed items are removed from the queue", () => { it("keeps cumulative session totals when completed items are removed from the queue", () => {
@@ -9237,7 +9434,7 @@ describe("download manager", () => {
expect(snap.settings.providerDailyUsageBytes || {}).toEqual({}); expect(snap.settings.providerDailyUsageBytes || {}).toEqual({});
}); });
it("resets extraction state atomically for selected package items", () => { it("resets extraction state without discarding definitive link availability", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
tempDirs.push(root); tempDirs.push(root);
const session = emptySession(); const session = emptySession();
@@ -9293,7 +9490,9 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
manager.resetItems(itemIds); (manager as any).manualExtractArchiveFilters.set(packageId, new Set(["stale-archive"]));
await manager.resetItems(itemIds);
expect((manager as any).manualExtractArchiveFilters.has(packageId)).toBe(false);
const snapshot = manager.getSnapshot().session; const snapshot = manager.getSnapshot().session;
expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({ expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({
@@ -9309,9 +9508,15 @@ describe("download manager", () => {
progressPercent: 0, progressPercent: 0,
lastError: "", lastError: "",
fullStatus: "Wartet", fullStatus: "Wartet",
onlineStatus: undefined onlineStatus: "online"
})); }));
} }
await manager.resetPackage(packageId);
const packageSnapshot = manager.getSnapshot().session;
for (const itemId of itemIds) {
expect(packageSnapshot.items[itemId].onlineStatus).toBe("online");
}
}); });
it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => { it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => {
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { sortPackageOrderByService } from "../src/renderer/App";
describe("download package sorting", () => {
it("sorts the Service column by its visible provider labels", () => {
const packages = {
a: { id: "a", itemIds: ["item-a"] },
b: { id: "b", itemIds: ["item-b"] }
} as any;
const items = {
"item-a": { provider: "realdebrid", providerLabel: "Real-Debrid" },
"item-b": { provider: "debridlink", providerLabel: "Debrid-Link" }
} as any;
expect(sortPackageOrderByService(["a", "b"], packages, items, false)).toEqual(["b", "a"]);
expect(sortPackageOrderByService(["a", "b"], packages, items, true)).toEqual(["a", "b"]);
});
it("uses filtered visible services instead of hidden package items", () => {
const packages = {
a: { id: "a", itemIds: ["a-hidden", "a-visible"] },
b: { id: "b", itemIds: ["b-visible"] }
} as any;
const items = {
"a-visible": { provider: "debridlink", providerLabel: "ZZZ Visible" },
"a-hidden": { provider: "realdebrid", providerLabel: "AAA Hidden" },
"b-visible": { provider: "realdebrid", providerLabel: "Real-Debrid" }
} as any;
expect(sortPackageOrderByService(["b", "a"], packages, items, false, {
a: [items["a-visible"]],
b: [items["b-visible"]]
})).toEqual(["b", "a"]);
});
});
+127 -5
View File
@@ -777,6 +777,36 @@ function findButton(node: ReactNode, label: string): ReactElement {
return findElement(node, (element) => element.type === "button" && element.props.children === label); return findElement(node, (element) => element.type === "button" && element.props.children === label);
} }
function dispatchColumnSortPointerGesture(header: ReactElement, label: string, clientXs: readonly number[], deliverPointerClick: boolean): void {
const startX = clientXs[0];
const endX = clientXs[clientXs.length - 1];
if (startX === undefined || endX === undefined) throw new Error("Pointer gesture requires coordinates");
const columnHeader = findElement(header, (element) => element.props["data-download-column"] === "name");
const sortButton = findElement(columnHeader, (element) => element.type === "button" && String(element.props.children).startsWith(label));
const capturedPointers = new Set<number>();
const currentTarget = {
closest: () => null,
hasPointerCapture: (pointerId: number) => capturedPointers.has(pointerId),
releasePointerCapture: (pointerId: number) => capturedPointers.delete(pointerId),
setPointerCapture: (pointerId: number) => capturedPointers.add(pointerId)
};
const target = { closest: (selector: string) => selector === ".downloads-column-sort" ? {} : null };
const event = (clientX: number) => ({
button: 0,
clientX,
currentTarget,
isPrimary: true,
pointerId: 7,
preventDefault: () => {},
target
});
columnHeader.props.onPointerDown(event(startX));
clientXs.slice(1, -1).forEach((clientX) => columnHeader.props.onPointerMove(event(clientX)));
columnHeader.props.onPointerUp(event(endX));
if (deliverPointerClick) sortButton.props.onClick({ detail: 1 });
}
function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsViewModel> = {}): DownloadsViewModel { function withRuntime(input: DownloadsModelInput, overrides: Partial<DownloadsViewModel> = {}): DownloadsViewModel {
return { return {
...buildDownloadsViewModel(input), ...buildDownloadsViewModel(input),
@@ -1523,6 +1553,9 @@ describe("download table row contracts", () => {
expect(getAvailabilitySummary([ expect(getAvailabilitySummary([
item("unknown-a", "package-a", "queued", { onlineStatus: undefined }) item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
])).toEqual({ online: 0, total: 1, state: "checking" }); ])).toEqual({ online: 0, total: 1, state: "checking" });
expect(getAvailabilitySummary([
item("active-a", "package-a", "downloading", { onlineStatus: undefined })
])).toEqual({ online: 1, total: 1, state: "online" });
}); });
it("shows reset package availability as one compact unchecked label", () => { it("shows reset package availability as one compact unchecked label", () => {
@@ -1549,6 +1582,20 @@ describe("download table row contracts", () => {
expect(html).not.toContain(">online</span>"); expect(html).not.toContain(">online</span>");
}); });
it("shows an actively downloading item as online even without a stored availability result", () => {
const html = renderToStaticMarkup(ItemRowContent({
actions: createActions(),
columnOrder: ["name", "availability"],
gridTemplate: "200px 150px",
item: item("active-availability", "package-a", "downloading", { onlineStatus: undefined }),
selected: false
}));
expect(html).toContain(">Online</span>");
expect(html).not.toContain(">Ungeprüft</span>");
expect(html).toContain('class="downloads-link-state online"');
});
it("renders availability for package and file rows", () => { it("renders availability for package and file rows", () => {
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" }); const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
const packageHtml = renderToStaticMarkup(PackageCardContent({ const packageHtml = renderToStaticMarkup(PackageCardContent({
@@ -1613,7 +1660,7 @@ describe("download table row contracts", () => {
selectedVersion: 0 selectedVersion: 0
})); }));
expect(html).toContain(">70%</b>"); expect(html).toContain(">94%</b>");
}); });
it("never exposes archive filenames as the visible package status", () => { it("never exposes archive filenames as the visible package status", () => {
@@ -1779,7 +1826,7 @@ describe("download table row contracts", () => {
expect(html).toMatch(/aria-sort="descending"[^>]*data-download-column="name"/); expect(html).toMatch(/aria-sort="descending"[^>]*data-download-column="name"/);
expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="size"/); expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="size"/);
expect(html).not.toMatch(/aria-sort="[^"]+"[^>]*data-download-column="account"/); expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="account"/);
expect(moveLeft.props.type).toBe("button"); expect(moveLeft.props.type).toBe("button");
expect(calls).toEqual([ expect(calls).toEqual([
["down", "size", 250], ["down", "size", 250],
@@ -1788,6 +1835,81 @@ describe("download table row contracts", () => {
]); ]);
}); });
it.each([
{ clientXs: [100, 100], deliverPointerClick: false },
{ clientXs: [100, 104, 104], deliverPointerClick: true }
])("sorts exactly once when a captured pointer gesture stays below the drag threshold", ({ clientXs, deliverPointerClick }) => {
const sorted: string[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
columnOrder: ["name", "size"],
gridTemplate: "200px 100px",
selectedCount: 0,
sortColumn: "name",
sortDirection: "asc",
visibleIds: []
});
dispatchColumnSortPointerGesture(header, "Name", clientXs, deliverPointerClick);
expect(sorted).toEqual(["name"]);
});
it("never sorts when a pointer gesture reaches the drag threshold", () => {
const sorted: string[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
columnOrder: ["name", "size"],
gridTemplate: "200px 100px",
selectedCount: 0,
sortColumn: "name",
sortDirection: "asc",
visibleIds: []
});
dispatchColumnSortPointerGesture(header, "Name", [100, 105, 101], true);
expect(sorted).toEqual([]);
});
it("keeps sortable headers keyboard operable", () => {
const sorted: string[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
columnOrder: ["name", "size"],
gridTemplate: "200px 100px",
selectedCount: 0,
sortColumn: "name",
sortDirection: "asc",
visibleIds: []
});
const sortButton = findElement(header, (element) => element.type === "button" && String(element.props.children).startsWith("Name"));
sortButton.props.onClick({ detail: 0 });
expect(sorted).toEqual(["name"]);
});
it("exposes Service as a sortable column header", () => {
const sorted: string[] = [];
const header = DownloadsTableHeader({
actions: createActions({ onSortColumn: (column) => sorted.push(column) }),
columnOrder: ["account"],
gridTemplate: "100px",
selectedCount: 0,
sortColumn: "service",
sortDirection: "desc",
visibleIds: []
});
const serviceHeader = findElement(header, (element) => element.props["data-download-column"] === "account");
const sortButton = findElement(serviceHeader, (element) => element.type === "button");
sortButton.props.onClick({ detail: 0 });
expect(serviceHeader.props["aria-sort"]).toBe("descending");
expect(sorted).toEqual(["service"]);
});
it("opens the column menu without letting the same context event close it again", () => { it("opens the column menu without letting the same context event close it again", () => {
const calls: Array<[string, number, number]> = []; const calls: Array<[string, number, number]> = [];
const header = DownloadsTableHeader({ const header = DownloadsTableHeader({
@@ -2088,7 +2210,7 @@ describe("download table row contracts", () => {
selectedVersion: 0 selectedVersion: 0
})); }));
expect(html).toMatch(/title="0\/1 · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s); expect(html).toMatch(/title="0\/1 fertig · Entpacken - 1% · Tonspur: 1 OK[^\"]*episode\.mkv: remuxed \(German kept\)"/s);
}); });
it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => { it("shows only a compact extraction error while retaining diagnostics in the tooltip", () => {
@@ -2163,7 +2285,7 @@ describe("download table row contracts", () => {
selectedIds: new Set<string>(), selectedIds: new Set<string>(),
selectedVersion: 0 selectedVersion: 0
})); }));
expect(errorHtml).toMatch(/>Entpack-Fehler<\/span>/); expect(errorHtml).toMatch(/>Download fertig · 1 Entpackfehler<\/span>/);
expect(errorHtml).not.toMatch(/>2\/2<\/span>/); expect(errorHtml).not.toMatch(/>2\/2<\/span>/);
}); });
@@ -2182,7 +2304,7 @@ describe("download table row contracts", () => {
})); }));
expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2); expect(html.match(/>Download läuft<\/span>/g)).toHaveLength(2);
expect(html).toContain('title="0/1"'); expect(html).toContain('title="0/1 fertig"');
}); });
it("commits Enter and the resulting Blur rename sequence exactly once", () => { it("commits Enter and the resulting Blur rename sequence exactly once", () => {
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types";
import { buildExtractNowContextAction } from "../src/renderer/views/downloads/extract-action";
function item(id: string, packageId: string, status: DownloadItem["status"], fullStatus: string): DownloadItem {
return {
id,
packageId,
url: `https://example.invalid/${id}`,
provider: "realdebrid",
status,
retries: 0,
speedBps: 0,
downloadedBytes: status === "completed" ? 100 : 0,
totalBytes: 100,
progressPercent: status === "completed" ? 100 : 0,
fileName: `${id}.part1.rar`,
targetPath: `C:\\Downloads\\${id}.part1.rar`,
resumable: true,
attempts: 0,
lastError: "",
fullStatus,
createdAt: 1,
updatedAt: 1
};
}
function pkg(id: string, itemIds: string[]): PackageEntry {
return {
id,
name: id,
outputDir: `C:\\Downloads\\${id}`,
extractDir: `C:\\Downloads\\_entpackt\\${id}`,
itemIds,
enabled: true,
cancelled: false,
status: "completed",
priority: "normal",
createdAt: 1,
updatedAt: 1
};
}
describe("extract now context action", () => {
it("targets one completed child item so the manager can resolve its complete archive set", () => {
const items = { part2: item("part2", "pkg-1", "completed", "Fertig") };
const action = buildExtractNowContextAction({
contextItemId: "part2",
selectedPackageIds: [],
selectedItemIds: ["part2"],
packages: { "pkg-1": pkg("pkg-1", ["part2"]) },
items
});
expect(action).toEqual({
label: "Jetzt entpacken",
request: { packageIds: [], itemIds: ["part2"] },
targetCount: 1
});
});
it("targets every selected package that has completed unextracted files", () => {
const items = {
a: item("a", "pkg-a", "completed", "Entpack-Fehler: Passwort"),
b: item("b", "pkg-b", "completed", "Entpacken - Ausstehend"),
c: item("c", "pkg-c", "queued", "Wartet")
};
const action = buildExtractNowContextAction({
selectedPackageIds: ["pkg-a", "pkg-b", "pkg-c"],
selectedItemIds: [],
packages: {
"pkg-a": pkg("pkg-a", ["a"]),
"pkg-b": pkg("pkg-b", ["b"]),
"pkg-c": pkg("pkg-c", ["c"])
},
items
});
expect(action).toEqual({
label: "Jetzt entpacken (2)",
request: { packageIds: ["pkg-a", "pkg-b"], itemIds: [] },
targetCount: 2
});
});
it("hides the action for extracted or incomplete selections", () => {
const items = {
extracted: item("extracted", "pkg-1", "completed", "Entpackt in 4s"),
queued: item("queued", "pkg-2", "queued", "Wartet")
};
expect(buildExtractNowContextAction({
selectedPackageIds: ["pkg-1", "pkg-2"],
selectedItemIds: [],
packages: {
"pkg-1": pkg("pkg-1", ["extracted"]),
"pkg-2": pkg("pkg-2", ["queued"])
},
items
})).toBeNull();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { normalizeExtractNowRequest } from "../src/shared/extract-now";
describe("extract now request", () => {
it("deduplicates package and item targets while preserving their order", () => {
expect(normalizeExtractNowRequest({
packageIds: ["pkg-2", "pkg-1", "pkg-2"],
itemIds: ["item-2", "item-1", "item-2"]
})).toEqual({
packageIds: ["pkg-2", "pkg-1"],
itemIds: ["item-2", "item-1"]
});
});
it("rejects empty, malformed and oversized selections", () => {
expect(() => normalizeExtractNowRequest({ packageIds: [], itemIds: [] })).toThrow(/mindestens/i);
expect(() => normalizeExtractNowRequest({ packageIds: ["pkg"], itemIds: [4] })).toThrow(/itemIds/i);
expect(() => normalizeExtractNowRequest({ packageIds: Array.from({ length: 2001 }, (_, index) => `pkg-${index}`), itemIds: [] })).toThrow(/höchstens/i);
expect(() => normalizeExtractNowRequest({ packageIds: Array.from({ length: 2001 }, () => "pkg"), itemIds: [] })).toThrow(/höchstens/i);
expect(() => normalizeExtractNowRequest({ packageIds: ["p".repeat(257)], itemIds: [] })).toThrow(/Länge/i);
expect(() => normalizeExtractNowRequest({ packageIds: ["pkg"], itemIds: [], extra: true })).toThrow(/unbekannt/i);
expect(() => normalizeExtractNowRequest(null)).toThrow(/Objekt/i);
});
});
+9
View File
@@ -171,6 +171,15 @@ describe("renderer localization", () => {
["Geplant: Heute 22:15", "Scheduled: Today 22:15"], ["Geplant: Heute 22:15", "Scheduled: Today 22:15"],
["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"], ["Tonspur: 2 OK · 1 ohne DE-Tag · ffmpeg fehlt · 3 Fehler", "Audio track: 2 OK · 1 without DE tag · ffmpeg missing · 3 errors"],
["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"], ["4/8 fertig · 2 Fehler", "4/8 completed · 2 errors"],
["7 Entpackfehler · 1 Wiederholung", "7 extraction errors · 1 retry"],
["Download fertig · 1 Entpackfehler", "Download complete · 1 extraction error"],
["Jetzt entpacken (2)", "Extract now (2)"],
["1 Entpackfehler", "1 extraction error"],
["2 Wiederholungen", "2 retries"],
["Download fertig", "Download complete"],
["1 Fehler · 2 abgebrochen", "1 error · 2 cancelled"],
["1 Entpackfehler · 2 Fehler", "1 extraction error · 2 errors"],
["Warte auf Festplatte", "Waiting for disk"],
["Entpacken 52%", "Extracting 52%"], ["Entpacken 52%", "Extracting 52%"],
["Fehlgeschlagen nach 3 Versuchen: HTTP 503 von https://host.test/a", "Failed after 3 attempts: HTTP 503 von https://host.test/a"], ["Fehlgeschlagen nach 3 Versuchen: HTTP 503 von https://host.test/a", "Failed after 3 attempts: HTTP 503 von https://host.test/a"],
["Update-Check fehlgeschlagen: ECONNRESET https://api.test/v1", "Update check failed: ECONNRESET https://api.test/v1"], ["Update-Check fehlgeschlagen: ECONNRESET https://api.test/v1", "Update check failed: ECONNRESET https://api.test/v1"],
+119
View File
@@ -0,0 +1,119 @@
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { LinkAddressesDialog, type LinkAddressesDialogProps } from "../src/renderer/ui/LinkAddressesDialog";
function findElements(node: ReactNode, predicate: (element: ReactElement<Record<string, unknown>>) => boolean): ReactElement<Record<string, unknown>>[] {
if (Array.isArray(node)) {
return node.flatMap((child) => findElements(child, predicate));
}
if (!isValidElement<Record<string, unknown>>(node)) {
return [];
}
const matches = predicate(node) ? [node] : [];
return [...matches, ...findElements(node.props.children as ReactNode, predicate)];
}
function createDialog(overrides: Partial<LinkAddressesDialogProps> = {}): ReactElement {
return LinkAddressesDialog({
title: "Testpaket",
links: [
{ name: "Erste Datei.mkv", url: "https://example.com/first" },
{ name: "Zweite Datei.mkv", url: "https://example.com/second" }
],
isPackage: true,
onClose: vi.fn(),
writeClipboardText: vi.fn(async () => true),
onToast: vi.fn(),
...overrides
});
}
function buttonByText(tree: ReactElement, label: string): ReactElement<Record<string, unknown>> {
const button = findElements(tree, (element) => element.type === "button" && element.props.children === label)[0];
expect(button, `Button ${label} fehlt`).toBeDefined();
return button;
}
async function click(button: ReactElement<Record<string, unknown>>): Promise<void> {
const onClick = button.props.onClick as (() => void | Promise<void>) | undefined;
expect(onClick).toBeTypeOf("function");
await onClick?.();
}
describe("LinkAddressesDialog", () => {
it("kopiert einzelne Namen und URLs ausschließlich über den sicheren Writer", async () => {
const writeClipboardText = vi.fn(async () => true);
const onToast = vi.fn();
const tree = createDialog({ writeClipboardText, onToast });
const firstName = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Erste Datei.mkv kopieren")[0];
const firstUrl = findElements(tree, (element) => element.type === "button" && element.props["aria-label"] === "Link kopieren")[0];
await click(firstName);
await click(firstUrl);
expect(writeClipboardText).toHaveBeenNthCalledWith(1, "Erste Datei.mkv");
expect(writeClipboardText).toHaveBeenNthCalledWith(2, "https://example.com/first");
expect(onToast).toHaveBeenNthCalledWith(1, "Name kopiert");
expect(onToast).toHaveBeenNthCalledWith(2, "Link kopiert");
});
it("meldet Erfolg nur bei true und behandelt false sowie Ablehnungen als Fehler", async () => {
const writeClipboardText = vi.fn()
.mockResolvedValueOnce(false)
.mockRejectedValueOnce(new Error("clipboard unavailable"));
const onToast = vi.fn();
const tree = createDialog({ writeClipboardText, onToast });
await click(buttonByText(tree, "Alle Namen kopieren"));
await click(buttonByText(tree, "Alle Links kopieren"));
expect(onToast).toHaveBeenNthCalledWith(1, "Kopieren fehlgeschlagen");
expect(onToast).toHaveBeenNthCalledWith(2, "Kopieren fehlgeschlagen");
expect(onToast).not.toHaveBeenCalledWith("Alle Namen kopiert");
expect(onToast).not.toHaveBeenCalledWith("Alle Links kopiert");
});
it("übergibt große Pakettexte ohne Kürzung oder Normalisierung", async () => {
const longName = `Groß-${"n".repeat(300_000)}`;
const longUrl = `https://example.com/${"u".repeat(300_000)}`;
const writeClipboardText = vi.fn(async () => true);
const onToast = vi.fn();
const tree = createDialog({
links: [
{ name: longName, url: longUrl },
{ name: " Zeilenende ", url: "https://example.com/trailing " }
],
writeClipboardText,
onToast
});
await click(buttonByText(tree, "Alle Namen kopieren"));
await click(buttonByText(tree, "Alle Links kopieren"));
expect(writeClipboardText).toHaveBeenNthCalledWith(1, `${longName}\n Zeilenende `);
expect(writeClipboardText).toHaveBeenNthCalledWith(2, `${longUrl}\nhttps://example.com/trailing `);
expect(onToast).toHaveBeenNthCalledWith(1, "Alle Namen kopiert");
expect(onToast).toHaveBeenNthCalledWith(2, "Alle Links kopiert");
});
it("behält Dialogdesign, Paketaktionen und Schließen-Verhalten bei", async () => {
const onClose = vi.fn();
const packageTree = createDialog({ onClose });
const singleTree = createDialog({ isPackage: false });
const dialog = findElements(packageTree, (element) => typeof element.type === "function")[0];
expect(dialog.props.className).toBe("link-popup");
expect(dialog.props.size).toBe("wide");
expect(dialog.props.title).toBe("Linkadressen anzeigen");
expect(findElements(packageTree, (element) => element.props.className === "link-popup-row")).toHaveLength(2);
expect(findElements(packageTree, (element) => element.props.className === "link-popup-name link-popup-click")).toHaveLength(2);
expect(findElements(packageTree, (element) => element.props.className === "link-popup-url link-popup-click")).toHaveLength(2);
expect(buttonByText(packageTree, "Alle Namen kopieren")).toBeDefined();
expect(buttonByText(packageTree, "Alle Links kopieren")).toBeDefined();
expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Namen kopieren")).toHaveLength(0);
expect(findElements(singleTree, (element) => element.type === "button" && element.props.children === "Alle Links kopieren")).toHaveLength(0);
await click(buttonByText(packageTree, "Schließen"));
expect(onClose).toHaveBeenCalledOnce();
});
});
+50
View File
@@ -397,6 +397,56 @@ describe("mega-web-fallback", () => {
} }
}); });
it("starts an already queued account job after caller abort even when the old raw job ignores its signal", async () => {
let releaseFirstLogin: () => void = () => {};
let markFirstLoginStarted: () => void = () => {};
const firstLoginGate = new Promise<void>((resolve) => {
releaseFirstLogin = resolve;
});
const firstLoginStarted = new Promise<void>((resolve) => {
markFirstLoginStarted = resolve;
});
const fallback = new MegaWebFallback(() => ({ login: "same", password: "pw" }));
const internals = fallback as unknown as {
login: (login: string, password: string) => Promise<string>;
generate: (link: string, cookie: string) => Promise<{ directUrl: string; fileName: string }>;
sessions: Map<string, { cookie: string; setAt: number }>;
};
let loginCount = 0;
vi.spyOn(internals, "login").mockImplementation(async () => {
loginCount += 1;
if (loginCount === 1) {
markFirstLoginStarted();
await firstLoginGate;
return "stale-cookie";
}
return "fresh-cookie";
});
vi.spyOn(internals, "generate").mockImplementation(async (link, cookie) => ({
directUrl: `https://mega.direct/${cookie}/${link.endsWith("second") ? "second" : "first"}`,
fileName: "result.bin"
}));
const firstController = new AbortController();
const first = fallback.unrestrict("https://mega.debrid/first", firstController.signal, { login: "same", password: "pw" });
await firstLoginStarted;
const second = fallback.unrestrict("https://mega.debrid/second", undefined, { login: "same", password: "pw" });
firstController.abort("stop");
await expect(first).rejects.toThrow(/aborted/i);
try {
const outcome = await Promise.race([
second.then((result) => result?.directUrl || "missing"),
new Promise<string>((resolve) => setTimeout(() => resolve("blocked"), 150))
]);
expect(outcome).toBe("https://mega.direct/fresh-cookie/second");
} finally {
releaseFirstLogin();
}
await new Promise((resolve) => setTimeout(resolve, 20));
expect(internals.sessions.get("same")?.cookie).toBe("fresh-cookie");
});
it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => { it("klassifiziert einen Abbruch WAEHREND in der Queue als Queue-Timeout (nicht harter Abbruch), damit der belegte Account nicht bestraft wird", async () => {
let releaseLogin: () => void = () => {}; let releaseLogin: () => void = () => {};
const loginGate = new Promise<void>((resolve) => { releaseLogin = resolve; }); const loginGate = new Promise<void>((resolve) => { releaseLogin = resolve; });
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from "vitest";
import type { DownloadItem, PackageEntry } from "../src/shared/types";
import type { DownloadPackageRow } from "../src/renderer/views/downloads/downloads-model";
import { buildPackagePresentation } from "../src/renderer/views/downloads/package-presentation";
function item(id: string, fullStatus: string, overrides: Partial<DownloadItem> = {}): DownloadItem {
return {
id,
packageId: "pkg",
url: `https://example.invalid/${id}`,
provider: "realdebrid",
status: "completed",
retries: 0,
speedBps: 0,
downloadedBytes: 100,
totalBytes: 100,
progressPercent: 100,
fileName: `${id}.rar`,
targetPath: `C:\\Downloads\\${id}.rar`,
resumable: true,
attempts: 0,
lastError: "",
fullStatus,
createdAt: 1,
updatedAt: 1,
...overrides
};
}
function row(items: DownloadItem[], overrides: Partial<PackageEntry> = {}): DownloadPackageRow {
const entry = {
id: "pkg",
name: "Paket",
outputDir: "C:\\Downloads\\Paket",
extractDir: "C:\\Downloads\\_entpackt\\Paket",
itemIds: items.map((entry) => entry.id),
enabled: true,
cancelled: false,
status: "completed",
priority: "normal",
createdAt: 1,
updatedAt: 1,
...overrides
} as PackageEntry;
return { package: entry, items, allItems: items, collapsed: true };
}
describe("download package presentation", () => {
it("reserves 90 percent for completed downloads and 10 percent for extraction", () => {
expect(buildPackagePresentation(row([
item("a", "Entpack-Fehler: Passwort"),
item("b", "Entpack-Fehler: CRC")
])).progress.value).toBe(90);
expect(buildPackagePresentation(row([
item("a", "Entpackt in 4s"),
item("b", "Entpack-Fehler: CRC")
])).progress.value).toBe(95);
expect(buildPackagePresentation(row([
item("a", "Entpackt in 4s"),
item("b", "Entpackt in 5s")
])).progress.value).toBe(100);
});
it("keeps ordinary completed downloads at 100 percent when no extraction phase exists", () => {
const presentation = buildPackagePresentation(row([
item("a", "Fertig"),
item("b", "Fertig")
]));
expect(presentation.progress.value).toBe(100);
expect(presentation.status).toBe("Fertig");
});
it("does not move backwards when an archive download enters extraction", () => {
const active = item("archive", "Download läuft", {
status: "downloading",
downloadedBytes: 99,
progressPercent: 99
});
const before = buildPackagePresentation(row([active], { status: "downloading" }));
const after = buildPackagePresentation(row([{ ...active, status: "completed", downloadedBytes: 100, progressPercent: 100, fullStatus: "Entpacken - Ausstehend" }], { status: "extracting" }));
expect(before.progress.value).toBe(89);
expect(after.progress.value).toBe(90);
});
it("summarizes mixed extraction errors and a live retry instead of showing a fraction", () => {
const items = [
...Array.from({ length: 7 }, (_, index) => item(`failed-${index}`, "Entpack-Fehler: Keine entpackten Dateien erkannt")),
item("retry", "Link-Umwandlung erneut, Versuch 6/...", {
status: "validating",
retries: 6,
downloadedBytes: 0,
progressPercent: 0
})
];
const presentation = buildPackagePresentation(row(items, { status: "queued" }));
expect(presentation.status).toBe("7 Entpackfehler · 1 Wiederholung");
expect(presentation.details).toContain("7 Entpackfehler");
expect(presentation.details).toContain("1 Wiederholung");
});
it("keeps a single normal active download compact", () => {
const presentation = buildPackagePresentation(row([
item("active", "Download läuft", {
status: "downloading",
downloadedBytes: 50,
progressPercent: 50
})
], { status: "downloading" }));
expect(presentation.status).toBe("Download läuft");
});
});
+18
View File
@@ -158,6 +158,24 @@ describe("package lifecycle telemetry", () => {
})); }));
}); });
it("keeps a completed download with an extraction error out of successful package results", () => {
const item = { ...downloadItem("item-1"), fullStatus: "Entpack-Fehler: falsches Passwort", lastError: "falsches Passwort" };
const result = finalizePackageResult(telemetry({
package: packageEntry({ status: "failed", itemIds: [item.id] }),
items: [item],
archiveOperations: []
}));
expect(result).toEqual(expect.objectContaining({
status: "failed",
successfulFiles: 0,
failedFiles: 1,
extractionFailures: 1,
failurePhase: "extract",
errorCategory: "Entpacken"
}));
});
it("classifies a package with no successful files and a download failure as failed", () => { it("classifies a package with no successful files and a download failure as failed", () => {
const item = downloadItem("item-1", "failed"); const item = downloadItem("item-1", "failed");
const result = finalizePackageResult(telemetry({ const result = finalizePackageResult(telemetry({
+105 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { resolveArchiveItemsFromList } from "../src/main/download-manager"; import {
markPlannedHybridArchiveItemsPending,
resolveArchiveItemsFromList,
resolveSelectedArchiveSetsFromCandidates,
} from "../src/main/download-manager";
type MinimalItem = { type MinimalItem = {
targetPath?: string; targetPath?: string;
@@ -154,4 +158,104 @@ describe("resolveArchiveItemsFromList", () => {
expect(result2).toHaveLength(2); expect(result2).toHaveLength(2);
expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true); expect(result2.every((i: any) => i.fileName.includes("S01E02"))).toBe(true);
}); });
it("resolves every multipart volume beside the selected non-first part without crossing directories", () => {
const items = [
{
targetPath: "C:\\Downloads\\Package\\Disc A\\Movie.part1.rar",
fileName: "Movie.part1.rar",
id: "disc-a-part-1",
status: "completed",
},
{
targetPath: "C:\\Downloads\\Package\\Disc A\\Movie.part2.rar",
fileName: "Movie.part2.rar",
id: "disc-a-part-2",
status: "completed",
},
{
targetPath: "C:\\Downloads\\Package\\Disc B\\Movie.part1.rar",
fileName: "Movie.part1.rar",
id: "disc-b-part-1",
status: "completed",
},
{
targetPath: "C:\\Downloads\\Package\\Disc B\\Movie.part2.rar",
fileName: "Movie.part2.rar",
id: "disc-b-part-2",
status: "completed",
},
];
const result = resolveArchiveItemsFromList(
"Movie.part2.rar",
items as any,
"C:\\Downloads\\Package\\Disc A\\Movie.part2.rar"
);
expect(result.map((item: any) => item.id)).toEqual([
"disc-a-part-1",
"disc-a-part-2",
]);
});
});
describe("resolveSelectedArchiveSetsFromCandidates", () => {
it("maps a selected non-first part to its canonical archive and complete multipart set", () => {
const items = [
{ id: "e01-1", fileName: "Episode.E01.part1.rar", targetPath: "C:\\Downloads\\Episode.E01.part1.rar", status: "completed" },
{ id: "e01-2", fileName: "Episode.E01.part2.rar", targetPath: "C:\\Downloads\\Episode.E01.part2.rar", status: "completed" },
{ id: "e02-1", fileName: "Episode.E02.part1.rar", targetPath: "C:\\Downloads\\Episode.E02.part1.rar", status: "completed" },
{ id: "e02-2", fileName: "Episode.E02.part2.rar", targetPath: "C:\\Downloads\\Episode.E02.part2.rar", status: "completed" }
];
const selected = resolveSelectedArchiveSetsFromCandidates(
["C:\\Downloads\\Episode.E01.part1.rar", "C:\\Downloads\\Episode.E02.part1.rar"],
items as any,
new Set(["e01-2"])
);
expect([...selected.archivePaths]).toEqual(["C:\\Downloads\\Episode.E01.part1.rar"]);
expect([...selected.itemIds].sort()).toEqual(["e01-1", "e01-2"]);
});
});
describe("markPlannedHybridArchiveItemsPending", () => {
it("keeps unplanned incomplete archive groups waiting", () => {
const items = [
{
id: "planned-part-1",
status: "completed",
fullStatus: "Entpacken - Warten auf Parts",
updatedAt: 1,
},
{
id: "foreign-part-1",
status: "completed",
fullStatus: "Entpacken - Warten auf Parts",
updatedAt: 2,
},
];
const changed = markPlannedHybridArchiveItemsPending(
items as any,
new Set(["planned-part-1"]),
100
);
expect(changed).toBe(true);
expect(items).toEqual([
{
id: "planned-part-1",
status: "completed",
fullStatus: "Entpacken - Ausstehend",
updatedAt: 100,
},
{
id: "foreign-part-1",
status: "completed",
fullStatus: "Entpacken - Warten auf Parts",
updatedAt: 2,
},
]);
});
}); });
@@ -274,6 +274,35 @@ describe("download disclosure in the headless visual harness", () => {
throw new Error(`Visual driver capture did not reach its ready state: ${name}`); throw new Error(`Visual driver capture did not reach its ready state: ${name}`);
} }
it("sorts package rows through a real captured pointer click", async () => {
await loadDenseDownloads(1500);
if (!client) throw new Error("Chrome DevTools client is missing");
const point = await client.evaluate<{ x: number; y: number }>(`(() => {
const button = [...document.querySelectorAll('.downloads-column-sort')].find((entry) => entry.textContent?.startsWith('Name'));
if (!(button instanceof HTMLElement)) throw new Error('Name sort button missing');
const rect = button.getBoundingClientRect();
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
})()`);
const readState = (): Promise<{ ariaSort: string | null; names: string[] }> => client!.evaluate(`(() => ({
ariaSort: document.querySelector('[data-download-column="name"]')?.getAttribute('aria-sort') || null,
names: [...document.querySelectorAll('.downloads-package-row .downloads-name-cell strong')].map((entry) => entry.textContent || '')
}))()`);
await client.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", clickCount: 1 });
await client.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", clickCount: 1 });
await delay(120);
const descending = await readState();
await client.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", clickCount: 1 });
await client.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", clickCount: 1 });
await delay(120);
const ascending = await readState();
expect(descending.ariaSort).toBe("descending");
expect(ascending.ariaSort).toBe("ascending");
expect(descending.names.length).toBeGreaterThan(1);
expect(ascending.names).toEqual([...descending.names].reverse());
});
async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise<DisclosureSample[]> { async function measureDisclosure(action: "einklappen" | "ausklappen"): Promise<DisclosureSample[]> {
if (!client) throw new Error("Chrome DevTools client is missing"); if (!client) throw new Error("Chrome DevTools client is missing");
return client.evaluate<DisclosureSample[]>(`(async () => { return client.evaluate<DisclosureSample[]>(`(async () => {
+10 -1
View File
@@ -359,11 +359,20 @@ export function createVisualElectronApi(
entry.status = "extracting"; entry.status = "extracting";
} }
}, },
extractNow: async (packageId) => { extractNow: async (request) => {
for (const packageId of request.packageIds) {
const entry = fixture.snapshot.session.packages[packageId]; const entry = fixture.snapshot.session.packages[packageId];
if (entry) { if (entry) {
entry.status = "extracting"; entry.status = "extracting";
} }
}
for (const itemId of request.itemIds) {
const item = fixture.snapshot.session.items[itemId];
const entry = item ? fixture.snapshot.session.packages[item.packageId] : undefined;
if (entry) {
entry.status = "extracting";
}
}
}, },
resetPackage: async (packageId) => { resetPackage: async (packageId) => {
const entry = fixture.snapshot.session.packages[packageId]; const entry = fixture.snapshot.session.packages[packageId];