Release v1.4.6 with extraction resume safety and smoother runtime
This commit is contained in:
parent
05a75d0ac5
commit
c8385cfb2f
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.5",
|
"version": "1.4.6",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.5",
|
"version": "1.4.6",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.5",
|
"version": "1.4.6",
|
||||||
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -175,6 +175,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private packagePostProcessTasks = new Map<string, Promise<void>>();
|
private packagePostProcessTasks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
private packagePostProcessAbortControllers = new Map<string, AbortController>();
|
||||||
|
|
||||||
private reservedTargetPaths = new Map<string, string>();
|
private reservedTargetPaths = new Map<string, string>();
|
||||||
|
|
||||||
private claimedTargetPathByItem = new Map<string, string>();
|
private claimedTargetPathByItem = new Map<string, string>();
|
||||||
@ -189,6 +191,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private lastSchedulerHeartbeatAt = 0;
|
private lastSchedulerHeartbeatAt = 0;
|
||||||
|
|
||||||
|
private lastReconnectMarkAt = 0;
|
||||||
|
|
||||||
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
|
public constructor(settings: AppSettings, session: SessionState, storagePaths: StoragePaths, options: DownloadManagerOptions = {}) {
|
||||||
super();
|
super();
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
@ -435,6 +439,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
public clearAll(): void {
|
public clearAll(): void {
|
||||||
this.stop();
|
this.stop();
|
||||||
|
this.abortPostProcessing("clear_all");
|
||||||
this.session.packageOrder = [];
|
this.session.packageOrder = [];
|
||||||
this.session.packages = {};
|
this.session.packages = {};
|
||||||
this.session.items = {};
|
this.session.items = {};
|
||||||
@ -446,6 +451,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.reservedTargetPaths.clear();
|
this.reservedTargetPaths.clear();
|
||||||
this.claimedTargetPathByItem.clear();
|
this.claimedTargetPathByItem.clear();
|
||||||
this.packagePostProcessTasks.clear();
|
this.packagePostProcessTasks.clear();
|
||||||
|
this.packagePostProcessAbortControllers.clear();
|
||||||
this.packagePostProcessQueue = Promise.resolve();
|
this.packagePostProcessQueue = Promise.resolve();
|
||||||
this.summary = null;
|
this.summary = null;
|
||||||
this.persistNow();
|
this.persistNow();
|
||||||
@ -764,6 +770,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!pkg || pkg.cancelled || pkg.status !== "completed") {
|
if (!pkg || pkg.cancelled || pkg.status !== "completed") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (this.packagePostProcessTasks.has(packageId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const items = pkg.itemIds
|
const items = pkg.itemIds
|
||||||
.map((itemId) => this.session.items[itemId])
|
.map((itemId) => this.session.items[itemId])
|
||||||
@ -995,6 +1004,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.summaryText = "";
|
this.session.summaryText = "";
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
|
this.lastReconnectMarkAt = 0;
|
||||||
this.speedEvents = [];
|
this.speedEvents = [];
|
||||||
this.speedBytesLastWindow = 0;
|
this.speedBytesLastWindow = 0;
|
||||||
this.summary = null;
|
this.summary = null;
|
||||||
@ -1008,6 +1018,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
|
this.abortPostProcessing("stop");
|
||||||
for (const active of this.activeTasks.values()) {
|
for (const active of this.activeTasks.values()) {
|
||||||
active.abortReason = "stop";
|
active.abortReason = "stop";
|
||||||
active.abortController.abort("stop");
|
active.abortController.abort("stop");
|
||||||
@ -1022,6 +1033,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
|
this.abortPostProcessing("shutdown");
|
||||||
|
|
||||||
let requeuedItems = 0;
|
let requeuedItems = 0;
|
||||||
for (const active of this.activeTasks.values()) {
|
for (const active of this.activeTasks.values()) {
|
||||||
@ -1050,6 +1062,18 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const item of Object.values(this.session.items)) {
|
||||||
|
if (item.status === "completed" && /^Entpacken/i.test(item.fullStatus || "")) {
|
||||||
|
item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
const pkg = this.session.packages[item.packageId];
|
||||||
|
if (pkg) {
|
||||||
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.speedEvents = [];
|
this.speedEvents = [];
|
||||||
this.speedBytesLastWindow = 0;
|
this.speedBytesLastWindow = 0;
|
||||||
this.runItemIds.clear();
|
this.runItemIds.clear();
|
||||||
@ -1288,22 +1312,55 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.claimedTargetPathByItem.delete(itemId);
|
this.claimedTargetPathByItem.delete(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private abortPostProcessing(reason: string): void {
|
||||||
|
for (const [packageId, controller] of this.packagePostProcessAbortControllers.entries()) {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
controller.abort(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkg = this.session.packages[packageId];
|
||||||
|
if (!pkg) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pkg.status === "extracting" || pkg.status === "integrity_check") {
|
||||||
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const itemId of pkg.itemIds) {
|
||||||
|
const item = this.session.items[itemId];
|
||||||
|
if (!item || item.status !== "completed") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^Entpacken/i.test(item.fullStatus || "")) {
|
||||||
|
item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private runPackagePostProcessing(packageId: string): Promise<void> {
|
private runPackagePostProcessing(packageId: string): Promise<void> {
|
||||||
const existing = this.packagePostProcessTasks.get(packageId);
|
const existing = this.packagePostProcessTasks.get(packageId);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
this.packagePostProcessAbortControllers.set(packageId, abortController);
|
||||||
|
|
||||||
const task = this.packagePostProcessQueue
|
const task = this.packagePostProcessQueue
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
await this.handlePackagePostProcessing(packageId);
|
await this.handlePackagePostProcessing(packageId, abortController.signal);
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`);
|
logger.warn(`Post-Processing für Paket fehlgeschlagen: ${compactErrorText(error)}`);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.packagePostProcessTasks.delete(packageId);
|
this.packagePostProcessTasks.delete(packageId);
|
||||||
|
this.packagePostProcessAbortControllers.delete(packageId);
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
});
|
});
|
||||||
@ -1393,8 +1450,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) {
|
if (this.reconnectActive() && (this.nonResumableActive > 0 || this.activeTasks.size === 0)) {
|
||||||
this.markQueuedAsReconnectWait();
|
const markNow = nowMs();
|
||||||
await sleep(200);
|
if (markNow - this.lastReconnectMarkAt >= 900) {
|
||||||
|
this.lastReconnectMarkAt = markNow;
|
||||||
|
const changed = this.markQueuedAsReconnectWait();
|
||||||
|
if (!changed) {
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await sleep(220);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1431,6 +1495,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const until = nowMs() + this.settings.reconnectWaitSeconds * 1000;
|
const until = nowMs() + this.settings.reconnectWaitSeconds * 1000;
|
||||||
this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until);
|
this.session.reconnectUntil = Math.max(this.session.reconnectUntil, until);
|
||||||
this.session.reconnectReason = reason;
|
this.session.reconnectReason = reason;
|
||||||
|
this.lastReconnectMarkAt = 0;
|
||||||
|
|
||||||
for (const active of this.activeTasks.values()) {
|
for (const active of this.activeTasks.values()) {
|
||||||
if (active.resumable) {
|
if (active.resumable) {
|
||||||
@ -1443,7 +1508,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private markQueuedAsReconnectWait(): void {
|
private markQueuedAsReconnectWait(): boolean {
|
||||||
|
let changed = false;
|
||||||
for (const item of Object.values(this.session.items)) {
|
for (const item of Object.values(this.session.items)) {
|
||||||
const pkg = this.session.packages[item.packageId];
|
const pkg = this.session.packages[item.packageId];
|
||||||
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
||||||
@ -1453,9 +1519,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.status = "reconnect_wait";
|
item.status = "reconnect_wait";
|
||||||
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
|
item.fullStatus = `Reconnect-Wait (${Math.ceil((this.session.reconnectUntil - nowMs()) / 1000)}s)`;
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.emitState();
|
if (changed) {
|
||||||
|
this.emitState();
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
|
private findNextQueuedItem(): { packageId: string; itemId: string } | null {
|
||||||
@ -1923,6 +1993,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
let written = writeMode === "a" ? existingBytes : 0;
|
let written = writeMode === "a" ? existingBytes : 0;
|
||||||
let windowBytes = 0;
|
let windowBytes = 0;
|
||||||
let windowStarted = nowMs();
|
let windowStarted = nowMs();
|
||||||
|
const itemCount = Object.keys(this.session.items).length;
|
||||||
|
const uiUpdateIntervalMs = itemCount >= 1500
|
||||||
|
? 650
|
||||||
|
: itemCount >= 700
|
||||||
|
? 420
|
||||||
|
: itemCount >= 250
|
||||||
|
? 280
|
||||||
|
: 170;
|
||||||
|
let lastUiEmitAt = 0;
|
||||||
|
let lastProgressPercent = item.progressPercent;
|
||||||
|
|
||||||
const waitDrain = (): Promise<void> => new Promise((resolve, reject) => {
|
const waitDrain = (): Promise<void> => new Promise((resolve, reject) => {
|
||||||
const onDrain = (): void => {
|
const onDrain = (): void => {
|
||||||
@ -2027,8 +2107,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.downloadedBytes = written;
|
item.downloadedBytes = written;
|
||||||
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0;
|
item.progressPercent = item.totalBytes ? Math.max(0, Math.min(100, Math.floor((written / item.totalBytes) * 100))) : 0;
|
||||||
item.fullStatus = `Download läuft (${providerLabel(item.provider)})`;
|
item.fullStatus = `Download läuft (${providerLabel(item.provider)})`;
|
||||||
item.updatedAt = nowMs();
|
const nowTick = nowMs();
|
||||||
this.emitState();
|
const progressChanged = item.progressPercent !== lastProgressPercent;
|
||||||
|
if (progressChanged || nowTick - lastUiEmitAt >= uiUpdateIntervalMs) {
|
||||||
|
item.updatedAt = nowTick;
|
||||||
|
this.emitState();
|
||||||
|
lastUiEmitAt = nowTick;
|
||||||
|
lastProgressPercent = item.progressPercent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@ -2274,11 +2360,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handlePackagePostProcessing(packageId: string): Promise<void> {
|
private async handlePackagePostProcessing(packageId: string, signal?: AbortSignal): Promise<void> {
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
if (!pkg || pkg.cancelled) {
|
if (!pkg || pkg.cancelled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
const items = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
||||||
const success = items.filter((item) => item.status === "completed").length;
|
const success = items.filter((item) => item.status === "completed").length;
|
||||||
const failed = items.filter((item) => item.status === "failed").length;
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
@ -2299,9 +2388,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.emitState();
|
this.emitState();
|
||||||
|
|
||||||
const updateExtractingStatus = (text: string): void => {
|
const updateExtractingStatus = (text: string): void => {
|
||||||
|
const updatedAt = nowMs();
|
||||||
for (const entry of completedItems) {
|
for (const entry of completedItems) {
|
||||||
|
if (entry.fullStatus === text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
entry.fullStatus = text;
|
entry.fullStatus = text;
|
||||||
entry.updatedAt = nowMs();
|
entry.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2317,10 +2410,19 @@ export class DownloadManager extends EventEmitter {
|
|||||||
removeLinks: this.settings.removeLinkFilesAfterExtract,
|
removeLinks: this.settings.removeLinkFilesAfterExtract,
|
||||||
removeSamples: this.settings.removeSamplesAfterExtract,
|
removeSamples: this.settings.removeSamplesAfterExtract,
|
||||||
passwordList: this.settings.archivePasswordList,
|
passwordList: this.settings.archivePasswordList,
|
||||||
|
signal,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
const label = progress.phase === "done"
|
const label = progress.phase === "done"
|
||||||
? "Entpacken 100%"
|
? "Entpacken 100%"
|
||||||
: `Entpacken ${progress.percent}% (${progress.current}/${progress.total})`;
|
: (() => {
|
||||||
|
const archive = progress.archiveName ? ` · ${progress.archiveName}` : "";
|
||||||
|
const elapsed = progress.elapsedMs && progress.elapsedMs >= 1000
|
||||||
|
? ` · ${Math.floor(progress.elapsedMs / 1000)}s`
|
||||||
|
: "";
|
||||||
|
const activeArchive = Number(progress.archivePercent ?? 0) > 0 ? 1 : 0;
|
||||||
|
const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive));
|
||||||
|
return `Entpacken ${progress.percent}% (${currentDisplay}/${progress.total})${archive}${elapsed}`;
|
||||||
|
})();
|
||||||
updateExtractingStatus(label);
|
updateExtractingStatus(label);
|
||||||
this.emitState();
|
this.emitState();
|
||||||
}
|
}
|
||||||
@ -2343,6 +2445,19 @@ export class DownloadManager extends EventEmitter {
|
|||||||
pkg.status = "completed";
|
pkg.status = "completed";
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const reasonRaw = String(error || "");
|
||||||
|
if (reasonRaw.includes("aborted:extract")) {
|
||||||
|
for (const entry of completedItems) {
|
||||||
|
if (/^Entpacken/i.test(entry.fullStatus || "")) {
|
||||||
|
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
||||||
|
}
|
||||||
|
entry.updatedAt = nowMs();
|
||||||
|
}
|
||||||
|
pkg.status = pkg.enabled ? "queued" : "paused";
|
||||||
|
pkg.updatedAt = nowMs();
|
||||||
|
logger.info(`Post-Processing Entpacken abgebrochen: pkg=${pkg.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const reason = compactErrorText(error);
|
const reason = compactErrorText(error);
|
||||||
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
|
logger.error(`Post-Processing Entpacken Exception: pkg=${pkg.name}, reason=${reason}`);
|
||||||
for (const entry of completedItems) {
|
for (const entry of completedItems) {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export interface ExtractOptions {
|
|||||||
removeLinks: boolean;
|
removeLinks: boolean;
|
||||||
removeSamples: boolean;
|
removeSamples: boolean;
|
||||||
passwordList?: string;
|
passwordList?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
onProgress?: (update: ExtractProgressUpdate) => void;
|
onProgress?: (update: ExtractProgressUpdate) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -28,9 +29,18 @@ export interface ExtractProgressUpdate {
|
|||||||
total: number;
|
total: number;
|
||||||
percent: number;
|
percent: number;
|
||||||
archiveName: string;
|
archiveName: string;
|
||||||
|
archivePercent?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
phase: "extracting" | "done";
|
phase: "extracting" | "done";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_EXTRACT_OUTPUT_BUFFER = 48 * 1024;
|
||||||
|
const EXTRACT_PROGRESS_FILE = ".rd_extract_progress.json";
|
||||||
|
|
||||||
|
type ExtractResumeState = {
|
||||||
|
completedArchives: string[];
|
||||||
|
};
|
||||||
|
|
||||||
function findArchiveCandidates(packageDir: string): string[] {
|
function findArchiveCandidates(packageDir: string): string[] {
|
||||||
const files = fs.readdirSync(packageDir, { withFileTypes: true })
|
const files = fs.readdirSync(packageDir, { withFileTypes: true })
|
||||||
.filter((entry) => entry.isFile())
|
.filter((entry) => entry.isFile())
|
||||||
@ -59,6 +69,77 @@ function cleanErrorText(text: string): string {
|
|||||||
return String(text || "").replace(/\s+/g, " ").trim().slice(0, 240);
|
return String(text || "").replace(/\s+/g, " ").trim().slice(0, 240);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendLimited(base: string, chunk: string, maxLen = MAX_EXTRACT_OUTPUT_BUFFER): string {
|
||||||
|
const next = `${base}${chunk}`;
|
||||||
|
if (next.length <= maxLen) {
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return next.slice(next.length - maxLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProgressPercent(chunk: string): number | null {
|
||||||
|
const text = String(chunk || "");
|
||||||
|
const regex = /(?:^|\D)(\d{1,3})%/g;
|
||||||
|
let match: RegExpExecArray | null = regex.exec(text);
|
||||||
|
let latest: number | null = null;
|
||||||
|
while (match) {
|
||||||
|
const value = Number(match[1]);
|
||||||
|
if (Number.isFinite(value) && value >= 0 && value <= 100) {
|
||||||
|
latest = value;
|
||||||
|
}
|
||||||
|
match = regex.exec(text);
|
||||||
|
}
|
||||||
|
return latest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldPreferExternalZip(archivePath: string): boolean {
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(archivePath);
|
||||||
|
return stat.size >= 64 * 1024 * 1024;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractProgressFilePath(packageDir: string): string {
|
||||||
|
return path.join(packageDir, EXTRACT_PROGRESS_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExtractResumeState(packageDir: string): Set<string> {
|
||||||
|
const progressPath = extractProgressFilePath(packageDir);
|
||||||
|
if (!fs.existsSync(progressPath)) {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(fs.readFileSync(progressPath, "utf8")) as Partial<ExtractResumeState>;
|
||||||
|
const names = Array.isArray(payload.completedArchives) ? payload.completedArchives : [];
|
||||||
|
return new Set(names.map((value) => String(value || "").trim()).filter(Boolean));
|
||||||
|
} catch {
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeExtractResumeState(packageDir: string, completedArchives: Set<string>): void {
|
||||||
|
const progressPath = extractProgressFilePath(packageDir);
|
||||||
|
const payload: ExtractResumeState = {
|
||||||
|
completedArchives: Array.from(completedArchives).sort((a, b) => a.localeCompare(b))
|
||||||
|
};
|
||||||
|
fs.writeFileSync(progressPath, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearExtractResumeState(packageDir: string): void {
|
||||||
|
try {
|
||||||
|
fs.rmSync(extractProgressFilePath(packageDir), { force: true });
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExtractAbortError(errorText: string): boolean {
|
||||||
|
const text = String(errorText || "").toLowerCase();
|
||||||
|
return text.includes("aborted:extract") || text.includes("extract_aborted");
|
||||||
|
}
|
||||||
|
|
||||||
function archivePasswords(listInput: string): string[] {
|
function archivePasswords(listInput: string): string[] {
|
||||||
const custom = String(listInput || "")
|
const custom = String(listInput || "")
|
||||||
.split(/\r?\n/g)
|
.split(/\r?\n/g)
|
||||||
@ -109,48 +190,81 @@ function isNoExtractorError(errorText: string): boolean {
|
|||||||
type ExtractSpawnResult = {
|
type ExtractSpawnResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
missingCommand: boolean;
|
missingCommand: boolean;
|
||||||
|
aborted: boolean;
|
||||||
errorText: string;
|
errorText: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function runExtractCommand(command: string, args: string[]): Promise<ExtractSpawnResult> {
|
function runExtractCommand(
|
||||||
|
command: string,
|
||||||
|
args: string[],
|
||||||
|
onChunk?: (chunk: string) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<ExtractSpawnResult> {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.resolve({ ok: false, missingCommand: false, aborted: true, errorText: "aborted:extract" });
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let output = "";
|
let output = "";
|
||||||
const child = spawn(command, args, { windowsHide: true });
|
const child = spawn(command, args, { windowsHide: true });
|
||||||
|
|
||||||
child.stdout.on("data", (chunk) => {
|
const finish = (result: ExtractSpawnResult): void => {
|
||||||
output += String(chunk || "");
|
|
||||||
});
|
|
||||||
child.stderr.on("data", (chunk) => {
|
|
||||||
output += String(chunk || "");
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on("error", (error) => {
|
|
||||||
if (settled) {
|
if (settled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
|
if (signal && onAbort) {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAbort = signal
|
||||||
|
? (): void => {
|
||||||
|
try {
|
||||||
|
child.kill();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
finish({ ok: false, missingCommand: false, aborted: true, errorText: "aborted:extract" });
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (signal && onAbort) {
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => {
|
||||||
|
const text = String(chunk || "");
|
||||||
|
output = appendLimited(output, text);
|
||||||
|
onChunk?.(text);
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (chunk) => {
|
||||||
|
const text = String(chunk || "");
|
||||||
|
output = appendLimited(output, text);
|
||||||
|
onChunk?.(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", (error) => {
|
||||||
const text = cleanErrorText(String(error));
|
const text = cleanErrorText(String(error));
|
||||||
resolve({
|
finish({
|
||||||
ok: false,
|
ok: false,
|
||||||
missingCommand: text.toLowerCase().includes("enoent"),
|
missingCommand: text.toLowerCase().includes("enoent"),
|
||||||
|
aborted: false,
|
||||||
errorText: text
|
errorText: text
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on("close", (code) => {
|
child.on("close", (code) => {
|
||||||
if (settled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
settled = true;
|
|
||||||
if (code === 0 || code === 1) {
|
if (code === 0 || code === 1) {
|
||||||
resolve({ ok: true, missingCommand: false, errorText: "" });
|
finish({ ok: true, missingCommand: false, aborted: false, errorText: "" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cleaned = cleanErrorText(output);
|
const cleaned = cleanErrorText(output);
|
||||||
resolve({
|
finish({
|
||||||
ok: false,
|
ok: false,
|
||||||
missingCommand: false,
|
missingCommand: false,
|
||||||
|
aborted: false,
|
||||||
errorText: cleaned || `Exit Code ${String(code ?? "?")}`
|
errorText: cleaned || `Exit Code ${String(code ?? "?")}`
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -208,7 +322,9 @@ async function runExternalExtract(
|
|||||||
archivePath: string,
|
archivePath: string,
|
||||||
targetDir: string,
|
targetDir: string,
|
||||||
conflictMode: ConflictMode,
|
conflictMode: ConflictMode,
|
||||||
passwordCandidates: string[]
|
passwordCandidates: string[],
|
||||||
|
onArchiveProgress?: (percent: number) => void,
|
||||||
|
signal?: AbortSignal
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const command = await resolveExtractorCommand();
|
const command = await resolveExtractorCommand();
|
||||||
const passwords = passwordCandidates;
|
const passwords = passwordCandidates;
|
||||||
@ -216,13 +332,35 @@ async function runExternalExtract(
|
|||||||
|
|
||||||
fs.mkdirSync(targetDir, { recursive: true });
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
let announcedStart = false;
|
||||||
|
let bestPercent = 0;
|
||||||
|
|
||||||
for (const password of passwords) {
|
for (const password of passwords) {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
throw new Error("aborted:extract");
|
||||||
|
}
|
||||||
|
if (!announcedStart) {
|
||||||
|
announcedStart = true;
|
||||||
|
onArchiveProgress?.(0);
|
||||||
|
}
|
||||||
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password);
|
const args = buildExternalExtractArgs(command, archivePath, targetDir, conflictMode, password);
|
||||||
const result = await runExtractCommand(command, args);
|
const result = await runExtractCommand(command, args, (chunk) => {
|
||||||
|
const parsed = parseProgressPercent(chunk);
|
||||||
|
if (parsed === null || parsed <= bestPercent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bestPercent = parsed;
|
||||||
|
onArchiveProgress?.(bestPercent);
|
||||||
|
}, signal);
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
onArchiveProgress?.(100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result.aborted) {
|
||||||
|
throw new Error("aborted:extract");
|
||||||
|
}
|
||||||
|
|
||||||
if (result.missingCommand) {
|
if (result.missingCommand) {
|
||||||
resolvedExtractorCommand = null;
|
resolvedExtractorCommand = null;
|
||||||
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
resolveFailureReason = NO_EXTRACTOR_MESSAGE;
|
||||||
@ -467,9 +605,14 @@ function removeEmptyDirectoryTree(rootDir: string): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
|
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new Error("aborted:extract");
|
||||||
|
}
|
||||||
|
|
||||||
const candidates = findArchiveCandidates(options.packageDir);
|
const candidates = findArchiveCandidates(options.packageDir);
|
||||||
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
|
clearExtractResumeState(options.packageDir);
|
||||||
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
|
logger.info(`Entpacken übersprungen (keine Archive gefunden): ${options.packageDir}`);
|
||||||
return { extracted: 0, failed: 0, lastError: "" };
|
return { extracted: 0, failed: 0, lastError: "" };
|
||||||
}
|
}
|
||||||
@ -477,68 +620,148 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
const conflictMode = effectiveConflictMode(options.conflictMode);
|
const conflictMode = effectiveConflictMode(options.conflictMode);
|
||||||
const passwordCandidates = archivePasswords(options.passwordList || "");
|
const passwordCandidates = archivePasswords(options.passwordList || "");
|
||||||
const beforeFingerprint = captureDirFingerprint(options.targetDir);
|
const beforeFingerprint = captureDirFingerprint(options.targetDir);
|
||||||
let extracted = 0;
|
const resumeCompleted = readExtractResumeState(options.packageDir);
|
||||||
|
const resumeCompletedAtStart = resumeCompleted.size;
|
||||||
|
const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath)));
|
||||||
|
for (const archiveName of Array.from(resumeCompleted.values())) {
|
||||||
|
if (!candidateNames.has(archiveName)) {
|
||||||
|
resumeCompleted.delete(archiveName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resumeCompleted.size > 0) {
|
||||||
|
writeExtractResumeState(options.packageDir, resumeCompleted);
|
||||||
|
} else {
|
||||||
|
clearExtractResumeState(options.packageDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCandidates = candidates.filter((archivePath) => !resumeCompleted.has(path.basename(archivePath)));
|
||||||
|
let extracted = resumeCompleted.size;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
let lastError = "";
|
let lastError = "";
|
||||||
const extractedArchives: string[] = [];
|
const extractedArchives = new Set<string>();
|
||||||
|
for (const archivePath of candidates) {
|
||||||
|
if (resumeCompleted.has(path.basename(archivePath))) {
|
||||||
|
extractedArchives.add(archivePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const emitProgress = (current: number, archiveName: string, phase: "extracting" | "done"): void => {
|
const emitProgress = (
|
||||||
|
current: number,
|
||||||
|
archiveName: string,
|
||||||
|
phase: "extracting" | "done",
|
||||||
|
archivePercent?: number,
|
||||||
|
elapsedMs?: number
|
||||||
|
): void => {
|
||||||
if (!options.onProgress) {
|
if (!options.onProgress) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const total = Math.max(1, candidates.length);
|
const total = Math.max(1, candidates.length);
|
||||||
const percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100)));
|
let percent = Math.max(0, Math.min(100, Math.floor((current / total) * 100)));
|
||||||
options.onProgress({ current, total, percent, archiveName, phase });
|
if (phase !== "done") {
|
||||||
|
const boundedCurrent = Math.max(0, Math.min(total, current));
|
||||||
|
const boundedArchivePercent = Math.max(0, Math.min(100, Number(archivePercent ?? 0)));
|
||||||
|
percent = Math.max(0, Math.min(100, Math.floor(((boundedCurrent + (boundedArchivePercent / 100)) / total) * 100)));
|
||||||
|
}
|
||||||
|
options.onProgress({
|
||||||
|
current,
|
||||||
|
total,
|
||||||
|
percent,
|
||||||
|
archiveName,
|
||||||
|
archivePercent,
|
||||||
|
elapsedMs,
|
||||||
|
phase
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
emitProgress(0, "", "extracting");
|
emitProgress(extracted, "", "extracting");
|
||||||
|
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of pendingCandidates) {
|
||||||
|
if (options.signal?.aborted) {
|
||||||
|
throw new Error("aborted:extract");
|
||||||
|
}
|
||||||
const archiveName = path.basename(archivePath);
|
const archiveName = path.basename(archivePath);
|
||||||
emitProgress(extracted + failed, archiveName, "extracting");
|
const archiveStartedAt = Date.now();
|
||||||
|
let archivePercent = 0;
|
||||||
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, 0);
|
||||||
|
const pulseTimer = setInterval(() => {
|
||||||
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
|
}, 1100);
|
||||||
logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`);
|
logger.info(`Entpacke Archiv: ${path.basename(archivePath)} -> ${options.targetDir}`);
|
||||||
try {
|
try {
|
||||||
const ext = path.extname(archivePath).toLowerCase();
|
const ext = path.extname(archivePath).toLowerCase();
|
||||||
if (ext === ".zip") {
|
if (ext === ".zip") {
|
||||||
try {
|
const preferExternal = shouldPreferExternalZip(archivePath);
|
||||||
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
|
if (preferExternal) {
|
||||||
} catch {
|
try {
|
||||||
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates);
|
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
|
||||||
|
archivePercent = Math.max(archivePercent, value);
|
||||||
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
|
}, options.signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (isNoExtractorError(String(error))) {
|
||||||
|
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
extractZipArchive(archivePath, options.targetDir, options.conflictMode);
|
||||||
|
archivePercent = 100;
|
||||||
|
} catch {
|
||||||
|
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
|
||||||
|
archivePercent = Math.max(archivePercent, value);
|
||||||
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
|
}, options.signal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates);
|
await runExternalExtract(archivePath, options.targetDir, options.conflictMode, passwordCandidates, (value) => {
|
||||||
|
archivePercent = Math.max(archivePercent, value);
|
||||||
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
|
}, options.signal);
|
||||||
}
|
}
|
||||||
extracted += 1;
|
extracted += 1;
|
||||||
extractedArchives.push(archivePath);
|
extractedArchives.add(archivePath);
|
||||||
|
resumeCompleted.add(archiveName);
|
||||||
|
writeExtractResumeState(options.packageDir, resumeCompleted);
|
||||||
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
logger.info(`Entpacken erfolgreich: ${path.basename(archivePath)}`);
|
||||||
emitProgress(extracted + failed, archiveName, "extracting");
|
archivePercent = 100;
|
||||||
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed += 1;
|
failed += 1;
|
||||||
const errorText = String(error);
|
const errorText = String(error);
|
||||||
|
if (isExtractAbortError(errorText)) {
|
||||||
|
throw new Error("aborted:extract");
|
||||||
|
}
|
||||||
lastError = errorText;
|
lastError = errorText;
|
||||||
logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${errorText}`);
|
logger.error(`Entpack-Fehler ${path.basename(archivePath)}: ${errorText}`);
|
||||||
emitProgress(extracted + failed, archiveName, "extracting");
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
if (isNoExtractorError(errorText)) {
|
if (isNoExtractorError(errorText)) {
|
||||||
const remaining = candidates.length - (extracted + failed);
|
const remaining = candidates.length - (extracted + failed);
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
failed += remaining;
|
failed += remaining;
|
||||||
emitProgress(candidates.length, archiveName, "extracting");
|
emitProgress(candidates.length, archiveName, "extracting", 0, Date.now() - archiveStartedAt);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
clearInterval(pulseTimer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (extracted > 0) {
|
if (extracted > 0) {
|
||||||
const afterFingerprint = captureDirFingerprint(options.targetDir);
|
const afterFingerprint = captureDirFingerprint(options.targetDir);
|
||||||
const changedOutput = hasDirChanges(beforeFingerprint, afterFingerprint);
|
const changedOutput = hasDirChanges(beforeFingerprint, afterFingerprint);
|
||||||
if (!changedOutput && conflictMode !== "skip") {
|
const hadResumeProgress = resumeCompletedAtStart > 0;
|
||||||
|
if (!changedOutput && conflictMode !== "skip" && !hadResumeProgress) {
|
||||||
lastError = "Keine entpackten Dateien erkannt";
|
lastError = "Keine entpackten Dateien erkannt";
|
||||||
failed += extracted;
|
failed += extracted;
|
||||||
extracted = 0;
|
extracted = 0;
|
||||||
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
|
logger.error(`Entpacken ohne neue Ausgabe erkannt: ${options.targetDir}. Cleanup wird NICHT ausgeführt.`);
|
||||||
} else {
|
} else {
|
||||||
const removedArchives = cleanupArchives(extractedArchives, options.cleanupMode);
|
const cleanupSources = failed === 0 ? candidates : Array.from(extractedArchives.values());
|
||||||
|
const removedArchives = cleanupArchives(cleanupSources, options.cleanupMode);
|
||||||
if (options.cleanupMode !== "none") {
|
if (options.cleanupMode !== "none") {
|
||||||
logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`);
|
logger.info(`Archive-Cleanup abgeschlossen: ${removedArchives} Datei(en) entfernt`);
|
||||||
}
|
}
|
||||||
@ -551,6 +774,10 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`);
|
logger.info(`Sample-Cleanup: ${removedSamples.files} Datei(en), ${removedSamples.dirs} Ordner entfernt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (failed === 0 && resumeCompleted.size >= candidates.length) {
|
||||||
|
clearExtractResumeState(options.packageDir);
|
||||||
|
}
|
||||||
|
|
||||||
if (options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) {
|
if (options.cleanupMode === "delete" && !hasAnyFilesRecursive(options.packageDir)) {
|
||||||
const removedDirs = removeEmptyDirectoryTree(options.packageDir);
|
const removedDirs = removeEmptyDirectoryTree(options.packageDir);
|
||||||
if (removedDirs > 0) {
|
if (removedDirs > 0) {
|
||||||
@ -568,6 +795,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (failed > 0) {
|
||||||
|
if (resumeCompleted.size > 0) {
|
||||||
|
writeExtractResumeState(options.packageDir, resumeCompleted);
|
||||||
|
} else {
|
||||||
|
clearExtractResumeState(options.packageDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emitProgress(candidates.length, "", "done");
|
emitProgress(candidates.length, "", "done");
|
||||||
|
|
||||||
logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`);
|
logger.info(`Entpacken beendet: extracted=${extracted}, failed=${failed}, targetDir=${options.targetDir}`);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { DragEvent, KeyboardEvent, ReactElement, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
import { DragEvent, KeyboardEvent, ReactElement, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type {
|
import type {
|
||||||
AppSettings,
|
AppSettings,
|
||||||
AppTheme,
|
AppTheme,
|
||||||
@ -97,6 +97,7 @@ export function App(): ReactElement {
|
|||||||
]);
|
]);
|
||||||
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
|
const [activeCollectorTab, setActiveCollectorTab] = useState(collectorTabs[0].id);
|
||||||
const activeCollectorTabRef = useRef(activeCollectorTab);
|
const activeCollectorTabRef = useRef(activeCollectorTab);
|
||||||
|
const activeTabRef = useRef<Tab>(tab);
|
||||||
const draggedPackageIdRef = useRef<string | null>(null);
|
const draggedPackageIdRef = useRef<string | null>(null);
|
||||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||||
const [downloadSearch, setDownloadSearch] = useState("");
|
const [downloadSearch, setDownloadSearch] = useState("");
|
||||||
@ -114,6 +115,10 @@ export function App(): ReactElement {
|
|||||||
activeCollectorTabRef.current = activeCollectorTab;
|
activeCollectorTabRef.current = activeCollectorTab;
|
||||||
}, [activeCollectorTab]);
|
}, [activeCollectorTab]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeTabRef.current = tab;
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
settingsDirtyRef.current = settingsDirty;
|
settingsDirtyRef.current = settingsDirty;
|
||||||
}, [settingsDirty]);
|
}, [settingsDirty]);
|
||||||
@ -146,6 +151,22 @@ export function App(): ReactElement {
|
|||||||
unsubscribe = window.rd.onStateUpdate((state) => {
|
unsubscribe = window.rd.onStateUpdate((state) => {
|
||||||
latestStateRef.current = state;
|
latestStateRef.current = state;
|
||||||
if (stateFlushTimerRef.current) { return; }
|
if (stateFlushTimerRef.current) { return; }
|
||||||
|
|
||||||
|
const itemCount = Object.keys(state.session.items).length;
|
||||||
|
let flushDelay = itemCount >= 1500
|
||||||
|
? 850
|
||||||
|
: itemCount >= 700
|
||||||
|
? 620
|
||||||
|
: itemCount >= 250
|
||||||
|
? 420
|
||||||
|
: 180;
|
||||||
|
if (!state.session.running) {
|
||||||
|
flushDelay = Math.min(flushDelay, 260);
|
||||||
|
}
|
||||||
|
if (activeTabRef.current !== "downloads") {
|
||||||
|
flushDelay = Math.max(flushDelay, 320);
|
||||||
|
}
|
||||||
|
|
||||||
stateFlushTimerRef.current = setTimeout(() => {
|
stateFlushTimerRef.current = setTimeout(() => {
|
||||||
stateFlushTimerRef.current = null;
|
stateFlushTimerRef.current = null;
|
||||||
if (latestStateRef.current) {
|
if (latestStateRef.current) {
|
||||||
@ -156,7 +177,7 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
latestStateRef.current = null;
|
latestStateRef.current = null;
|
||||||
}
|
}
|
||||||
}, 220);
|
}, flushDelay);
|
||||||
});
|
});
|
||||||
unsubClipboard = window.rd.onClipboardDetected((links) => {
|
unsubClipboard = window.rd.onClipboardDetected((links) => {
|
||||||
showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000);
|
showToast(`Zwischenablage: ${links.length} Link(s) erkannt`, 3000);
|
||||||
@ -182,7 +203,7 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const packages = useMemo(() => snapshot.session.packageOrder
|
const packages = useMemo(() => snapshot.session.packageOrder
|
||||||
.map((id: string) => snapshot.session.packages[id])
|
.map((id: string) => snapshot.session.packages[id])
|
||||||
.filter(Boolean), [snapshot]);
|
.filter(Boolean), [snapshot.session.packageOrder, snapshot.session.packages]);
|
||||||
|
|
||||||
const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]);
|
const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]);
|
||||||
|
|
||||||
@ -643,7 +664,7 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [snapshot]);
|
}, [snapshot.session.items]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -1060,7 +1081,7 @@ interface PackageCardProps {
|
|||||||
onDragEnd: () => void;
|
onDragEnd: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, editingName, collapsed, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
||||||
const done = items.filter((item) => item.status === "completed").length;
|
const done = items.filter((item) => item.status === "completed").length;
|
||||||
const failed = items.filter((item) => item.status === "failed").length;
|
const failed = items.filter((item) => item.status === "failed").length;
|
||||||
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
const cancelled = items.filter((item) => item.status === "cancelled").length;
|
||||||
@ -1130,4 +1151,45 @@ function PackageCard({ pkg, items, packageSpeed, isFirst, isLast, isEditing, edi
|
|||||||
</table>}
|
</table>}
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}, (prev, next) => {
|
||||||
|
if (prev.pkg.id !== next.pkg.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prev.pkg.updatedAt !== next.pkg.updatedAt
|
||||||
|
|| prev.pkg.status !== next.pkg.status
|
||||||
|
|| prev.pkg.enabled !== next.pkg.enabled
|
||||||
|
|| prev.pkg.name !== next.pkg.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prev.packageSpeed !== next.packageSpeed
|
||||||
|
|| prev.isFirst !== next.isFirst
|
||||||
|
|| prev.isLast !== next.isLast
|
||||||
|
|| prev.isEditing !== next.isEditing
|
||||||
|
|| prev.collapsed !== next.collapsed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ((prev.isEditing || next.isEditing) && prev.editingName !== next.editingName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (prev.items.length !== next.items.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (let index = 0; index < prev.items.length; index += 1) {
|
||||||
|
const a = prev.items[index];
|
||||||
|
const b = next.items[index];
|
||||||
|
if (!a || !b) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (a.id !== b.id
|
||||||
|
|| a.updatedAt !== b.updatedAt
|
||||||
|
|| a.status !== b.status
|
||||||
|
|| a.progressPercent !== b.progressPercent
|
||||||
|
|| a.speedBps !== b.speedBps
|
||||||
|
|| a.retries !== b.retries
|
||||||
|
|| a.provider !== b.provider
|
||||||
|
|| a.fullStatus !== b.fullStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|||||||
@ -2807,6 +2807,69 @@ describe("download manager", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("marks extracting items as resumable extraction on shutdown", () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "extract-stop-pkg";
|
||||||
|
const itemId = "extract-stop-item";
|
||||||
|
const createdAt = Date.now() - 20_000;
|
||||||
|
const outputDir = path.join(root, "downloads", "extract-stop");
|
||||||
|
const extractDir = path.join(root, "extract", "extract-stop");
|
||||||
|
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "extract-stop",
|
||||||
|
outputDir,
|
||||||
|
extractDir,
|
||||||
|
status: "extracting",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/extract-stop",
|
||||||
|
provider: "realdebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 123,
|
||||||
|
totalBytes: 123,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: "extract-stop.part01.rar",
|
||||||
|
targetPath: path.join(outputDir, "extract-stop.part01.rar"),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Entpacken 40%",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: true
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.prepareForShutdown();
|
||||||
|
const snapshot = manager.getSnapshot();
|
||||||
|
expect(snapshot.session.packages[packageId]?.status).toBe("queued");
|
||||||
|
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpacken abgebrochen (wird fortgesetzt)");
|
||||||
|
});
|
||||||
|
|
||||||
it("recovers pending extraction on startup for completed package", async () => {
|
it("recovers pending extraction on startup for completed package", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
@ -274,4 +274,61 @@ describe("extractor", () => {
|
|||||||
expect(result.failed).toBe(1);
|
expect(result.failed).toBe(1);
|
||||||
expect(fs.existsSync(targetDir)).toBe(false);
|
expect(fs.existsSync(targetDir)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resumes extraction from persisted progress file", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = path.join(root, "pkg");
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
|
const zipA = new AdmZip();
|
||||||
|
zipA.addFile("a.txt", Buffer.from("a"));
|
||||||
|
zipA.writeZip(path.join(packageDir, "a.zip"));
|
||||||
|
|
||||||
|
const zipB = new AdmZip();
|
||||||
|
zipB.addFile("b.txt", Buffer.from("b"));
|
||||||
|
zipB.writeZip(path.join(packageDir, "b.zip"));
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(packageDir, ".rd_extract_progress.json"), JSON.stringify({ completedArchives: ["a.zip"] }), "utf8");
|
||||||
|
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir,
|
||||||
|
cleanupMode: "none",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.extracted).toBe(2);
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(fs.existsSync(path.join(targetDir, "b.txt"))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(packageDir, ".rd_extract_progress.json"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts extraction immediately when abort signal is set", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = path.join(root, "pkg");
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile("file.txt", Buffer.from("x"));
|
||||||
|
zip.writeZip(path.join(packageDir, "file.zip"));
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir,
|
||||||
|
cleanupMode: "none",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false,
|
||||||
|
signal: controller.signal
|
||||||
|
})).rejects.toThrow("aborted:extract");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user