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 { randomUUID } from "node:crypto";
import { app } from "electron";
import type { ExtractNowRequest } from "../shared/extract-now";
import {
AddLinksPayload,
AccountCheckScope,
@@ -1038,9 +1039,9 @@ export class AppController {
this.manager.retryExtraction(packageId);
}
public extractNow(packageId: string): void {
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId });
this.manager.extractNow(packageId);
public extractNow(request: ExtractNowRequest): void {
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageIds: request.packageIds, itemIds: request.itemIds });
this.manager.extractNow(request);
}
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)]);
}
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> {
const body = response.body;
if (!body) {
@@ -2509,14 +2517,17 @@ class MegaDebridClient {
} catch (error) {
const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Timeout/abort on THIS account (the shared unrestrict timeout fired). The
// account-wide cooldown exists ONLY to make the retry rotate to another
// account — so it is set only when another usable account actually exists.
// With no rotation target (single account / all others busy), cooling the
// sole account would freeze EVERY queued item while the account is healthy;
// a >60s timeout is a slow-LINK signal, not an unhealthy-account signal, so
// we park just this link (mega_debrid_slow_link) and leave the account free
// for other items. A quick user-cancel (below the min run) parks nothing.
if (isCallerAbortSignal(signal)) {
traceConversionPhase({
phase: "mega-account",
provider: providerName.includes("API") ? "megadebrid-api" : "megadebrid-web",
account: rotationLabel,
workMs: elapsedMs,
outcome: "aborted",
detail: abortText
});
throw error;
}
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
const otherUsableAccounts = orderedEntries.reduce((count, candidate) => {
@@ -3259,9 +3270,12 @@ class DebridLinkClient {
sourceAccountLabel: apiKey.label
};
} catch (error) {
const failure = await this.classifyKeyFailure(error, apiKey, link, signal);
const elapsedMs = Date.now() - testStartedAt;
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)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
if (ranLongEnough) {
+253 -151
View File
@@ -28,6 +28,7 @@ import {
StartConflictResolutionResult,
UiSnapshot, DebridAccountStatus } from "../shared/types";
import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import type { ExtractNowRequest } from "../shared/extract-now";
import { extractHosterFromUrl } from "../shared/hoster";
import { isMegaDebridTransientResolveFailure, germanMegaDebridResolveReason } from "../shared/mega-debrid-errors";
import { getMegaDebridAccountsForMode } from "../shared/mega-debrid-accounts";
@@ -42,7 +43,8 @@ import {
addRealDebridAccountDailyUsageBytes,
addRealDebridAccountTotalUsageBytes,
getProviderUsageDayKey,
isProviderDailyLimitReached
isProviderDailyLimitReached,
isRealDebridAccountDailyLimitReached
} 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 { parseCollectorInput } from "./link-parser";
@@ -1623,7 +1625,7 @@ export function decideAutoRenameBaseName(
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_ZIP_SPLIT_RE = /^(.*)\.zip\.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 normalizedArchivePath = String(archivePath || "").trim();
if (normalizedArchivePath) {
const archivePathKey = pathKey(path.join(
path.dirname(path.resolve(normalizedArchivePath)),
normalizeArchiveMatchName(normalizedArchivePath)
));
const pathMatches = items.filter((item) => {
const candidateItems = normalizedArchivePath
? items.filter((item) => {
const targetPath = String(item.targetPath || "").trim();
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 =>
normalizeArchiveMatchName(item.targetPath || item.fileName || "");
@@ -1693,11 +1688,11 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download
}
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;
}
const exactMatch = items.filter((item) => itemBaseName(item).toLowerCase() === entryLower);
const exactMatch = candidateItems.filter((item) => itemBaseName(item).toLowerCase() === entryLower);
if (exactMatch.length > 0) return exactMatch;
const archiveStem = entryLower
@@ -1708,23 +1703,64 @@ export function resolveArchiveItemsFromList(archiveName: string, items: Download
.replace(/\.\d{3}$/i, "")
.replace(/\.(zip|7z)$/i, "");
if (archiveStem.length > 3) {
const stemMatch = items.filter((item) => {
const stemMatch = candidateItems.filter((item) => {
const name = itemBaseName(item).toLowerCase();
return name.startsWith(archiveStem) && /\.(rar|r\d{2,3}|zip|7z|\d{3})$/i.test(name);
});
if (stemMatch.length > 0) return stemMatch;
}
if (items.length === 1) {
const singleName = itemBaseName(items[0]).toLowerCase();
if (candidateItems.length === 1) {
const singleName = itemBaseName(candidateItems[0]).toLowerCase();
if (/\.(rar|zip|7z|\d{3})$/i.test(singleName)) {
return items;
return candidateItems;
}
}
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 {
return String(fileName || "").replace(/ \(\d+\)(?=\.[^.]+$)/, "");
}
@@ -1970,6 +2006,10 @@ export class DownloadManager extends EventEmitter {
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 reservedTargetPaths = new Map<string, string>();
@@ -3022,6 +3062,8 @@ export class DownloadManager extends EventEmitter {
this.packageHybridPostProcessTasks.delete(packageId);
this.hybridExtractRequeue.delete(packageId);
this.manualExtractArchiveFilters.delete(packageId);
this.manualExtractPackages.delete(packageId);
this.clearHybridArchiveState(packageId);
return tasks;
}
@@ -3350,6 +3392,8 @@ export class DownloadManager extends EventEmitter {
this.packageDeferredPostProcessTasks.clear();
this.packageHybridPostProcessControllers.clear();
this.packageHybridPostProcessTasks.clear();
this.manualExtractArchiveFilters.clear();
this.manualExtractPackages.clear();
this.hybridExtractRequeue.clear();
this.hybridExtractedPaths.clear();
this.hybridFailedArchives.clear();
@@ -6481,7 +6525,6 @@ export class DownloadManager extends EventEmitter {
item.targetPath = "";
item.provider = null;
item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs();
}
@@ -6564,7 +6607,6 @@ export class DownloadManager extends EventEmitter {
item.targetPath = "";
item.provider = null;
item.fullStatus = "Wartet";
item.onlineStatus = undefined;
item.updatedAt = nowMs();
if (this.session.running) {
@@ -8222,7 +8264,7 @@ export class DownloadManager extends EventEmitter {
const candidates = await findArchiveCandidates(pkg.outputDir);
for (const candidate of candidates) {
const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), completedItems);
const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), completedItems, candidate);
if (archiveItems.length === 0) {
continue;
}
@@ -8266,7 +8308,7 @@ export class DownloadManager extends EventEmitter {
private buildHybridArchiveRetryMarker(pkg: PackageEntry, items: DownloadItem[], archiveKey: string): string {
const archiveName = path.basename(archiveKey);
const archiveItems = resolveArchiveItemsFromList(archiveName, items)
const archiveItems = resolveArchiveItemsFromList(archiveName, items, archiveKey)
.slice()
.sort((left, right) => {
const leftName = (left.fileName || left.targetPath || left.id || "").toLowerCase();
@@ -8303,7 +8345,7 @@ export class DownloadManager extends EventEmitter {
return 0;
}
const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items)
const archiveItems = resolveArchiveItemsFromList(failure.archiveName, items, failure.archivePath)
.filter((item) => item.status === "completed");
if (archiveItems.length === 0) {
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(
completedItems: DownloadItem[],
resolveArchiveItems: (archiveName: string) => DownloadItem[],
failedArchiveErrors: Map<string, string>,
resolveArchiveItems: (archiveName: string, archivePath?: string) => DownloadItem[],
failedArchiveErrors: Map<string, { archiveName: string; archivePath: string; errorText: string }>,
fallbackReason: string,
previousStatuses: Map<string, string>,
appliedAt = nowMs()
): void {
const affectedItemIds = new Set<string>();
for (const [archiveName, errorText] of failedArchiveErrors.entries()) {
const reason = compactErrorText(errorText || fallbackReason || "Entpacken fehlgeschlagen");
for (const entry of resolveArchiveItems(archiveName)) {
for (const failure of failedArchiveErrors.values()) {
const reason = compactErrorText(failure.errorText || fallbackReason || "Entpacken fehlgeschlagen");
for (const entry of resolveArchiveItems(failure.archiveName, failure.archivePath)) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) {
continue;
}
entry.fullStatus = formatExtractFailureLabel(reason, archiveName);
entry.fullStatus = formatExtractFailureLabel(reason, failure.archiveName);
entry.updatedAt = appliedAt;
affectedItemIds.add(entry.id);
}
@@ -8890,6 +8932,8 @@ export class DownloadManager extends EventEmitter {
// orphan that newer task (uncancellable) and allow a duplicate concurrent run.
if (this.packagePostProcessTasks.get(packageId) === handle.task) {
this.packagePostProcessTasks.delete(packageId);
this.manualExtractArchiveFilters.delete(packageId);
this.manualExtractPackages.delete(packageId);
}
if (this.packagePostProcessAbortControllers.get(packageId) === abortController) {
this.packagePostProcessAbortControllers.delete(packageId);
@@ -9133,23 +9177,35 @@ export class DownloadManager extends EventEmitter {
});
this.beginPackageResultGeneration(packageId, false, true);
this.reactivateStandalonePackageResult(packageId);
this.manualExtractPackages.add(packageId);
this.persistSoon();
this.emitState(true);
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];
if (!pkg || pkg.cancelled) return;
if (this.packagePostProcessTasks.has(packageId)) return;
if (!pkg || pkg.cancelled) return false;
if (this.packagePostProcessTasks.has(packageId)) return false;
this.clearHybridArchiveState(packageId);
if (!pkg.enabled) {
pkg.enabled = true;
}
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
const completedItems = items.filter((item) => item.status === "completed");
const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus));
if (targetItems.length === 0) return;
const targetItems = completedItems.filter((item) => !isExtractedLabel(item.fullStatus) && (!selectedItemIds || selectedItemIds.has(item.id)));
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.updatedAt = nowMs();
for (const item of targetItems) {
@@ -9166,6 +9222,55 @@ export class DownloadManager extends EventEmitter {
this.persistSoon();
this.emitState(true);
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 {
@@ -9447,7 +9552,7 @@ export class DownloadManager extends EventEmitter {
if (effectiveProvider === "realdebrid") {
const configuredAccounts = getRealDebridAccounts(this.settings);
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());
}
if (effectiveProvider === "megadebrid-api") {
@@ -13353,7 +13458,7 @@ export class DownloadManager extends EventEmitter {
continue;
}
const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), packageItems);
const archiveItems = resolveArchiveItemsFromList(path.basename(candidate), packageItems, candidate);
if (archiveItems.length === 0) {
continue;
}
@@ -13465,6 +13570,14 @@ export class DownloadManager extends EventEmitter {
const findReadyStart = nowMs();
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;
if (findReadyMs > 200) {
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;
}
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 retryMarker = this.buildHybridArchiveRetryMarker(pkg, items, archiveKey);
if (!allItemsStillInError || previousFailure.marker !== retryMarker) {
@@ -13514,48 +13627,19 @@ export class DownloadManager extends EventEmitter {
this.emitState();
const hybridExtractStartMs = nowMs();
const hybridFileNames = 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>();
const plannedHybridItemIds = new Set<string>();
for (const archiveKey of readyArchives) {
const parts = collectArchiveCleanupTargets(archiveKey, dirFiles);
for (const part of parts) {
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);
for (const item of resolveArchiveItemsFromList(path.basename(archiveKey), completedItems, archiveKey)) {
plannedHybridItemIds.add(item.id);
}
hybridFileNames.add(path.basename(archiveKey).toLowerCase());
}
if (dirFiles && archiveStems.size > 0) {
for (const fileName of dirFiles) {
const lower = fileName.toLowerCase();
if (!KNOWN_SMALL_FILE_RE.test(lower)) continue;
const companionStem = lower.replace(/\.[^.]+$/, "");
if (archiveStems.has(companionStem)) {
hybridFileNames.add(lower);
const cleanupTargetKeys = new Set(collectArchiveCleanupTargets(archiveKey).map((target) => pathKey(target)));
for (const item of completedItems) {
if (item.targetPath && cleanupTargetKeys.has(pathKey(item.targetPath))) {
plannedHybridItemIds.add(item.id);
}
}
}
const isHybridItem = (item: DownloadItem): boolean => {
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);
const hybridItems = completedItems.filter((item) => plannedHybridItemIds.has(item.id));
if (hybridItems.length > 0 && hybridItems.every((item) => isExtractedLabel(item.fullStatus))) {
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]) {
const archiveParts = collectArchiveCleanupTargets(archiveKey, dirFiles);
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);
});
const archiveItems = resolveArchiveItemsFromList(path.basename(archiveKey), completedItems, archiveKey);
if (archiveItems.length > 0 && archiveItems.every((item) => isExtractedLabel(item.fullStatus))) {
readyArchives.delete(archiveKey);
}
@@ -13586,10 +13660,8 @@ export class DownloadManager extends EventEmitter {
const resolveArchiveItems = (archiveName: string, archivePath = ""): DownloadItem[] =>
resolveArchiveItemsFromList(archiveName, items, archivePath);
const readyArchiveKeyByName = new Map<string, string>();
const readyArchiveMarkers = new Map<string, string>();
for (const archiveKey of readyArchives) {
readyArchiveKeyByName.set(path.basename(archiveKey).toLowerCase(), archiveKey);
readyArchiveMarkers.set(archiveKey, this.buildHybridArchiveRetryMarker(pkg, items, archiveKey));
}
@@ -13601,7 +13673,6 @@ export class DownloadManager extends EventEmitter {
let hybridLastEmitAt = 0;
let hybridLastProgressCurrent: number | null = null;
const allDownloaded = completedItems.length >= items.length;
let labelsChanged = false;
for (const entry of completedItems) {
if (isExtractedLabel(entry.fullStatus)) {
@@ -13610,9 +13681,7 @@ export class DownloadManager extends EventEmitter {
if (isExtractErrorLabel(entry.fullStatus)) {
continue;
}
const belongsToReady = allDownloaded
|| hybridFileNames.has((entry.fileName || "").toLowerCase())
|| (entry.targetPath && hybridFileNames.has(path.basename(entry.targetPath).toLowerCase()));
const belongsToReady = plannedHybridItemIds.has(entry.id);
const targetLabel = belongsToReady ? "Entpacken - Ausstehend" : "Entpacken - Warten auf Parts";
if (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),
onOutput: (event) => scope.add(event),
onArchiveFailure: (failure) => {
failedArchiveCategories.set(String(failure.archiveName || "").toLowerCase(), failure.category);
const failedArchiveKey = readyArchiveKeyByName.get(String(failure.archiveName || "").toLowerCase());
const failedArchiveKey = pathKey(failure.archivePath);
failedArchiveCategories.set(failedArchiveKey, failure.category);
if (failedArchiveKey) {
failedArchiveErrors.set(failedArchiveKey, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen");
}
if (autoRecoveredArchives.has(failure.archiveName)) {
if (autoRecoveredArchives.has(failedArchiveKey)) {
return;
}
const changed = this.autoRecoverArchiveCrcFailure(pkg, items, failure, "hybrid");
if (changed > 0) {
autoRecoveredArchives.add(failure.archiveName);
autoRecoveredArchives.add(failedArchiveKey);
}
},
onProgress: (progress) => {
@@ -13680,10 +13749,11 @@ export class DownloadManager extends EventEmitter {
hybridLastProgressCurrent = currentCount;
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);
hybridResolvedItems.set(progress.archiveName, resolved);
hybridStartTimes.set(progress.archiveName, nowMs());
hybridResolvedItems.set(progressKey, resolved);
hybridStartTimes.set(progressKey, nowMs());
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(", ")}]`);
} else {
@@ -13703,15 +13773,15 @@ export class DownloadManager extends EventEmitter {
this.emitState(true);
}
}
const archItems = hybridResolvedItems.get(progress.archiveName) || [];
const archItems = hybridResolvedItems.get(progressKey) || [];
if (archiveFinished) {
const doneAt = nowMs();
const startedAt = hybridStartTimes.get(progress.archiveName) || doneAt;
const startedAt = hybridStartTimes.get(progressKey) || doneAt;
const doneLabel = progress.archiveSuccess === false
? "Entpacken - Error"
: formatExtractDone(doneAt - startedAt);
const archiveKey = readyArchiveKeyByName.get(progress.archiveName.toLowerCase());
const archiveKey = readyArchives.has(progressKey) ? progressKey : undefined;
if (archiveKey && progress.archiveSuccess !== false) {
this.clearHybridArchiveState(packageId, archiveKey);
}
@@ -13719,15 +13789,15 @@ export class DownloadManager extends EventEmitter {
pkg,
progress,
archItems,
failedArchiveCategories.get(progress.archiveName.toLowerCase()) || ""
failedArchiveCategories.get(progressKey) || ""
);
for (const entry of archItems) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
entry.fullStatus = doneLabel;
entry.updatedAt = doneAt;
}
hybridResolvedItems.delete(progress.archiveName);
hybridStartTimes.delete(progress.archiveName);
hybridResolvedItems.delete(progressKey);
hybridStartTimes.delete(progressKey);
const done = currentCount;
if (done < progress.total) {
pkg.postProcessLabel = `Entpacken (${done}/${progress.total}) - Nächstes Archiv...`;
@@ -13780,12 +13850,7 @@ export class DownloadManager extends EventEmitter {
const now = nowMs();
if (now - hybridLastEmitAt >= EXTRACT_PROGRESS_EMIT_INTERVAL_MS) {
hybridLastEmitAt = now;
for (const entry of items) {
if (entry.status === "completed" && entry.fullStatus === "Entpacken - Warten auf Parts") {
entry.fullStatus = "Entpacken - Ausstehend";
entry.updatedAt = now;
}
}
markPlannedHybridArchiveItemsPending(items, plannedHybridItemIds, now);
this.emitState();
}
}
@@ -14044,6 +14109,8 @@ export class DownloadManager extends EventEmitter {
});
const allDone = this.areAllPackageItemRefsFinished(pkg);
const manualExtraction = this.manualExtractPackages.has(packageId);
const shouldExtract = this.settings.autoExtract || manualExtraction;
if (!allDone && success + failed + cancelled >= items.length) {
logger.warn(
`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...";
this.emitState();
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));
let extractedCount = 0;
if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
if (shouldExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
pkg.postProcessLabel = "Entpacken vorbereiten...";
pkg.status = "extracting";
this.emitState();
@@ -14142,9 +14209,10 @@ export class DownloadManager extends EventEmitter {
extractAbortController.abort("extract_timeout");
}
}, extractTimeoutMs);
let fullExtractionItems = completedItems;
try {
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 fullResolvedItems = new Map<string, DownloadItem[]>();
const fullStartTimes = new Map<string, number>();
@@ -14161,13 +14229,24 @@ export class DownloadManager extends EventEmitter {
}
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>();
for (const archivePath of fullArchiveSet) {
const archiveItems = resolveArchiveItems(path.basename(archivePath));
const archiveItems = resolveArchiveItems(path.basename(archivePath), archivePath);
for (const entry of archiveItems) {
fullExtractItemIds.add(entry.id);
}
}
fullExtractionItems = manualArchiveFilter
? completedItems.filter((entry) => fullExtractItemIds.has(entry.id))
: completedItems;
const pendingAt = nowMs();
for (const entry of completedItems) {
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),
onOutput: (event) => scope.add(event),
onArchiveFailure: (failure) => {
fullFailedArchiveCategories.set(failure.archiveName.toLowerCase(), failure.category);
if (autoRecoveredArchives.has(failure.archiveName)) {
const failureKey = pathKey(failure.archivePath);
fullFailedArchiveCategories.set(failureKey, failure.category);
if (autoRecoveredArchives.has(failureKey)) {
return;
}
const changed = this.autoRecoverArchiveCrcFailure(pkg, completedItems, failure, "full");
if (changed > 0) {
autoRecoveredArchives.add(failure.archiveName);
fullFailedArchiveErrors.delete(failure.archiveName);
fullFailedArchiveCategories.delete(failure.archiveName.toLowerCase());
autoRecoveredArchives.add(failureKey);
fullFailedArchiveErrors.delete(failureKey);
fullFailedArchiveCategories.delete(failureKey);
return;
}
fullFailedArchiveErrors.set(
failure.archiveName,
failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen"
failureKey,
{
archiveName: failure.archiveName,
archivePath: failure.archivePath,
errorText: failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen"
}
);
},
onProgress: (progress) => {
@@ -14233,10 +14317,11 @@ export class DownloadManager extends EventEmitter {
fullLastProgressCurrent = currentCount;
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);
fullResolvedItems.set(progress.archiveName, resolved);
fullStartTimes.set(progress.archiveName, nowMs());
fullResolvedItems.set(progressKey, resolved);
fullStartTimes.set(progressKey, nowMs());
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(", ")}]`);
} else {
@@ -14251,11 +14336,11 @@ export class DownloadManager extends EventEmitter {
emitExtractStatus(`Entpacken ${progress.percent}% · ${progress.archiveName}`, true);
}
}
const archiveItems = fullResolvedItems.get(progress.archiveName) || [];
const archiveItems = fullResolvedItems.get(progressKey) || [];
if (archiveFinished) {
const doneAt = nowMs();
const startedAt = fullStartTimes.get(progress.archiveName) || doneAt;
const startedAt = fullStartTimes.get(progressKey) || doneAt;
const doneLabel = progress.archiveSuccess === false
? "Entpacken - Error"
: formatExtractDone(doneAt - startedAt);
@@ -14263,15 +14348,15 @@ export class DownloadManager extends EventEmitter {
pkg,
progress,
archiveItems,
fullFailedArchiveCategories.get(progress.archiveName.toLowerCase()) || ""
fullFailedArchiveCategories.get(pathKey(progress.archivePath || progress.archiveName)) || ""
);
for (const entry of archiveItems) {
if (entry.status !== "completed" || isExtractedLabel(entry.fullStatus)) continue;
entry.fullStatus = doneLabel;
entry.updatedAt = doneAt;
}
fullResolvedItems.delete(progress.archiveName);
fullStartTimes.delete(progress.archiveName);
fullResolvedItems.delete(progressKey);
fullStartTimes.delete(progressKey);
const done = currentCount;
if (done < progress.total) {
emitExtractStatus(`Entpacken (${done}/${progress.total}) - Nächstes Archiv...`, true);
@@ -14328,7 +14413,7 @@ export class DownloadManager extends EventEmitter {
this.diskWaitEvents = [{ ...error.event, packageId }];
const retryAt = error.event.retryAt;
this.packageDiskRetryAfterByPackage.set(packageId, retryAt);
for (const entry of completedItems) {
for (const entry of fullExtractionItems) {
entry.fullStatus = "Warte auf Festplatte";
entry.lastError = "Zu wenig Speicherplatz";
entry.updatedAt = nowMs();
@@ -14363,18 +14448,18 @@ export class DownloadManager extends EventEmitter {
const reason = compactErrorText(result.lastError || "Entpacken fehlgeschlagen");
const failAt = nowMs();
if (fullFailedArchiveErrors.size > 0) {
const archiveSummaries = [...fullFailedArchiveErrors.entries()]
const archiveSummaries = [...fullFailedArchiveErrors.values()]
.slice(0, 3)
.map(([archiveName, errorText]) => `${archiveName}: ${summarizeExtractFailureReason(errorText)}`)
.map((failure) => `${failure.archiveName}: ${summarizeExtractFailureReason(failure.errorText)}`)
.join(" | ");
logger.warn(`Post-Processing Entpacken Fehlerdetails: pkg=${pkg.name}, archives=${archiveSummaries}`);
this.logPackageForPackage(pkg, "WARN", "Post-Processing Entpacken Fehlerdetails", {
failedArchives: [...fullFailedArchiveErrors.keys()],
failedArchives: [...fullFailedArchiveErrors.values()].map((failure) => failure.archivePath),
summary: archiveSummaries
});
}
this.applyPackageExtractFailureStatuses(
completedItems,
fullExtractionItems,
resolveArchiveItems,
fullFailedArchiveErrors,
reason,
@@ -14383,8 +14468,9 @@ export class DownloadManager extends EventEmitter {
);
pkg.status = "failed";
} else {
const hasExtractedOutput = this.getPackageOutputScope(pkg).completeFiles()
.some((filePath) => isPathInsideDir(filePath, pkg.extractDir));
const hasExtractedOutput = this.getPackageOutputScope(pkg).records()
.some((record) => isPathInsideDir(record.outputPath, pkg.extractDir)
&& (!manualArchiveFilter || fullArchiveSet.has(pathKey(record.archivePath))));
const sourceExists = await this.existsAsync(pkg.outputDir);
let finalStatusText = "";
@@ -14398,13 +14484,19 @@ export class DownloadManager extends EventEmitter {
}
const finalAt = nowMs();
for (const entry of completedItems) {
for (const entry of fullExtractionItems) {
if (!isExtractedLabel(entry.fullStatus)) {
entry.fullStatus = finalStatusText;
entry.updatedAt = finalAt;
}
}
pkg.status = "completed";
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";
}
}
} catch (error) {
const reasonRaw = String(error || "");
@@ -14414,7 +14506,7 @@ export class DownloadManager extends EventEmitter {
if (timedOut) {
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
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)) {
entry.fullStatus = formatExtractFailureLabel(timeoutReason);
entry.updatedAt = nowMs();
@@ -14424,7 +14516,7 @@ export class DownloadManager extends EventEmitter {
pkg.updatedAt = nowMs();
timeoutHandled = true;
} else {
for (const entry of completedItems) {
for (const entry of fullExtractionItems) {
if (/^Entpacken/i.test(entry.fullStatus || "") || /^Passwort/i.test(entry.fullStatus || "")) {
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
entry.updatedAt = nowMs();
@@ -14439,7 +14531,7 @@ export class DownloadManager extends EventEmitter {
if (!timeoutHandled) {
const reason = compactErrorText(error);
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)) {
entry.fullStatus = formatExtractFailureLabel(reason);
entry.updatedAt = nowMs();
@@ -14481,7 +14573,15 @@ export class DownloadManager extends EventEmitter {
alreadyMarkedExtracted
});
void this.runDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount);
void this.runDeferredPostExtraction(
packageId,
pkg,
success,
failed,
alreadyMarkedExtracted,
extractedCount,
manualExtraction
);
}
private runDeferredPostExtraction(
@@ -14490,10 +14590,11 @@ export class DownloadManager extends EventEmitter {
success: number,
failed: number,
alreadyMarkedExtracted: boolean,
extractedCount: number
extractedCount: number,
manualSelection = false
): Promise<void> {
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(() => {
const tasks = this.packageDeferredPostProcessTasks.get(packageId);
tasks?.delete(task);
@@ -14516,7 +14617,8 @@ export class DownloadManager extends EventEmitter {
success: number,
failed: number,
alreadyMarkedExtracted: boolean,
extractedCount: number
extractedCount: number,
manualSelection: boolean
): Promise<void> {
const replacedController = this.packageDeferredPostProcessAbortControllers.get(packageId);
if (replacedController && !replacedController.signal.aborted) {
@@ -14537,7 +14639,7 @@ export class DownloadManager extends EventEmitter {
try {
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 nestedCandidates = outputScope.archiveFiles()
.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...";
this.emitState();
throwIfAborted();
@@ -14639,7 +14741,7 @@ export class DownloadManager extends EventEmitter {
}
}
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0) {
if ((extractedCount > 0 || alreadyMarkedExtracted) && failed === 0 && !manualSelection) {
throwIfAborted();
await clearExtractResumeState(pkg.outputDir, packageId);
await clearExtractResumeState(pkg.outputDir);
+2
View File
@@ -85,6 +85,7 @@ export interface ExtractProgressUpdate {
export interface ExtractArchiveFailureInfo {
archiveName: string;
archivePath: string;
errorText: string;
category: ExtractErrorCategory;
suggestRedownload: boolean;
@@ -4195,6 +4196,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<E
const hintedError = error as ExtractionErrorWithHints;
options.onArchiveFailure?.({
archiveName,
archivePath,
errorText,
category: errorCategory,
suggestRedownload: hintedError?.suggestRedownload === true,
+5 -11
View File
@@ -26,6 +26,8 @@ import { migrateProductUserDataDirectory } from "./storage";
import { validateCollectorContainerInspectionRequest, validateCollectorInspectionRequest } from "../shared/collector";
import { DailyStartScheduler, hasDailyStartRulePatch, prepareDailyStartSettingsPatch } from "./daily-start-scheduler";
import { forceDarkNativeTheme } from "./native-theme";
import { normalizeExtractNowRequest } from "../shared/extract-now";
import { validateClipboardWriteText } from "./clipboard-write";
forceDarkNativeTheme(nativeTheme);
@@ -44,7 +46,6 @@ function validatePlainObject(value: unknown, name: string): Record<string, unkno
}
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
const CLIPBOARD_WRITE_MAX_BYTES = 4096;
const RENAME_PACKAGE_MAX_CHARS = 240;
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
"realdebrid",
@@ -645,9 +646,8 @@ function registerIpcHandlers(): void {
validateString(packageId, "packageId");
return controller.retryExtraction(packageId);
});
handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, packageId: string) => {
validateString(packageId, "packageId");
return controller.extractNow(packageId);
handleTrusted(IPC_CHANNELS.EXTRACT_NOW, (_event: IpcMainInvokeEvent, request: unknown) => {
return controller.extractNow(normalizeExtractNowRequest(request));
});
handleTrusted(IPC_CHANNELS.RESET_PACKAGE, (_event: IpcMainInvokeEvent, packageId: string) => {
validateString(packageId, "packageId");
@@ -714,14 +714,8 @@ function registerIpcHandlers(): void {
return next;
});
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");
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 {
clipboard.writeText(text);
return true;
+136 -71
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> {
if (!signal) {
return promise;
}
if (signal.aborted) {
throw abortErrorFactory();
}
type MegaWebQueueTask = {
run: (canMutate: () => boolean) => Promise<unknown>;
resolve: (value: unknown) => void;
reject: (reason?: unknown) => void;
signal?: AbortSignal;
queuedAt: number;
workStartedAt: number | null;
owner: symbol;
settled: boolean;
onAbort: () => void;
};
return new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = (): void => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(abortErrorFactory());
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then((value) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
resolve(value);
}, (error) => {
if (settled) {
return;
}
settled = true;
signal.removeEventListener("abort", onAbort);
reject(error);
});
});
}
type MegaWebQueueState = {
active: MegaWebQueueTask | null;
pending: MegaWebQueueTask[];
};
export class MegaWebFallback {
// Pro Account eine eigene Warteschlange: Umwandlungen auf DEMSELBEN Account laufen
// seriell (kein Doppel-Login, kein Hammern eines einzelnen Accounts), verschiedene
// Accounts laufen parallel. So koennen die Links eines Pakets ueber mehrere Accounts
// 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;
@@ -248,14 +227,16 @@ export class MegaWebFallback {
}
const key = creds.login.trim().toLowerCase();
const sessionGeneration = this.sessionGeneration;
return this.runExclusive(async () => {
return this.runExclusive(async (canMutate) => {
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);
if (!generated) {
this.sessions.delete(key);
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration);
if (canMutate()) {
this.sessions.delete(key);
}
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal, sessionGeneration, canMutate);
generated = await this.generate(link, cookie, overallSignal);
if (!generated) {
return null;
@@ -275,14 +256,15 @@ export class MegaWebFallback {
login: string,
password: string,
signal?: AbortSignal,
generation = this.sessionGeneration
generation = this.sessionGeneration,
canMutate: () => boolean = () => true
): Promise<string> {
const existing = this.sessions.get(key);
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
return existing.cookie;
}
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() });
}
return cookie;
@@ -293,36 +275,119 @@ export class MegaWebFallback {
this.sessions.clear();
}
private async runExclusive<T>(job: () => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
const queuedAt = Date.now();
const QUEUE_WAIT_TIMEOUT_MS = 90000;
let workStarted = false;
const guardedJob = async (): Promise<T> => {
throwIfAborted(signal);
const waited = Date.now() - queuedAt;
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
traceConversionPhase({ phase: "web-queue", provider: "megadebrid-web", queueWaitMs: waited, outcome: "queue-timeout", detail: `${Math.floor(waited / 1000)}s in Web-Queue gewartet` });
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
private runExclusive<T>(job: (canMutate: () => boolean) => Promise<T>, key: string, signal?: AbortSignal): Promise<T> {
return new Promise<T>((resolve, reject) => {
const state = this.queues.get(key) ?? { active: null, pending: [] };
let task: MegaWebQueueTask;
task = {
run: job,
resolve: (value: unknown) => resolve(value as T),
reject,
signal,
queuedAt: Date.now(),
workStartedAt: null,
owner: Symbol(key),
settled: false,
onAbort: () => this.abortQueueTask(key, state, task)
};
state.pending.push(task);
this.queues.set(key, state);
if (signal?.aborted) {
task.onAbort();
return;
}
workStarted = true;
const workStartedAt = Date.now();
try {
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;
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);
}
};
const prev = this.queues.get(key) ?? Promise.resolve();
const run = prev.then(guardedJob, guardedJob);
this.queues.set(key, run.then(() => undefined, () => undefined));
return raceWithAbort(run, signal, () =>
workStarted
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()
: 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> {
+10 -3
View File
@@ -97,6 +97,7 @@ function getFailure(
remuxOperations: readonly RemuxOperationMetric[],
remuxFallbackFailures: number,
archiveOperations: readonly ArchiveOperationMetric[],
extractionErrors: readonly string[],
downloadErrors: readonly string[]
): { failurePhase: FailurePhase; errorCategory: string } {
if (cleanupErrorCategory) {
@@ -107,8 +108,8 @@ function getFailure(
return { failurePhase: "remux", errorCategory: projectPackageFailureCategory("remux", failedRemux?.errorCategory) };
}
const failedArchive = archiveOperations.find((operation) => operation.status === "failed");
if (failedArchive) {
return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive.errorCategory) };
if (failedArchive || extractionErrors.length > 0) {
return { failurePhase: "extract", errorCategory: projectPackageFailureCategory("extract", failedArchive?.errorCategory || extractionErrors[0]) };
}
const downloadError = downloadErrors.find(Boolean);
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 completedDownloads = cleanedCompletedDownloads + telemetry.items.filter((item) => item.status === "completed").length;
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 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 failedRemuxOperations = remuxOperations.filter((operation) => operation.status === "failed").length;
const cancelledRemuxOperations = remuxOperations.filter((operation) => operation.status === "cancelled").length;
@@ -163,6 +169,7 @@ export function finalizePackageResult(telemetry: PackageTelemetry): PackageResul
remuxOperations,
audioStripFailures,
archiveOperations,
itemExtractionFailures.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 type { ExtractNowRequest } from "../shared/extract-now";
import {
AddLinksPayload,
AccountCheckScope,
@@ -118,7 +119,7 @@ const api: ElectronApi = {
revealAccountSecret: (input: AccountSecretRequest): Promise<AccountSecretResult> => ipcRenderer.invoke(IPC_CHANNELS.REVEAL_ACCOUNT_SECRET, input),
getArchivePasswordList: () => ipcRenderer.invoke(IPC_CHANNELS.GET_ARCHIVE_PASSWORD_LIST),
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),
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
onHistoryEntryAdded: (callback: (entry: HistoryEntry) => void): (() => void) => {
+69 -48
View File
@@ -63,6 +63,7 @@ import { BackupPassphraseDialog } from "./ui/BackupPassphraseDialog";
import { Dialog } from "./ui/Dialog";
import { Icon } from "./ui/Icon";
import { Toast } from "./ui/Toast";
import { LinkAddressesDialog } from "./ui/LinkAddressesDialog";
import {
buildCollectorTransferPackages,
buildCollectorWorkspaceViewModel,
@@ -106,6 +107,7 @@ import {
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 { 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 {
DownloadsContent,
@@ -1469,6 +1471,29 @@ function formatUpdateInstallProgress(progress: UpdateInstallProgress): string {
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(
completedGeneration: number,
currentGeneration: number
@@ -4426,7 +4451,7 @@ export function App(): ReactElement {
const onCopyOnlineBackupKey = async (): Promise<void> => {
if (!onlineBackupDialog?.key) return;
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);
} catch {
showToast("Schlüssel konnte nicht kopiert werden", 2600);
@@ -4495,11 +4520,11 @@ export function App(): ReactElement {
detailsLabel: "Einträge anzeigen"
});
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);
}
} 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;
}
try {
await navigator.clipboard.writeText(remoteDiag.code);
if (!(await window.rd.writeClipboardText(remoteDiag.code))) throw new Error("clipboard_write_rejected");
showToast("Verbindungscode kopiert", 2200);
} catch {
showToast("Kopieren fehlgeschlagen", 2200);
@@ -4720,6 +4745,14 @@ export function App(): ReactElement {
? sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending)
: column === "hoster"
? 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);
pendingPackageOrderRef.current = [...sorted];
pendingPackageOrderAtRef.current = Date.now();
@@ -4732,7 +4765,7 @@ export function App(): ReactElement {
setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: serverPackageOrderRef.current } }));
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 => {
void performQuickAction(async () => {
@@ -5374,7 +5407,7 @@ export function App(): ReactElement {
},
onCopyIdentity: (label, value) => {
void window.rd.writeClipboardText(value)
.then(() => showToast(`${label} kopiert`))
.then((copied) => copied ? showToast(`${label} kopiert`) : showToast("Kopieren fehlgeschlagen"))
.catch(() => showToast("Kopieren fehlgeschlagen"));
},
onAdd: openCreateAccountDialog,
@@ -6416,18 +6449,27 @@ export function App(): ReactElement {
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 hasItems = selectedItemIds.length > 0;
const extractAction = buildExtractNowContextAction({
contextItemId: contextMenu.itemId,
selectedPackageIds,
selectedItemIds,
packages: snapshot.session.packages,
items: snapshot.session.items
});
return (
<ContextMenu ariaLabel="Downloadaktionen" onClose={() => setContextMenu(null)} open ref={ctxMenuRef} x={contextMenu.x} y={contextMenu.y}>
{(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 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);
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>
)}
<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" />
<button className="ctx-menu-item" onClick={() => showLinksPopup(contextMenu.packageId, contextMenu.itemId)}>Linkadressen anzeigen</button>
{hasPackages && !contextMenu.itemId && (
@@ -6497,16 +6539,13 @@ export function App(): ReactElement {
setContextMenu(null);
}}>Zurücksetzen{multi ? ` (${selectedItemIds.length})` : ""}</button>
)}
{hasPackages && !multi && (() => {
const pkg = snapshot.session.packages[contextMenu.packageId];
const items = pkg?.itemIds.map((id) => snapshot.session.items[id]).filter(Boolean) || [];
const someCompleted = items.some((item) => item && item.status === "completed" && !/^Entpackt\b/i.test(item.fullStatus || ""));
return (<>
{someCompleted && (
<button className="ctx-menu-item" onClick={() => { void window.rd.extractNow(contextMenu.packageId).catch(() => {}); setContextMenu(null); }}>Jetzt entpacken</button>
)}
</>);
})()}
{extractAction && (
<button className="ctx-menu-item" onClick={() => {
void window.rd.extractNow(extractAction.request)
.catch((error) => showToast(`Entpacken fehlgeschlagen: ${String(error)}`, 2600));
setContextMenu(null);
}}>{extractAction.label}</button>
)}
{hasPackages && !contextMenu.itemId && (<>
<div className="ctx-menu-sep" />
<div className="ctx-menu-sub">
@@ -6687,8 +6726,8 @@ export function App(): ReactElement {
type="button"
title={`${key.masked}\nMaskierte Kennung kopieren`}
onClick={() => {
void navigator.clipboard.writeText(key.masked)
.then(() => showToast("Maskierte Kennung kopiert", 1800))
void window.rd.writeClipboardText(key.masked)
.then((copied) => copied ? showToast("Maskierte Kennung kopiert", 1800) : showToast("Kopieren fehlgeschlagen", 2200))
.catch(() => showToast("Kopieren fehlgeschlagen", 2200));
}}
>
@@ -6739,32 +6778,14 @@ export function App(): ReactElement {
/>
) : null}
{linkPopup ? (
<Dialog actions={null} className="link-popup" onClose={() => setLinkPopup(null)} open size="wide" title="Linkadressen anzeigen">
<p>{linkPopup.title}</p>
<div className="link-popup-list">
{linkPopup.links.map((link, i) => (
<div key={i} className="link-popup-row">
<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>
<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>
</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>
<LinkAddressesDialog
isPackage={linkPopup.isPackage}
links={linkPopup.links}
onClose={() => setLinkPopup(null)}
onToast={showToast}
title={linkPopup.title}
writeClipboardText={window.rd.writeClipboardText}
/>
) : 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"],
["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."],
["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"],
["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"],
@@ -222,11 +222,41 @@ export function normalizeLanguage(value: unknown): AppLanguage {
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 {
for (const [german, english] of prefixedPairs) {
const source = language === "en" ? german : english;
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") {
const update = value.match(/^(.+) ist verfügbar\. Installierte Version: (.+)\.$/);
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")}`;
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")}`;
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+%)$/);
if (extracting) return `Extracting ${extracting[1]}`;
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")}`;
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")}`;
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+%)$/);
if (extracting) return `Entpacken ${extracting[1]}`;
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
} from "../../download-format";
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_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";
interface DownloadColumnPointerGesture {
dragged: boolean;
pointerId: number;
sortColumn?: DownloadSortColumn;
startX: number;
}
const downloadColumnPointerGestures = new WeakMap<HTMLDivElement, DownloadColumnPointerGesture>();
type HosterLabel = ReturnType<typeof formatHosterLabel>;
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" },
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" },
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)" },
status: { label: "Status", width: "minmax(var(--downloads-status-min, 210px), 1.2fr)" },
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";
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 } {
const total = items.length;
const online = items.filter((item) => item.onlineStatus === "online").length;
const offline = items.filter((item) => item.onlineStatus === "offline").length;
const availability = items.map(effectiveItemOnlineStatus);
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 && offline === total) return { online, total, state: "offline" };
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;
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") {
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 === "speed") return <span className="downloads-cell">{item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}</span>;
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";
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 function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0));
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 };
return buildPackagePresentation(row).progress;
}
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 {
const entry = row.package;
const stats = getPackageProgress(row);
const presentation = buildPackagePresentation(row);
const stats = presentation.progress;
if (column === "name") {
return (
<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)
? "Entpacken - Ausstehend"
: compactPostProcessLabel;
const extractFailure = row.allItems.find((item) => /^Entpack-Fehler\b/i.test(item.fullStatus || ""));
const waitsForDisk = row.allItems.some((item) => compactDownloadStatus(item.fullStatus || "") === "Warte auf Festplatte");
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}` : ""}`;
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating");
const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
const details = `${presentation.details}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
const status = presentation.extractFailureCount === 0
&& presentation.retryCount === 0
&& postProcessLabel
&& (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
? postProcessLabel
: extractFailure ? "Entpack-Fehler"
: waitsForDisk ? "Warte auf Festplatte"
: downloading ? "Download läuft" : details;
const statusDetails = extractFailure ? `${details}\n${extractFailure.fullStatus}` : details;
: presentation.status;
const statusDetails = presentation.extractFailure ? `${details}\n${presentation.extractFailure.fullStatus}` : details;
const title = audio?.tooltip ? `${statusDetails}\n${audio.tooltip}` : statusDetails;
return <DownloadStatusCell status={status} title={title} />;
}
@@ -533,6 +520,11 @@ function moveColumnWithPointerActions(column: string, direction: -1 | 1, element
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 {
const allSelected = visibleIds.length > 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}
key={column}
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) => {
if (event.button !== 0 || !event.isPrimary) 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);
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) => {
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);
actions.onColumnPointerUp(column, event);
if (gesture?.pointerId === event.pointerId && gesture.sortColumn && !gesture.dragged) actions.onSortColumn(gesture.sortColumn);
}}
role="columnheader"
>
{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 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}
@@ -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,
UpdateInstallResult
} from "./types";
import type { ExtractNowRequest } from "./extract-now";
import { isRealDebridWebAccountId } from "./real-debrid-accounts";
import type { CollectorInspectionRequest, CollectorInspectionResult } from "./collector";
@@ -143,7 +144,7 @@ export interface ElectronApi {
revealAccountSecret: (input: AccountSecretRequest) => Promise<AccountSecretResult>;
getArchivePasswordList: () => Promise<ArchivePasswordListResult>;
retryExtraction: (packageId: string) => Promise<void>;
extractNow: (packageId: string) => Promise<void>;
extractNow: (request: ExtractNowRequest) => Promise<void>;
resetPackage: (packageId: string) => Promise<void>;
getHistory: () => Promise<HistoryEntry[]>;
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");
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).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");
});
+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();
});
it("cools down a Debrid-Link key on an abort that ran long enough (retry rotates to the next key)", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0";
it("does not cool down a Debrid-Link key when the caller aborts after more than eight seconds", async () => {
const settings = {
...defaultSettings(),
token: "",
@@ -674,10 +673,13 @@ describe("debrid service", () => {
autoProviderFallback: false
};
const controller = new AbortController();
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")) {
controller.abort();
now += 9_000;
controller.abort("stop");
throw new Error("aborted");
}
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)
).rejects.toThrow();
const cooldown = getDebridLinkKeyCooldownStateForTests(keyId);
expect(cooldown?.remainingMs ?? 0).toBeGreaterThan(60_000);
expect(getDebridLinkKeyCooldownStateForTests(keyId)).toBeNull();
});
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 () => {
@@ -2158,12 +2199,10 @@ describe("debrid service", () => {
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const controller = new AbortController();
let calls = 0;
const megaWeb = vi.fn((): Promise<{ fileName: string; directUrl: string; fileSize: number | null; retriesUsed: number }> => {
calls += 1;
if (calls === 1) {
controller.abort("simulated-60s-timeout");
if (calls <= REQUEST_RETRIES) {
return Promise.reject(new Error("aborted"));
}
return Promise.resolve({
@@ -2176,7 +2215,7 @@ describe("debrid service", () => {
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(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;
const callerController = new AbortController();
const timeoutController = new AbortController();
const signal = AbortSignal.any([callerController.signal, timeoutController.signal]);
const loginsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
loginsSeen.push(account?.login);
if (account?.login === "user1") {
timeoutController.abort(new DOMException("The operation timed out", "TimeoutError"));
throw new Error("aborted:debrid");
}
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`;
// 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).not.toContain("user2");
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
@@ -2399,6 +2442,41 @@ describe("debrid service", () => {
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
}, 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 () => {
const settings = {
...defaultSettings(),
+216 -11
View File
@@ -997,7 +997,7 @@ describe("deterministic stop and restart lifecycle", () => {
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.setSystemTime(new Date("2026-08-22T08:00:00.000Z"));
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();
expect(waiting).toMatchObject({
canStart: false,
canStart: true,
lifecycle: {
phase: "waiting_provider",
phase: "idle",
retryAt: Date.parse("2026-08-22T08:00:01.000Z")
}
});
await vi.advanceTimersByTimeAsync(999);
expect(events.some((snapshot) => snapshot.canStart)).toBe(false);
await vi.advanceTimersByTimeAsync(1);
expect(events.at(-1)).toMatchObject({
canStart: true,
@@ -2390,6 +2389,189 @@ describe("download manager", () => {
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", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-startup-dup-merge-"));
tempDirs.push(root);
@@ -6833,6 +7015,7 @@ describe("download manager", () => {
itemIds.map((itemId) => session.items[itemId]!),
{
archiveName: "show.s01e01.part1.rar",
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file",
category: "crc_error",
suggestRedownload: true,
@@ -6926,6 +7109,7 @@ describe("download manager", () => {
itemIds.map((itemId) => session.items[itemId]!),
{
archiveName: "show.s01e01.part1.rar",
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file",
category: "crc_error",
suggestRedownload: true,
@@ -7019,6 +7203,7 @@ describe("download manager", () => {
itemIds.map((itemId) => session.items[itemId]!),
{
archiveName: "show.s01e01.part1.rar",
archivePath: path.join(outputDir, "show.s01e01.part1.rar"),
errorText: "Checksum error in the encrypted file",
category: "crc_error",
suggestRedownload: true,
@@ -7358,7 +7543,7 @@ describe("download manager", () => {
}
completedItems[0].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, "");
return completedItems.filter((item: any) => String(item.fileName || "").toLowerCase().startsWith(`${base}.part`));
};
@@ -7367,7 +7552,11 @@ describe("download manager", () => {
{},
completedItems,
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",
previousStatuses,
createdAt + 5_000
@@ -7415,8 +7604,12 @@ describe("download manager", () => {
(DownloadManager.prototype as any).applyPackageExtractFailureStatuses.call(
{},
completedItems,
(archiveName: string) => resolveArchiveItemsFromList(archiveName, completedItems),
new Map([["show.s01e01.part1.rar", "Checksum error in the encrypted file"]]),
(archiveName: string, archivePath: string) => resolveArchiveItemsFromList(archiveName, completedItems, archivePath),
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",
previousStatuses,
createdAt + 5_000
@@ -8231,6 +8424,8 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state"))
);
(manager as any).manualExtractArchiveFilters.set(packageId, new Set([targetPath]));
(manager as any).manualExtractPackages.add(packageId);
manager.clearAll();
const snapshot = manager.getSnapshot();
expect(snapshot.stats.totalPackages).toBe(0);
@@ -8238,6 +8433,8 @@ describe("download manager", () => {
expect(snapshot.stats.totalDownloaded).toBe(0);
expect(snapshot.session.totalDownloadedBytes).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", () => {
@@ -9237,7 +9434,7 @@ describe("download manager", () => {
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-"));
tempDirs.push(root);
const session = emptySession();
@@ -9293,7 +9490,9 @@ describe("download manager", () => {
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;
expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({
@@ -9309,9 +9508,15 @@ describe("download manager", () => {
progressPercent: 0,
lastError: "",
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 () => {
+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);
}
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 {
return {
...buildDownloadsViewModel(input),
@@ -1523,6 +1553,9 @@ describe("download table row contracts", () => {
expect(getAvailabilitySummary([
item("unknown-a", "package-a", "queued", { onlineStatus: undefined })
])).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", () => {
@@ -1549,6 +1582,20 @@ describe("download table row contracts", () => {
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", () => {
const onlineItem = item("online-file", "package-a", "queued", { onlineStatus: "online" });
const packageHtml = renderToStaticMarkup(PackageCardContent({
@@ -1613,7 +1660,7 @@ describe("download table row contracts", () => {
selectedVersion: 0
}));
expect(html).toContain(">70%</b>");
expect(html).toContain(">94%</b>");
});
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="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(calls).toEqual([
["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", () => {
const calls: Array<[string, number, number]> = [];
const header = DownloadsTableHeader({
@@ -2088,7 +2210,7 @@ describe("download table row contracts", () => {
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", () => {
@@ -2163,7 +2285,7 @@ describe("download table row contracts", () => {
selectedIds: new Set<string>(),
selectedVersion: 0
}));
expect(errorHtml).toMatch(/>Entpack-Fehler<\/span>/);
expect(errorHtml).toMatch(/>Download fertig · 1 Entpackfehler<\/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).toContain('title="0/1"');
expect(html).toContain('title="0/1 fertig"');
});
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"],
["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"],
["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%"],
["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"],
+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 () => {
let releaseLogin: () => void = () => {};
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", () => {
const item = downloadItem("item-1", "failed");
const result = finalizePackageResult(telemetry({
+105 -1
View File
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { resolveArchiveItemsFromList } from "../src/main/download-manager";
import {
markPlannedHybridArchiveItemsPending,
resolveArchiveItemsFromList,
resolveSelectedArchiveSetsFromCandidates,
} from "../src/main/download-manager";
type MinimalItem = {
targetPath?: string;
@@ -154,4 +158,104 @@ describe("resolveArchiveItemsFromList", () => {
expect(result2).toHaveLength(2);
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}`);
}
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[]> {
if (!client) throw new Error("Chrome DevTools client is missing");
return client.evaluate<DisclosureSample[]>(`(async () => {
+13 -4
View File
@@ -359,10 +359,19 @@ export function createVisualElectronApi(
entry.status = "extracting";
}
},
extractNow: async (packageId) => {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
entry.status = "extracting";
extractNow: async (request) => {
for (const packageId of request.packageIds) {
const entry = fixture.snapshot.session.packages[packageId];
if (entry) {
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) => {