Release v1.4.7 with ENOENT extraction recovery and lag optimizations
Some checks are pending
Build and Release / build (push) Waiting to run
Some checks are pending
Build and Release / build (push) Waiting to run
This commit is contained in:
parent
dbf1c34282
commit
3b9c4a4e88
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.6",
|
"version": "1.4.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.4.6",
|
"version": "1.4.7",
|
||||||
"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.6",
|
"version": "1.4.7",
|
||||||
"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",
|
||||||
|
|||||||
@ -113,6 +113,10 @@ function isFinishedStatus(status: DownloadStatus): boolean {
|
|||||||
return status === "completed" || status === "failed" || status === "cancelled";
|
return status === "completed" || status === "failed" || status === "cancelled";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExtractedLabel(statusText: string): boolean {
|
||||||
|
return /^entpackt\b/i.test(String(statusText || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
function providerLabel(provider: DownloadItem["provider"]): string {
|
function providerLabel(provider: DownloadItem["provider"]): string {
|
||||||
if (provider === "realdebrid") {
|
if (provider === "realdebrid") {
|
||||||
return "Real-Debrid";
|
return "Real-Debrid";
|
||||||
@ -169,6 +173,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private speedBytesLastWindow = 0;
|
private speedBytesLastWindow = 0;
|
||||||
|
|
||||||
|
private lastPersistAt = 0;
|
||||||
|
|
||||||
private cleanupQueue: Promise<void> = Promise.resolve();
|
private cleanupQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
private packagePostProcessQueue: Promise<void> = Promise.resolve();
|
private packagePostProcessQueue: Promise<void> = Promise.resolve();
|
||||||
@ -1203,13 +1209,28 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (this.persistTimer) {
|
if (this.persistTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const itemCount = Object.keys(this.session.items).length;
|
||||||
|
const minGapMs = this.session.running
|
||||||
|
? itemCount >= 1500
|
||||||
|
? 1300
|
||||||
|
: itemCount >= 700
|
||||||
|
? 950
|
||||||
|
: itemCount >= 250
|
||||||
|
? 700
|
||||||
|
: 450
|
||||||
|
: 250;
|
||||||
|
const sinceLastPersist = nowMs() - this.lastPersistAt;
|
||||||
|
const delay = Math.max(120, minGapMs - sinceLastPersist);
|
||||||
|
|
||||||
this.persistTimer = setTimeout(() => {
|
this.persistTimer = setTimeout(() => {
|
||||||
this.persistTimer = null;
|
this.persistTimer = null;
|
||||||
this.persistNow();
|
this.persistNow();
|
||||||
}, 250);
|
}, delay);
|
||||||
}
|
}
|
||||||
|
|
||||||
private persistNow(): void {
|
private persistNow(): void {
|
||||||
|
this.lastPersistAt = nowMs();
|
||||||
saveSession(this.storagePaths, this.session);
|
saveSession(this.storagePaths, this.session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1397,7 +1418,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
if (this.settings.autoExtract && failed === 0 && success > 0) {
|
if (this.settings.autoExtract && failed === 0 && success > 0) {
|
||||||
const needsPostProcess = pkg.status !== "completed"
|
const needsPostProcess = pkg.status !== "completed"
|
||||||
|| items.some((item) => item.status === "completed" && item.fullStatus !== "Entpackt");
|
|| items.some((item) => item.status === "completed" && !isExtractedLabel(item.fullStatus));
|
||||||
if (needsPostProcess) {
|
if (needsPostProcess) {
|
||||||
void this.runPackagePostProcessing(packageId);
|
void this.runPackagePostProcessing(packageId);
|
||||||
} else if (pkg.status !== "completed") {
|
} else if (pkg.status !== "completed") {
|
||||||
@ -1475,7 +1496,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sleep(120);
|
const maxParallel = Math.max(1, this.settings.maxParallel);
|
||||||
|
const schedulerSleepMs = this.activeTasks.size >= maxParallel ? 170 : 120;
|
||||||
|
await sleep(schedulerSleepMs);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
this.scheduleRunning = false;
|
this.scheduleRunning = false;
|
||||||
@ -2381,7 +2404,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const completedItems = items.filter((item) => item.status === "completed");
|
const completedItems = items.filter((item) => item.status === "completed");
|
||||||
const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => item.fullStatus === "Entpackt");
|
const alreadyMarkedExtracted = completedItems.length > 0 && completedItems.every((item) => isExtractedLabel(item.fullStatus));
|
||||||
|
|
||||||
if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
|
if (this.settings.autoExtract && failed === 0 && success > 0 && !alreadyMarkedExtracted) {
|
||||||
pkg.status = "extracting";
|
pkg.status = "extracting";
|
||||||
@ -2436,11 +2459,22 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
pkg.status = "failed";
|
pkg.status = "failed";
|
||||||
} else {
|
} else {
|
||||||
if (result.extracted > 0) {
|
const hasExtractedOutput = this.directoryHasAnyFiles(pkg.extractDir);
|
||||||
for (const entry of completedItems) {
|
const sourceExists = fs.existsSync(pkg.outputDir);
|
||||||
entry.fullStatus = "Entpackt";
|
let finalStatusText = "";
|
||||||
entry.updatedAt = nowMs();
|
|
||||||
|
if (result.extracted > 0 || hasExtractedOutput) {
|
||||||
|
finalStatusText = "Entpackt";
|
||||||
|
} else if (!sourceExists) {
|
||||||
|
finalStatusText = "Entpackt (Quelle fehlt)";
|
||||||
|
logger.warn(`Post-Processing ohne Quellordner: pkg=${pkg.name}, outputDir fehlt`);
|
||||||
|
} else {
|
||||||
|
finalStatusText = "Entpackt (keine Archive)";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const entry of completedItems) {
|
||||||
|
entry.fullStatus = finalStatusText;
|
||||||
|
entry.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
pkg.status = "completed";
|
pkg.status = "completed";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,9 +42,18 @@ type ExtractResumeState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function findArchiveCandidates(packageDir: string): string[] {
|
function findArchiveCandidates(packageDir: string): string[] {
|
||||||
const files = fs.readdirSync(packageDir, { withFileTypes: true })
|
if (!packageDir || !fs.existsSync(packageDir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
files = fs.readdirSync(packageDir, { withFileTypes: true })
|
||||||
.filter((entry) => entry.isFile())
|
.filter((entry) => entry.isFile())
|
||||||
.map((entry) => path.join(packageDir, entry.name));
|
.map((entry) => path.join(packageDir, entry.name));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const preferred = files.filter((file) => /\.part0*1\.rar$/i.test(file));
|
const preferred = files.filter((file) => /\.part0*1\.rar$/i.test(file));
|
||||||
const zip = files.filter((file) => /\.zip$/i.test(file));
|
const zip = files.filter((file) => /\.zip$/i.test(file));
|
||||||
@ -408,56 +417,6 @@ function escapeRegex(value: string): string {
|
|||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureDirFingerprint(rootDir: string): Map<string, string> {
|
|
||||||
const fingerprint = new Map<string, string>();
|
|
||||||
if (!fs.existsSync(rootDir)) {
|
|
||||||
return fingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stack = [rootDir];
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const current = stack.pop() as string;
|
|
||||||
let entries: fs.Dirent[] = [];
|
|
||||||
try {
|
|
||||||
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const full = path.join(current, entry.name);
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
stack.push(full);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!entry.isFile()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const stat = fs.statSync(full);
|
|
||||||
const relative = path.relative(rootDir, full).toLowerCase();
|
|
||||||
fingerprint.set(relative, `${stat.size}:${stat.mtimeMs}`);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasDirChanges(before: Map<string, string>, after: Map<string, string>): boolean {
|
|
||||||
if (after.size > before.size) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (const [relative, meta] of after.entries()) {
|
|
||||||
if (before.get(relative) !== meta) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function collectArchiveCleanupTargets(sourceArchivePath: string): string[] {
|
export function collectArchiveCleanupTargets(sourceArchivePath: string): string[] {
|
||||||
const targets = new Set<string>([sourceArchivePath]);
|
const targets = new Set<string>([sourceArchivePath]);
|
||||||
const dir = path.dirname(sourceArchivePath);
|
const dir = path.dirname(sourceArchivePath);
|
||||||
@ -619,7 +578,6 @@ 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 resumeCompleted = readExtractResumeState(options.packageDir);
|
const resumeCompleted = readExtractResumeState(options.packageDir);
|
||||||
const resumeCompletedAtStart = resumeCompleted.size;
|
const resumeCompletedAtStart = resumeCompleted.size;
|
||||||
const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath)));
|
const candidateNames = new Set(candidates.map((archivePath) => path.basename(archivePath)));
|
||||||
@ -751,10 +709,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (extracted > 0) {
|
if (extracted > 0) {
|
||||||
const afterFingerprint = captureDirFingerprint(options.targetDir);
|
const hasOutputAfter = hasAnyFilesRecursive(options.targetDir);
|
||||||
const changedOutput = hasDirChanges(beforeFingerprint, afterFingerprint);
|
|
||||||
const hadResumeProgress = resumeCompletedAtStart > 0;
|
const hadResumeProgress = resumeCompletedAtStart > 0;
|
||||||
if (!changedOutput && conflictMode !== "skip" && !hadResumeProgress) {
|
if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) {
|
||||||
lastError = "Keine entpackten Dateien erkannt";
|
lastError = "Keine entpackten Dateien erkannt";
|
||||||
failed += extracted;
|
failed += extracted;
|
||||||
extracted = 0;
|
extracted = 0;
|
||||||
|
|||||||
@ -3,6 +3,14 @@ import path from "node:path";
|
|||||||
|
|
||||||
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
|
||||||
let fallbackLogFilePath: string | null = null;
|
let fallbackLogFilePath: string | null = null;
|
||||||
|
const LOG_FLUSH_INTERVAL_MS = 120;
|
||||||
|
const LOG_BUFFER_LIMIT_CHARS = 1_000_000;
|
||||||
|
|
||||||
|
let pendingLines: string[] = [];
|
||||||
|
let pendingChars = 0;
|
||||||
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
let flushInFlight = false;
|
||||||
|
let exitHookAttached = false;
|
||||||
|
|
||||||
export function configureLogger(baseDir: string): void {
|
export function configureLogger(baseDir: string): void {
|
||||||
logFilePath = path.join(baseDir, "rd_downloader.log");
|
logFilePath = path.join(baseDir, "rd_downloader.log");
|
||||||
@ -20,31 +28,126 @@ function appendLine(filePath: string, line: string): { ok: boolean; errorText: s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
|
async function appendChunk(filePath: string, chunk: string): Promise<{ ok: boolean; errorText: string }> {
|
||||||
const line = `${new Date().toISOString()} [${level}] ${message}\n`;
|
|
||||||
const primary = appendLine(logFilePath, line);
|
|
||||||
|
|
||||||
if (fallbackLogFilePath) {
|
|
||||||
const fallback = appendLine(fallbackLogFilePath, line);
|
|
||||||
if (!primary.ok && !fallback.ok) {
|
|
||||||
try {
|
try {
|
||||||
process.stderr.write(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await fs.promises.appendFile(filePath, chunk, "utf8");
|
||||||
|
return { ok: true, errorText: "" };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, errorText: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStderr(text: string): void {
|
||||||
|
try {
|
||||||
|
process.stderr.write(text);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore stderr failures
|
// ignore stderr failures
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushSyncPending(): void {
|
||||||
|
if (pendingLines.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = pendingLines.join("");
|
||||||
|
pendingLines = [];
|
||||||
|
pendingChars = 0;
|
||||||
|
|
||||||
|
const primary = appendLine(logFilePath, chunk);
|
||||||
|
if (fallbackLogFilePath) {
|
||||||
|
const fallback = appendLine(fallbackLogFilePath, chunk);
|
||||||
|
if (!primary.ok && !fallback.ok) {
|
||||||
|
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!primary.ok) {
|
if (!primary.ok) {
|
||||||
|
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush(immediate = false): void {
|
||||||
|
if (flushInFlight) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (immediate) {
|
||||||
|
if (flushTimer) {
|
||||||
|
clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
}
|
||||||
|
void flushAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (flushTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null;
|
||||||
|
void flushAsync();
|
||||||
|
}, LOG_FLUSH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushAsync(): Promise<void> {
|
||||||
|
if (flushInFlight || pendingLines.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushInFlight = true;
|
||||||
|
const chunk = pendingLines.join("");
|
||||||
|
pendingLines = [];
|
||||||
|
pendingChars = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
process.stderr.write(`LOGGER write failed: ${primary.errorText}\n`);
|
const primary = await appendChunk(logFilePath, chunk);
|
||||||
} catch {
|
if (fallbackLogFilePath) {
|
||||||
// ignore stderr failures
|
const fallback = await appendChunk(fallbackLogFilePath, chunk);
|
||||||
|
if (!primary.ok && !fallback.ok) {
|
||||||
|
writeStderr(`LOGGER write failed (primary+fallback): ${primary.errorText} | ${fallback.errorText}\n`);
|
||||||
|
}
|
||||||
|
} else if (!primary.ok) {
|
||||||
|
writeStderr(`LOGGER write failed: ${primary.errorText}\n`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
flushInFlight = false;
|
||||||
|
if (pendingLines.length > 0) {
|
||||||
|
scheduleFlush(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureExitHook(): void {
|
||||||
|
if (exitHookAttached) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
exitHookAttached = true;
|
||||||
|
process.once("beforeExit", flushSyncPending);
|
||||||
|
process.once("exit", flushSyncPending);
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
|
||||||
|
ensureExitHook();
|
||||||
|
const line = `${new Date().toISOString()} [${level}] ${message}\n`;
|
||||||
|
pendingLines.push(line);
|
||||||
|
pendingChars += line.length;
|
||||||
|
|
||||||
|
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||||
|
const removed = pendingLines.shift();
|
||||||
|
if (!removed) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
pendingChars = Math.max(0, pendingChars - removed.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === "ERROR") {
|
||||||
|
scheduleFlush(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
|
||||||
export const logger = {
|
export const logger = {
|
||||||
info: (msg: string): void => write("INFO", msg),
|
info: (msg: string): void => write("INFO", msg),
|
||||||
warn: (msg: string): void => write("WARN", msg),
|
warn: (msg: string): void => write("WARN", msg),
|
||||||
|
|||||||
@ -205,7 +205,7 @@ export function loadSession(paths: StoragePaths): SessionState {
|
|||||||
|
|
||||||
export function saveSession(paths: StoragePaths, session: SessionState): void {
|
export function saveSession(paths: StoragePaths, session: SessionState): void {
|
||||||
ensureBaseDir(paths.baseDir);
|
ensureBaseDir(paths.baseDir);
|
||||||
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, null, 2);
|
const payload = JSON.stringify({ ...session, updatedAt: Date.now() });
|
||||||
const tempPath = `${paths.sessionFile}.tmp`;
|
const tempPath = `${paths.sessionFile}.tmp`;
|
||||||
fs.writeFileSync(tempPath, payload, "utf8");
|
fs.writeFileSync(tempPath, payload, "utf8");
|
||||||
fs.renameSync(tempPath, paths.sessionFile);
|
fs.renameSync(tempPath, paths.sessionFile);
|
||||||
|
|||||||
@ -201,21 +201,39 @@ export function App(): ReactElement {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const packages = useMemo(() => snapshot.session.packageOrder
|
const downloadsTabActive = tab === "downloads";
|
||||||
.map((id: string) => snapshot.session.packages[id])
|
|
||||||
.filter(Boolean), [snapshot.session.packageOrder, snapshot.session.packages]);
|
|
||||||
|
|
||||||
const packageOrderKey = useMemo(() => snapshot.session.packageOrder.join("|"), [snapshot.session.packageOrder]);
|
const packages = useMemo(() => {
|
||||||
|
if (!downloadsTabActive) {
|
||||||
|
return [] as PackageEntry[];
|
||||||
|
}
|
||||||
|
return snapshot.session.packageOrder
|
||||||
|
.map((id: string) => snapshot.session.packages[id])
|
||||||
|
.filter(Boolean);
|
||||||
|
}, [downloadsTabActive, snapshot.session.packageOrder, snapshot.session.packages]);
|
||||||
|
|
||||||
|
const packageOrderKey = useMemo(() => {
|
||||||
|
if (!downloadsTabActive) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return snapshot.session.packageOrder.join("|");
|
||||||
|
}, [downloadsTabActive, snapshot.session.packageOrder]);
|
||||||
|
|
||||||
const packagePosition = useMemo(() => {
|
const packagePosition = useMemo(() => {
|
||||||
|
if (!downloadsTabActive) {
|
||||||
|
return new Map<string, number>();
|
||||||
|
}
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, number>();
|
||||||
snapshot.session.packageOrder.forEach((id, index) => {
|
snapshot.session.packageOrder.forEach((id, index) => {
|
||||||
map.set(id, index);
|
map.set(id, index);
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [packageOrderKey]);
|
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder]);
|
||||||
|
|
||||||
const itemsByPackage = useMemo(() => {
|
const itemsByPackage = useMemo(() => {
|
||||||
|
if (!downloadsTabActive) {
|
||||||
|
return new Map<string, DownloadItem[]>();
|
||||||
|
}
|
||||||
const map = new Map<string, DownloadItem[]>();
|
const map = new Map<string, DownloadItem[]>();
|
||||||
for (const packageId of snapshot.session.packageOrder) {
|
for (const packageId of snapshot.session.packageOrder) {
|
||||||
const pkg = snapshot.session.packages[packageId];
|
const pkg = snapshot.session.packages[packageId];
|
||||||
@ -228,9 +246,12 @@ export function App(): ReactElement {
|
|||||||
map.set(packageId, items);
|
map.set(packageId, items);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [packageOrderKey, snapshot.session.items, snapshot.session.packages]);
|
}, [downloadsTabActive, packageOrderKey, snapshot.session.items, snapshot.session.packages, snapshot.session.packageOrder]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!downloadsTabActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCollapsedPackages((prev) => {
|
setCollapsedPackages((prev) => {
|
||||||
const next: Record<string, boolean> = {};
|
const next: Record<string, boolean> = {};
|
||||||
const defaultCollapsed = snapshot.session.packageOrder.length >= 24;
|
const defaultCollapsed = snapshot.session.packageOrder.length >= 24;
|
||||||
@ -239,7 +260,7 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}, [packageOrderKey, snapshot.session.packageOrder.length]);
|
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder.length]);
|
||||||
|
|
||||||
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
const deferredDownloadSearch = useDeferredValue(downloadSearch);
|
||||||
|
|
||||||
|
|||||||
@ -2941,4 +2941,136 @@ describe("download manager", () => {
|
|||||||
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
|
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
|
||||||
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt");
|
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not fail startup post-processing when source package dir is missing but extract output exists", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
|
||||||
|
const outputDir = path.join(root, "downloads", "missing-source-ok");
|
||||||
|
const extractDir = path.join(root, "extract", "missing-source-ok");
|
||||||
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(extractDir, "episode.mkv"), "ok", "utf8");
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "missing-source-ok-pkg";
|
||||||
|
const itemId = "missing-source-ok-item";
|
||||||
|
const createdAt = Date.now() - 20_000;
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "missing-source-ok",
|
||||||
|
outputDir,
|
||||||
|
extractDir,
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/missing-source-ok",
|
||||||
|
provider: "megadebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 123,
|
||||||
|
totalBytes: 123,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: "missing-source-ok.part01.rar",
|
||||||
|
targetPath: path.join(outputDir, "missing-source-ok.part01.rar"),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Fertig (123 B)",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: true,
|
||||||
|
enableIntegrityCheck: false,
|
||||||
|
cleanupMode: "none"
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000);
|
||||||
|
const snapshot = manager.getSnapshot();
|
||||||
|
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
|
||||||
|
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks missing source package dir as extracted instead of failed", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
|
||||||
|
const outputDir = path.join(root, "downloads", "missing-source-empty");
|
||||||
|
const extractDir = path.join(root, "extract", "missing-source-empty");
|
||||||
|
|
||||||
|
const session = emptySession();
|
||||||
|
const packageId = "missing-source-empty-pkg";
|
||||||
|
const itemId = "missing-source-empty-item";
|
||||||
|
const createdAt = Date.now() - 20_000;
|
||||||
|
session.packageOrder = [packageId];
|
||||||
|
session.packages[packageId] = {
|
||||||
|
id: packageId,
|
||||||
|
name: "missing-source-empty",
|
||||||
|
outputDir,
|
||||||
|
extractDir,
|
||||||
|
status: "downloading",
|
||||||
|
itemIds: [itemId],
|
||||||
|
cancelled: false,
|
||||||
|
enabled: true,
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
session.items[itemId] = {
|
||||||
|
id: itemId,
|
||||||
|
packageId,
|
||||||
|
url: "https://dummy/missing-source-empty",
|
||||||
|
provider: "megadebrid",
|
||||||
|
status: "completed",
|
||||||
|
retries: 0,
|
||||||
|
speedBps: 0,
|
||||||
|
downloadedBytes: 123,
|
||||||
|
totalBytes: 123,
|
||||||
|
progressPercent: 100,
|
||||||
|
fileName: "missing-source-empty.part01.rar",
|
||||||
|
targetPath: path.join(outputDir, "missing-source-empty.part01.rar"),
|
||||||
|
resumable: true,
|
||||||
|
attempts: 1,
|
||||||
|
lastError: "",
|
||||||
|
fullStatus: "Fertig (123 B)",
|
||||||
|
createdAt,
|
||||||
|
updatedAt: createdAt
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
{
|
||||||
|
...defaultSettings(),
|
||||||
|
token: "rd-token",
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
autoExtract: true,
|
||||||
|
enableIntegrityCheck: false,
|
||||||
|
cleanupMode: "none"
|
||||||
|
},
|
||||||
|
session,
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => manager.getSnapshot().session.items[itemId]?.fullStatus.startsWith("Entpackt"), 12000);
|
||||||
|
const snapshot = manager.getSnapshot();
|
||||||
|
expect(snapshot.session.packages[packageId]?.status).toBe("completed");
|
||||||
|
expect(snapshot.session.items[itemId]?.fullStatus).toBe("Entpackt (Quelle fehlt)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -331,4 +331,25 @@ describe("extractor", () => {
|
|||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
})).rejects.toThrow("aborted:extract");
|
})).rejects.toThrow("aborted:extract");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("handles missing package source directory without throwing", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-extract-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const packageDir = path.join(root, "pkg-missing");
|
||||||
|
const targetDir = path.join(root, "out");
|
||||||
|
fs.mkdirSync(targetDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(targetDir, "video.mkv"), "ok", "utf8");
|
||||||
|
|
||||||
|
const result = await extractPackageArchives({
|
||||||
|
packageDir,
|
||||||
|
targetDir,
|
||||||
|
cleanupMode: "none",
|
||||||
|
conflictMode: "overwrite",
|
||||||
|
removeLinks: false,
|
||||||
|
removeSamples: false
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.failed).toBe(0);
|
||||||
|
expect(result.extracted).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user