Migrate app to Node Electron with modern React UI
Build and Release / build (push) Has been cancelled
Build and Release / build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,846 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { AppSettings, DownloadItem, DownloadSummary, DownloadStatus, PackageEntry, ParsedPackageInput, SessionState, UiSnapshot } from "../shared/types";
|
||||
import { CHUNK_SIZE, REQUEST_RETRIES } from "./constants";
|
||||
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "./cleanup";
|
||||
import { extractPackageArchives } from "./extractor";
|
||||
import { validateFileAgainstManifest } from "./integrity";
|
||||
import { logger } from "./logger";
|
||||
import { RealDebridClient } from "./realdebrid";
|
||||
import { StoragePaths, saveSession } from "./storage";
|
||||
import { compactErrorText, ensureDirPath, filenameFromUrl, formatEta, humanSize, nowMs, sanitizeFilename, sleep } from "./utils";
|
||||
|
||||
type ActiveTask = {
|
||||
itemId: string;
|
||||
packageId: string;
|
||||
abortController: AbortController;
|
||||
abortReason: "stop" | "cancel" | "reconnect" | "none";
|
||||
resumable: boolean;
|
||||
speedEvents: Array<{ at: number; bytes: number }>;
|
||||
nonResumableCounted: boolean;
|
||||
};
|
||||
|
||||
function cloneSession(session: SessionState): SessionState {
|
||||
return JSON.parse(JSON.stringify(session)) as SessionState;
|
||||
}
|
||||
|
||||
function parseContentRangeTotal(contentRange: string | null): number | null {
|
||||
if (!contentRange) {
|
||||
return null;
|
||||
}
|
||||
const match = contentRange.match(/\/(\d+)$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function canRetryStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500;
|
||||
}
|
||||
|
||||
function isFinishedStatus(status: DownloadStatus): boolean {
|
||||
return status === "completed" || status === "failed" || status === "cancelled";
|
||||
}
|
||||
|
||||
function nextAvailablePath(targetPath: string): string {
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return targetPath;
|
||||
}
|
||||
const parsed = path.parse(targetPath);
|
||||
let i = 1;
|
||||
while (true) {
|
||||
const candidate = path.join(parsed.dir, `${parsed.name} (${i})${parsed.ext}`);
|
||||
if (!fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
export class DownloadManager extends EventEmitter {
|
||||
private settings: AppSettings;
|
||||
|
||||
private session: SessionState;
|
||||
|
||||
private storagePaths: StoragePaths;
|
||||
|
||||
private rdClient: RealDebridClient;
|
||||
|
||||
private activeTasks = new Map<string, ActiveTask>();
|
||||
|
||||
private scheduleRunning = false;
|
||||
|
||||
private persistTimer: NodeJS.Timeout | null = null;
|
||||
|
||||
private speedEvents: Array<{ at: number; bytes: number }> = [];
|
||||
|
||||
private summary: DownloadSummary | null = null;
|
||||
|
||||
private nonResumableActive = 0;
|
||||
|
||||
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths) {
|
||||
super();
|
||||
this.settings = settings;
|
||||
this.session = cloneSession(session);
|
||||
this.storagePaths = storagePaths;
|
||||
this.rdClient = new RealDebridClient(settings.token);
|
||||
this.applyOnStartCleanupPolicy();
|
||||
this.normalizeSessionStatuses();
|
||||
}
|
||||
|
||||
public setSettings(next: AppSettings): void {
|
||||
const tokenChanged = next.token !== this.settings.token;
|
||||
this.settings = next;
|
||||
if (tokenChanged) {
|
||||
this.rdClient = new RealDebridClient(next.token);
|
||||
}
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public getSettings(): AppSettings {
|
||||
return this.settings;
|
||||
}
|
||||
|
||||
public getSession(): SessionState {
|
||||
return cloneSession(this.session);
|
||||
}
|
||||
|
||||
public getSummary(): DownloadSummary | null {
|
||||
return this.summary;
|
||||
}
|
||||
|
||||
public getSnapshot(): UiSnapshot {
|
||||
const now = nowMs();
|
||||
this.speedEvents = this.speedEvents.filter((event) => event.at >= now - 3000);
|
||||
const speedBps = this.speedEvents.reduce((acc, event) => acc + event.bytes, 0) / 3;
|
||||
|
||||
const totalItems = Object.keys(this.session.items).length;
|
||||
const doneItems = Object.values(this.session.items).filter((item) => isFinishedStatus(item.status)).length;
|
||||
const elapsed = this.session.runStartedAt > 0 ? (now - this.session.runStartedAt) / 1000 : 0;
|
||||
const rate = doneItems > 0 && elapsed > 0 ? doneItems / elapsed : 0;
|
||||
const remaining = totalItems - doneItems;
|
||||
const eta = remaining > 0 && rate > 0 ? remaining / rate : -1;
|
||||
|
||||
return {
|
||||
settings: this.settings,
|
||||
session: this.getSession(),
|
||||
summary: this.summary,
|
||||
speedText: `Geschwindigkeit: ${humanSize(Math.max(0, Math.floor(speedBps)))}/s`,
|
||||
etaText: `ETA: ${formatEta(eta)}`,
|
||||
canStart: !this.session.running,
|
||||
canStop: this.session.running,
|
||||
canPause: this.session.running
|
||||
};
|
||||
}
|
||||
|
||||
public clearAll(): void {
|
||||
this.stop();
|
||||
this.session.packageOrder = [];
|
||||
this.session.packages = {};
|
||||
this.session.items = {};
|
||||
this.session.summaryText = "";
|
||||
this.summary = null;
|
||||
this.persistNow();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public addPackages(packages: ParsedPackageInput[]): { addedPackages: number; addedLinks: number } {
|
||||
let addedPackages = 0;
|
||||
let addedLinks = 0;
|
||||
for (const pkg of packages) {
|
||||
const links = pkg.links.filter((link) => !!link.trim());
|
||||
if (links.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const packageId = uuidv4();
|
||||
const outputDir = ensureDirPath(this.settings.outputDir, pkg.name);
|
||||
const extractBase = this.settings.extractDir || path.join(this.settings.outputDir, "_entpackt");
|
||||
const extractDir = this.settings.createExtractSubfolder ? ensureDirPath(extractBase, pkg.name) : extractBase;
|
||||
const packageEntry: PackageEntry = {
|
||||
id: packageId,
|
||||
name: sanitizeFilename(pkg.name),
|
||||
outputDir,
|
||||
extractDir,
|
||||
status: "queued",
|
||||
itemIds: [],
|
||||
cancelled: false,
|
||||
createdAt: nowMs(),
|
||||
updatedAt: nowMs()
|
||||
};
|
||||
|
||||
for (const link of links) {
|
||||
const itemId = uuidv4();
|
||||
const fileName = filenameFromUrl(link);
|
||||
const item: DownloadItem = {
|
||||
id: itemId,
|
||||
packageId,
|
||||
url: link,
|
||||
status: "queued",
|
||||
retries: 0,
|
||||
speedBps: 0,
|
||||
downloadedBytes: 0,
|
||||
totalBytes: null,
|
||||
progressPercent: 0,
|
||||
fileName,
|
||||
targetPath: path.join(outputDir, fileName),
|
||||
resumable: true,
|
||||
attempts: 0,
|
||||
lastError: "",
|
||||
fullStatus: "Wartet",
|
||||
createdAt: nowMs(),
|
||||
updatedAt: nowMs()
|
||||
};
|
||||
packageEntry.itemIds.push(itemId);
|
||||
this.session.items[itemId] = item;
|
||||
addedLinks += 1;
|
||||
}
|
||||
|
||||
this.session.packages[packageId] = packageEntry;
|
||||
this.session.packageOrder.push(packageId);
|
||||
addedPackages += 1;
|
||||
}
|
||||
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return { addedPackages, addedLinks };
|
||||
}
|
||||
|
||||
public cancelPackage(packageId: string): void {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg) {
|
||||
return;
|
||||
}
|
||||
pkg.cancelled = true;
|
||||
pkg.status = "cancelled";
|
||||
pkg.updatedAt = nowMs();
|
||||
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.status === "queued" || item.status === "validating" || item.status === "reconnect_wait") {
|
||||
item.status = "cancelled";
|
||||
item.fullStatus = "Entfernt";
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
const active = this.activeTasks.get(itemId);
|
||||
if (active) {
|
||||
active.abortReason = "cancel";
|
||||
active.abortController.abort("cancel");
|
||||
}
|
||||
}
|
||||
|
||||
const removed = cleanupCancelledPackageArtifacts(pkg.outputDir);
|
||||
logger.info(`Paket ${pkg.name} abgebrochen, ${removed} Artefakte gelöscht`);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
if (this.session.running) {
|
||||
return;
|
||||
}
|
||||
this.session.running = true;
|
||||
this.session.paused = false;
|
||||
this.session.runStartedAt = this.session.runStartedAt || nowMs();
|
||||
this.summary = null;
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
this.ensureScheduler();
|
||||
}
|
||||
|
||||
public stop(): void {
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
for (const active of this.activeTasks.values()) {
|
||||
active.abortReason = "stop";
|
||||
active.abortController.abort("stop");
|
||||
}
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
public togglePause(): boolean {
|
||||
if (!this.session.running) {
|
||||
return false;
|
||||
}
|
||||
this.session.paused = !this.session.paused;
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
return this.session.paused;
|
||||
}
|
||||
|
||||
private normalizeSessionStatuses(): void {
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (item.status === "downloading" || item.status === "validating" || item.status === "extracting" || item.status === "integrity_check") {
|
||||
item.status = "queued";
|
||||
item.speedBps = 0;
|
||||
}
|
||||
}
|
||||
for (const pkg of Object.values(this.session.packages)) {
|
||||
if (pkg.status === "downloading" || pkg.status === "validating" || pkg.status === "extracting" || pkg.status === "integrity_check") {
|
||||
pkg.status = "queued";
|
||||
}
|
||||
}
|
||||
this.persistSoon();
|
||||
}
|
||||
|
||||
private applyOnStartCleanupPolicy(): void {
|
||||
if (this.settings.completedCleanupPolicy !== "on_start") {
|
||||
return;
|
||||
}
|
||||
for (const pkgId of [...this.session.packageOrder]) {
|
||||
const pkg = this.session.packages[pkgId];
|
||||
if (!pkg) {
|
||||
continue;
|
||||
}
|
||||
pkg.itemIds = pkg.itemIds.filter((itemId) => {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
if (item.status === "completed") {
|
||||
delete this.session.items[itemId];
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (pkg.itemIds.length === 0) {
|
||||
delete this.session.packages[pkgId];
|
||||
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== pkgId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private persistSoon(): void {
|
||||
if (this.persistTimer) {
|
||||
return;
|
||||
}
|
||||
this.persistTimer = setTimeout(() => {
|
||||
this.persistTimer = null;
|
||||
this.persistNow();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
private persistNow(): void {
|
||||
saveSession(this.storagePaths, this.session);
|
||||
}
|
||||
|
||||
private emitState(): void {
|
||||
this.emit("state", this.getSnapshot());
|
||||
}
|
||||
|
||||
private async ensureScheduler(): Promise<void> {
|
||||
if (this.scheduleRunning) {
|
||||
return;
|
||||
}
|
||||
this.scheduleRunning = true;
|
||||
try {
|
||||
while (this.session.running) {
|
||||
if (this.session.paused) {
|
||||
await sleep(120);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) {
|
||||
this.markQueuedAsReconnectWait();
|
||||
await sleep(200);
|
||||
continue;
|
||||
}
|
||||
|
||||
while (this.activeTasks.size < Math.max(1, this.settings.maxParallel)) {
|
||||
const next = this.findNextQueuedItem();
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
this.startItem(next.packageId, next.itemId);
|
||||
}
|
||||
|
||||
if (this.activeTasks.size === 0 && !this.hasQueuedItems()) {
|
||||
this.finishRun();
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(120);
|
||||
}
|
||||
} finally {
|
||||
this.scheduleRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private reconnectActive(): boolean {
|
||||
return this.session.reconnectUntil > nowMs();
|
||||
}
|
||||
|
||||
private requestReconnect(reason: string): void {
|
||||
if (!this.settings.autoReconnect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const until = nowMs() + this.settings.reconnectWaitSeconds * 1000;
|
||||
this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until);
|
||||
this.session.reconnectReason = reason;
|
||||
|
||||
for (const active of this.activeTasks.values()) {
|
||||
if (active.resumable) {
|
||||
active.abortReason = "reconnect";
|
||||
active.abortController.abort("reconnect");
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`Reconnect angefordert: ${reason}`);
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
private markQueuedAsReconnectWait(): void {
|
||||
for (const item of Object.values(this.session.items)) {
|
||||
if (item.status === "queued") {
|
||||
item.status = "reconnect_wait";
|
||||
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
|
||||
item.updatedAt = nowMs();
|
||||
}
|
||||
}
|
||||
this.emitState();
|
||||
}
|
||||
|
||||
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
|
||||
for (const packageId of this.session.packageOrder) {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg || pkg.cancelled) {
|
||||
continue;
|
||||
}
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = this.session.items[itemId];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (item.status === "queued" || item.status === "reconnect_wait") {
|
||||
return { packageId, itemId };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private hasQueuedItems(): boolean {
|
||||
return Object.values(this.session.items).some((item) => item.status === "queued" || item.status === "reconnect_wait");
|
||||
}
|
||||
|
||||
private startItem(packageId: string, itemId: string): void {
|
||||
const item = this.session.items[itemId];
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!item || !pkg || pkg.cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.status = "validating";
|
||||
item.fullStatus = "Link wird via Real-Debrid umgewandelt";
|
||||
item.updatedAt = nowMs();
|
||||
pkg.status = "downloading";
|
||||
pkg.updatedAt = nowMs();
|
||||
|
||||
const active: ActiveTask = {
|
||||
itemId,
|
||||
packageId,
|
||||
abortController: new AbortController(),
|
||||
abortReason: "none",
|
||||
resumable: true,
|
||||
speedEvents: [],
|
||||
nonResumableCounted: false
|
||||
};
|
||||
this.activeTasks.set(itemId, active);
|
||||
this.emitState();
|
||||
|
||||
void this.processItem(active).finally(() => {
|
||||
if (active.nonResumableCounted) {
|
||||
this.nonResumableActive = Math.max(0, this.nonResumableActive - 1);
|
||||
}
|
||||
this.activeTasks.delete(itemId);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
});
|
||||
}
|
||||
|
||||
private async processItem(active: ActiveTask): Promise<void> {
|
||||
const item = this.session.items[active.itemId];
|
||||
const pkg = this.session.packages[active.packageId];
|
||||
if (!item || !pkg) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const unrestricted = await this.rdClient.unrestrictLink(item.url);
|
||||
item.retries = unrestricted.retriesUsed;
|
||||
item.fileName = sanitizeFilename(unrestricted.fileName || filenameFromUrl(item.url));
|
||||
fs.mkdirSync(pkg.outputDir, { recursive: true });
|
||||
item.targetPath = nextAvailablePath(path.join(pkg.outputDir, item.fileName));
|
||||
item.totalBytes = unrestricted.fileSize;
|
||||
item.status = "downloading";
|
||||
item.fullStatus = "Download läuft";
|
||||
item.updatedAt = nowMs();
|
||||
this.emitState();
|
||||
|
||||
const maxAttempts = REQUEST_RETRIES;
|
||||
let done = false;
|
||||
let downloadRetries = 0;
|
||||
while (!done && item.attempts < maxAttempts) {
|
||||
item.attempts += 1;
|
||||
const result = await this.downloadToFile(active, unrestricted.directUrl, item.targetPath, item.totalBytes);
|
||||
downloadRetries += result.retriesUsed;
|
||||
active.resumable = result.resumable;
|
||||
if (!active.resumable && !active.nonResumableCounted) {
|
||||
active.nonResumableCounted = true;
|
||||
this.nonResumableActive += 1;
|
||||
}
|
||||
|
||||
if (this.settings.enableIntegrityCheck) {
|
||||
item.status = "integrity_check";
|
||||
item.fullStatus = "CRC-Check läuft";
|
||||
item.updatedAt = nowMs();
|
||||
this.emitState();
|
||||
|
||||
const validation = await validateFileAgainstManifest(item.targetPath, pkg.outputDir);
|
||||
if (!validation.ok) {
|
||||
item.lastError = validation.message;
|
||||
item.fullStatus = `${validation.message}, Neuversuch`;
|
||||
try {
|
||||
fs.rmSync(item.targetPath, { force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (item.attempts < maxAttempts) {
|
||||
item.status = "queued";
|
||||
item.progressPercent = 0;
|
||||
item.downloadedBytes = 0;
|
||||
item.totalBytes = unrestricted.fileSize;
|
||||
this.emitState();
|
||||
await sleep(300);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Integritätsprüfung fehlgeschlagen (${validation.message})`);
|
||||
}
|
||||
}
|
||||
|
||||
done = true;
|
||||
}
|
||||
|
||||
item.retries += downloadRetries;
|
||||
item.status = "completed";
|
||||
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
|
||||
item.progressPercent = 100;
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
pkg.updatedAt = nowMs();
|
||||
|
||||
await this.handlePackagePostProcessing(pkg.id);
|
||||
this.applyCompletedCleanupPolicy(pkg.id, item.id);
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
} catch (error) {
|
||||
const reason = active.abortReason;
|
||||
if (reason === "cancel") {
|
||||
item.status = "cancelled";
|
||||
item.fullStatus = "Entfernt";
|
||||
} else if (reason === "stop") {
|
||||
item.status = "cancelled";
|
||||
item.fullStatus = "Gestoppt";
|
||||
} else if (reason === "reconnect") {
|
||||
item.status = "queued";
|
||||
item.fullStatus = "Wartet auf Reconnect";
|
||||
} else {
|
||||
item.status = "failed";
|
||||
item.lastError = compactErrorText(error);
|
||||
item.fullStatus = `Fehler: ${item.lastError}`;
|
||||
}
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
this.persistSoon();
|
||||
this.emitState();
|
||||
}
|
||||
}
|
||||
|
||||
private async downloadToFile(
|
||||
active: ActiveTask,
|
||||
directUrl: string,
|
||||
targetPath: string,
|
||||
knownTotal: number | null
|
||||
): Promise<{ retriesUsed: number; resumable: boolean }> {
|
||||
const item = this.session.items[active.itemId];
|
||||
if (!item) {
|
||||
throw new Error("Download-Item fehlt");
|
||||
}
|
||||
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= REQUEST_RETRIES; attempt += 1) {
|
||||
const existingBytes = fs.existsSync(targetPath) ? fs.statSync(targetPath).size : 0;
|
||||
const headers: Record<string, string> = {};
|
||||
if (existingBytes > 0) {
|
||||
headers.Range = `bytes=${existingBytes}-`;
|
||||
}
|
||||
|
||||
if (this.reconnectActive()) {
|
||||
await sleep(250);
|
||||
continue;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(directUrl, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: active.abortController.signal
|
||||
});
|
||||
} catch (error) {
|
||||
lastError = compactErrorText(error);
|
||||
if (attempt < REQUEST_RETRIES) {
|
||||
item.fullStatus = `Verbindungsfehler, retry ${attempt + 1}/${REQUEST_RETRIES}`;
|
||||
this.emitState();
|
||||
await sleep(300 * attempt);
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
lastError = compactErrorText(text || `HTTP ${response.status}`);
|
||||
if (this.settings.autoReconnect && [429, 503].includes(response.status)) {
|
||||
this.requestReconnect(`HTTP ${response.status}`);
|
||||
}
|
||||
if (canRetryStatus(response.status) && attempt < REQUEST_RETRIES) {
|
||||
item.fullStatus = `Serverfehler ${response.status}, retry ${attempt + 1}/${REQUEST_RETRIES}`;
|
||||
this.emitState();
|
||||
await sleep(350 * attempt);
|
||||
continue;
|
||||
}
|
||||
throw new Error(lastError);
|
||||
}
|
||||
|
||||
const acceptRanges = (response.headers.get("accept-ranges") || "").toLowerCase().includes("bytes");
|
||||
const resumable = response.status === 206 || acceptRanges;
|
||||
active.resumable = resumable;
|
||||
|
||||
const contentLength = Number(response.headers.get("content-length") || 0);
|
||||
const totalFromRange = parseContentRangeTotal(response.headers.get("content-range"));
|
||||
if (knownTotal && knownTotal > 0) {
|
||||
item.totalBytes = knownTotal;
|
||||
} else if (totalFromRange) {
|
||||
item.totalBytes = totalFromRange;
|
||||
} else if (contentLength > 0) {
|
||||
item.totalBytes = existingBytes + contentLength;
|
||||
}
|
||||
|
||||
const writeMode = existingBytes > 0 && response.status === 206 ? "a" : "w";
|
||||
if (writeMode === "w" && existingBytes > 0) {
|
||||
fs.rmSync(targetPath, { force: true });
|
||||
}
|
||||
|
||||
const stream = fs.createWriteStream(targetPath, { flags: writeMode });
|
||||
let written = writeMode === "a" ? existingBytes : 0;
|
||||
let windowBytes = 0;
|
||||
let windowStarted = nowMs();
|
||||
|
||||
try {
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
throw new Error("Leerer Response-Body");
|
||||
}
|
||||
const reader = body.getReader();
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
const chunk = value;
|
||||
if (active.abortController.signal.aborted) {
|
||||
throw new Error(`aborted:${active.abortReason}`);
|
||||
}
|
||||
while (this.session.paused && this.session.running && !active.abortController.signal.aborted) {
|
||||
item.status = "paused";
|
||||
item.fullStatus = "Pausiert";
|
||||
this.emitState();
|
||||
await sleep(120);
|
||||
}
|
||||
if (this.reconnectActive() && active.resumable) {
|
||||
active.abortReason = "reconnect";
|
||||
active.abortController.abort("reconnect");
|
||||
throw new Error("aborted:reconnect");
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(chunk);
|
||||
await this.applySpeedLimit(buffer.length, windowBytes, windowStarted);
|
||||
stream.write(buffer);
|
||||
written += buffer.length;
|
||||
windowBytes += buffer.length;
|
||||
this.session.totalDownloadedBytes += buffer.length;
|
||||
this.speedEvents.push({ at: nowMs(), bytes: buffer.length });
|
||||
this.speedEvents = this.speedEvents.filter((event) => event.at >= nowMs() - 3000);
|
||||
|
||||
const elapsed = Math.max((nowMs() - windowStarted) / 1000, 0.1);
|
||||
const speed = windowBytes / elapsed;
|
||||
if (elapsed >= 1.2) {
|
||||
windowStarted = nowMs();
|
||||
windowBytes = 0;
|
||||
}
|
||||
|
||||
item.status = "downloading";
|
||||
item.speedBps = Math.max(0, Math.floor(speed));
|
||||
item.downloadedBytes = written;
|
||||
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0;
|
||||
item.fullStatus = "Download läuft";
|
||||
item.updatedAt = nowMs();
|
||||
this.emitState();
|
||||
}
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.end(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
item.downloadedBytes = written;
|
||||
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 100;
|
||||
item.speedBps = 0;
|
||||
item.updatedAt = nowMs();
|
||||
return { retriesUsed: attempt - 1, resumable };
|
||||
}
|
||||
|
||||
throw new Error(lastError || "Download fehlgeschlagen");
|
||||
}
|
||||
|
||||
private async applySpeedLimit(chunkBytes: number, localWindowBytes: number, localWindowStarted: number): Promise<void> {
|
||||
if (!this.settings.speedLimitEnabled || this.settings.speedLimitKbps <= 0) {
|
||||
return;
|
||||
}
|
||||
const bytesPerSecond = this.settings.speedLimitKbps * 1024;
|
||||
const now = nowMs();
|
||||
const elapsed = Math.max((now - localWindowStarted) / 1000, 0.1);
|
||||
if (this.settings.speedLimitMode === "per_download") {
|
||||
const projected = localWindowBytes + chunkBytes;
|
||||
const allowed = bytesPerSecond * elapsed;
|
||||
if (projected > allowed) {
|
||||
const sleepMs = Math.ceil(((projected - allowed) / bytesPerSecond) * 1000);
|
||||
if (sleepMs > 0) {
|
||||
await sleep(Math.min(300, sleepMs));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const globalBytes = this.speedEvents.reduce((acc, event) => acc + event.bytes, 0) + chunkBytes;
|
||||
const globalAllowed = bytesPerSecond * 3;
|
||||
if (globalBytes > globalAllowed) {
|
||||
await sleep(Math.min(250, Math.ceil(((globalBytes - globalAllowed) / bytesPerSecond) * 1000)));
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePackagePostProcessing(packageId: string): Promise<void> {
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg || pkg.cancelled) {
|
||||
return;
|
||||
}
|
||||
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
||||
const success = items.filter((item) => item.status === "completed").length;
|
||||
const failed = items.filter((item) => item.status === "failed").length;
|
||||
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||
|
||||
if (success + failed + cancelled < items.length) {
|
||||
pkg.status = "downloading";
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.settings.autoExtract && failed === 0 && success > 0) {
|
||||
pkg.status = "extracting";
|
||||
this.emitState();
|
||||
const result = await extractPackageArchives({
|
||||
packageDir: pkg.outputDir,
|
||||
targetDir: pkg.extractDir,
|
||||
cleanupMode: this.settings.cleanupMode,
|
||||
conflictMode: this.settings.extractConflictMode,
|
||||
removeLinks: this.settings.removeLinkFilesAfterExtract,
|
||||
removeSamples: this.settings.removeSamplesAfterExtract
|
||||
});
|
||||
if (result.failed > 0) {
|
||||
pkg.status = "failed";
|
||||
} else {
|
||||
pkg.status = "completed";
|
||||
}
|
||||
} else if (failed > 0) {
|
||||
pkg.status = "failed";
|
||||
} else if (cancelled > 0 && success === 0) {
|
||||
pkg.status = "cancelled";
|
||||
} else {
|
||||
pkg.status = "completed";
|
||||
}
|
||||
pkg.updatedAt = nowMs();
|
||||
}
|
||||
|
||||
private applyCompletedCleanupPolicy(packageId: string, itemId: string): void {
|
||||
const policy = this.settings.completedCleanupPolicy;
|
||||
if (policy === "never" || policy === "on_start") {
|
||||
return;
|
||||
}
|
||||
|
||||
const pkg = this.session.packages[packageId];
|
||||
if (!pkg) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (policy === "immediate") {
|
||||
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
||||
delete this.session.items[itemId];
|
||||
}
|
||||
|
||||
if (policy === "package_done") {
|
||||
const hasOpen = pkg.itemIds.some((id) => {
|
||||
const item = this.session.items[id];
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
return item.status !== "completed";
|
||||
});
|
||||
if (!hasOpen) {
|
||||
for (const id of pkg.itemIds) {
|
||||
delete this.session.items[id];
|
||||
}
|
||||
delete this.session.packages[packageId];
|
||||
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
|
||||
}
|
||||
}
|
||||
|
||||
if (pkg.itemIds.length === 0) {
|
||||
delete this.session.packages[packageId];
|
||||
this.session.packageOrder = this.session.packageOrder.filter((id) => id !== packageId);
|
||||
}
|
||||
}
|
||||
|
||||
private finishRun(): void {
|
||||
this.session.running = false;
|
||||
this.session.paused = false;
|
||||
const items = Object.values(this.session.items);
|
||||
const total = items.length;
|
||||
const success = items.filter((item) => item.status === "completed").length;
|
||||
const failed = items.filter((item) => item.status === "failed").length;
|
||||
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||
const extracted = Object.values(this.session.packages).filter((pkg) => pkg.status === "completed").length;
|
||||
const duration = this.session.runStartedAt > 0 ? Math.max(1, Math.floor((nowMs() - this.session.runStartedAt) / 1000)) : 1;
|
||||
const avgSpeed = Math.floor(this.session.totalDownloadedBytes / duration);
|
||||
this.summary = {
|
||||
total,
|
||||
success,
|
||||
failed,
|
||||
cancelled,
|
||||
extracted,
|
||||
durationSeconds: duration,
|
||||
averageSpeedBps: avgSpeed
|
||||
};
|
||||
this.session.summaryText = `Summary: Dauer ${duration}s, Ø Speed ${humanSize(avgSpeed)}/s, Erfolg ${success}/${Math.max(total, 1)}`;
|
||||
this.persistNow();
|
||||
this.emitState();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user