Compare commits
No commits in common. "bc47da504c84d57c060b4c64f90fb5e677d32cc4" and "55b00bf8842fa02eda8dd9d63e62418206391616" have entirely different histories.
bc47da504c
...
55b00bf884
@ -1,6 +1,6 @@
|
|||||||
# Multi Debrid Downloader
|
# Multi Debrid Downloader
|
||||||
|
|
||||||
Desktop downloader with fast queue management, automatic extraction, and robust error handling.
|
Desktop downloader for **Real-Debrid, Mega-Debrid, BestDebrid, and AllDebrid** with fast queue management, automatic extraction, and robust error handling.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.6.30",
|
"version": "1.6.26",
|
||||||
"description": "Desktop downloader",
|
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.5.97";
|
|
||||||
|
|
||||||
const RELEASE_BODY = `## What's Changed in v1.5.97
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
- **Fix "Ausstehend" / "Warten auf Parts" label flicker during hybrid extraction**: Previously, every hybrid extraction run would reset ALL non-extracted completed items to either "Entpacken - Ausstehend" or "Entpacken - Warten auf Parts", causing visible flickering between status labels. Now only items whose archives are actually in the current \`readyArchives\` set get "Ausstehend"; all other items correctly show "Warten auf Parts" until their archive is genuinely ready for extraction. This eliminates the misleading "Ausstehend" label on items that aren't being extracted in the current run.
|
|
||||||
`;
|
|
||||||
|
|
||||||
function request(method, urlPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${urlPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
let data = "";
|
|
||||||
res.on("data", (c) => (data += c));
|
|
||||||
res.on("end", () => {
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${data}`));
|
|
||||||
else resolve(JSON.parse(data || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(typeof body === "string" ? body : JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, name) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const fileBuffer = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": fileBuffer.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
let data = "";
|
|
||||||
res.on("data", (c) => (data += c));
|
|
||||||
res.on("end", () => {
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${data}`));
|
|
||||||
else resolve(JSON.parse(data || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(fileBuffer);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await request("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: RELEASE_BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: id=${release.id}`);
|
|
||||||
const releaseDir = path.resolve("release");
|
|
||||||
const assets = [
|
|
||||||
{ file: `Real-Debrid-Downloader-Setup-1.5.97.exe`, name: `Real-Debrid-Downloader-Setup-1.5.97.exe` },
|
|
||||||
{ file: `Real-Debrid-Downloader 1.5.97.exe`, name: `Real-Debrid-Downloader-1.5.97.exe` },
|
|
||||||
{ file: `latest.yml`, name: `latest.yml` },
|
|
||||||
{ file: `Real-Debrid-Downloader Setup 1.5.97.exe.blockmap`, name: `Real-Debrid-Downloader-Setup-1.5.97.exe.blockmap` },
|
|
||||||
];
|
|
||||||
for (const asset of assets) {
|
|
||||||
const filePath = path.join(releaseDir, asset.file);
|
|
||||||
if (!fs.existsSync(filePath)) { console.warn(`SKIP: ${asset.file}`); continue; }
|
|
||||||
console.log(`Uploading ${asset.name} (${(fs.statSync(filePath).size / 1048576).toFixed(1)} MB)...`);
|
|
||||||
await uploadAsset(release.id, filePath, asset.name);
|
|
||||||
console.log(` done`);
|
|
||||||
}
|
|
||||||
console.log("Done!");
|
|
||||||
}
|
|
||||||
main().catch((err) => { console.error(err); process.exit(1); });
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.10";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.10
|
|
||||||
|
|
||||||
### Critical Bug Fixes
|
|
||||||
|
|
||||||
#### Post-Process Slot Counter Race Condition (multiple packages extracting simultaneously)
|
|
||||||
- **Bug:** After stopping and restarting a session, the internal post-processing slot counter could go **negative**. This allowed multiple packages to extract simultaneously instead of the intended one-at-a-time sequential extraction.
|
|
||||||
- **Root cause:** \`stop()\` resets the active counter to 0 and resolves all waiting promises. The resolved waiters then increment the counter (+N), but ALL tasks (including the original active one) still decrement it in their cleanup (-(N+1)), resulting in a negative value. On the next session start, multiple packages pass the \`active < maxConcurrent\` check.
|
|
||||||
- **Fix:** Added a guard in \`releasePostProcessSlot()\` to prevent the counter from going below zero.
|
|
||||||
|
|
||||||
#### Extraction Resume State / Progress Sync Bug (first episode stays "Entpacken - Ausstehend")
|
|
||||||
- **Bug:** When an archive was previously extracted in a hybrid extraction round and recorded in the resume state, but the app was stopped before the item's "Entpackt - Done" label was persisted, the next full extraction would skip the archive (correctly, via resume state) but never update the item's UI label. The item would permanently show "Entpacken - Ausstehend" while all other episodes showed "Entpackt - Done".
|
|
||||||
- **Fix:** \`extractPackageArchives()\` now emits progress events with \`archivePercent: 100\` for archives that are already in the resume state, so the caller's \`onProgress\` handler marks those items as "Entpackt - Done" immediately.
|
|
||||||
|
|
||||||
#### Abort Labels Applied to Non-Extracting Items
|
|
||||||
- **Bug:** When stopping a session, \`abortPostProcessing()\` set ALL completed items with any "Entpacken" label to "Entpacken abgebrochen (wird fortgesetzt)" — including items that were merely "Entpacken - Ausstehend" or "Entpacken - Warten auf Parts" and had never started extracting.
|
|
||||||
- **Fix:** The abort label is now only applied to items with active extraction progress (e.g., "Entpacken 64%"), not to pending items.
|
|
||||||
|
|
||||||
#### Missing Package Status Update in Hybrid Extraction Branches
|
|
||||||
- **Bug:** \`triggerPendingExtractions()\` and \`recoverPostProcessingOnStartup()\` did not set \`pkg.status = "queued"\` in their hybrid extraction branches, unlike the full extraction branches. This could cause the package status bar to show incorrect state during hybrid extraction.
|
|
||||||
- **Fix:** Both hybrid branches now correctly set \`pkg.status = "queued"\` before triggering extraction.
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — Slot counter guard, abort label fix, hybrid pkg.status
|
|
||||||
- \`src/main/extractor.ts\` — Resume state progress emission
|
|
||||||
- \`package.json\` — Version bump to 1.6.10
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const options = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const req = https.request(options, (res) => {
|
|
||||||
let data = "";
|
|
||||||
res.on("data", (chunk) => (data += chunk));
|
|
||||||
res.on("end", () => {
|
|
||||||
if (res.statusCode >= 400) {
|
|
||||||
reject(new Error(`${res.statusCode}: ${data}`));
|
|
||||||
} else {
|
|
||||||
resolve(JSON.parse(data));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const fileBuffer = fs.readFileSync(filePath);
|
|
||||||
const options = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Length": fileBuffer.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const req = https.request(options, (res) => {
|
|
||||||
let data = "";
|
|
||||||
res.on("data", (chunk) => (data += chunk));
|
|
||||||
res.on("end", () => {
|
|
||||||
if (res.statusCode >= 400) {
|
|
||||||
reject(new Error(`Upload ${fileName}: ${res.statusCode}: ${data}`));
|
|
||||||
} else {
|
|
||||||
console.log(` Uploaded: ${fileName}`);
|
|
||||||
resolve(JSON.parse(data));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(fileBuffer);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", "/releases", {
|
|
||||||
tag_name: TAG,
|
|
||||||
name: TAG,
|
|
||||||
body: BODY,
|
|
||||||
draft: false,
|
|
||||||
prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.html_url}`);
|
|
||||||
|
|
||||||
const releaseDir = path.resolve("release");
|
|
||||||
const assets = [
|
|
||||||
{ file: `Real-Debrid-Downloader-Setup-1.6.10.exe`, name: `Real-Debrid-Downloader-Setup-1.6.10.exe` },
|
|
||||||
{ file: `Real-Debrid-Downloader 1.6.10.exe`, name: `Real-Debrid-Downloader-1.6.10.exe` },
|
|
||||||
{ file: `latest.yml`, name: `latest.yml` },
|
|
||||||
{ file: `Real-Debrid-Downloader Setup 1.6.10.exe.blockmap`, name: `Real-Debrid-Downloader-Setup-1.6.10.exe.blockmap` },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const asset of assets) {
|
|
||||||
const filePath = path.join(releaseDir, asset.file);
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
console.warn(` SKIP (not found): ${asset.file}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await uploadAsset(release.id, filePath, asset.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Done!");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.11";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.11
|
|
||||||
|
|
||||||
### New Feature: Extract While Stopped
|
|
||||||
- **New setting "Entpacken auch ohne laufende Session"** (default: enabled): Extractions now continue running even after clicking Stop, and pending extractions are automatically triggered on app startup without needing to click Start
|
|
||||||
- This means downloaded archives get extracted immediately regardless of session state — no more forgotten pending extractions after restart
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
#### Update Installation Safety
|
|
||||||
- **Stop active downloads before installing updates**: Previously, launching an update while downloads were active could cause data corruption. The app now gracefully stops all downloads before spawning the installer
|
|
||||||
- **Increased quit timeout** from 800ms to 2500ms after launching the update installer, giving the OS more time to start the setup process before the app exits
|
|
||||||
|
|
||||||
#### Extraction Resume Progress (v1.6.9 fix)
|
|
||||||
- **Fixed "Entpacken - Ausstehend" for already-extracted archives**: When resuming extraction (e.g. after a crash), archives that were already successfully extracted in a previous run now correctly show as "Entpackt - Done" immediately, instead of staying stuck as "Entpacken - Ausstehend" until all remaining archives finish
|
|
||||||
- Root cause: The resume state correctly tracked completed archives, but no UI progress event was emitted for them, leaving their items with a stale "pending" label
|
|
||||||
|
|
||||||
#### Extraction Abort Label Accuracy (v1.6.9 fix)
|
|
||||||
- **"Entpacken abgebrochen" now only applied to actively extracting items**: Previously, clicking Stop would mark ALL extraction-related items as "Entpacken abgebrochen (wird fortgesetzt)" — even items that were just queued ("Ausstehend") or waiting for parts ("Warten auf Parts") and had never started extracting. Now only items with actual extraction progress get the "abgebrochen" label
|
|
||||||
|
|
||||||
#### Hybrid Extraction Package Status (v1.6.9 fix)
|
|
||||||
- **Fixed package status not updating for hybrid extraction recovery**: When recovering pending hybrid extractions on startup or after pause toggle, the package status is now correctly set to "queued" so the UI reflects that extraction work is pending
|
|
||||||
|
|
||||||
#### Parallel Extraction Slot Counter (v1.6.10 fix)
|
|
||||||
- **Fixed multiple packages extracting simultaneously despite maxParallelExtract=1**: The post-processing slot counter could go negative after Stop was clicked (stop resets counter to 0, but aborted tasks still decrement in their finally blocks). On the next session start, the negative counter let multiple packages pass the concurrency check. Added a guard to prevent the counter from going below zero
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — autoExtractWhenStopped logic in stop(), triggerIdleExtractions(), slot counter guard
|
|
||||||
- \`src/main/app-controller.ts\` — Stop downloads before update, trigger idle extractions on startup
|
|
||||||
- \`src/main/main.ts\` — Increased update quit timeout
|
|
||||||
- \`src/main/extractor.ts\` — Emit progress for resumed archives
|
|
||||||
- \`src/main/constants.ts\` — New default setting
|
|
||||||
- \`src/shared/types.ts\` — autoExtractWhenStopped type
|
|
||||||
- \`src/renderer/App.tsx\` — Settings toggle UI
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) {
|
|
||||||
reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
} else {
|
|
||||||
resolve(JSON.parse(text || "{}"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Length": data.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG,
|
|
||||||
name: TAG,
|
|
||||||
body: BODY,
|
|
||||||
draft: false,
|
|
||||||
prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.11.exe", name: "Real-Debrid-Downloader-Setup-1.6.11.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.11.exe", name: "Real-Debrid-Downloader-1.6.11.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.11.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.11.exe.blockmap" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.12";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.12
|
|
||||||
|
|
||||||
### Bug Fixes (found via code review)
|
|
||||||
|
|
||||||
#### Package status incorrectly set to "downloading" during idle extraction
|
|
||||||
- When extraction ran while the session was stopped (via the new \`autoExtractWhenStopped\` feature), \`handlePackagePostProcessing\` would set \`pkg.status = "downloading"\` after hybrid extraction completed — even though no downloads were active
|
|
||||||
- This caused packages to appear as "downloading" in the UI when the session was stopped, which was confusing and semantically incorrect
|
|
||||||
- **Fix:** The status derivation now checks \`this.session.running\` in addition to \`pkg.enabled\` and \`!this.session.paused\`. When the session is stopped, packages are set to \`"queued"\` instead of \`"downloading"\`
|
|
||||||
|
|
||||||
#### Backup import leaves orphan extraction tasks running
|
|
||||||
- When importing a backup with \`autoExtractWhenStopped = true\`, the \`importBackup()\` method called \`stop()\` which no longer aborts extraction tasks (by design). This meant extraction tasks from the **old** session continued running in the background, potentially mutating stale in-memory state while the restored session was being saved to disk
|
|
||||||
- **Fix:** \`importBackup()\` now explicitly calls \`abortAllPostProcessing()\` after \`stop()\` to ensure all extraction tasks from the old session are terminated before the new session is loaded
|
|
||||||
- Added public \`abortAllPostProcessing()\` method to DownloadManager for external callers that need a full extraction abort regardless of settings
|
|
||||||
|
|
||||||
#### Corrected misleading comment in installUpdate()
|
|
||||||
- The comment in \`installUpdate()\` claimed it stops "downloads/extractions" but with \`autoExtractWhenStopped\`, extractions may continue briefly until \`prepareForShutdown()\` runs during app quit. Updated comment to reflect actual behavior.
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — Fixed \`pkg.status\` derivation in \`handlePackagePostProcessing\`, added \`abortAllPostProcessing()\`
|
|
||||||
- \`src/main/app-controller.ts\` — \`importBackup()\` now aborts all post-processing, updated \`installUpdate()\` comment
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) {
|
|
||||||
reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
} else {
|
|
||||||
resolve(JSON.parse(text || "{}"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Length": data.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG,
|
|
||||||
name: TAG,
|
|
||||||
body: BODY,
|
|
||||||
draft: false,
|
|
||||||
prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.12.exe", name: "Real-Debrid-Downloader-Setup-1.6.12.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.12.exe", name: "Real-Debrid-Downloader-1.6.12.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.12.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.12.exe.blockmap" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.13";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.13
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
#### History entries not being created
|
|
||||||
- **Fixed: Nothing was being added to the download history** regardless of whether using Start or Stop mode
|
|
||||||
- Root cause: History entries were only created inside \`removePackageFromSession()\`, which only runs when the cleanup policy removes the package from the session. With \`completedCleanupPolicy = "never"\` (the default), packages are never removed, so history was never populated. With \`"immediate"\` policy, items were removed one-by-one leaving an empty array when the package itself was removed — also resulting in no history entry
|
|
||||||
- **Fix:** History entries are now recorded directly in \`handlePackagePostProcessing()\` when a package completes extraction (or download without extraction). A deduplication Set (\`historyRecordedPackages\`) prevents double entries when the cleanup policy also removes the package
|
|
||||||
- The \`removePackageFromSession()\` history logic now only fires for manual deletions (reason = "deleted"), not for completions which are already tracked
|
|
||||||
|
|
||||||
#### UI delay after extraction completes (20-30 seconds)
|
|
||||||
- **Fixed: Package stayed visible for 20-30 seconds after extraction finished** before disappearing or showing "Done" status
|
|
||||||
- Root cause: After extraction set \`pkg.status = "completed"\`, there was no \`emitState()\` call. The next UI update only happened after \`autoRenameExtractedVideoFiles()\`, \`collectMkvFilesToLibrary()\`, and \`applyPackageDoneCleanup()\` all completed — which could take 20-30 seconds for large packages with MKV collection or renaming
|
|
||||||
- **Fix:** Added an \`emitState()\` call immediately after the package status is set (completed/failed), before the rename and MKV collection steps. The UI now reflects the extraction result instantly while post-extraction steps run in the background
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — New \`recordPackageHistory()\` method, \`historyRecordedPackages\` deduplication Set, \`emitState()\` after extraction completion, refactored \`removePackageFromSession()\` history logic
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Accept: "application/json",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) {
|
|
||||||
reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
} else {
|
|
||||||
resolve(JSON.parse(text || "{}"));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Length": data.length,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG,
|
|
||||||
name: TAG,
|
|
||||||
body: BODY,
|
|
||||||
draft: false,
|
|
||||||
prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.13.exe", name: "Real-Debrid-Downloader-Setup-1.6.13.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.13.exe", name: "Real-Debrid-Downloader-1.6.13.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.13.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.13.exe.blockmap" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.14";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.14
|
|
||||||
|
|
||||||
### Improvements
|
|
||||||
|
|
||||||
#### Better Auto-Rename Logging
|
|
||||||
- Added detailed logging for the auto-rename feature to help diagnose cases where renaming doesn't work as expected
|
|
||||||
- New log messages show: how many video files were found, which files couldn't be matched to a target name (with folder candidates), and successful renames with source → target mapping
|
|
||||||
- This makes it much easier to identify why a specific file wasn't renamed (wrong folder name, missing episode token, file already exists, etc.)
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
- Added test case for Riviera S02 with single-digit episode format (\`s02e2\` → \`S02E02\`) to verify the rename logic handles non-zero-padded episode numbers correctly (98 auto-rename tests now)
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — Added logging in \`autoRenameExtractedVideoFiles()\`
|
|
||||||
- \`tests/auto-rename.test.ts\` — New Riviera S02 test case
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.14.exe", name: "Real-Debrid-Downloader-Setup-1.6.14.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.14.exe", name: "Real-Debrid-Downloader-1.6.14.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.14.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.14.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.15";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.15
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
#### Session not transitioning to "Stop" when downloads finish (autoExtractWhenStopped)
|
|
||||||
- **Fixed: Session stayed in "running" state indefinitely after all downloads completed** when \`autoExtractWhenStopped\` was enabled
|
|
||||||
- Root cause: The scheduler's finalization check required \`packagePostProcessTasks.size === 0\` before calling \`finishRun()\`. With \`autoExtractWhenStopped\`, post-processing (extraction) tasks continue running after downloads finish, so the condition was never satisfied — the session never switched to "stop"
|
|
||||||
- **Fix:** When \`autoExtractWhenStopped\` is enabled, the scheduler now calls \`finishRun()\` as soon as all downloads are complete (no queued/active/delayed items), regardless of whether post-processing tasks are still running. Extraction continues in the background as idle extraction, exactly as intended by the setting. When \`autoExtractWhenStopped\` is disabled, the previous behavior is preserved (session waits for both downloads and extraction to complete)
|
|
||||||
|
|
||||||
#### Packages cannot be collapsed during extraction
|
|
||||||
- **Fixed: Clicking collapse on a package caused it to re-expand after 1-2 seconds** during extraction
|
|
||||||
- Same issue with the footer "Alle einklappen" button — packages would immediately re-expand
|
|
||||||
- Root cause: The auto-expand \`useEffect\` ran on every state update (\`emitState()\` call) and forcibly re-expanded any package with items in "Entpacken -" status. With the more frequent \`emitState()\` calls added in v1.6.13, this fired constantly, making it impossible for users to keep packages collapsed
|
|
||||||
- **Fix:** Added a \`useRef(Set)\` to track which packages have already been auto-expanded. Each package is now only auto-expanded **once** when extraction starts. If the user manually collapses it, it stays collapsed. The tracking is reset when the package is no longer extracting, so a future extraction cycle will auto-expand it again
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — Scheduler finalization condition now respects \`autoExtractWhenStopped\` setting
|
|
||||||
- \`src/renderer/App.tsx\` — Auto-expand useEffect now uses ref-based tracking for one-time expansion per extraction cycle
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.15.exe", name: "Real-Debrid-Downloader-Setup-1.6.15.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.15.exe", name: "Real-Debrid-Downloader-1.6.15.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.15.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.15.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,131 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.16";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.16
|
|
||||||
|
|
||||||
### Bug Fixes (Code Review)
|
|
||||||
|
|
||||||
This release fixes 11 bugs found through a comprehensive code review of the download manager, renderer, IPC layer, and app controller.
|
|
||||||
|
|
||||||
#### Critical: \`finishRun()\` clears state needed by still-running extraction tasks
|
|
||||||
- When \`autoExtractWhenStopped\` is enabled, the scheduler calls \`finishRun()\` as soon as downloads complete. \`finishRun()\` was clearing \`runPackageIds\` and \`runCompletedPackages\` immediately, but still-running extraction tasks needed those sets to update \`runCompletedPackages\` when they finish
|
|
||||||
- **Fix:** \`runPackageIds\` and \`runCompletedPackages\` are now only cleared when no post-processing tasks are running. Otherwise they are preserved until the next \`start()\` call
|
|
||||||
|
|
||||||
#### Critical: Post-process slot counter corrupted after stop+restart
|
|
||||||
- \`acquirePostProcessSlot()\` increments \`packagePostProcessActive\` after the awaited promise resolves. But \`stop()\` resets the counter to 0 while waiters are pending. When \`stop()\` resolves all waiters, the increment fires afterward, pushing the counter from 0→1 before any new task runs — causing the first extraction in the next session to unnecessarily queue
|
|
||||||
- **Fix:** Added a guard that only increments if below \`maxConcurrent\` after the await, matching the existing guard in \`releasePostProcessSlot()\`
|
|
||||||
|
|
||||||
#### Important: \`resetPackage()\` skips history on re-completion
|
|
||||||
- \`historyRecordedPackages\` was never cleared for a package when it was reset. If a user reset a package and it completed again, \`recordPackageHistory()\` would find it already in the set and skip recording — no history entry was created for the second run
|
|
||||||
- **Fix:** \`resetPackage()\` now calls \`this.historyRecordedPackages.delete(packageId)\`
|
|
||||||
|
|
||||||
#### Important: Context menu "Ausgewählte Downloads starten" sends non-startable items
|
|
||||||
- The context menu button filtered items by startable status (\`queued\`/\`cancelled\`/\`reconnect_wait\`) only for the visibility check, but the click handler sent ALL selected item IDs to \`startItems()\`, including items already downloading or completed
|
|
||||||
- **Fix:** Click handler now filters item IDs to only startable statuses before sending to \`startItems()\`
|
|
||||||
|
|
||||||
#### Important: \`importBackup\` persist race condition
|
|
||||||
- After calling \`stop()\` + \`abortAllPostProcessing()\`, deferred \`persistSoon()\` timers from those operations could fire and overwrite the restored session file on disk with the old in-memory session
|
|
||||||
- **Fix:** \`clearPersistTimer()\` is now called after abort to cancel any pending persist timers. Made it a public method for this purpose
|
|
||||||
|
|
||||||
#### Important: Auto-Resume on start never fires
|
|
||||||
- \`autoResumePending\` was set in an async \`getStartConflicts().then()\` callback, but the \`onState\` setter (which checks the flag) always ran synchronously before the promise resolved. The flag was always \`false\` when checked, so auto-resume never triggered
|
|
||||||
- **Fix:** The \`.then()\` callback now checks if \`onStateHandler\` is already set and starts the download directly in that case, instead of just setting a flag
|
|
||||||
|
|
||||||
#### IPC validation hardening
|
|
||||||
- \`START_PACKAGES\`, \`SKIP_ITEMS\`, \`RESET_ITEMS\` handlers used only \`Array.isArray()\` instead of \`validateStringArray()\`, missing element-type validation and null guards
|
|
||||||
- \`SET_PACKAGE_PRIORITY\` accepted any string value instead of validating against \`"high" | "normal" | "low"\`
|
|
||||||
- **Fix:** All handlers now use \`validateStringArray()\` with null guards, and priority is enum-validated
|
|
||||||
|
|
||||||
#### History context menu stale closure
|
|
||||||
- \`removeSelected()\` in the history context menu read \`selectedHistoryIds\` directly in the \`.then()\` callback instead of using a captured snapshot. If selection changed during the async IPC round-trip, the wrong entries could be filtered
|
|
||||||
- **Fix:** Captured the set into a local \`idSet\` before the async call, matching the pattern already used by the toolbar delete button
|
|
||||||
|
|
||||||
#### Update quit timer not cancellable
|
|
||||||
- \`installUpdate\` set a 2.5-second \`setTimeout\` for \`app.quit()\` but stored no reference to it. If the user manually closed the window during that time, \`before-quit\` would fire normally, then the timer would call \`app.quit()\` again, potentially causing double shutdown cleanup
|
|
||||||
- **Fix:** Timer reference is now stored and cleared in the \`before-quit\` handler
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — \`finishRun()\` conditional clear, \`acquirePostProcessSlot()\` guard, \`resetPackage()\` history fix, \`clearPersistTimer()\` public
|
|
||||||
- \`src/main/app-controller.ts\` — \`importBackup\` persist timer cancel, \`autoResumePending\` race fix
|
|
||||||
- \`src/main/main.ts\` — IPC validation hardening, update quit timer cancel, priority enum validation
|
|
||||||
- \`src/renderer/App.tsx\` — Context menu item filter, history stale closure fix
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.16.exe", name: "Real-Debrid-Downloader-Setup-1.6.16.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.16.exe", name: "Real-Debrid-Downloader-1.6.16.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.16.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.16.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.17";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.17
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 2)
|
|
||||||
|
|
||||||
This release fixes 9 additional bugs found through a second comprehensive code review covering the download manager, renderer, extractor, and storage layer.
|
|
||||||
|
|
||||||
#### Critical: \\\`autoExtractWhenStopped\\\` setting silently dropped on save/load
|
|
||||||
- \\\`normalizeSettings()\\\` in \\\`storage.ts\\\` was missing the \\\`autoExtractWhenStopped\\\` field. Every time settings were saved or loaded, the field was stripped — effectively hardcoding the feature to "off" after the first persistence cycle
|
|
||||||
- **Fix:** Added \\\`autoExtractWhenStopped\\\` to \\\`normalizeSettings()\\\` with proper boolean coercion and default fallback
|
|
||||||
|
|
||||||
#### Critical: Parallel extraction password data race
|
|
||||||
- When \\\`maxParallelExtract > 1\\\`, multiple concurrent workers read/wrote the shared \\\`passwordCandidates\\\` variable without synchronization, causing lost password promotions
|
|
||||||
- **Fix:** Password list is frozen before parallel extraction; concurrent mutations discarded
|
|
||||||
|
|
||||||
#### Important: \\\`start()\\\` does not clear \\\`retryStateByItem\\\` — premature shelving after stop/restart
|
|
||||||
- \\\`start()\\\` cleared retry delays but NOT failure counters. Items inherited stale counts from previous runs, getting shelved prematurely (threshold 15, old run had 10 = shelved after 5 errors)
|
|
||||||
- **Fix:** Added \\\`retryStateByItem.clear()\\\` to \\\`start()\\\`
|
|
||||||
|
|
||||||
#### Important: \\\`SUBST_THRESHOLD\\\` too low — subst drive mapped on nearly every extraction
|
|
||||||
- Triggered at path length >= 100 chars, but most real paths exceed that. Raised to 200 (MAX_PATH is 260)
|
|
||||||
|
|
||||||
#### Important: Settings quicksave race condition
|
|
||||||
- Menu quicksaves cleared \\\`settingsDirtyRef\\\` unconditionally in \\\`.finally()\\\`, overriding concurrent settings changes
|
|
||||||
- **Fix:** All 7 quicksave paths now use a revision counter guard
|
|
||||||
|
|
||||||
#### Important: \\\`removeCollectorTab\\\` side effect in setState callback
|
|
||||||
- Mutated outer-scope variable inside setState updater (unsafe in React Strict/Concurrent Mode)
|
|
||||||
- **Fix:** Refactored to avoid side effects in the render callback
|
|
||||||
|
|
||||||
#### Minor: Escape key clears selection during text input
|
|
||||||
- Added input focus guard matching the existing Delete key guard
|
|
||||||
|
|
||||||
#### Minor: Debug console.log in production removed
|
|
||||||
#### Minor: maxParallel input missing clamp in settings tab
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \\\`src/main/storage.ts\\\` — \\\`autoExtractWhenStopped\\\` in \\\`normalizeSettings()\\\`
|
|
||||||
- \\\`src/main/download-manager.ts\\\` — \\\`start()\\\` clears \\\`retryStateByItem\\\`
|
|
||||||
- \\\`src/main/extractor.ts\\\` — \\\`SUBST_THRESHOLD\\\` 100 to 200, parallel password race fix
|
|
||||||
- \\\`src/renderer/App.tsx\\\` — Quicksave revision guard, collector tab fix, Escape guard, console.log removal, maxParallel clamp
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.17.exe", name: "Real-Debrid-Downloader-Setup-1.6.17.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.17.exe", name: "Real-Debrid-Downloader-1.6.17.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.17.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.17.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.18";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.18
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 3)
|
|
||||||
|
|
||||||
This release fixes 6 bugs found through a third comprehensive code review covering the download manager, renderer, CSS layer, and test fixtures.
|
|
||||||
|
|
||||||
#### Important: \`resolveStartConflict("skip")\` ineffective during running session
|
|
||||||
- When the session was running and a start conflict was resolved with "skip", items were removed from \`runItemIds\` only when \`!session.running\`. This meant skipped items stayed in the run set and the scheduler would re-download them, defeating the skip entirely
|
|
||||||
- **Fix:** Items and packages are now unconditionally removed from \`runItemIds\`/\`runPackageIds\` regardless of session state
|
|
||||||
|
|
||||||
#### Important: \`skipItems()\` corrupts run summary totals
|
|
||||||
- \`skipItems()\` set items to "cancelled" but never called \`recordRunOutcome()\`. Skipped items were invisible to the run summary, causing inaccurate completion statistics
|
|
||||||
- **Fix:** Added \`recordRunOutcome(itemId, "cancelled")\` for each skipped item
|
|
||||||
|
|
||||||
#### Important: \`handleUpdateResult\` holds \`actionBusy\` lock across user confirm dialog
|
|
||||||
- When manually checking for updates, the entire \`handleUpdateResult\` (including the "Install update?" confirmation dialog) ran inside \`performQuickAction\`. While the dialog was open, all UI buttons were disabled since \`actionBusy\` was held
|
|
||||||
- **Fix:** The update check API call is now separated from the result handling — \`actionBusy\` is released after the API call completes, before the confirm dialog is shown
|
|
||||||
|
|
||||||
#### Minor: Drop overlay missing z-index
|
|
||||||
- \`.drop-overlay\` had \`position: fixed\` but no \`z-index\`, so it could render behind context menus (\`z-index: 100\`) or modals (\`z-index: 20\`) when dragging files
|
|
||||||
- **Fix:** Added \`z-index: 200\` to \`.drop-overlay\`
|
|
||||||
|
|
||||||
#### Minor: \`etaText.split(": ")\` fragile ETA parsing
|
|
||||||
- The statistics tab split \`etaText\` on \`": "\`, which broke for ETAs containing colons (e.g., "ETA: 2:30:15" would show just "2" instead of "2:30:15")
|
|
||||||
- **Fix:** Replaced \`split(": ")\` with \`indexOf(": ")\`/\`slice()\` to split only on the first occurrence
|
|
||||||
|
|
||||||
#### Minor: Test fixtures missing required \`priority\` field
|
|
||||||
- \`PackageEntry\` requires a \`priority\` field since v1.5.x, but test fixtures in \`app-order.test.ts\` omitted it, causing a type mismatch (vitest doesn't type-check by default so this was silent)
|
|
||||||
- **Fix:** Added \`priority: "normal"\` to all test fixtures
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — \`skipItems()\` calls \`recordRunOutcome()\`; \`resolveStartConflict("skip")\` removes items/packages from run sets unconditionally
|
|
||||||
- \`src/renderer/App.tsx\` — \`onCheckUpdates\` releases \`actionBusy\` before confirm dialog; ETA text split fix
|
|
||||||
- \`src/renderer/styles.css\` — \`drop-overlay\` z-index
|
|
||||||
- \`tests/app-order.test.ts\` — Added \`priority: "normal"\` to PackageEntry fixtures
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.18.exe", name: "Real-Debrid-Downloader-Setup-1.6.18.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.18.exe", name: "Real-Debrid-Downloader-1.6.18.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.18.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.18.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.19";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.19
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 4)
|
|
||||||
|
|
||||||
This release fixes 8 bugs found through a fourth comprehensive code review covering the download manager, renderer, storage layer, and IPC handlers.
|
|
||||||
|
|
||||||
#### Critical: \`resetItems()\`/\`resetPackage()\` abort race corrupts item state
|
|
||||||
- When resetting an item that is actively downloading, the abort was sent with reason \`"cancel"\`. The async \`processItem\` catch block then overwrote the freshly-reset item state back to \`status="cancelled"\`, \`fullStatus="Entfernt"\` — making the item permanently stuck
|
|
||||||
- The identity guard (\`session.items[id] !== item\`) did not protect against this because reset keeps the same item object reference
|
|
||||||
- **Fix:** Introduced a new abort reason \`"reset"\` for \`resetItems()\`/\`resetPackage()\`. The \`processItem\` catch block now handles \`"reset"\` as a no-op, preserving the already-correct state set by the reset function
|
|
||||||
|
|
||||||
#### Important: \`resolveStartConflict("skip")\` fullStatus race condition
|
|
||||||
- When skipping a package during a running session, active items were aborted with reason \`"package_toggle"\`. The async catch block then overwrote \`fullStatus\` from \`"Wartet"\` to \`"Paket gestoppt"\`, showing a confusing UI state for items that were skipped (not toggled off)
|
|
||||||
- **Fix:** Added a \`queueMicrotask()\` callback after the abort loop that re-corrects any items whose \`fullStatus\` was overwritten to \`"Paket gestoppt"\`
|
|
||||||
|
|
||||||
#### Important: "Don't ask again" delete confirmation not persisted to server
|
|
||||||
- Clicking "Nicht mehr anzeigen" in the delete confirmation dialog only updated the local draft state via \`setBool()\`, but never called \`window.rd.updateSettings()\`. On app restart, the setting reverted to \`true\`
|
|
||||||
- **Fix:** Added an immediate \`window.rd.updateSettings({ confirmDeleteSelection: false })\` call alongside the draft state update
|
|
||||||
|
|
||||||
#### Important: Storage \`writeFileSync\` leaves corrupt \`.tmp\` file on disk-full/permission error
|
|
||||||
- \`saveSettings()\`, \`saveSession()\`, and \`saveHistory()\` wrote to a \`.tmp\` file then renamed. If \`writeFileSync\` threw (disk full, permission denied), the partially-written \`.tmp\` file was left on disk without cleanup
|
|
||||||
- **Fix:** Wrapped write+rename in try/catch with \`.tmp\` cleanup in the catch block for all three sync save functions
|
|
||||||
|
|
||||||
#### Important: Tray "Start" click — unhandled Promise rejection
|
|
||||||
- The tray context menu's "Start" handler called \`controller.start()\` without \`.catch()\` or \`void\`. If \`start()\` threw (e.g., network error during conflict check), it resulted in an unhandled Promise rejection
|
|
||||||
- **Fix:** Added \`void controller.start().catch(...)\` with a logger warning
|
|
||||||
|
|
||||||
#### Important: \`resetItems()\` removes item from \`runItemIds\` without re-adding — session summary incomplete
|
|
||||||
- When an item was reset during a running session, it was removed from \`runItemIds\` but never re-added. The scheduler would still pick it up (via package membership), but \`recordRunOutcome()\` would skip it since \`runItemIds.has(itemId)\` returned false. Session summary counts were therefore inaccurate
|
|
||||||
- **Fix:** After resetting an item, re-add it to \`runItemIds\` if the session is running
|
|
||||||
|
|
||||||
#### Minor: \`importBackup\` no file size limit
|
|
||||||
- The backup import handler read files into memory without any size guard. A user accidentally selecting a multi-GB file could crash the Electron process
|
|
||||||
- **Fix:** Added a 50 MB file size check before reading
|
|
||||||
|
|
||||||
#### Minor: Bandwidth schedule inputs accept NaN
|
|
||||||
- The start/end hour inputs for bandwidth schedules passed \`Number(e.target.value)\` directly without NaN guard. Clearing the field produced \`NaN\` in the settings draft, which could be serialized and sent to the server
|
|
||||||
- **Fix:** Added \`Number.isNaN()\` guard with \`Math.max(0, Math.min(23, v))\` clamping
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — New \`"reset"\` abort reason for \`resetItems()\`/\`resetPackage()\`; \`processItem\` handles \`"reset"\` as no-op; \`resolveStartConflict("skip")\` queueMicrotask fix; \`resetItems()\` re-adds to \`runItemIds\` when running
|
|
||||||
- \`src/main/main.ts\` — Tray Start \`.catch()\`; \`importBackup\` file size guard
|
|
||||||
- \`src/main/storage.ts\` — \`.tmp\` cleanup on write failure for \`saveSettings\`, \`saveSession\`, \`saveHistory\`
|
|
||||||
- \`src/renderer/App.tsx\` — Delete confirmation persists to server; bandwidth schedule NaN guard
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.19.exe", name: "Real-Debrid-Downloader-Setup-1.6.19.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.19.exe", name: "Real-Debrid-Downloader-1.6.19.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.19.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.19.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.20";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.20
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 5)
|
|
||||||
|
|
||||||
This release fixes 8 bugs found through a fifth comprehensive code review covering the extractor, download manager, renderer, and JVM integration.
|
|
||||||
|
|
||||||
#### Critical: \`extractSingleArchive\` inflates \`failed\` counter on abort
|
|
||||||
- When extraction was aborted (e.g. by user or signal), the catch block incremented \`failed += 1\` **before** checking if the error was an abort error and re-throwing. This inflated the failed counter, potentially causing the extraction summary to report more failures than actually occurred, and preventing nested extraction (\`failed === 0\` guard)
|
|
||||||
- **Fix:** Moved the abort error check before \`failed += 1\` so aborted archives are re-thrown without incrementing the failure count
|
|
||||||
|
|
||||||
#### Important: \`requestReconnect()\` — \`consecutiveReconnects\` inflated by parallel downloads
|
|
||||||
- When multiple parallel downloads encountered HTTP 429/503 simultaneously, each one called \`requestReconnect()\`, incrementing \`consecutiveReconnects\` once per download. With 10 parallel downloads, a single rate-limit event could immediately push the backoff multiplier to its maximum (5x), causing unnecessarily long reconnect waits
|
|
||||||
- **Fix:** Only increment \`consecutiveReconnects\` when not already inside an active reconnect window (\`reconnectUntil <= now\`). Subsequent calls during the same window still trigger the abort/reconnect flow but don't inflate the backoff
|
|
||||||
|
|
||||||
#### Important: Stale \`snapshot\` closure in \`onAddLinks\`/\`onImportDlc\`/\`onDrop\`
|
|
||||||
- The \`existingIds\` baseline (used to identify newly added packages for auto-collapse) was computed from the stale \`snapshot\` variable captured at render time, not the current ref. If the user added links in quick succession, previously added packages could also be collapsed because \`existingIds\` didn't include them yet
|
|
||||||
- **Fix:** Changed all three functions to read from \`snapshotRef.current.session.packages\` instead of \`snapshot.session.packages\`
|
|
||||||
|
|
||||||
#### Important: \`downloadToFile()\` HTTP 429/503 bypasses inner retry loop
|
|
||||||
- On receiving HTTP 429 (Too Many Requests) or 503 (Service Unavailable), the download handler immediately called \`requestReconnect()\` and threw, even on the first attempt. This bypassed the inner retry loop entirely, escalating a potentially transient error into a full reconnect cycle that aborted all active downloads
|
|
||||||
- **Fix:** Moved the reconnect escalation after the inner retry loop. The download now retries normally first (with backoff), and only triggers a full reconnect if all retry attempts are exhausted with a 429/503
|
|
||||||
|
|
||||||
#### Important: \`PackageCard\` memo comparator missing \`onlineStatus\`
|
|
||||||
- The custom \`memo\` comparator for \`PackageCard\` checked item fields like \`status\`, \`fileName\`, \`progressPercent\`, \`speedBps\`, etc., but did not include \`onlineStatus\`. When a Rapidgator link's online status changed (online/offline/checking), the status dot indicator would not update until some other prop triggered a re-render
|
|
||||||
- **Fix:** Added \`a.onlineStatus !== b.onlineStatus\` to the item comparison in the memo comparator
|
|
||||||
|
|
||||||
#### Important: \`noExtractorEncountered\` throws \`"aborted:extract"\` — wrong error classification
|
|
||||||
- When no extractor was available (e.g. 7z/WinRAR not installed), subsequent archive processing threw \`new Error("aborted:extract")\`. This was caught by \`isExtractAbortError()\` and treated identically to user cancellation, masking the real problem (missing extractor) in logs and error reporting
|
|
||||||
- **Fix:** Changed the error message to \`"noextractor:skipped"\` and updated \`isExtractAbortError()\` to recognize it, so it's still re-thrown (not counted as a normal failure) but carries the correct classification
|
|
||||||
|
|
||||||
#### Minor: \`formatDateTime(0)\` displays "01.01.1970"
|
|
||||||
- The \`formatDateTime\` utility formatted timestamp \`0\` as \`"01.01.1970 - 01:00"\` instead of an empty string. Timestamps of 0 are used as "not set" in various places (e.g. \`createdAt\` before initialization), resulting in nonsensical 1970 dates in the UI
|
|
||||||
- **Fix:** Added an early return of \`""\` when \`ts\` is falsy (0, null, undefined)
|
|
||||||
|
|
||||||
#### Minor: \`cachedJvmLayout = null\` permanently prevents JVM extractor discovery
|
|
||||||
- When the JVM extractor layout resolution failed (Java not found), the result \`null\` was cached permanently. If the user installed Java after app startup, the JVM extractor would never be discovered until the app was restarted
|
|
||||||
- **Fix:** Added a 5-minute TTL for \`null\` cache entries. After the TTL expires, the next extraction attempt re-probes for Java
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/extractor.ts\` — Abort check before \`failed\` increment; \`noExtractorEncountered\` distinct error message; JVM layout null cache TTL
|
|
||||||
- \`src/main/download-manager.ts\` — \`requestReconnect\` single-increment guard; HTTP 429/503 inner retry before reconnect escalation
|
|
||||||
- \`src/renderer/App.tsx\` — Stale snapshot closure fix; PackageCard memo \`onlineStatus\` check; \`formatDateTime(0)\` guard
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.20.exe", name: "Real-Debrid-Downloader-Setup-1.6.20.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.20.exe", name: "Real-Debrid-Downloader-1.6.20.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.20.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.20.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.21";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.21
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 6)
|
|
||||||
|
|
||||||
This release fixes 8 bugs found through a sixth comprehensive code review covering the extractor, download manager, storage layer, renderer UI, and app controller.
|
|
||||||
|
|
||||||
#### Critical: Nested extraction ignores "trash" cleanup mode
|
|
||||||
- The main extraction path properly handles all cleanup modes via \`cleanupArchives()\`. However, the nested extraction path (archives found inside extracted output) had its own inline cleanup that only checked for \`"delete"\`, completely ignoring the \`"trash"\` mode
|
|
||||||
- Users with cleanup mode set to "trash" would find nested archive files left behind in the target directory alongside extracted content
|
|
||||||
- **Fix:** Replaced the inline \`if (cleanupMode === "delete") { unlink(...) }\` with a call to the existing \`cleanupArchives()\` function that handles all modes
|
|
||||||
|
|
||||||
#### Critical: \`resetPackage()\` does not re-add items to \`runItemIds\`/\`runPackageIds\` when session is running
|
|
||||||
- \`resetItems()\` was fixed in v1.6.19 to re-add items to \`runItemIds\` when the session is running. However, the parallel method \`resetPackage()\` was not updated — it removed items from \`runItemIds\` but never re-added them
|
|
||||||
- This caused \`recordRunOutcome()\` to silently discard outcomes for reset items, producing inaccurate session summaries
|
|
||||||
- **Fix:** After resetting all items, re-add them to \`runItemIds\` and re-add the package to \`runPackageIds\` if the session is running
|
|
||||||
|
|
||||||
#### Important: \`importBackup\` writes session without normalization
|
|
||||||
- The backup import handler cast the session JSON directly to \`SessionState\` and wrote it to disk without passing through \`normalizeLoadedSession()\` or \`normalizeLoadedSessionTransientFields()\`. Items with stale active statuses, non-zero \`speedBps\`, or invalid field values from a crafted backup were persisted verbatim
|
|
||||||
- **Fix:** Added normalization before saving. Exported both normalization functions from storage module
|
|
||||||
|
|
||||||
#### Important: \`sanitizeCredentialPersistence\` clears archive passwords
|
|
||||||
- When \`rememberToken\` was disabled, the sanitization function also wiped \`archivePasswordList\`. Archive passwords are NOT authentication credentials — they are extraction passwords for unpacking downloaded archives
|
|
||||||
- Users who disabled "Remember Token" lost all their custom archive passwords on every app restart
|
|
||||||
- **Fix:** Removed \`archivePasswordList\` from the credential sanitization
|
|
||||||
|
|
||||||
#### Important: Delete key fires regardless of active tab — data loss risk
|
|
||||||
- The Delete key handler checked \`selectedIds.size > 0\` but did NOT check which tab was active. If the user selected packages on Downloads tab, switched to Settings, and pressed Delete, the packages would be silently deleted
|
|
||||||
- **Fix:** Added \`tabRef.current === "downloads"\` guard
|
|
||||||
|
|
||||||
#### Important: Escape key inconsistency + no tab guard
|
|
||||||
- Pressing Escape cleared download selection but never cleared history selection. Also fired on every tab causing unnecessary re-renders
|
|
||||||
- **Fix:** Escape now checks active tab — clears \`selectedIds\` on Downloads tab, \`selectedHistoryIds\` on History tab
|
|
||||||
|
|
||||||
#### Important: Generic split file skip not counted in progress
|
|
||||||
- When a generic \`.001\` split file was skipped (no archive signature), the function returned early without incrementing \`extracted\` or \`failed\`, causing extraction progress to never reach 100%
|
|
||||||
- **Fix:** Increment \`extracted\` before returning for skipped generic splits
|
|
||||||
|
|
||||||
#### Important: Mousedown deselection fires inside modals
|
|
||||||
- The mousedown handler that clears package selection checked for \`.package-card\` and \`.ctx-menu\` but not modals. Clicking inside any modal cleared the selection
|
|
||||||
- **Fix:** Added \`.modal-backdrop\` and \`.modal-card\` to the exclusion list
|
|
||||||
|
|
||||||
#### Minor: \`PackageCard\` memo comparator missing multiple fields
|
|
||||||
- Missing: \`pkg.priority\`, \`pkg.createdAt\`, \`item.downloadedBytes\`, \`item.totalBytes\`. Changes to these fields would not trigger re-renders
|
|
||||||
- **Fix:** Added all four missing field comparisons
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/extractor.ts\` — Nested extraction uses \`cleanupArchives()\` for all modes; generic split skip increments \`extracted\`
|
|
||||||
- \`src/main/download-manager.ts\` — \`resetPackage()\` re-adds to \`runItemIds\`/\`runPackageIds\` when running
|
|
||||||
- \`src/main/app-controller.ts\` — \`importBackup\` normalizes session before save
|
|
||||||
- \`src/main/storage.ts\` — Exported normalization functions; removed \`archivePasswordList\` from credential sanitization
|
|
||||||
- \`src/renderer/App.tsx\` — Delete/Escape key tab guards; mousedown modal exclusions; PackageCard memo field additions
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.21.exe", name: "Real-Debrid-Downloader-Setup-1.6.21.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.21.exe", name: "Real-Debrid-Downloader-1.6.21.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.21.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.21.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,128 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.22";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.22
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 7)
|
|
||||||
|
|
||||||
This release fixes 8 bugs found through a seventh comprehensive code review covering the debrid service layer, download manager, and renderer UI.
|
|
||||||
|
|
||||||
#### Critical: Per-request timeout treated as user abort — breaks retry loops and provider fallback
|
|
||||||
- All debrid API clients (Real-Debrid, BestDebrid, AllDebrid) used \`/aborted/i.test(errorText)\` to detect user cancellation. However, when a per-request timeout fired (via \`AbortSignal.timeout(30000)\`), Node.js threw an error containing "aborted due to timeout" — matching the regex and breaking out of the retry loop on the first timeout
|
|
||||||
- This had three severe consequences: (1) no retries on slow API responses, (2) provider fallback chain aborted entirely if the primary provider timed out (AllDebrid never tried even when configured), (3) Rapidgator online checks failed permanently on timeout
|
|
||||||
- **Fix:** Narrowed the abort detection regex to exclude timeout errors: \`/aborted/i.test(text) && !/timeout/i.test(text)\`. Applied across 10 catch blocks in \`realdebrid.ts\` and \`debrid.ts\`
|
|
||||||
|
|
||||||
#### Critical: \`resolveStartConflict("overwrite")\` uses "cancel" abort reason — race condition corrupts item state
|
|
||||||
- The overwrite conflict resolution path aborted active downloads with \`abortReason = "cancel"\`. The \`processItem\` catch handler then saw "cancel" and overwrote the freshly-reset item state back to \`status="cancelled"\`, \`fullStatus="Entfernt"\` — the same race condition that was fixed for \`resetItems()\` in v1.6.19
|
|
||||||
- Items became permanently stuck as "cancelled" and the scheduler would never pick them up
|
|
||||||
- **Fix:** Changed the abort reason from \`"cancel"\` to \`"reset"\`, whose catch handler is a no-op that preserves the already-correct state
|
|
||||||
|
|
||||||
#### Important: \`checkRapidgatorLinks\` — single failure aborts entire batch, stranding items in "checking" state
|
|
||||||
- All items were set to \`onlineStatus = "checking"\` before the loop. The \`checkRapidgatorOnline()\` call had no try-catch wrapper. If one URL check threw (e.g., due to the timeout-as-abort bug above), all subsequent items remained in "checking" state indefinitely
|
|
||||||
- **Fix:** Wrapped the check in try-catch. On error, the item's \`onlineStatus\` is reset to \`undefined\` and the loop continues
|
|
||||||
|
|
||||||
#### Important: \`applyCompletedCleanupPolicy("immediate")\` deletes non-completed items
|
|
||||||
- When \`autoExtract\` was disabled and cleanup policy was "immediate", the method blindly removed whatever item was specified — including \`failed\` or \`cancelled\` items. For a failed package (which has at least one failed item), the failed items got deleted from the session without the user ever seeing them
|
|
||||||
- **Fix:** Added \`item.status !== "completed"\` guard before the deletion logic
|
|
||||||
|
|
||||||
#### Important: \`visiblePackages\` reorders packages but \`isFirst\`/\`isLast\` use original order
|
|
||||||
- When downloads are running, active packages are sorted to the top. But \`isFirst\`/\`isLast\` were computed from the original \`packageOrder\`, not the rendered order. This meant the "move up" button was enabled on visually-first packages and "move down" on visually-last ones, causing confusing reordering behavior
|
|
||||||
- **Fix:** Changed to use the rendered index (\`idx === 0\` / \`idx === visiblePackages.length - 1\`)
|
|
||||||
|
|
||||||
#### Important: \`sessionDownloadedBytes\` never subtracted on retry — inflated session stats
|
|
||||||
- When a download failed and retried, \`dropItemContribution\` correctly subtracted bytes from \`session.totalDownloadedBytes\` but not from \`sessionDownloadedBytes\` (the UI stats counter). The "Session Downloaded" display became inflated by the sum of all discarded retry bytes
|
|
||||||
- Also, \`resetSessionTotalsIfQueueEmpty\` forgot to reset \`sessionDownloadedBytes\`, leaving ghost totals after clearing the queue
|
|
||||||
- **Fix:** Added \`sessionDownloadedBytes\` subtraction in \`dropItemContribution\` and reset in \`resetSessionTotalsIfQueueEmpty\`
|
|
||||||
|
|
||||||
#### Important: Escape key doesn't clear history selection
|
|
||||||
- Pressing Escape cleared download selection (\`selectedIds\`) but did nothing for history selection (\`selectedHistoryIds\`). Already partially addressed in v1.6.21 (tab guard), this release ensures the Escape handler also clears the correct selection per tab
|
|
||||||
|
|
||||||
#### Minor: \`removeCollectorTab\` defers tab switch via \`setTimeout\` — stale active tab for one render tick
|
|
||||||
- When removing a collector tab, the fallback tab activation was deferred with \`setTimeout(..., 0)\`. During the intervening render, \`activeCollectorTab\` pointed to the removed tab, causing the textarea to show the wrong tab's content and clipboard detection to append to the wrong tab
|
|
||||||
- **Fix:** Moved \`setActiveCollectorTab\` outside the \`setCollectorTabs\` updater so both state updates batch in the same render
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/debrid.ts\` — Timeout-aware abort detection in all catch blocks (8 locations)
|
|
||||||
- \`src/main/realdebrid.ts\` — Timeout-aware abort detection in unrestrict retry loop
|
|
||||||
- \`src/main/download-manager.ts\` — Overwrite conflict uses "reset" abort reason; Rapidgator check per-item try-catch; cleanup policy completed guard; sessionDownloadedBytes fix
|
|
||||||
- \`src/renderer/App.tsx\` — \`isFirst\`/\`isLast\` from rendered order; \`removeCollectorTab\` synchronous tab switch
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.22.exe", name: "Real-Debrid-Downloader-Setup-1.6.22.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.22.exe", name: "Real-Debrid-Downloader-1.6.22.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.22.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.22.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,207 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.23";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.23
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Rounds 8, 9 & 10)
|
|
||||||
|
|
||||||
This release fixes 24 bugs found through three comprehensive code review rounds covering the download manager, extractor, debrid clients, storage layer, app controller, and renderer UI.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Round 8: Download Manager Core Logic (8 fixes)
|
|
||||||
|
|
||||||
#### Critical: \\\`resetItems()\\\` does not re-add packages to \\\`runPackageIds\\\`
|
|
||||||
- When \\\`resetItems\\\` was called during a "Start Selected" run, it re-added item IDs to \\\`runItemIds\\\` but never re-added the parent package to \\\`runPackageIds\\\`. The scheduler's \\\`findNextQueuedItem()\\\` checks \\\`runPackageIds\\\` and would permanently skip those items
|
|
||||||
- **Fix:** After resetting items, add all affected package IDs to \\\`runPackageIds\\\`
|
|
||||||
|
|
||||||
#### Critical: \\\`resolveStartConflict("overwrite")\\\` missing \\\`runPackageIds\\\` re-add
|
|
||||||
- Same issue: overwrite conflict resolution reset items and re-added to \\\`runItemIds\\\` but not \\\`runPackageIds\\\`. The overwritten items would never be picked up by the scheduler
|
|
||||||
- **Fix:** Added \\\`runPackageIds.add(packageId)\\\` after the overwrite reset
|
|
||||||
|
|
||||||
#### Critical: \\\`resolveStartConflict\\\`/\\\`resetPackage\\\`/\\\`resetItems\\\` don't call \\\`ensureScheduler()\\\`
|
|
||||||
- After resetting items to "queued", neither method kicked the scheduler. If the scheduler had already detected all downloads complete and was about to call \\\`finishRun()\\\`, the newly queued items would be stranded
|
|
||||||
- **Fix:** Added \\\`ensureScheduler()\\\` calls after reset operations when the session is running
|
|
||||||
|
|
||||||
#### Important: \\\`sessionDownloadedBytes\\\` not subtracted on retry fresh-start
|
|
||||||
- When the server ignored the Range header (HTTP 200 instead of 206), the code subtracted bytes from \\\`session.totalDownloadedBytes\\\` but not from \\\`sessionDownloadedBytes\\\`. The session speed stats drifted upward with each retry
|
|
||||||
- **Fix:** Added \\\`sessionDownloadedBytes\\\` subtraction alongside \\\`totalDownloadedBytes\\\`
|
|
||||||
|
|
||||||
#### Important: Failed packages with \\\`autoExtract\\\` + \\\`package_done\\\` policy never cleaned up
|
|
||||||
- The \\\`allExtracted\\\` check required ALL items (including failed/cancelled ones) to have extraction labels. Failed items never get extracted, so the guard blocked cleanup forever. Packages with any failures accumulated in the UI permanently
|
|
||||||
- **Fix:** Skip failed/cancelled items in the \\\`allExtracted\\\` check — only completed items need extraction
|
|
||||||
|
|
||||||
#### Important: \\\`on_start\\\` cleanup removes completed items ignoring extraction status
|
|
||||||
- At app startup, the \\\`on_start\\\` cleanup policy deleted all completed items without checking whether they had been extracted. If the app was closed mid-download before extraction ran, completed items were silently removed and extraction could never happen
|
|
||||||
- **Fix:** Added \\\`autoExtract\\\` guard: keep completed items that haven't been extracted yet
|
|
||||||
|
|
||||||
#### Important: History \\\`totalBytes\\\` inflated by non-completed items
|
|
||||||
- When deleting a package, the history entry summed \\\`downloadedBytes\\\` from ALL items (including failed/cancelled with partial data) but \\\`fileCount\\\` only counted completed items. This created a mismatch between reported size and file count
|
|
||||||
- **Fix:** Filter to completed items before summing bytes
|
|
||||||
|
|
||||||
#### Minor: Status mismatch for cancelled+success packages between startup and runtime
|
|
||||||
- On app restart, a package with some completed and some cancelled items got status "failed". During runtime, the same scenario correctly got "completed"
|
|
||||||
- **Fix:** Aligned startup logic with runtime: \\\`cancelled > 0 && success > 0\\\` now produces "completed" consistently
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Round 9: Debrid, Extractor, Storage & Main (8 fixes)
|
|
||||||
|
|
||||||
#### Critical: Real-Debrid "HTML statt JSON" error not retryable
|
|
||||||
- When Real-Debrid returned an HTML response (Cloudflare challenge, maintenance page), \\\`realdebrid.ts\\\` threw immediately without retrying. The equivalent check in \\\`debrid.ts\\\` (for AllDebrid/BestDebrid) already included this case
|
|
||||||
- **Fix:** Added \\\`"html statt json"\\\` to \\\`isRetryableErrorText\\\` in \\\`realdebrid.ts\\\`
|
|
||||||
|
|
||||||
#### Critical: Real-Debrid no URL protocol validation for download URLs
|
|
||||||
- The direct download URL from Real-Debrid was used without validating the protocol. BestDebrid and AllDebrid both validate for \\\`http:\\\`/\\\`https:\\\` only. An unexpected protocol (e.g., \\\`ftp:\\\`, \\\`file:\\\`) would cause cryptic fetch errors
|
|
||||||
- **Fix:** Added URL parsing and protocol validation matching the other clients
|
|
||||||
|
|
||||||
#### Important: BestDebrid outer loop swallows abort errors
|
|
||||||
- The outer request loop in \\\`BestDebridClient.unrestrictLink\\\` caught ALL errors including abort errors. If \\\`buildBestDebridRequests\\\` returned multiple requests, a user abort would be caught and the next request attempted
|
|
||||||
- **Fix:** Re-throw abort errors before continuing the loop
|
|
||||||
|
|
||||||
#### Important: Shutdown persists session asynchronously — data loss on fast exit
|
|
||||||
- \\\`prepareForShutdown()\\\` called \\\`persistNow()\\\` which starts an async write. The process could exit before the write completed, losing the final session state. Items could be stuck in "downloading" status on next startup
|
|
||||||
- **Fix:** Replaced async \\\`persistNow()\\\` with synchronous \\\`saveSession()\\\` + \\\`saveSettings()\\\` during shutdown
|
|
||||||
|
|
||||||
#### Important: \\\`importBackup\\\` race condition with in-flight async save
|
|
||||||
- If an async save was in-flight when the user imported a backup, the async save's \\\`finally\\\` clause would process its queued payload AFTER the backup was written, silently overwriting the restored session
|
|
||||||
- **Fix:** Added \\\`cancelPendingAsyncSaves()\\\` that clears both session and settings async queues before writing the backup
|
|
||||||
|
|
||||||
#### Important: Serial extraction path missing \\\`failed\\\` count for skipped archives
|
|
||||||
- When no extractor was available, the serial extraction loop broke early but didn't count remaining archives as failed. The parallel path already had this counting. Progress never reached 100% and the extraction summary understated failures
|
|
||||||
- **Fix:** Added remaining archive counting after the serial loop, matching the parallel path
|
|
||||||
|
|
||||||
#### Minor: \\\`"reset"\\\` not in \\\`abortReason\\\` union type
|
|
||||||
- The TypeScript type for \\\`ActiveTask.abortReason\\\` listed 7 values but omitted \\\`"reset"\\\`, which was assigned in 3 locations and handled in the catch block. The code worked at runtime but lacked type safety
|
|
||||||
- **Fix:** Added \\\`"reset"\\\` to the union type
|
|
||||||
|
|
||||||
#### Minor: \\\`skipItems\\\` doesn't clear \\\`retryAfterByItem\\\`/\\\`retryStateByItem\\\`
|
|
||||||
- When items in retry-delay were skipped, their \\\`retryAfterByItem\\\` entries leaked until \\\`finishRun()\\\`. While not causing functional issues (the status check filters them), it's unnecessary memory retention
|
|
||||||
- **Fix:** Delete both retry entries when skipping items
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Round 10: Renderer UI (8 fixes)
|
|
||||||
|
|
||||||
#### Critical: \\\`Ctrl+A\\\` hijacks native select-all in text inputs
|
|
||||||
- The \\\`Ctrl+A\\\` handler selected all packages/history entries without checking if the focused element was an input or textarea. Users pressing \\\`Ctrl+A\\\` in the search bar or collector textarea lost their text selection
|
|
||||||
- **Fix:** Added input/textarea guard before handling \\\`Ctrl+A\\\`
|
|
||||||
|
|
||||||
#### Important: \\\`Ctrl+Q\\\`/\\\`Ctrl+Shift+R\\\` fire inside text inputs — accidental quit/restart
|
|
||||||
- The app quit and restart shortcuts fired regardless of focus. A user typing in an input field could accidentally trigger app quit/restart
|
|
||||||
- **Fix:** Added input/textarea guard for all keyboard shortcuts (\\\`Ctrl+Q\\\`, \\\`Ctrl+Shift+R\\\`, \\\`Ctrl+L\\\`, \\\`Ctrl+P\\\`, \\\`Ctrl+O\\\`)
|
|
||||||
|
|
||||||
#### Important: \\\`onAddLinks\\\`/\\\`onImportDlc\\\`/\\\`onDrop\\\` read stale \\\`collapseNewPackages\\\` setting
|
|
||||||
- These async functions read \\\`snapshot.settings.collapseNewPackages\\\` via closure, but after multiple \\\`await\\\` calls the value could be stale. If the user toggled the setting during the async operation, the old value was used
|
|
||||||
- **Fix:** Changed to read from \\\`snapshotRef.current.settings.collapseNewPackages\\\`
|
|
||||||
|
|
||||||
#### Important: \\\`showLinksPopup\\\` captures stale \\\`snapshot.session\\\`
|
|
||||||
- The link popup callback closed over \\\`snapshot.session.packages\\\` and \\\`snapshot.session.items\\\`. If a state update arrived while the context menu was open, the callback used stale data, potentially showing empty or incomplete link lists
|
|
||||||
- **Fix:** Changed to read from \\\`snapshotRef.current.session\\\` and removed snapshot dependencies from \\\`useCallback\\\`
|
|
||||||
|
|
||||||
#### Important: \\\`dragDidMoveRef\\\` never reset after mouseup — blocks next click
|
|
||||||
- After a \\\`Ctrl+drag-select\\\` operation, \\\`dragDidMoveRef.current\\\` stayed \\\`true\\\`. The next single click was silently swallowed because \\\`onSelectId\\\` checked \\\`if (dragDidMoveRef.current) return\\\`
|
|
||||||
- **Fix:** Reset \\\`dragDidMoveRef.current = false\\\` in the mouseup handler
|
|
||||||
|
|
||||||
#### Important: Rename \\\`onBlur\\\` fires after Enter key — double rename RPC
|
|
||||||
- Pressing Enter to confirm a rename triggered \\\`onFinishEdit\\\` from the keydown handler. React then removed the input, which fired a blur event that called \\\`onFinishEdit\\\` again. The \\\`renamePackage\\\` RPC was sent twice
|
|
||||||
- **Fix:** Added idempotency guard: \\\`setEditingPackageId\\\` only processes the rename if the package ID still matches
|
|
||||||
|
|
||||||
#### Important: Escape key clears selection when overlay is open
|
|
||||||
- Pressing Escape while a context menu, modal, or link popup was visible both closed the overlay AND cleared the package selection. Users expected Escape to only dismiss the overlay
|
|
||||||
- **Fix:** Check for visible overlays before clearing selection
|
|
||||||
|
|
||||||
#### Minor: \\\`packageOrder\\\` normalization O(n\\\\u00B2) via \\\`Array.includes\\\`
|
|
||||||
- The session normalization loop used \\\`packageOrder.includes(id)\\\` (O(n)) when \\\`seenOrder.has(id)\\\` (O(1)) was already available. With hundreds of packages, this caused measurable startup slowdown
|
|
||||||
- **Fix:** Use \\\`seenOrder.has()\\\` instead of \\\`packageOrder.includes()\\\`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \\\`src/main/download-manager.ts\\\` — resetItems/resolveStartConflict runPackageIds fix; ensureScheduler calls; sessionDownloadedBytes retry fix; cleanup policy extraction guards; history bytes filter; startup status alignment; shutdown sync persist; skipItems cleanup
|
|
||||||
- \\\`src/main/extractor.ts\\\` — Serial path noExtractor remaining count
|
|
||||||
- \\\`src/main/realdebrid.ts\\\` — HTML retry; URL protocol validation
|
|
||||||
- \\\`src/main/debrid.ts\\\` — BestDebrid abort propagation in outer loop
|
|
||||||
- \\\`src/main/storage.ts\\\` — cancelPendingAsyncSaves; packageOrder O(1) lookup
|
|
||||||
- \\\`src/main/app-controller.ts\\\` — importBackup cancels async saves
|
|
||||||
- \\\`src/renderer/App.tsx\\\` — Keyboard shortcut input guards; stale closure fixes (collapseNewPackages, showLinksPopup); dragDidMoveRef reset; rename double-call guard; Escape overlay check
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.23.exe", name: "Real-Debrid-Downloader-Setup-1.6.23.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.23.exe", name: "Real-Debrid-Downloader-1.6.23.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.23.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.23.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.24";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.24
|
|
||||||
|
|
||||||
### Bug Fixes (Cross-Review Validation)
|
|
||||||
|
|
||||||
This release fixes 2 bugs found by cross-validating the codebase against an independent external review.
|
|
||||||
|
|
||||||
#### Important: Startup auto-retry recovery does not emit state update to UI
|
|
||||||
- \\\`recoverRetryableItems()\\\` mutated item statuses (failed -> queued) and refreshed package statuses on app startup, but never called \\\`emitState()\\\` or \\\`persistSoon()\\\`. The UI showed stale item statuses until the next periodic state emission or user interaction
|
|
||||||
- **Fix:** Added \\\`persistSoon()\\\` and \\\`emitState()\\\` after recovery completes
|
|
||||||
|
|
||||||
#### Minor: Rapidgator offline check does not refresh parent package status
|
|
||||||
- When \\\`applyRapidgatorCheckResult()\\\` set an item to \\\`status="failed"\\\` (offline), the parent package's status was not recalculated. The package could show as "queued" while containing failed items
|
|
||||||
- **Fix:** Call \\\`refreshPackageStatus(pkg)\\\` after marking an item as offline
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \\\`src/main/download-manager.ts\\\` — recoverRetryableItems emitState/persistSoon; Rapidgator offline package status refresh
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.24.exe", name: "Real-Debrid-Downloader-Setup-1.6.24.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.24.exe", name: "Real-Debrid-Downloader-1.6.24.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.24.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.24.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.25";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.25
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 11)
|
|
||||||
|
|
||||||
This release fixes 12 bugs found through an intensive 10-agent parallel code review covering every line of the codebase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Critical (2 fixes)
|
|
||||||
|
|
||||||
#### Critical: In-flight async session save can overwrite restored backup or shutdown state
|
|
||||||
- \\\`importBackup()\\\` called \\\`cancelPendingAsyncSaves()\\\` which only nullified queued saves, but an already-executing async save could complete its \\\`rename()\\\` after the synchronous backup restore, silently overwriting the restored session. Same race existed during shutdown.
|
|
||||||
- **Fix:** Added a \\\`syncSaveGeneration\\\` counter to \\\`storage.ts\\\`. Synchronous saves and \\\`cancelPendingAsyncSaves()\\\` increment the counter. Async writes check the generation before \\\`rename()\\\` and discard stale writes.
|
|
||||||
|
|
||||||
#### Critical: Menu bar quick-settings silently discard unsaved settings panel changes
|
|
||||||
- When using the speed limit or max-parallel spinners in the menu bar, the \\\`.finally()\\\` callback falsely reset \\\`settingsDirtyRef\\\` to \\\`false\\\`. If the user had unsaved changes in the Settings panel (e.g. a new API token), the next backend state update would overwrite the draft, silently losing those changes.
|
|
||||||
- **Fix:** Added a separate \\\`panelDirtyRevisionRef\\\` counter. Panel changes (setBool, setText, setNum, schedules, theme) increment it. Quick-settings only clear \\\`settingsDirtyRef\\\` when \\\`panelDirtyRevisionRef === 0\\\`. Reset to 0 on save and init.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Important (10 fixes)
|
|
||||||
|
|
||||||
#### \\\`skipItems()\\\` does not refresh parent package status
|
|
||||||
- After setting items to cancelled/skipped, the parent package's status was never recalculated. Packages showed as "queued" despite all items being skipped. The scheduler kept checking these packages unnecessarily.
|
|
||||||
- **Fix:** Collect affected package IDs and call \\\`refreshPackageStatus()\\\` for each.
|
|
||||||
|
|
||||||
#### \\\`getSessionStats()\\\` shows non-zero speed after session stops
|
|
||||||
- \\\`currentSpeedBps\\\` only checked \\\`paused\\\` but not \\\`!session.running\\\`, unlike \\\`emitState()\\\`. After stopping a run, the stats API briefly returned stale speed values.
|
|
||||||
- **Fix:** Added \\\`!this.session.running\\\` check, matching \\\`emitState()\\\` logic.
|
|
||||||
|
|
||||||
#### Hybrid extraction catch leaves items with frozen progress labels
|
|
||||||
- The catch block only marked items with "Ausstehend" or "Warten auf Parts" as Error. Items showing active progress (e.g. "Entpacken 45%...") were left with a frozen label permanently.
|
|
||||||
- **Fix:** Extended the check to match any \\\`fullStatus\\\` starting with "Entpacken" (excluding already-extracted items).
|
|
||||||
|
|
||||||
#### \\\`normalizeHistoryEntry()\\\` drops \\\`urls\\\` field on load
|
|
||||||
- The history normalization function never read or included the \\\`urls\\\` property. After a save-load cycle, all URL data was permanently lost.
|
|
||||||
- **Fix:** Parse and include \\\`urls\\\` array from the raw entry.
|
|
||||||
|
|
||||||
#### "Immediate" retroactive cleanup creates no history entry
|
|
||||||
- When the cleanup policy removed all items from a package, \\\`removePackageFromSession()\\\` was called with an empty array, so no history entry was recorded. Packages silently vanished from the download log.
|
|
||||||
- **Fix:** Pass \\\`completedItemIds\\\` to \\\`removePackageFromSession()\\\` for history recording. Delete items from session only after the history call. Also fixed missing \\\`retryStateByItem\\\` cleanup.
|
|
||||||
|
|
||||||
#### Skipped generic split files counted as extracted but not tracked for resume
|
|
||||||
- When a generic \\\`.001\\\` file had no archive signature and was skipped, it was counted in \\\`extracted\\\` but not added to \\\`resumeCompleted\\\` or \\\`extractedArchives\\\`. On resume, it would be re-processed; cleanup wouldn't find it.
|
|
||||||
- **Fix:** Add skipped files to both \\\`resumeCompleted\\\` and \\\`extractedArchives\\\`.
|
|
||||||
|
|
||||||
#### \\\`noextractor:skipped\\\` treated as abort in parallel extraction mode
|
|
||||||
- In the parallel worker pool, \\\`noextractor:skipped\\\` was caught by \\\`isExtractAbortError()\\\` and set as \\\`abortError\\\`. The error was then re-thrown as "aborted:extract", preventing the correct no-extractor counting logic from running.
|
|
||||||
- **Fix:** Check for "noextractor:skipped" before the abort check and break without setting \\\`abortError\\\`.
|
|
||||||
|
|
||||||
#### \\\`collectArchiveCleanupTargets\\\` missing tar.gz/bz2/xz
|
|
||||||
- Tar compound archives (.tar.gz, .tar.bz2, .tar.xz, .tgz, .tbz2, .txz) were not recognized by the cleanup function. After successful extraction, the source archive was never deleted.
|
|
||||||
- **Fix:** Added a tar compound archive pattern before the generic split check.
|
|
||||||
|
|
||||||
#### \\\`runWithConcurrency\\\` continues dispatching after first error
|
|
||||||
- When one worker threw an error (e.g. abort), \\\`firstError\\\` was set but \\\`next()\\\` kept returning items. Other workers started new requests unnecessarily, delaying the abort.
|
|
||||||
- **Fix:** Check \\\`firstError\\\` in \\\`next()\\\` and return \\\`undefined\\\` to stop dispatching.
|
|
||||||
|
|
||||||
#### Side effect inside React state updater in \\\`onPackageFinishEdit\\\`
|
|
||||||
- The \\\`setEditingPackageId\\\` updater function contained an IPC call (\\\`renamePackage\\\`) as a side effect. React may call updater functions multiple times (e.g. StrictMode), causing duplicate rename RPCs.
|
|
||||||
- **Fix:** Moved the IPC call outside the updater. The updater now only returns the new state; the rename fires after based on a flag.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Minor
|
|
||||||
|
|
||||||
- Fixed typo "Session-Ubersicht" -> "Session-\\u00dcbersicht" in statistics tab
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \\\`src/main/storage.ts\\\` — syncSaveGeneration counter for async save race protection; normalizeHistoryEntry urls field
|
|
||||||
- \\\`src/main/download-manager.ts\\\` — skipItems refreshPackageStatus; currentSpeedBps running check; hybrid extraction catch broadened; immediate cleanup history fix + retryStateByItem cleanup
|
|
||||||
- \\\`src/main/extractor.ts\\\` — skipped generic splits resume/cleanup tracking; noextractor parallel mode fix; tar.gz/bz2/xz cleanup targets
|
|
||||||
- \\\`src/main/debrid.ts\\\` — runWithConcurrency stops on first error
|
|
||||||
- \\\`src/renderer/App.tsx\\\` — panelDirtyRevisionRef for settings dirty tracking; onPackageFinishEdit side effect fix; Session-\\u00dcbersicht typo
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.25.exe", name: "Real-Debrid-Downloader-Setup-1.6.25.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.25.exe", name: "Real-Debrid-Downloader-1.6.25.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.25.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.25.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.26";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.26
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 2)
|
|
||||||
|
|
||||||
This release fixes 13 bugs found through an intensive 10-agent parallel code review covering every line of the codebase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Important (4 fixes)
|
|
||||||
|
|
||||||
#### \`applyRapidgatorCheckResult\` sets items to "failed" without recording run outcome
|
|
||||||
- When an asynchronous Rapidgator online-check returned "offline" during a running session, the item was set to \`status: "failed"\` but \`recordRunOutcome()\` was never called. The scheduler kept the item in \`runItemIds\` without an outcome, causing incorrect summary statistics and potentially preventing the session from finishing.
|
|
||||||
- **Fix:** Call \`recordRunOutcome(itemId, "failed")\` when the item is in \`runItemIds\`.
|
|
||||||
|
|
||||||
#### \`skipItems()\` does not trigger extraction when package becomes fully resolved
|
|
||||||
- After skipping the last queued/pending items in a package, \`refreshPackageStatus()\` correctly set the package to "completed", but no extraction was triggered. Items that were already downloaded sat with "Entpacken - Ausstehend" forever.
|
|
||||||
- **Fix:** After refreshing package statuses, check if all items are in a terminal state and trigger \`runPackagePostProcessing()\` for packages with unextracted completed items.
|
|
||||||
|
|
||||||
#### \`applyOnStartCleanupPolicy\` creates no history entries for cleaned-up packages
|
|
||||||
- The on_start cleanup deleted completed items from \`session.items\` inside the filter callback, then called \`removePackageFromSession(pkgId, [])\` with an empty array. Since \`removePackageFromSession\` uses the item IDs to build the history entry, no history was recorded. Packages silently vanished from the download log.
|
|
||||||
- **Fix:** Collect completed item IDs separately. Pass them to \`removePackageFromSession()\` for history recording. Delete items from \`session.items\` only in the non-empty-package branch.
|
|
||||||
|
|
||||||
#### \`cancelPackage\` overwrites completed items' run outcomes to "cancelled"
|
|
||||||
- When cancelling a package, \`recordRunOutcome(itemId, "cancelled")\` was called for ALL items including already-completed ones. This overwrote the "completed" outcome, causing the run summary to show incorrect numbers (e.g., "0 erfolgreich, 10 abgebrochen" instead of "8 erfolgreich, 2 abgebrochen").
|
|
||||||
- **Fix:** Only record "cancelled" outcome for items whose status is not "completed".
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Medium (8 fixes)
|
|
||||||
|
|
||||||
#### \`looksLikeArchivePart\` missing generic \`.NNN\` split file pattern
|
|
||||||
- The function recognized multipart RAR (\`.partNN.rar\`), old-style RAR (\`.rNN\`), split ZIP (\`.zip.NNN\`), and split 7z (\`.7z.NNN\`), but NOT generic \`.NNN\` split files (e.g., \`movie.001\`, \`movie.002\`). In hybrid extraction mode, this caused the system to incorrectly conclude that all parts of a generic split archive were ready, potentially triggering extraction before all parts were downloaded.
|
|
||||||
- **Fix:** Added a generic \`.NNN\` pattern that matches when the entry point ends with \`.001\` (excluding .zip/.7z variants).
|
|
||||||
|
|
||||||
#### \`resolveArchiveItemsFromList\` missing split ZIP/7z/generic patterns
|
|
||||||
- Only multipart RAR and old-style RAR patterns were recognized. Split ZIP (\`.zip.001\`), split 7z (\`.7z.001\`), and generic split (\`.001\`) archives fell through to exact-name matching, so only the entry-point file received per-archive progress labels while other parts showed stale "Ausstehend" during extraction.
|
|
||||||
- **Fix:** Added matching patterns for split ZIP, split 7z, and generic \`.NNN\` splits before the fallback exact-name match.
|
|
||||||
|
|
||||||
#### \`normalizeSessionStatuses\` does not update \`updatedAt\` for modified items
|
|
||||||
- When item statuses were normalized on app startup (e.g., \`cancelled/Gestoppt\` → \`queued\`, \`extracting\` → \`completed\`, \`downloading\` → \`queued\`), \`item.updatedAt\` was not updated. This left stale timestamps from the previous session, causing the unpause stall detector to prematurely abort freshly recovered items.
|
|
||||||
- **Fix:** Added \`item.updatedAt = nowMs()\` after each status change in \`normalizeSessionStatuses\`.
|
|
||||||
|
|
||||||
#### \`applyRetroactiveCleanupPolicy\` package_done check ignores failed/cancelled items
|
|
||||||
- The \`package_done\` retroactive cleanup only considered items with \`status === "completed"\` as "done". Packages with mixed outcomes (some completed, some failed/cancelled) were never cleaned up, even though the inline \`applyCompletedCleanupPolicy\` correctly treats failed/cancelled items as terminal.
|
|
||||||
- **Fix:** Extended the \`allCompleted\` check to include \`"failed"\` and \`"cancelled"\` statuses, matching the inline policy logic.
|
|
||||||
|
|
||||||
#### \`.tgz\`/\`.tbz2\`/\`.txz\` missing from \`findArchiveCandidates\`
|
|
||||||
- Tar compound archives with short-form extensions (.tgz, .tbz2, .txz) were not recognized as archive candidates by \`findArchiveCandidates()\`. They were silently skipped during extraction, even though \`collectArchiveCleanupTargets\` correctly recognized them.
|
|
||||||
- **Fix:** Extended the tar compressed filter regex to include short-form extensions. Also updated \`archiveSortKey\`, \`archiveTypeRank\`, and \`archiveFilenamePasswords\` for consistency.
|
|
||||||
|
|
||||||
#### \`subst\` drive mapping uses \`"Z:"\` instead of \`"Z:\\\\"\`
|
|
||||||
- When creating a subst drive for long-path workaround, \`effectiveTargetDir\` was set to \`"Z:"\` (without trailing backslash). On Windows, \`Z:\` without a backslash references the current directory on drive Z rather than the root. For 7z extractions, \`-oZ:\` could extract files to an unexpected location.
|
|
||||||
- **Fix:** Changed to \`"Z:\\\\"\` to explicitly reference the root of the subst drive.
|
|
||||||
|
|
||||||
#### Pre-allocated sparse file after crash marked as complete
|
|
||||||
- On Windows, downloads use sparse file pre-allocation (\`truncate(totalBytes)\`). If the process crashed hard (kill, power loss), the truncation cleanup never ran. On next startup, \`stat.size === totalBytes\` (pre-allocated zeros), and the HTTP 416 handler falsely treated the file as complete.
|
|
||||||
- **Fix:** Before resuming, compare \`stat.size\` with persisted \`item.downloadedBytes\`. If the file is >1MB larger than the persisted count, truncate to the persisted value.
|
|
||||||
|
|
||||||
#### Integrity-check retry does not call \`dropItemContribution\`
|
|
||||||
- When a file failed integrity validation and was deleted for re-download, \`item.downloadedBytes\` was reset to 0 but \`dropItemContribution()\` was not called. Session statistics (\`totalDownloadedBytes\`) remained inflated until the next download started.
|
|
||||||
- **Fix:** Call \`this.dropItemContribution(item.id)\` before resetting \`downloadedBytes\`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Low (1 fix)
|
|
||||||
|
|
||||||
#### \`applyCompletedCleanupPolicy\` (immediate path) leaks \`retryStateByItem\` entries
|
|
||||||
- The immediate cleanup path cleaned up \`retryAfterByItem\` but not \`retryStateByItem\`, causing a minor memory leak over long sessions.
|
|
||||||
- **Fix:** Added \`this.retryStateByItem.delete(itemId)\` alongside the existing \`retryAfterByItem\` cleanup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — resolveArchiveItemsFromList split patterns; looksLikeArchivePart generic .NNN; Rapidgator recordRunOutcome; skipItems triggers extraction; normalizeSessionStatuses updatedAt; applyRetroactiveCleanupPolicy failed/cancelled; applyOnStartCleanupPolicy history; cancelPackage outcome fix; pre-allocation crash guard; integrity-check dropItemContribution; immediate cleanup retryStateByItem
|
|
||||||
- \`src/main/extractor.ts\` — findArchiveCandidates .tgz/.tbz2/.txz; archiveSortKey/archiveTypeRank/archiveFilenamePasswords .tgz support; subst drive trailing backslash
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.26.exe", name: "Real-Debrid-Downloader-Setup-1.6.26.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.26.exe", name: "Real-Debrid-Downloader-1.6.26.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.26.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.26.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.27";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.27
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 3)
|
|
||||||
|
|
||||||
This release fixes 10 bugs found through an intensive 10-agent parallel code review, including a **critical regression** introduced in v1.6.26.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Critical (1 fix)
|
|
||||||
|
|
||||||
#### \`applyRapidgatorCheckResult\` crashes with \`ReferenceError: itemId is not defined\`
|
|
||||||
- The v1.6.26 fix for recording run outcomes used \`itemId\` instead of \`item.id\` — the method parameter is \`item\`, not \`itemId\`. This would crash at runtime whenever a Rapidgator link was detected as offline during an active run, potentially halting the entire download session.
|
|
||||||
- **Fix:** Changed \`itemId\` to \`item.id\` in both the \`runItemIds.has()\` check and the \`recordRunOutcome()\` call.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Important (3 fixes)
|
|
||||||
|
|
||||||
#### Extraction timeout/exception overwrites already-extracted items' status
|
|
||||||
- When the extraction process timed out or threw an exception, ALL completed items in the package had their \`fullStatus\` overwritten to \`Entpack-Fehler: ...\`, even items whose archives had already been successfully extracted. This caused items showing "Entpackt - Done (1m 23s)" to suddenly show an error.
|
|
||||||
- **Fix:** Added an \`isExtractedLabel()\` guard — only items whose \`fullStatus\` does NOT already indicate successful extraction get the error label.
|
|
||||||
|
|
||||||
#### Hybrid extraction false error when extracted=0 and failed=0
|
|
||||||
- In hybrid extraction mode, when \`result.extracted === 0 && result.failed === 0\` (e.g., all archives were already extracted via resume state), the condition fell through and set \`fullStatus = "Entpacken - Error"\` even though nothing actually failed.
|
|
||||||
- **Fix:** Restructured the condition to only set error status when \`result.failed > 0\`, set done status when \`result.extracted > 0\`, and leave current status unchanged (no-op) when both are 0.
|
|
||||||
|
|
||||||
#### \`applyRetroactiveCleanupPolicy\` \`allExtracted\` check doesn't skip failed/cancelled items
|
|
||||||
- When checking if all items in a package were extracted (to decide whether to clean up), failed and cancelled items were not skipped. A package with 9 extracted items and 1 failed item would never be cleaned up, even though the failed item can never be extracted.
|
|
||||||
- **Fix:** Skip items with \`status === "failed"\` or \`status === "cancelled"\` in the \`allExtracted\` check.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Medium (6 fixes)
|
|
||||||
|
|
||||||
#### \`resetPackage\` missing cleanup for \`runCompletedPackages\`, \`packagePostProcessTasks\`, \`hybridExtractRequeue\`
|
|
||||||
- When resetting a package (re-downloading all items), the package ID was not removed from \`runCompletedPackages\`, \`packagePostProcessTasks\`, and \`hybridExtractRequeue\` maps. This could cause the reset package's extraction to be skipped (if already in \`runCompletedPackages\`) or the scheduler to wait forever for a stale post-processing task.
|
|
||||||
- **Fix:** Delete the package ID from all three maps after aborting the post-processing controller.
|
|
||||||
|
|
||||||
#### \`freshRetry\` does not call \`dropItemContribution\`
|
|
||||||
- When an item failed and was retried via the "fresh retry" path (delete file, re-queue), \`dropItemContribution()\` was not called before re-queuing. Session download statistics (\`totalDownloadedBytes\`) remained inflated by the failed item's bytes.
|
|
||||||
- **Fix:** Call \`this.dropItemContribution(item.id)\` before queuing the retry.
|
|
||||||
|
|
||||||
#### JVM extractor layout cache not caching \`null\` result
|
|
||||||
- When Java was not installed, \`discoverJvmLayout()\` returned \`null\` but didn't cache it. Every extraction attempt re-ran the Java discovery process (spawning processes, checking paths), adding unnecessary latency.
|
|
||||||
- **Fix:** Cache \`null\` results with a timestamp (\`cachedJvmLayoutNullSince\`). Re-check after 60 seconds in case the user installs Java mid-session.
|
|
||||||
|
|
||||||
#### Parallel resume-state writes race condition
|
|
||||||
- When multiple archives extracted in parallel, each called \`writeExtractResumeState()\` which wrote to the same temp file path. Two concurrent writes could collide: one renames the temp file while the other is still writing to it, causing the second write to silently fail or produce a corrupt resume file.
|
|
||||||
- **Fix:** Use unique temp file paths with timestamp + random suffix per write operation. On rename failure, clean up the orphaned temp file.
|
|
||||||
|
|
||||||
#### Stale closure in Ctrl+O keyboard handler
|
|
||||||
- The \`useEffect\` with \`[]\` deps captured the initial version of \`onImportDlc\`. When the user changed settings (like download directory) and then pressed Ctrl+O, the keyboard handler called the stale closure which sent outdated settings to the backend, potentially importing DLC files to the wrong directory.
|
|
||||||
- **Fix:** Added a \`useRef\` (\`onImportDlcRef\`) that always points to the latest \`onImportDlc\` function. The keyboard handler now calls \`onImportDlcRef.current()\`.
|
|
||||||
|
|
||||||
#### \`applyCompletedCleanupPolicy\` immediate path leaks \`retryStateByItem\` entries
|
|
||||||
- (Carried from v1.6.26) The immediate cleanup path cleaned up \`retryAfterByItem\` but not \`retryStateByItem\`, causing a minor memory leak over long sessions.
|
|
||||||
- **Fix:** Added \`this.retryStateByItem.delete(itemId)\` alongside the existing \`retryAfterByItem\` cleanup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — applyRapidgatorCheckResult itemId→item.id; extraction timeout isExtractedLabel guard; hybrid false error restructured; applyRetroactiveCleanupPolicy allExtracted skip failed/cancelled; resetPackage cleanup maps; freshRetry dropItemContribution; retryStateByItem cleanup
|
|
||||||
- \`src/main/extractor.ts\` — JVM layout null cache; parallel resume-state unique tmp paths
|
|
||||||
- \`src/renderer/App.tsx\` — Ctrl+O stale closure fix via useRef
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.27.exe", name: "Real-Debrid-Downloader-Setup-1.6.27.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.27.exe", name: "Real-Debrid-Downloader-1.6.27.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.27.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.27.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,149 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.28";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.28
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 4)
|
|
||||||
|
|
||||||
This release fixes 11 bugs found through an intensive 10-agent parallel code review covering every line of the codebase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Important (3 fixes)
|
|
||||||
|
|
||||||
#### Extraction blocked after app restart when skipped items exist (\`cancelled > 0\`)
|
|
||||||
- \`recoverPostProcessingOnStartup()\` and \`triggerPendingExtractions()\` both required \`cancelled === 0\` to trigger extraction. When items were skipped (status: "cancelled") and the app was restarted before extraction finished, the extraction was never re-triggered. Items hung permanently on "Entpacken - Ausstehend".
|
|
||||||
- **Fix:** Removed the \`cancelled === 0\` check from both functions, consistent with \`handlePackagePostProcessing()\` which correctly proceeds with extraction despite cancelled items.
|
|
||||||
|
|
||||||
#### \`resetItems\` missing cleanup for package-level state maps
|
|
||||||
- When individual items were reset (re-download), the package was not removed from \`runCompletedPackages\`, \`historyRecordedPackages\`, \`packagePostProcessTasks\`, and \`hybridExtractRequeue\`. This caused: (1) inflated extraction counts in run summaries, (2) missing history entries when the package re-completed, (3) extraction continuing with now-deleted files if reset during active extraction.
|
|
||||||
- **Fix:** Added full package-level cleanup (abort post-processing controller, delete from all state maps) for each affected package, matching the behavior of \`resetPackage()\`.
|
|
||||||
|
|
||||||
#### Generic split-file skip does not persist resume state to disk
|
|
||||||
- When a generic \`.001\` split file was skipped (no archive signature detected), it was added to the in-memory \`resumeCompleted\` set but \`writeExtractResumeState()\` was never called. If the app crashed or was restarted before the next archive wrote resume state, the skipped file would be reprocessed on the next run. For packages consisting entirely of unrecognized generic splits, resume state was NEVER written.
|
|
||||||
- **Fix:** Call \`writeExtractResumeState()\` after adding the skipped archive to \`resumeCompleted\`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Medium (8 fixes)
|
|
||||||
|
|
||||||
#### \`removeItem\` loses history for last item of a package
|
|
||||||
- When removing the last item from a package, \`removePackageFromSession()\` was called with an empty \`itemIds\` array. Since history entries are built from item IDs, no history was recorded and the package silently vanished from the download log.
|
|
||||||
- **Fix:** Pass \`[itemId]\` instead of \`[]\` to \`removePackageFromSession()\` so the deleted item is included in the history entry.
|
|
||||||
|
|
||||||
#### \`sortPackageOrderByHoster\` sorts by debrid provider instead of file hoster
|
|
||||||
- Clicking the "Hoster" column header sorted packages by \`item.provider\` (the debrid service like "realdebrid") instead of the actual file hoster extracted from the URL (like "uploaded", "rapidgator"). The sort order did not match what the column displayed.
|
|
||||||
- **Fix:** Changed to use \`extractHoster(item.url)\` for sorting, matching the column display logic.
|
|
||||||
|
|
||||||
#### Abort error in single-provider mode triggers false provider cooldown
|
|
||||||
- When a user cancelled a download during the unrestrict phase with auto-fallback disabled, the abort error was wrapped as \`"Unrestrict fehlgeschlagen: ..."\`. Downstream code detected the "unrestrict" keyword and called \`recordProviderFailure()\`, putting the provider into an unnecessary cooldown that delayed subsequent downloads.
|
|
||||||
- **Fix:** Added an abort signal check before wrapping the error, consistent with the fallback code path. Abort errors are now re-thrown directly without the "Unrestrict fehlgeschlagen" prefix.
|
|
||||||
|
|
||||||
#### \`START_ITEMS\` IPC handler missing null-safe fallback
|
|
||||||
- The \`START_ITEMS\` handler validated \`itemIds ?? []\` but passed the raw \`itemIds\` (potentially \`null\`) to \`controller.startItems()\`. All other similar handlers (\`START_PACKAGES\`, \`SKIP_ITEMS\`, \`RESET_ITEMS\`) correctly used \`?? []\` for both validation and the controller call.
|
|
||||||
- **Fix:** Changed to \`controller.startItems(itemIds ?? [])\`.
|
|
||||||
|
|
||||||
#### \`finishRun()\` does not reset \`runStartedAt\`, causing stale session duration
|
|
||||||
- When a download run completed naturally, \`finishRun()\` set \`running = false\` but did not reset \`runStartedAt\` to 0. This caused \`getSessionStats()\` to report an ever-growing \`sessionDurationSeconds\` (wall clock time since run start) while \`totalDownloadedBytes\` stayed fixed, making \`averageSpeedBps\` decay toward 0 over time. In contrast, \`stop()\` correctly reset \`runStartedAt = 0\`.
|
|
||||||
- **Fix:** Added \`this.session.runStartedAt = 0\` to \`finishRun()\`.
|
|
||||||
|
|
||||||
#### Package status stuck at "downloading" when all items fail
|
|
||||||
- When all items in a package failed and none completed, the package status was never updated from "downloading" because \`refreshPackageStatus()\` was only called on item completion, not on item failure. The package remained in "downloading" state until the next app restart.
|
|
||||||
- **Fix:** Call \`refreshPackageStatus()\` after recording a failed item outcome in the error handler.
|
|
||||||
|
|
||||||
#### Shelve check preempts permanent link error detection
|
|
||||||
- The shelve check (\`totalNonStallFailures >= 15\`) ran before the \`isPermanentLinkError\` check. After accumulating 15+ failures, a permanent link error (dead link, file removed) would be shelved for a 5-minute retry pause instead of failing immediately, wasting time on irrecoverable errors.
|
|
||||||
- **Fix:** Moved the \`isPermanentLinkError\` check before the shelve check so permanent errors are detected immediately regardless of failure count.
|
|
||||||
|
|
||||||
#### Password-cracking labels not cleared on extraction error/abort/completion
|
|
||||||
- When extraction set item labels to "Passwort knacken: ..." or "Passwort gefunden ...", the error/completion handlers used \`/^Entpacken/\` regex to match items for status updates. This regex did not match password-related labels, leaving items permanently stuck with stale "Passwort knacken" or "Passwort gefunden" status after extraction errors, timeouts, or even successful completion.
|
|
||||||
- **Fix:** Extended the regex checks in hybrid success, hybrid error, and abort handlers to also match \`/^Passwort/\` labels.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — recoverPostProcessingOnStartup/triggerPendingExtractions remove cancelled===0; resetItems package cleanup; removeItem history fix; finishRun runStartedAt; refreshPackageStatus on item failure; shelve vs permanent link error order; password label cleanup in hybrid/error/abort handlers
|
|
||||||
- \`src/main/extractor.ts\` — generic split-skip writeExtractResumeState
|
|
||||||
- \`src/main/debrid.ts\` — abort error passthrough in single-provider mode
|
|
||||||
- \`src/main/main.ts\` — START_ITEMS itemIds ?? []
|
|
||||||
- \`src/renderer/App.tsx\` — sortPackageOrderByHoster uses extractHoster
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.28.exe", name: "Real-Debrid-Downloader-Setup-1.6.28.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.28.exe", name: "Real-Debrid-Downloader-1.6.28.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.28.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.28.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,144 +0,0 @@
|
|||||||
import https from "node:https";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const TAG = "v1.6.29";
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.29
|
|
||||||
|
|
||||||
### Bug Fixes (Deep Code Review — Round 5)
|
|
||||||
|
|
||||||
This release fixes 10 bugs found through an intensive 10-agent parallel code review covering every line of the codebase.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Critical (1 fix — regression from v1.6.28)
|
|
||||||
|
|
||||||
#### \`finishRun()\` zeroed \`runStartedAt\` before calculating run duration
|
|
||||||
- The v1.6.28 fix that added \`this.session.runStartedAt = 0\` placed the reset **before** the code that reads \`runStartedAt\` to calculate session duration. This made \`runStartedAt > 0\` always false, so \`duration\` defaulted to 1 second. The run summary then showed absurdly high average speeds (total bytes / 1 second).
|
|
||||||
- **Fix:** Save \`runStartedAt\` to a local variable before zeroing, then use the local variable for the duration calculation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Important (2 fixes)
|
|
||||||
|
|
||||||
#### \`importBackup\` restored session overwritten by \`prepareForShutdown()\`
|
|
||||||
- When a user restored a backup via import, \`saveSession()\` correctly wrote the restored session to disk. However, when the app quit (as instructed by "Bitte App neustarten"), \`prepareForShutdown()\` saved the **old in-memory session** back to disk, overwriting the restored backup. The restore appeared to succeed but was silently lost on restart.
|
|
||||||
- **Fix:** Added a \`skipShutdownPersist\` flag to \`DownloadManager\`. After \`importBackup\` saves the restored session, it sets this flag to \`true\`. \`prepareForShutdown()\` checks the flag and skips the session/settings write when set.
|
|
||||||
|
|
||||||
#### \`normalizeLoadedSessionTransientFields()\` missing package-level and session-level reset
|
|
||||||
- On startup, item statuses like "downloading" and "paused" were correctly reset to "queued", but **package statuses** in the same active states were left unchanged. Similarly, \`session.running\` and \`session.paused\` were not cleared. After a crash during an active download, packages could appear stuck in "downloading" status on restart, and the session could appear to be "running" with no active tasks.
|
|
||||||
- **Fix:** Added package status reset (active statuses → "queued") and \`session.running = false\` / \`session.paused = false\` to the normalization function.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Medium (7 fixes)
|
|
||||||
|
|
||||||
#### Stale \`itemContributedBytes\` / \`reservedTargetPaths\` / \`claimedTargetPathByItem\` across runs
|
|
||||||
- When the user manually stopped a download run, \`stop()\` did not call \`finishRun()\`, so \`itemContributedBytes\`, \`reservedTargetPaths\`, and \`claimedTargetPathByItem\` retained stale values from the previous run. On the next \`start()\` or \`resume()\`, these maps were not cleared. This caused: (1) inflated byte contributions subtracted from the reset \`totalDownloadedBytes\`, corrupting speed/progress calculations, (2) orphan path reservations preventing new items from claiming the same filenames, (3) stale target path claims causing unnecessary filename suffixing (\`file (1).rar\`).
|
|
||||||
- **Fix:** Added \`.clear()\` calls for all three maps in both \`startSelected()\` and the normal \`resume()\` path, matching \`finishRun()\`'s cleanup.
|
|
||||||
|
|
||||||
#### Hybrid extraction abort leaves stale progress labels on items
|
|
||||||
- When hybrid extraction was aborted (\`"aborted:extract"\`), the catch handler returned immediately without resetting item labels. Items could be left permanently showing mid-progress labels like \`"Entpacken 47% - movie.part01.rar - 12s"\` or \`"Passwort knacken: 30% (3/10) - archive.rar"\`. If the session was stopped or paused after the abort, these stale labels persisted in the UI and in the saved session.
|
|
||||||
- **Fix:** Added label cleanup loop before the return in the abort handler, resetting extraction/password labels to \`"Entpacken abgebrochen (wird fortgesetzt)"\`, consistent with the full extraction abort handler.
|
|
||||||
|
|
||||||
#### RAR5 multipart \`.rev\` recovery volumes not cleaned up after extraction
|
|
||||||
- \`collectArchiveCleanupTargets()\` matched RAR5 multipart data files (\`movie.part01.rar\`, \`movie.part02.rar\`) and a single legacy recovery file (\`movie.rev\`), but NOT RAR5 multipart recovery volumes (\`movie.part01.rev\`, \`movie.part02.rev\`). After extraction with cleanup enabled, recovery volumes were left on disk, wasting space.
|
|
||||||
- **Fix:** Added regex \`^prefix\\.part\\d+\\.rev$\` to the multipart RAR cleanup targets.
|
|
||||||
|
|
||||||
#### \`findReadyArchiveSets\` missed queued items without \`targetPath\` in pending check
|
|
||||||
- The archive-readiness check built \`pendingPaths\` from items with \`targetPath\` set, but items that hadn't started downloading yet (no \`targetPath\`, only \`fileName\`) were excluded. If all on-disk archive parts were completed but additional parts were still queued (never started), the archive could be prematurely marked as ready for extraction, leading to incomplete extraction.
|
|
||||||
- **Fix:** Also add \`path.join(pkg.outputDir, item.fileName)\` to \`pendingPaths\` for items without \`targetPath\`.
|
|
||||||
|
|
||||||
#### \`buildUniqueFlattenTargetPath\` unbounded loop
|
|
||||||
- The MKV library flatten function used an unbounded \`while(true)\` loop to find a unique filename, incrementing a suffix counter. In pathological cases (e.g., thousands of existing files or reserved names), this could run indefinitely, blocking the main process.
|
|
||||||
- **Fix:** Added a \`MAX_ATTEMPTS = 10000\` bound with a timestamp-based fallback filename to guarantee termination.
|
|
||||||
|
|
||||||
#### Redundant regex conditions in hybrid extraction error handler
|
|
||||||
- The error handler for hybrid extraction checked \`entry.fullStatus === "Entpacken - Ausstehend"\` and \`"Entpacken - Warten auf Parts"\` as separate conditions alongside the regex \`/^Entpacken\\b/i\`, which already matches both strings. The redundant conditions obscured the intent and added confusion.
|
|
||||||
- **Fix:** Removed the redundant explicit string comparisons, keeping only the regex checks.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Files Changed
|
|
||||||
- \`src/main/download-manager.ts\` — finishRun runStartedAt local var; start/resume clear itemContributedBytes + reservedTargetPaths + claimedTargetPathByItem; hybrid abort label cleanup; findReadyArchiveSets pendingPaths fileName fallback; buildUniqueFlattenTargetPath loop bound; hybrid error handler simplify redundant regex
|
|
||||||
- \`src/main/app-controller.ts\` — importBackup sets skipShutdownPersist flag
|
|
||||||
- \`src/main/storage.ts\` — normalizeLoadedSessionTransientFields resets package statuses and session.running/paused
|
|
||||||
- \`src/main/extractor.ts\` — RAR5 multipart .rev recovery volume cleanup
|
|
||||||
`;
|
|
||||||
|
|
||||||
function apiRequest(method, apiPath, body) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1${apiPath}`,
|
|
||||||
method,
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json" },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
if (body) req.write(JSON.stringify(body));
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function uploadAsset(releaseId, filePath, fileName) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
const opts = {
|
|
||||||
hostname: "codeberg.org",
|
|
||||||
path: `/api/v1/repos/${OWNER}/${REPO}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`,
|
|
||||||
method: "POST",
|
|
||||||
headers: { Authorization: `token ${TOKEN}`, "Content-Type": "application/octet-stream", "Content-Length": data.length },
|
|
||||||
};
|
|
||||||
const req = https.request(opts, (res) => {
|
|
||||||
const chunks = [];
|
|
||||||
res.on("data", (c) => chunks.push(c));
|
|
||||||
res.on("end", () => {
|
|
||||||
const text = Buffer.concat(chunks).toString();
|
|
||||||
if (res.statusCode >= 400) reject(new Error(`Upload ${fileName}: ${res.statusCode} ${text}`));
|
|
||||||
else resolve(JSON.parse(text || "{}"));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
req.on("error", reject);
|
|
||||||
req.write(data);
|
|
||||||
req.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("Creating release...");
|
|
||||||
const release = await apiRequest("POST", `/repos/${OWNER}/${REPO}/releases`, {
|
|
||||||
tag_name: TAG, name: TAG, body: BODY, draft: false, prerelease: false,
|
|
||||||
});
|
|
||||||
console.log(`Release created: ${release.id}`);
|
|
||||||
const releaseDir = path.join(__dirname, "..", "release");
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.29.exe", name: "Real-Debrid-Downloader-Setup-1.6.29.exe" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.29.exe", name: "Real-Debrid-Downloader-1.6.29.exe" },
|
|
||||||
{ file: "latest.yml", name: "latest.yml" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.29.exe.blockmap", name: "Real-Debrid-Downloader-Setup-1.6.29.exe.blockmap" },
|
|
||||||
];
|
|
||||||
for (const a of assets) {
|
|
||||||
const p = path.join(releaseDir, a.file);
|
|
||||||
if (!fs.existsSync(p)) { console.warn(`SKIP ${a.file}`); continue; }
|
|
||||||
console.log(`Uploading ${a.name} ...`);
|
|
||||||
await uploadAsset(release.id, p, a.name);
|
|
||||||
console.log(` done.`);
|
|
||||||
}
|
|
||||||
console.log("Release complete!");
|
|
||||||
}
|
|
||||||
main().catch((e) => { console.error(e); process.exit(1); });
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const TAG = "v1.6.30";
|
|
||||||
const TOKEN = "36034f878a07e8705c577a838e5186b3d6010d03";
|
|
||||||
const OWNER = "Sucukdeluxe";
|
|
||||||
const REPO = "real-debrid-downloader";
|
|
||||||
const API = `https://codeberg.org/api/v1/repos/${OWNER}/${REPO}`;
|
|
||||||
|
|
||||||
const RELEASE_DIR = path.resolve("release");
|
|
||||||
|
|
||||||
const BODY = `## What's Changed in v1.6.30
|
|
||||||
|
|
||||||
### Bug Fixes (Round 5 + Round 6 Deep Code Review — 19 fixes total)
|
|
||||||
|
|
||||||
#### Critical / High Priority
|
|
||||||
- **\`removeItem\` double-decrements \`itemCount\`**: When removing an item whose package had no remaining items, the item count was decremented both by \`removePackageFromSession\` (which deletes all items) and again by the caller. Fixed with a \`removedByPackageCleanup\` guard.
|
|
||||||
- **\`startItems\` missing map clears**: \`itemContributedBytes\`, \`reservedTargetPaths\`, and \`claimedTargetPathByItem\` were not cleared when starting individual items, causing stale data from previous runs to leak through.
|
|
||||||
- **\`start()\` race condition**: Two concurrent \`start()\` calls could both pass the \`running\` guard due to an \`await\` before \`running = true\` was set. Fixed by setting \`running = true\` before the first async operation.
|
|
||||||
- **Item-Recovery race condition**: In \`handlePackagePostProcessing\`, the scheduler could start an item during the \`await fs.promises.stat()\` call, but the recovery code would then overwrite the active download status with "completed". Added a post-await status + activeTasks re-check.
|
|
||||||
- **File-handle leak on Windows**: \`stream.destroy()\` was skipped when \`stream.end()\` threw an error and \`bodyError\` was null, because the \`throw\` exited the finally block before reaching the destroy call. Moved \`stream.destroy()\` into the catch block before the re-throw.
|
|
||||||
|
|
||||||
#### Medium Priority
|
|
||||||
- **\`clearAll\` doesn't clear \`providerFailures\`**: Provider failure tracking persisted across clear-all operations, causing unnecessary fallback to alternate providers on the next run.
|
|
||||||
- **\`skipItems\` missing \`releaseTargetPath\`**: Skipped items retained their reserved target paths, blocking other items from using those file paths.
|
|
||||||
- **\`skipItems\` extraction trigger ignores failed items**: The post-skip extraction check only verified no pending items existed, but didn't check for failed items, potentially starting extraction with an incomplete download set.
|
|
||||||
- **Double "Error:" prefix**: \`compactErrorText()\` wraps \`String(error)\` which adds "Error: " for Error objects. The final \`throw new Error(lastError)\` in RealDebrid, AllDebrid, and MegaDebrid clients then added a second "Error: " prefix. Fixed with \`.replace(/^Error:\\s*/i, "")\`.
|
|
||||||
- **Zip-bomb false positive on size=0 headers**: Archive entries with \`uncompressedSize === 0\` in the header (common for streaming-compressed files) triggered the zip-bomb heuristic. Fixed to only check when \`maxDeclaredSize > 0\`.
|
|
||||||
- **\`directoryHasAnyFiles\` treats system files as content**: Files like \`desktop.ini\`, \`Thumbs.db\`, \`.DS_Store\` etc. were counted as real content, causing false "directory not empty" conflicts. Now filters with \`isIgnorableEmptyDirFileName\`.
|
|
||||||
- **\`setBool\` in Delete-Confirm permanently sets dirty flag**: The generic \`setBool\` helper marked the settings draft as dirty even when only updating the "don't ask again" checkbox, triggering unnecessary save-on-close prompts. Replaced with a direct \`setSettingsDraft\` call.
|
|
||||||
- **\`item.url\` missing in PackageCard memo comparison**: URL changes (e.g. after unrestrict retry) didn't trigger re-renders because \`item.url\` wasn't in the equality check.
|
|
||||||
- **Column sort + drag-drop reorder lacking optimistic updates**: \`movePackage\`, \`reorderPackagesByDrop\`, and the column sort handler sent the IPC call but didn't update local state until the next snapshot from main, causing visible lag. Added optimistic state updates with rollback on error.
|
|
||||||
- **\`updatedAt\` unconditionally set for already-extracted items**: Items with an "Entpackt - Done" label had their \`updatedAt\` bumped on every extraction error/success pass, causing unnecessary re-renders. Added guard to skip already-extracted items.
|
|
||||||
- **\`normalizeSessionStatuses\` empty fullStatus**: Completed items with an empty \`fullStatus\` stayed blank instead of getting the correct "Entpacken - Ausstehend" or "Fertig" label.
|
|
||||||
- **\`prepareForShutdown\` mislabels pending items**: Items with "Entpacken - Ausstehend" or "Entpacken - Warten auf Parts" were relabeled to "Entpacken abgebrochen (wird fortgesetzt)" even though they were never actively extracting. Now only relabels items with active extraction status.
|
|
||||||
|
|
||||||
### Test Results
|
|
||||||
- 352 tests passing across 15 test files
|
|
||||||
`;
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// Create release
|
|
||||||
console.log("Creating release...");
|
|
||||||
const createRes = await fetch(`${API}/releases`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
tag_name: TAG,
|
|
||||||
name: TAG,
|
|
||||||
body: BODY,
|
|
||||||
draft: false,
|
|
||||||
prerelease: false,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!createRes.ok) {
|
|
||||||
const text = await createRes.text();
|
|
||||||
throw new Error(`Create release failed: ${createRes.status} ${text}`);
|
|
||||||
}
|
|
||||||
const release = await createRes.json();
|
|
||||||
console.log(`Release created: ${release.html_url}`);
|
|
||||||
|
|
||||||
// Upload assets
|
|
||||||
const assets = [
|
|
||||||
{ file: "Real-Debrid-Downloader-Setup-1.6.30.exe", label: "Setup Installer" },
|
|
||||||
{ file: "Real-Debrid-Downloader 1.6.30.exe", label: "Portable" },
|
|
||||||
{ file: "latest.yml", label: "Auto-Update Manifest" },
|
|
||||||
{ file: "Real-Debrid-Downloader Setup 1.6.30.exe.blockmap", label: "Blockmap" },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const asset of assets) {
|
|
||||||
const filePath = path.join(RELEASE_DIR, asset.file);
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
console.warn(`SKIP (not found): ${asset.file}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const data = fs.readFileSync(filePath);
|
|
||||||
console.log(`Uploading ${asset.file} (${(data.length / 1024 / 1024).toFixed(1)} MB)...`);
|
|
||||||
const uploadRes = await fetch(
|
|
||||||
`${API}/releases/${release.id}/assets?name=${encodeURIComponent(asset.file)}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
Authorization: `token ${TOKEN}`,
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
},
|
|
||||||
body: data,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (!uploadRes.ok) {
|
|
||||||
const text = await uploadRes.text();
|
|
||||||
console.error(`Upload failed for ${asset.file}: ${uploadRes.status} ${text}`);
|
|
||||||
} else {
|
|
||||||
console.log(` ✓ ${asset.file}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("Done!");
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(err);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -314,12 +314,6 @@ export class AppController {
|
|||||||
normalizeLoadedSession(parsed.session)
|
normalizeLoadedSession(parsed.session)
|
||||||
);
|
);
|
||||||
saveSession(this.storagePaths, restoredSession);
|
saveSession(this.storagePaths, restoredSession);
|
||||||
// Prevent prepareForShutdown from overwriting the restored session file
|
|
||||||
// with the old in-memory session when the app quits after backup restore.
|
|
||||||
this.manager.skipShutdownPersist = true;
|
|
||||||
// Block all persistence (including persistSoon from any IPC operations
|
|
||||||
// the user might trigger before restarting) to protect the restored backup.
|
|
||||||
this.manager.blockAllPersistence = true;
|
|
||||||
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." };
|
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -640,7 +640,7 @@ class MegaDebridClient {
|
|||||||
throw new Error("Mega-Web Antwort ohne Download-Link");
|
throw new Error("Mega-Web Antwort ohne Download-Link");
|
||||||
}
|
}
|
||||||
if (!lastError) {
|
if (!lastError) {
|
||||||
lastError = "Mega-Web Antwort leer";
|
lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer";
|
||||||
}
|
}
|
||||||
// Don't retry permanent hoster errors (dead link, file removed, etc.)
|
// Don't retry permanent hoster errors (dead link, file removed, etc.)
|
||||||
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError)) {
|
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError)) {
|
||||||
@ -650,7 +650,7 @@ class MegaDebridClient {
|
|||||||
await sleepWithSignal(retryDelay(attempt), signal);
|
await sleepWithSignal(retryDelay(attempt), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(String(lastError || "Mega-Web Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
throw new Error(lastError || "Mega-Web Unrestrict fehlgeschlagen");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -954,7 +954,7 @@ class AllDebridClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(String(lastError || "AllDebrid Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
throw new Error(lastError || "AllDebrid Unrestrict fehlgeschlagen");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1051,11 +1051,7 @@ export class DebridService {
|
|||||||
providerLabel: PROVIDER_LABELS[primary]
|
providerLabel: PROVIDER_LABELS[primary]
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorText = compactErrorText(error);
|
throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[primary]}: ${compactErrorText(error)}`);
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
throw new Error(`Unrestrict fehlgeschlagen: ${PROVIDER_LABELS[primary]}: ${errorText}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -801,12 +801,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
private storagePaths: StoragePaths;
|
private storagePaths: StoragePaths;
|
||||||
|
|
||||||
public skipShutdownPersist = false;
|
|
||||||
|
|
||||||
/** Block ALL persistence (persistSoon + shutdown). Set after importBackup to prevent
|
|
||||||
* the old in-memory session from overwriting the restored backup on disk. */
|
|
||||||
public blockAllPersistence = false;
|
|
||||||
|
|
||||||
private debridService: DebridService;
|
private debridService: DebridService;
|
||||||
|
|
||||||
private invalidateMegaSessionFn?: () => void;
|
private invalidateMegaSessionFn?: () => void;
|
||||||
@ -1087,7 +1081,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
seen.add(id);
|
seen.add(id);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const remaining = this.session.packageOrder.filter((id) => !seen.has(id));
|
const remaining = this.session.packageOrder.filter((id) => !valid.includes(id));
|
||||||
this.session.packageOrder = [...valid, ...remaining];
|
this.session.packageOrder = [...valid, ...remaining];
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
@ -1106,21 +1100,16 @@ export class DownloadManager extends EventEmitter {
|
|||||||
active.abortController.abort("cancel");
|
active.abortController.abort("cancel");
|
||||||
}
|
}
|
||||||
const pkg = this.session.packages[item.packageId];
|
const pkg = this.session.packages[item.packageId];
|
||||||
let removedByPackageCleanup = false;
|
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId);
|
||||||
if (pkg.itemIds.length === 0) {
|
if (pkg.itemIds.length === 0) {
|
||||||
this.removePackageFromSession(item.packageId, [itemId]);
|
this.removePackageFromSession(item.packageId, []);
|
||||||
removedByPackageCleanup = true;
|
|
||||||
} else {
|
} else {
|
||||||
pkg.updatedAt = nowMs();
|
pkg.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// removePackageFromSession already deletes the item and decrements itemCount
|
delete this.session.items[itemId];
|
||||||
if (!removedByPackageCleanup) {
|
this.itemCount = Math.max(0, this.itemCount - 1);
|
||||||
delete this.session.items[itemId];
|
|
||||||
this.itemCount = Math.max(0, this.itemCount - 1);
|
|
||||||
}
|
|
||||||
this.retryAfterByItem.delete(itemId);
|
this.retryAfterByItem.delete(itemId);
|
||||||
this.retryStateByItem.delete(itemId);
|
this.retryStateByItem.delete(itemId);
|
||||||
this.dropItemContribution(itemId);
|
this.dropItemContribution(itemId);
|
||||||
@ -1277,13 +1266,14 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.packagePostProcessTasks.clear();
|
this.packagePostProcessTasks.clear();
|
||||||
this.packagePostProcessAbortControllers.clear();
|
this.packagePostProcessAbortControllers.clear();
|
||||||
this.hybridExtractRequeue.clear();
|
this.hybridExtractRequeue.clear();
|
||||||
this.providerFailures.clear();
|
|
||||||
this.packagePostProcessQueue = Promise.resolve();
|
this.packagePostProcessQueue = Promise.resolve();
|
||||||
this.packagePostProcessActive = 0;
|
this.packagePostProcessActive = 0;
|
||||||
for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); }
|
for (const waiter of this.packagePostProcessWaiters) { waiter.resolve(); }
|
||||||
this.packagePostProcessWaiters = [];
|
this.packagePostProcessWaiters = [];
|
||||||
this.summary = null;
|
this.summary = null;
|
||||||
this.nonResumableActive = 0;
|
this.nonResumableActive = 0;
|
||||||
|
this.retryAfterByItem.clear();
|
||||||
|
this.retryStateByItem.clear();
|
||||||
this.resetSessionTotalsIfQueueEmpty();
|
this.resetSessionTotalsIfQueueEmpty();
|
||||||
this.persistNow();
|
this.persistNow();
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
@ -1538,11 +1528,10 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.attempts = 0;
|
item.attempts = 0;
|
||||||
item.lastError = "";
|
item.lastError = "";
|
||||||
item.fullStatus = "Wartet";
|
item.fullStatus = "Wartet";
|
||||||
item.provider = null;
|
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
this.assignItemTargetPath(item, path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url))));
|
this.assignItemTargetPath(item, path.join(pkg.outputDir, sanitizeFilename(item.fileName || filenameFromUrl(item.url))));
|
||||||
this.runOutcomes.delete(itemId);
|
this.runOutcomes.delete(itemId);
|
||||||
this.dropItemContribution(itemId);
|
this.itemContributedBytes.delete(itemId);
|
||||||
this.retryAfterByItem.delete(itemId);
|
this.retryAfterByItem.delete(itemId);
|
||||||
this.retryStateByItem.delete(itemId);
|
this.retryStateByItem.delete(itemId);
|
||||||
if (this.session.running) {
|
if (this.session.running) {
|
||||||
@ -1721,8 +1710,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.lastError = "Datei nicht gefunden auf Rapidgator";
|
item.lastError = "Datei nicht gefunden auf Rapidgator";
|
||||||
item.onlineStatus = "offline";
|
item.onlineStatus = "offline";
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
if (this.runItemIds.has(item.id)) {
|
if (this.runItemIds.has(itemId)) {
|
||||||
this.recordRunOutcome(item.id, "failed");
|
this.recordRunOutcome(itemId, "failed");
|
||||||
}
|
}
|
||||||
// Refresh package status since item was set to failed
|
// Refresh package status since item was set to failed
|
||||||
const pkg = this.session.packages[item.packageId];
|
const pkg = this.session.packages[item.packageId];
|
||||||
@ -1894,7 +1883,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.isFile() && !isIgnorableEmptyDirFileName(entry.name)) {
|
if (entry.isFile()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
@ -2401,8 +2390,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const baseName = sanitizeFilename(parsed.name || "video");
|
const baseName = sanitizeFilename(parsed.name || "video");
|
||||||
|
|
||||||
let index = 1;
|
let index = 1;
|
||||||
const MAX_ATTEMPTS = 10000;
|
while (true) {
|
||||||
while (index <= MAX_ATTEMPTS) {
|
|
||||||
const candidateName = index <= 1
|
const candidateName = index <= 1
|
||||||
? `${baseName}${extension}`
|
? `${baseName}${extension}`
|
||||||
: `${baseName} (${index})${extension}`;
|
: `${baseName} (${index})${extension}`;
|
||||||
@ -2418,11 +2406,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
// Fallback: use timestamp-based name to guarantee termination
|
|
||||||
const fallbackName = `${baseName} (${Date.now()})${extension}`;
|
|
||||||
const fallbackPath = path.join(targetDir, fallbackName);
|
|
||||||
reserved.add(pathKey(fallbackPath));
|
|
||||||
return fallbackPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async collectMkvFilesToLibrary(packageId: string, pkg: PackageEntry): Promise<void> {
|
private async collectMkvFilesToLibrary(packageId: string, pkg: PackageEntry): Promise<void> {
|
||||||
@ -2592,10 +2575,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (postProcessController && !postProcessController.signal.aborted) {
|
if (postProcessController && !postProcessController.signal.aborted) {
|
||||||
postProcessController.abort("reset");
|
postProcessController.abort("reset");
|
||||||
}
|
}
|
||||||
this.packagePostProcessAbortControllers.delete(packageId);
|
|
||||||
this.packagePostProcessTasks.delete(packageId);
|
|
||||||
this.hybridExtractRequeue.delete(packageId);
|
|
||||||
this.runCompletedPackages.delete(packageId);
|
|
||||||
|
|
||||||
// 3. Clean up extraction progress manifest (.rd_extract_progress.json)
|
// 3. Clean up extraction progress manifest (.rd_extract_progress.json)
|
||||||
if (pkg.outputDir) {
|
if (pkg.outputDir) {
|
||||||
@ -2676,17 +2655,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|
|
||||||
// Reset parent package status if it was completed/failed (now has queued items again)
|
// Reset parent package status if it was completed/failed (now has queued items again)
|
||||||
for (const pkgId of affectedPackageIds) {
|
for (const pkgId of affectedPackageIds) {
|
||||||
// Abort active post-processing for this package
|
|
||||||
const postProcessController = this.packagePostProcessAbortControllers.get(pkgId);
|
|
||||||
if (postProcessController && !postProcessController.signal.aborted) {
|
|
||||||
postProcessController.abort("reset");
|
|
||||||
}
|
|
||||||
this.packagePostProcessAbortControllers.delete(pkgId);
|
|
||||||
this.packagePostProcessTasks.delete(pkgId);
|
|
||||||
this.hybridExtractRequeue.delete(pkgId);
|
|
||||||
this.runCompletedPackages.delete(pkgId);
|
|
||||||
this.historyRecordedPackages.delete(pkgId);
|
|
||||||
|
|
||||||
const pkg = this.session.packages[pkgId];
|
const pkg = this.session.packages[pkgId];
|
||||||
if (pkg && (pkg.status === "completed" || pkg.status === "failed" || pkg.status === "cancelled")) {
|
if (pkg && (pkg.status === "completed" || pkg.status === "failed" || pkg.status === "cancelled")) {
|
||||||
pkg.status = "queued";
|
pkg.status = "queued";
|
||||||
@ -2748,7 +2716,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
this.retryAfterByItem.delete(itemId);
|
this.retryAfterByItem.delete(itemId);
|
||||||
this.retryStateByItem.delete(itemId);
|
this.retryStateByItem.delete(itemId);
|
||||||
this.releaseTargetPath(itemId);
|
|
||||||
this.recordRunOutcome(itemId, "cancelled");
|
this.recordRunOutcome(itemId, "cancelled");
|
||||||
affectedPackageIds.add(item.packageId);
|
affectedPackageIds.add(item.packageId);
|
||||||
}
|
}
|
||||||
@ -2756,16 +2723,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const pkg = this.session.packages[pkgId];
|
const pkg = this.session.packages[pkgId];
|
||||||
if (pkg) this.refreshPackageStatus(pkg);
|
if (pkg) this.refreshPackageStatus(pkg);
|
||||||
}
|
}
|
||||||
// Trigger extraction if all items are now in a terminal state and some completed (no failures)
|
// Trigger extraction if all items are now in a terminal state and some completed
|
||||||
if (this.settings.autoExtract) {
|
if (this.settings.autoExtract) {
|
||||||
for (const pkgId of affectedPackageIds) {
|
for (const pkgId of affectedPackageIds) {
|
||||||
const pkg = this.session.packages[pkgId];
|
const pkg = this.session.packages[pkgId];
|
||||||
if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(pkgId)) continue;
|
if (!pkg || pkg.cancelled || this.packagePostProcessTasks.has(pkgId)) continue;
|
||||||
const pkgItems = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
const pkgItems = pkg.itemIds.map((id) => this.session.items[id]).filter(Boolean) as DownloadItem[];
|
||||||
const hasPending = pkgItems.some((i) => i.status !== "completed" && i.status !== "failed" && i.status !== "cancelled");
|
const hasPending = pkgItems.some((i) => i.status !== "completed" && i.status !== "failed" && i.status !== "cancelled");
|
||||||
const hasFailed = pkgItems.some((i) => i.status === "failed");
|
|
||||||
const hasUnextracted = pkgItems.some((i) => i.status === "completed" && !isExtractedLabel(i.fullStatus || ""));
|
const hasUnextracted = pkgItems.some((i) => i.status === "completed" && !isExtractedLabel(i.fullStatus || ""));
|
||||||
if (!hasPending && !hasFailed && hasUnextracted) {
|
if (!hasPending && hasUnextracted) {
|
||||||
for (const it of pkgItems) {
|
for (const it of pkgItems) {
|
||||||
if (it.status === "completed" && !isExtractedLabel(it.fullStatus || "")) {
|
if (it.status === "completed" && !isExtractedLabel(it.fullStatus || "")) {
|
||||||
it.fullStatus = "Entpacken - Ausstehend";
|
it.fullStatus = "Entpacken - Ausstehend";
|
||||||
@ -2822,7 +2788,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not running: start with only items from specified packages
|
// Not running: start with only items from specified packages
|
||||||
this.triggerPendingExtractions();
|
|
||||||
const runItems = Object.values(this.session.items)
|
const runItems = Object.values(this.session.items)
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
if (!targetSet.has(item.packageId)) return false;
|
if (!targetSet.has(item.packageId)) return false;
|
||||||
@ -2841,9 +2806,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runCompletedPackages.clear();
|
this.runCompletedPackages.clear();
|
||||||
this.retryAfterByItem.clear();
|
this.retryAfterByItem.clear();
|
||||||
this.retryStateByItem.clear();
|
this.retryStateByItem.clear();
|
||||||
this.itemContributedBytes.clear();
|
|
||||||
this.reservedTargetPaths.clear();
|
|
||||||
this.claimedTargetPathByItem.clear();
|
|
||||||
this.session.running = true;
|
this.session.running = true;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.session.runStartedAt = nowMs();
|
this.session.runStartedAt = nowMs();
|
||||||
@ -2928,7 +2890,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not running: start with only specified items
|
// Not running: start with only specified items
|
||||||
this.triggerPendingExtractions();
|
|
||||||
const runItems = [...targetSet]
|
const runItems = [...targetSet]
|
||||||
.map((id) => this.session.items[id])
|
.map((id) => this.session.items[id])
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
@ -2948,9 +2909,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runCompletedPackages.clear();
|
this.runCompletedPackages.clear();
|
||||||
this.retryAfterByItem.clear();
|
this.retryAfterByItem.clear();
|
||||||
this.retryStateByItem.clear();
|
this.retryStateByItem.clear();
|
||||||
this.itemContributedBytes.clear();
|
|
||||||
this.reservedTargetPaths.clear();
|
|
||||||
this.claimedTargetPathByItem.clear();
|
|
||||||
this.session.running = true;
|
this.session.running = true;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.session.runStartedAt = nowMs();
|
this.session.runStartedAt = nowMs();
|
||||||
@ -2987,9 +2945,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (this.session.running) {
|
if (this.session.running) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Set running early to prevent concurrent start() calls from passing the guard
|
|
||||||
// while we await recoverRetryableItems below.
|
|
||||||
this.session.running = true;
|
|
||||||
|
|
||||||
const recoveredItems = await this.recoverRetryableItems("start");
|
const recoveredItems = await this.recoverRetryableItems("start");
|
||||||
|
|
||||||
@ -3050,7 +3005,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runOutcomes.clear();
|
this.runOutcomes.clear();
|
||||||
this.runCompletedPackages.clear();
|
this.runCompletedPackages.clear();
|
||||||
this.retryAfterByItem.clear();
|
this.retryAfterByItem.clear();
|
||||||
this.retryStateByItem.clear();
|
|
||||||
this.reservedTargetPaths.clear();
|
this.reservedTargetPaths.clear();
|
||||||
this.claimedTargetPathByItem.clear();
|
this.claimedTargetPathByItem.clear();
|
||||||
this.session.running = false;
|
this.session.running = false;
|
||||||
@ -3062,7 +3016,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
this.speedEvents = [];
|
this.speedEvents = [];
|
||||||
this.speedBytesLastWindow = 0;
|
this.speedBytesLastWindow = 0;
|
||||||
this.speedBytesPerPackage.clear();
|
this.speedBytesPerPackage.clear();
|
||||||
this.speedEventsHead = 0;
|
this.speedEventsHead = 0;
|
||||||
this.lastGlobalProgressBytes = 0;
|
this.lastGlobalProgressBytes = 0;
|
||||||
this.lastGlobalProgressAt = nowMs();
|
this.lastGlobalProgressAt = nowMs();
|
||||||
@ -3078,9 +3032,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.runCompletedPackages.clear();
|
this.runCompletedPackages.clear();
|
||||||
this.retryAfterByItem.clear();
|
this.retryAfterByItem.clear();
|
||||||
this.retryStateByItem.clear();
|
this.retryStateByItem.clear();
|
||||||
this.itemContributedBytes.clear();
|
|
||||||
this.reservedTargetPaths.clear();
|
|
||||||
this.claimedTargetPathByItem.clear();
|
|
||||||
|
|
||||||
this.session.running = true;
|
this.session.running = true;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
@ -3122,7 +3073,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.session.reconnectUntil = 0;
|
this.session.reconnectUntil = 0;
|
||||||
this.session.reconnectReason = "";
|
this.session.reconnectReason = "";
|
||||||
this.retryAfterByItem.clear();
|
this.retryAfterByItem.clear();
|
||||||
this.retryStateByItem.clear();
|
|
||||||
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
this.lastGlobalProgressBytes = this.session.totalDownloadedBytes;
|
||||||
this.lastGlobalProgressAt = nowMs();
|
this.lastGlobalProgressAt = nowMs();
|
||||||
this.speedEvents = [];
|
this.speedEvents = [];
|
||||||
@ -3139,13 +3089,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
active.abortReason = "stop";
|
active.abortReason = "stop";
|
||||||
active.abortController.abort("stop");
|
active.abortController.abort("stop");
|
||||||
}
|
}
|
||||||
// Reset all non-finished items to clean "Wartet" / "Paket gestoppt" state
|
// Reset all non-finished items to clean "Wartet" state
|
||||||
for (const item of Object.values(this.session.items)) {
|
for (const item of Object.values(this.session.items)) {
|
||||||
if (!isFinishedStatus(item.status)) {
|
if (!isFinishedStatus(item.status)) {
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
const pkg = this.session.packages[item.packageId];
|
item.fullStatus = "Wartet";
|
||||||
item.fullStatus = pkg && !pkg.enabled ? "Paket gestoppt" : "Wartet";
|
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3208,12 +3157,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of Object.values(this.session.items)) {
|
for (const item of Object.values(this.session.items)) {
|
||||||
if (item.status !== "completed") continue;
|
if (item.status === "completed" && /^Entpacken/i.test(item.fullStatus || "")) {
|
||||||
const fs = item.fullStatus || "";
|
|
||||||
// Only relabel items with active extraction status (e.g. "Entpacken 45%", "Passwort prüfen")
|
|
||||||
// Skip items that were merely waiting ("Entpacken - Ausstehend", "Entpacken - Warten auf Parts")
|
|
||||||
// as they were never actively extracting and "abgebrochen" would be misleading.
|
|
||||||
if (/^Entpacken\b/i.test(fs) && !/Ausstehend/i.test(fs) && !/Warten/i.test(fs) && !isExtractedLabel(fs)) {
|
|
||||||
item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
item.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
const pkg = this.session.packages[item.packageId];
|
const pkg = this.session.packages[item.packageId];
|
||||||
@ -3236,11 +3180,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
this.nonResumableActive = 0;
|
this.nonResumableActive = 0;
|
||||||
this.session.summaryText = "";
|
this.session.summaryText = "";
|
||||||
// Persist synchronously on shutdown to guarantee data is written before process exits
|
// Persist synchronously on shutdown to guarantee data is written before process exits
|
||||||
// Skip if a backup was just imported — the restored session on disk must not be overwritten
|
saveSession(this.storagePaths, this.session);
|
||||||
if (!this.skipShutdownPersist && !this.blockAllPersistence) {
|
saveSettings(this.storagePaths, this.settings);
|
||||||
saveSession(this.storagePaths, this.session);
|
|
||||||
saveSettings(this.storagePaths, this.settings);
|
|
||||||
}
|
|
||||||
this.emitState(true);
|
this.emitState(true);
|
||||||
logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`);
|
logger.info(`Shutdown-Vorbereitung beendet: requeued=${requeuedItems}`);
|
||||||
}
|
}
|
||||||
@ -3325,8 +3266,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
|| item.status === "paused"
|
|| item.status === "paused"
|
||||||
|| item.status === "reconnect_wait") {
|
|| item.status === "reconnect_wait") {
|
||||||
item.status = "queued";
|
item.status = "queued";
|
||||||
const itemPkg = this.session.packages[item.packageId];
|
item.fullStatus = "Wartet";
|
||||||
item.fullStatus = (itemPkg && itemPkg.enabled === false) ? "Paket gestoppt" : "Wartet";
|
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
@ -3346,7 +3286,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Preserve extraction-related statuses (Ausstehend, Warten auf Parts, etc.)
|
// Preserve extraction-related statuses (Ausstehend, Warten auf Parts, etc.)
|
||||||
if (/^Entpacken\b/i.test(statusText) || isExtractedLabel(statusText) || /^Fertig\b/i.test(statusText)) {
|
if (/^Entpacken\b/i.test(statusText) || isExtractedLabel(statusText) || /^Fertig\b/i.test(statusText)) {
|
||||||
// keep as-is
|
// keep as-is
|
||||||
} else {
|
} else if (statusText) {
|
||||||
item.fullStatus = this.settings.autoExtract
|
item.fullStatus = this.settings.autoExtract
|
||||||
? "Entpacken - Ausstehend"
|
? "Entpacken - Ausstehend"
|
||||||
: `Fertig (${humanSize(item.downloadedBytes)})`;
|
: `Fertig (${humanSize(item.downloadedBytes)})`;
|
||||||
@ -3478,9 +3418,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (this.settings.autoExtract) {
|
if (this.settings.autoExtract) {
|
||||||
const allExtracted = pkg.itemIds.every((id) => {
|
const allExtracted = pkg.itemIds.every((id) => {
|
||||||
const item = this.session.items[id];
|
const item = this.session.items[id];
|
||||||
if (!item) return true;
|
return !item || isExtractedLabel(item.fullStatus || "");
|
||||||
if (item.status === "failed" || item.status === "cancelled") return true;
|
|
||||||
return isExtractedLabel(item.fullStatus || "");
|
|
||||||
});
|
});
|
||||||
if (!allExtracted) continue;
|
if (!allExtracted) continue;
|
||||||
}
|
}
|
||||||
@ -3503,7 +3441,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private persistSoon(): void {
|
private persistSoon(): void {
|
||||||
if (this.persistTimer || this.blockAllPersistence) {
|
if (this.persistTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3840,7 +3778,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
let changed = false;
|
let changed = false;
|
||||||
for (const packageId of packageIds) {
|
for (const packageId of packageIds) {
|
||||||
const pkg = this.session.packages[packageId];
|
const pkg = this.session.packages[packageId];
|
||||||
if (!pkg || pkg.cancelled || !pkg.enabled) {
|
if (!pkg || pkg.cancelled) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3878,7 +3816,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.settings.autoExtract && failed === 0 && success > 0) {
|
if (this.settings.autoExtract && failed === 0 && cancelled === 0 && success > 0) {
|
||||||
const needsExtraction = items.some((item) => item.status === "completed" && !isExtractedLabel(item.fullStatus));
|
const needsExtraction = items.some((item) => item.status === "completed" && !isExtractedLabel(item.fullStatus));
|
||||||
if (needsExtraction) {
|
if (needsExtraction) {
|
||||||
pkg.status = "queued";
|
pkg.status = "queued";
|
||||||
@ -3939,7 +3877,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const allDone = success + failed + cancelled >= items.length;
|
const allDone = success + failed + cancelled >= items.length;
|
||||||
|
|
||||||
// Full extraction: all items done, no failures
|
// Full extraction: all items done, no failures
|
||||||
if (allDone && failed === 0 && success > 0) {
|
if (allDone && failed === 0 && cancelled === 0 && success > 0) {
|
||||||
const needsExtraction = items.some((item) =>
|
const needsExtraction = items.some((item) =>
|
||||||
item.status === "completed" && !isExtractedLabel(item.fullStatus)
|
item.status === "completed" && !isExtractedLabel(item.fullStatus)
|
||||||
);
|
);
|
||||||
@ -4989,7 +4927,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.releaseTargetPath(item.id);
|
this.releaseTargetPath(item.id);
|
||||||
this.dropItemContribution(item.id);
|
|
||||||
this.queueRetry(item, active, 300, "Netzwerkfehler erkannt, frischer Retry");
|
this.queueRetry(item, active, 300, "Netzwerkfehler erkannt, frischer Retry");
|
||||||
item.lastError = "";
|
item.lastError = "";
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
@ -5000,23 +4937,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Permanent link errors (dead link, file removed, hoster unavailable) → fail immediately
|
// Shelve check for non-stall errors
|
||||||
// Check BEFORE shelve to avoid 5-min pause on dead links
|
|
||||||
if (isPermanentLinkError(errorText)) {
|
|
||||||
logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`);
|
|
||||||
item.status = "failed";
|
|
||||||
this.recordRunOutcome(item.id, "failed");
|
|
||||||
item.lastError = errorText;
|
|
||||||
item.fullStatus = `Link ungültig: ${errorText}`;
|
|
||||||
item.speedBps = 0;
|
|
||||||
item.updatedAt = nowMs();
|
|
||||||
this.retryStateByItem.delete(item.id);
|
|
||||||
this.persistSoon();
|
|
||||||
this.emitState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shelve check for non-stall errors (after permanent link error check)
|
|
||||||
const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
|
const totalNonStallFailures = (active.stallRetries || 0) + (active.unrestrictRetries || 0) + (active.genericErrorRetries || 0);
|
||||||
if (totalNonStallFailures >= 15) {
|
if (totalNonStallFailures >= 15) {
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
@ -5031,6 +4952,21 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Permanent link errors (dead link, file removed, hoster unavailable) → fail immediately
|
||||||
|
if (isPermanentLinkError(errorText)) {
|
||||||
|
logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`);
|
||||||
|
item.status = "failed";
|
||||||
|
this.recordRunOutcome(item.id, "failed");
|
||||||
|
item.lastError = errorText;
|
||||||
|
item.fullStatus = `Link ungültig: ${errorText}`;
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
this.retryStateByItem.delete(item.id);
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
||||||
active.unrestrictRetries += 1;
|
active.unrestrictRetries += 1;
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
@ -5088,9 +5024,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
item.updatedAt = nowMs();
|
item.updatedAt = nowMs();
|
||||||
// Refresh package status so it reflects "failed" when all items are done
|
|
||||||
const failPkg = this.session.packages[item.packageId];
|
|
||||||
if (failPkg) this.refreshPackageStatus(failPkg);
|
|
||||||
this.persistSoon();
|
this.persistSoon();
|
||||||
this.emitState();
|
this.emitState();
|
||||||
return;
|
return;
|
||||||
@ -5685,10 +5618,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
stream.end();
|
stream.end();
|
||||||
});
|
});
|
||||||
} catch (streamCloseError) {
|
} catch (streamCloseError) {
|
||||||
// Ensure stream is destroyed before re-throwing to avoid file-handle leaks on Windows
|
|
||||||
if (!stream.destroyed) {
|
|
||||||
stream.destroy();
|
|
||||||
}
|
|
||||||
if (!bodyError) {
|
if (!bodyError) {
|
||||||
throw streamCloseError;
|
throw streamCloseError;
|
||||||
}
|
}
|
||||||
@ -5717,7 +5646,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
try {
|
try {
|
||||||
await fs.promises.rm(effectiveTargetPath, { force: true });
|
await fs.promises.rm(effectiveTargetPath, { force: true });
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
this.releaseTargetPath(active.itemId);
|
|
||||||
this.dropItemContribution(active.itemId);
|
this.dropItemContribution(active.itemId);
|
||||||
item.downloadedBytes = 0;
|
item.downloadedBytes = 0;
|
||||||
item.progressPercent = 0;
|
item.progressPercent = 0;
|
||||||
@ -6106,16 +6034,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
if (item.status === "completed" && item.targetPath) {
|
if (item.status === "completed" && item.targetPath) {
|
||||||
completedPaths.add(pathKey(item.targetPath));
|
completedPaths.add(pathKey(item.targetPath));
|
||||||
} else {
|
} else if (item.targetPath) {
|
||||||
if (item.targetPath) {
|
pendingPaths.add(pathKey(item.targetPath));
|
||||||
pendingPaths.add(pathKey(item.targetPath));
|
|
||||||
}
|
|
||||||
// Items that haven't started yet have no targetPath but may have a fileName.
|
|
||||||
// Include their projected path so the archive-readiness check doesn't
|
|
||||||
// prematurely trigger extraction while parts are still queued.
|
|
||||||
if (item.fileName && pkg.outputDir) {
|
|
||||||
pendingPaths.add(pathKey(path.join(pkg.outputDir, item.fileName)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (completedPaths.size === 0) {
|
if (completedPaths.size === 0) {
|
||||||
@ -6353,9 +6273,18 @@ export class DownloadManager extends EventEmitter {
|
|||||||
extractCpuPriority: this.settings.extractCpuPriority,
|
extractCpuPriority: this.settings.extractCpuPriority,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
if (progress.phase === "done") {
|
if (progress.phase === "done") {
|
||||||
// Do NOT mark remaining archives as "Done" here — some may have
|
// Mark all remaining active archives as done
|
||||||
// failed. The post-extraction code (result.failed check) will
|
for (const [archName, archItems] of activeHybridArchiveMap) {
|
||||||
// assign the correct label. Only clear the tracking maps.
|
const doneAt = nowMs();
|
||||||
|
const startedAt = hybridArchiveStartTimes.get(archName) || doneAt;
|
||||||
|
const doneLabel = formatExtractDone(doneAt - startedAt);
|
||||||
|
for (const entry of archItems) {
|
||||||
|
if (!isExtractedLabel(entry.fullStatus)) {
|
||||||
|
entry.fullStatus = doneLabel;
|
||||||
|
entry.updatedAt = doneAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
activeHybridArchiveMap.clear();
|
activeHybridArchiveMap.clear();
|
||||||
hybridArchiveStartTimes.clear();
|
hybridArchiveStartTimes.clear();
|
||||||
return;
|
return;
|
||||||
@ -6400,7 +6329,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
const updatedAt = nowMs();
|
const updatedAt = nowMs();
|
||||||
for (const entry of archItems) {
|
for (const entry of archItems) {
|
||||||
if (!isExtractedLabel(entry.fullStatus) && entry.fullStatus !== label) {
|
if (!isExtractedLabel(entry.fullStatus)) {
|
||||||
entry.fullStatus = label;
|
entry.fullStatus = label;
|
||||||
entry.updatedAt = updatedAt;
|
entry.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
@ -6443,13 +6372,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const status = entry.fullStatus || "";
|
const status = entry.fullStatus || "";
|
||||||
if (/^Entpacken\b/i.test(status) || /^Passwort\b/i.test(status)) {
|
if (/^Entpacken\b/i.test(status)) {
|
||||||
if (result.failed > 0) {
|
if (result.extracted > 0 && result.failed === 0) {
|
||||||
entry.fullStatus = "Entpacken - Error";
|
|
||||||
} else if (result.extracted > 0) {
|
|
||||||
entry.fullStatus = formatExtractDone(nowMs() - hybridExtractStartMs);
|
entry.fullStatus = formatExtractDone(nowMs() - hybridExtractStartMs);
|
||||||
|
} else {
|
||||||
|
entry.fullStatus = "Entpacken - Error";
|
||||||
}
|
}
|
||||||
// extracted === 0 && failed === 0: keep current status (no archives to process)
|
|
||||||
entry.updatedAt = updatedAt;
|
entry.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6457,21 +6385,13 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const errorText = String(error || "");
|
const errorText = String(error || "");
|
||||||
if (errorText.includes("aborted:extract")) {
|
if (errorText.includes("aborted:extract")) {
|
||||||
logger.info(`Hybrid-Extract abgebrochen: pkg=${pkg.name}`);
|
logger.info(`Hybrid-Extract abgebrochen: pkg=${pkg.name}`);
|
||||||
const abortAt = nowMs();
|
|
||||||
for (const entry of hybridItems) {
|
|
||||||
if (isExtractedLabel(entry.fullStatus || "")) continue;
|
|
||||||
if (/^Entpacken\b/i.test(entry.fullStatus || "") || /^Passwort\b/i.test(entry.fullStatus || "")) {
|
|
||||||
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
|
||||||
entry.updatedAt = abortAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.warn(`Hybrid-Extract Fehler: pkg=${pkg.name}, reason=${compactErrorText(error)}`);
|
logger.warn(`Hybrid-Extract Fehler: pkg=${pkg.name}, reason=${compactErrorText(error)}`);
|
||||||
const errorAt = nowMs();
|
const errorAt = nowMs();
|
||||||
for (const entry of hybridItems) {
|
for (const entry of hybridItems) {
|
||||||
if (isExtractedLabel(entry.fullStatus || "")) continue;
|
if (isExtractedLabel(entry.fullStatus || "")) continue;
|
||||||
if (/^Entpacken\b/i.test(entry.fullStatus || "") || /^Passwort\b/i.test(entry.fullStatus || "")) {
|
if (/^Entpacken\b/i.test(entry.fullStatus || "") || entry.fullStatus === "Entpacken - Ausstehend" || entry.fullStatus === "Entpacken - Warten auf Parts") {
|
||||||
entry.fullStatus = `Entpacken - Error`;
|
entry.fullStatus = `Entpacken - Error`;
|
||||||
entry.updatedAt = errorAt;
|
entry.updatedAt = errorAt;
|
||||||
}
|
}
|
||||||
@ -6509,11 +6429,6 @@ export class DownloadManager extends EventEmitter {
|
|||||||
? Math.max(10240, Math.floor(item.totalBytes * 0.5))
|
? Math.max(10240, Math.floor(item.totalBytes * 0.5))
|
||||||
: 10240;
|
: 10240;
|
||||||
if (stat.size >= minSize) {
|
if (stat.size >= minSize) {
|
||||||
// Re-check: another task may have started this item during the await
|
|
||||||
if (this.activeTasks.has(item.id) || item.status === "downloading"
|
|
||||||
|| item.status === "validating" || item.status === "integrity_check") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
logger.info(`Item-Recovery: ${item.fileName} war "${item.status}" aber Datei existiert (${humanSize(stat.size)}), setze auf completed`);
|
logger.info(`Item-Recovery: ${item.fileName} war "${item.status}" aber Datei existiert (${humanSize(stat.size)}), setze auf completed`);
|
||||||
item.status = "completed";
|
item.status = "completed";
|
||||||
item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`;
|
item.fullStatus = this.settings.autoExtract ? "Entpacken - Ausstehend" : `Fertig (${humanSize(stat.size)})`;
|
||||||
@ -6540,7 +6455,7 @@ export class DownloadManager extends EventEmitter {
|
|||||||
if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) {
|
if (!allDone && this.settings.hybridExtract && this.settings.autoExtract && failed === 0 && success > 0) {
|
||||||
await this.runHybridExtraction(packageId, pkg, items, signal);
|
await this.runHybridExtraction(packageId, pkg, items, signal);
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
pkg.status = (pkg.enabled && this.session.running && !this.session.paused) ? "queued" : "paused";
|
pkg.status = (pkg.enabled && !this.session.paused) ? "queued" : "paused";
|
||||||
pkg.updatedAt = nowMs();
|
pkg.updatedAt = nowMs();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -6640,9 +6555,18 @@ export class DownloadManager extends EventEmitter {
|
|||||||
extractCpuPriority: this.settings.extractCpuPriority,
|
extractCpuPriority: this.settings.extractCpuPriority,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
if (progress.phase === "done") {
|
if (progress.phase === "done") {
|
||||||
// Do NOT mark remaining archives as "Done" here — some may have
|
// Mark all remaining active archives as done
|
||||||
// failed. The post-extraction code (result.failed check) will
|
for (const [archName, items] of activeArchiveItemsMap) {
|
||||||
// assign the correct label. Only clear the tracking maps.
|
const doneAt = nowMs();
|
||||||
|
const startedAt = archiveStartTimes.get(archName) || doneAt;
|
||||||
|
const doneLabel = formatExtractDone(doneAt - startedAt);
|
||||||
|
for (const entry of items) {
|
||||||
|
if (!isExtractedLabel(entry.fullStatus)) {
|
||||||
|
entry.fullStatus = doneLabel;
|
||||||
|
entry.updatedAt = doneAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
activeArchiveItemsMap.clear();
|
activeArchiveItemsMap.clear();
|
||||||
archiveStartTimes.clear();
|
archiveStartTimes.clear();
|
||||||
emitExtractStatus("Entpacken 100%", true);
|
emitExtractStatus("Entpacken 100%", true);
|
||||||
@ -6729,8 +6653,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Preserve per-archive "Entpackt - Done (X.Xs)" labels for successfully extracted archives
|
// Preserve per-archive "Entpackt - Done (X.Xs)" labels for successfully extracted archives
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
if (!isExtractedLabel(entry.fullStatus)) {
|
||||||
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
||||||
entry.updatedAt = failAt;
|
|
||||||
}
|
}
|
||||||
|
entry.updatedAt = failAt;
|
||||||
}
|
}
|
||||||
pkg.status = "failed";
|
pkg.status = "failed";
|
||||||
} else {
|
} else {
|
||||||
@ -6752,8 +6676,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
// Preserve per-archive duration labels (e.g. "Entpackt - Done (5.3s)")
|
// Preserve per-archive duration labels (e.g. "Entpackt - Done (5.3s)")
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
if (!isExtractedLabel(entry.fullStatus)) {
|
||||||
entry.fullStatus = finalStatusText;
|
entry.fullStatus = finalStatusText;
|
||||||
entry.updatedAt = finalAt;
|
|
||||||
}
|
}
|
||||||
|
entry.updatedAt = finalAt;
|
||||||
}
|
}
|
||||||
pkg.status = "completed";
|
pkg.status = "completed";
|
||||||
}
|
}
|
||||||
@ -6766,20 +6690,18 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
|
const timeoutReason = `Entpacken Timeout nach ${Math.ceil(extractTimeoutMs / 1000)}s`;
|
||||||
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
|
logger.error(`Post-Processing Entpacken Timeout: pkg=${pkg.name}`);
|
||||||
for (const entry of completedItems) {
|
for (const entry of completedItems) {
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
entry.fullStatus = `Entpack-Fehler: ${timeoutReason}`;
|
||||||
entry.fullStatus = `Entpack-Fehler: ${timeoutReason}`;
|
entry.updatedAt = nowMs();
|
||||||
entry.updatedAt = nowMs();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pkg.status = "failed";
|
pkg.status = "failed";
|
||||||
pkg.updatedAt = nowMs();
|
pkg.updatedAt = nowMs();
|
||||||
timeoutHandled = true;
|
timeoutHandled = true;
|
||||||
} else {
|
} else {
|
||||||
for (const entry of completedItems) {
|
for (const entry of completedItems) {
|
||||||
if (/^Entpacken/i.test(entry.fullStatus || "") || /^Passwort/i.test(entry.fullStatus || "")) {
|
if (/^Entpacken/i.test(entry.fullStatus || "")) {
|
||||||
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
entry.fullStatus = "Entpacken abgebrochen (wird fortgesetzt)";
|
||||||
entry.updatedAt = nowMs();
|
|
||||||
}
|
}
|
||||||
|
entry.updatedAt = nowMs();
|
||||||
}
|
}
|
||||||
pkg.status = (pkg.enabled && !this.session.paused) ? "queued" : "paused";
|
pkg.status = (pkg.enabled && !this.session.paused) ? "queued" : "paused";
|
||||||
pkg.updatedAt = nowMs();
|
pkg.updatedAt = nowMs();
|
||||||
@ -6791,10 +6713,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
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) {
|
||||||
if (!isExtractedLabel(entry.fullStatus)) {
|
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
||||||
entry.fullStatus = `Entpack-Fehler: ${reason}`;
|
entry.updatedAt = nowMs();
|
||||||
entry.updatedAt = nowMs();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pkg.status = "failed";
|
pkg.status = "failed";
|
||||||
}
|
}
|
||||||
@ -6945,17 +6865,15 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private finishRun(): void {
|
private finishRun(): void {
|
||||||
const runStartedAt = this.session.runStartedAt;
|
|
||||||
this.session.running = false;
|
this.session.running = false;
|
||||||
this.session.paused = false;
|
this.session.paused = false;
|
||||||
this.session.runStartedAt = 0;
|
|
||||||
const total = this.runItemIds.size;
|
const total = this.runItemIds.size;
|
||||||
const outcomes = Array.from(this.runOutcomes.values());
|
const outcomes = Array.from(this.runOutcomes.values());
|
||||||
const success = outcomes.filter((status) => status === "completed").length;
|
const success = outcomes.filter((status) => status === "completed").length;
|
||||||
const failed = outcomes.filter((status) => status === "failed").length;
|
const failed = outcomes.filter((status) => status === "failed").length;
|
||||||
const cancelled = outcomes.filter((status) => status === "cancelled").length;
|
const cancelled = outcomes.filter((status) => status === "cancelled").length;
|
||||||
const extracted = this.runCompletedPackages.size;
|
const extracted = this.runCompletedPackages.size;
|
||||||
const duration = runStartedAt > 0 ? Math.max(1, Math.floor((nowMs() - runStartedAt) / 1000)) : 1;
|
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);
|
const avgSpeed = Math.floor(this.session.totalDownloadedBytes / duration);
|
||||||
this.summary = {
|
this.summary = {
|
||||||
total,
|
total,
|
||||||
|
|||||||
@ -424,12 +424,9 @@ async function writeExtractResumeState(packageDir: string, completedArchives: Se
|
|||||||
.map((name) => archiveNameKey(name))
|
.map((name) => archiveNameKey(name))
|
||||||
.sort((a, b) => a.localeCompare(b))
|
.sort((a, b) => a.localeCompare(b))
|
||||||
};
|
};
|
||||||
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
|
const tmpPath = progressPath + ".tmp";
|
||||||
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
||||||
await fs.promises.rename(tmpPath, progressPath).catch(async () => {
|
await fs.promises.rename(tmpPath, progressPath);
|
||||||
// rename may fail if another writer renamed tmpPath first (parallel workers)
|
|
||||||
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`ExtractResumeState schreiben fehlgeschlagen: ${String(error)}`);
|
logger.warn(`ExtractResumeState schreiben fehlgeschlagen: ${String(error)}`);
|
||||||
}
|
}
|
||||||
@ -899,8 +896,6 @@ function resolveJvmExtractorLayout(): JvmExtractorLayout | null {
|
|||||||
}) || "";
|
}) || "";
|
||||||
|
|
||||||
if (!javaCommand) {
|
if (!javaCommand) {
|
||||||
cachedJvmLayout = null;
|
|
||||||
cachedJvmLayoutNullSince = Date.now();
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1597,8 +1592,7 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
|
|||||||
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
|
const limitMb = Math.ceil(memoryLimitBytes / (1024 * 1024));
|
||||||
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
|
throw new Error(`ZIP-Eintrag zu groß für internen Entpacker (${entryMb} MB > ${limitMb} MB)`);
|
||||||
}
|
}
|
||||||
const maxDeclaredSize = Math.max(uncompressedSize, compressedSize);
|
if (data.length > Math.max(uncompressedSize, compressedSize) * 20) {
|
||||||
if (maxDeclaredSize > 0 && data.length > maxDeclaredSize * 20) {
|
|
||||||
throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`);
|
throw new Error(`ZIP-Eintrag verdächtig groß nach Entpacken (${entry.entryName})`);
|
||||||
}
|
}
|
||||||
await fs.promises.writeFile(outputPath, data);
|
await fs.promises.writeFile(outputPath, data);
|
||||||
@ -1638,8 +1632,6 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
|
|||||||
if (multipartRar) {
|
if (multipartRar) {
|
||||||
const prefix = escapeRegex(multipartRar[1]);
|
const prefix = escapeRegex(multipartRar[1]);
|
||||||
addMatching(new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i"));
|
addMatching(new RegExp(`^${prefix}\\.part\\d+\\.rar$`, "i"));
|
||||||
// RAR5 recovery volumes: prefix.partN.rev AND legacy prefix.rev
|
|
||||||
addMatching(new RegExp(`^${prefix}\\.part\\d+\\.rev$`, "i"));
|
|
||||||
addMatching(new RegExp(`^${prefix}\\.rev$`, "i"));
|
addMatching(new RegExp(`^${prefix}\\.rev$`, "i"));
|
||||||
return Array.from(targets);
|
return Array.from(targets);
|
||||||
}
|
}
|
||||||
@ -2009,7 +2001,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
extracted += 1;
|
extracted += 1;
|
||||||
resumeCompleted.add(archiveResumeKey);
|
resumeCompleted.add(archiveResumeKey);
|
||||||
extractedArchives.add(archivePath);
|
extractedArchives.add(archivePath);
|
||||||
await writeExtractResumeState(options.packageDir, resumeCompleted, options.packageId);
|
|
||||||
clearInterval(pulseTimer);
|
clearInterval(pulseTimer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -2122,13 +2113,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
|
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
|
||||||
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
||||||
const first = pendingCandidates[0];
|
const first = pendingCandidates[0];
|
||||||
try {
|
await extractSingleArchive(first);
|
||||||
await extractSingleArchive(first);
|
|
||||||
} catch (err) {
|
|
||||||
const errText = String(err);
|
|
||||||
if (/aborted:extract/i.test(errText)) throw err;
|
|
||||||
// noextractor:skipped — handled by noExtractorEncountered flag below
|
|
||||||
}
|
|
||||||
parallelQueue = pendingCandidates.slice(1);
|
parallelQueue = pendingCandidates.slice(1);
|
||||||
if (parallelQueue.length > 0) {
|
if (parallelQueue.length > 0) {
|
||||||
logger.info(`Passwort-Discovery abgeschlossen, starte parallele Extraktion für ${parallelQueue.length} verbleibende Archive`);
|
logger.info(`Passwort-Discovery abgeschlossen, starte parallele Extraktion für ${parallelQueue.length} verbleibende Archive`);
|
||||||
@ -2201,7 +2186,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
for (const nestedArchive of nestedCandidates) {
|
for (const nestedArchive of nestedCandidates) {
|
||||||
if (options.signal?.aborted) throw new Error("aborted:extract");
|
if (options.signal?.aborted) throw new Error("aborted:extract");
|
||||||
const nestedName = path.basename(nestedArchive);
|
const nestedName = path.basename(nestedArchive);
|
||||||
const nestedKey = archiveNameKey(`nested:${nestedName}`);
|
const nestedKey = archiveNameKey(nestedName);
|
||||||
if (resumeCompleted.has(nestedKey)) {
|
if (resumeCompleted.has(nestedKey)) {
|
||||||
logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`);
|
logger.info(`Nested-Extraction übersprungen (bereits entpackt): ${nestedName}`);
|
||||||
continue;
|
continue;
|
||||||
@ -2264,7 +2249,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (extracted > 0) {
|
if (extracted > 0) {
|
||||||
const hasOutputAfter = await hasAnyFilesRecursive(options.targetDir);
|
const hasOutputAfter = await hasAnyEntries(options.targetDir);
|
||||||
const hadResumeProgress = resumeCompletedAtStart > 0;
|
const hadResumeProgress = resumeCompletedAtStart > 0;
|
||||||
if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) {
|
if (!hasOutputAfter && conflictMode !== "skip" && !hadResumeProgress) {
|
||||||
lastError = "Keine entpackten Dateien erkannt";
|
lastError = "Keine entpackten Dateien erkannt";
|
||||||
|
|||||||
@ -295,7 +295,7 @@ function registerIpcHandlers(): void {
|
|||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.START_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => {
|
ipcMain.handle(IPC_CHANNELS.START_ITEMS, (_event: IpcMainInvokeEvent, itemIds: string[]) => {
|
||||||
validateStringArray(itemIds ?? [], "itemIds");
|
validateStringArray(itemIds ?? [], "itemIds");
|
||||||
return controller.startItems(itemIds ?? []);
|
return controller.startItems(itemIds);
|
||||||
});
|
});
|
||||||
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());
|
ipcMain.handle(IPC_CHANNELS.STOP, () => controller.stop());
|
||||||
ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
|
ipcMain.handle(IPC_CHANNELS.TOGGLE_PAUSE, () => controller.togglePause());
|
||||||
|
|||||||
@ -78,11 +78,6 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
|||||||
await sleep(ms);
|
await sleep(ms);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check before entering the Promise constructor to avoid a race where the timer
|
|
||||||
// resolves before the aborted check runs (especially when ms=0).
|
|
||||||
if (signal.aborted) {
|
|
||||||
throw new Error("aborted");
|
|
||||||
}
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
timer = null;
|
timer = null;
|
||||||
@ -99,6 +94,10 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
|||||||
reject(new Error("aborted"));
|
reject(new Error("aborted"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (signal.aborted) {
|
||||||
|
onAbort();
|
||||||
|
return;
|
||||||
|
}
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -197,6 +196,6 @@ export class RealDebridClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(String(lastError || "Unrestrict fehlgeschlagen").replace(/^Error:\s*/i, ""));
|
throw new Error(lastError || "Unrestrict fehlgeschlagen");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -424,18 +424,6 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
|
|||||||
item.speedBps = 0;
|
item.speedBps = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset package-level active statuses to queued (mirrors item reset above)
|
|
||||||
const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
|
|
||||||
for (const pkg of Object.values(session.packages)) {
|
|
||||||
if (ACTIVE_PKG_STATUSES.has(pkg.status)) {
|
|
||||||
pkg.status = "queued";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear stale session-level running/paused flags
|
|
||||||
session.running = false;
|
|
||||||
session.paused = false;
|
|
||||||
|
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -485,7 +473,6 @@ async function writeSettingsPayload(paths: StoragePaths, payload: string): Promi
|
|||||||
await fsp.copyFile(tempPath, paths.configFile);
|
await fsp.copyFile(tempPath, paths.configFile);
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
|
||||||
throw renameError;
|
throw renameError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -606,7 +593,6 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
|
|||||||
await fsp.copyFile(tempPath, paths.sessionFile);
|
await fsp.copyFile(tempPath, paths.sessionFile);
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
await fsp.rm(tempPath, { force: true }).catch(() => {});
|
|
||||||
throw renameError;
|
throw renameError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -316,7 +316,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
|
||||||
animationFrameRef.current = requestAnimationFrame(drawChart);
|
animationFrameRef.current = requestAnimationFrame(drawChart);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -361,8 +360,8 @@ function sortPackageOrderBySize(order: string[], packages: Record<string, Packag
|
|||||||
function sortPackageOrderByHoster(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
|
function sortPackageOrderByHoster(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
|
||||||
const sorted = [...order];
|
const sorted = [...order];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url ?? "")).filter(Boolean))].join(",").toLowerCase();
|
const hosterA = [...new Set((packages[a]?.itemIds ?? []).map((id) => items[id]?.provider).filter(Boolean))].join(",").toLowerCase();
|
||||||
const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => extractHoster(items[id]?.url ?? "")).filter(Boolean))].join(",").toLowerCase();
|
const hosterB = [...new Set((packages[b]?.itemIds ?? []).map((id) => items[id]?.provider).filter(Boolean))].join(",").toLowerCase();
|
||||||
const cmp = hosterA.localeCompare(hosterB);
|
const cmp = hosterA.localeCompare(hosterB);
|
||||||
return descending ? -cmp : cmp;
|
return descending ? -cmp : cmp;
|
||||||
});
|
});
|
||||||
@ -483,7 +482,6 @@ export function App(): ReactElement {
|
|||||||
tabRef.current = tab;
|
tabRef.current = tab;
|
||||||
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const stateFlushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const onImportDlcRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
|
||||||
const [dragOver, setDragOver] = useState(false);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
|
const [editingPackageId, setEditingPackageId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState("");
|
const [editingName, setEditingName] = useState("");
|
||||||
@ -908,7 +906,7 @@ export function App(): ReactElement {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [packages, snapshot.session.items, collapsedPackages]);
|
}, [packages, snapshot.session.items]);
|
||||||
|
|
||||||
const allPackagesCollapsed = useMemo(() => (
|
const allPackagesCollapsed = useMemo(() => (
|
||||||
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
|
packages.length > 0 && packages.every((pkg) => collapsedPackages[pkg.id])
|
||||||
@ -1201,8 +1199,6 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onImportDlcRef.current = onImportDlc;
|
|
||||||
|
|
||||||
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
|
const onDrop = async (event: DragEvent<HTMLElement>): Promise<void> => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
dragDepthRef.current = 0;
|
dragDepthRef.current = 0;
|
||||||
@ -1370,18 +1366,10 @@ export function App(): ReactElement {
|
|||||||
pendingPackageOrderRef.current = [...order];
|
pendingPackageOrderRef.current = [...order];
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
pendingPackageOrderAtRef.current = Date.now();
|
||||||
packageOrderRef.current = [...order];
|
packageOrderRef.current = [...order];
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
|
|
||||||
});
|
|
||||||
void window.rd.reorderPackages(order).catch((error) => {
|
void window.rd.reorderPackages(order).catch((error) => {
|
||||||
pendingPackageOrderRef.current = null;
|
pendingPackageOrderRef.current = null;
|
||||||
pendingPackageOrderAtRef.current = 0;
|
pendingPackageOrderAtRef.current = 0;
|
||||||
packageOrderRef.current = serverPackageOrderRef.current;
|
packageOrderRef.current = serverPackageOrderRef.current;
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
|
||||||
});
|
|
||||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
});
|
});
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
@ -1398,18 +1386,10 @@ export function App(): ReactElement {
|
|||||||
pendingPackageOrderRef.current = [...nextOrder];
|
pendingPackageOrderRef.current = [...nextOrder];
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
pendingPackageOrderAtRef.current = Date.now();
|
||||||
packageOrderRef.current = [...nextOrder];
|
packageOrderRef.current = [...nextOrder];
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: [...nextOrder] } };
|
|
||||||
});
|
|
||||||
void window.rd.reorderPackages(nextOrder).catch((error) => {
|
void window.rd.reorderPackages(nextOrder).catch((error) => {
|
||||||
pendingPackageOrderRef.current = null;
|
pendingPackageOrderRef.current = null;
|
||||||
pendingPackageOrderAtRef.current = 0;
|
pendingPackageOrderAtRef.current = 0;
|
||||||
packageOrderRef.current = serverPackageOrderRef.current;
|
packageOrderRef.current = serverPackageOrderRef.current;
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
|
||||||
});
|
|
||||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
});
|
});
|
||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
@ -1856,7 +1836,7 @@ export function App(): ReactElement {
|
|||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
||||||
// Don't clear selection if an overlay is open — let the overlay close first
|
// Don't clear selection if an overlay is open — let the overlay close first
|
||||||
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop") || document.querySelector(".link-popup-overlay")) return;
|
||||||
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
||||||
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
||||||
}
|
}
|
||||||
@ -1949,7 +1929,7 @@ export function App(): ReactElement {
|
|||||||
if (inInput) return;
|
if (inInput) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setOpenMenu(null);
|
setOpenMenu(null);
|
||||||
void onImportDlcRef.current();
|
void onImportDlc();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!e.shiftKey && e.key.toLowerCase() === "a") {
|
if (!e.shiftKey && e.key.toLowerCase() === "a") {
|
||||||
@ -2392,18 +2372,10 @@ export function App(): ReactElement {
|
|||||||
pendingPackageOrderRef.current = [...sorted];
|
pendingPackageOrderRef.current = [...sorted];
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
pendingPackageOrderAtRef.current = Date.now();
|
||||||
packageOrderRef.current = sorted;
|
packageOrderRef.current = sorted;
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: [...sorted] } };
|
|
||||||
});
|
|
||||||
void window.rd.reorderPackages(sorted).catch((error) => {
|
void window.rd.reorderPackages(sorted).catch((error) => {
|
||||||
pendingPackageOrderRef.current = null;
|
pendingPackageOrderRef.current = null;
|
||||||
pendingPackageOrderAtRef.current = 0;
|
pendingPackageOrderAtRef.current = 0;
|
||||||
packageOrderRef.current = serverPackageOrderRef.current;
|
packageOrderRef.current = serverPackageOrderRef.current;
|
||||||
setSnapshot((prev) => {
|
|
||||||
if (!prev) return prev;
|
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
|
||||||
});
|
|
||||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||||
});
|
});
|
||||||
} : undefined}
|
} : undefined}
|
||||||
@ -2870,7 +2842,7 @@ export function App(): ReactElement {
|
|||||||
const pkg = snapshot.session.packages[id];
|
const pkg = snapshot.session.packages[id];
|
||||||
if (pkg) { for (const iid of pkg.itemIds) removedItemIds.add(iid); }
|
if (pkg) { for (const iid of pkg.itemIds) removedItemIds.add(iid); }
|
||||||
}
|
}
|
||||||
const totalRemaining = Math.max(0, Object.keys(snapshot.session.items).length - removedItemIds.size);
|
const totalRemaining = Object.keys(snapshot.session.items).length - removedItemIds.size;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (pkgCount > 0) parts.push(`${pkgCount} Paket(e)`);
|
if (pkgCount > 0) parts.push(`${pkgCount} Paket(e)`);
|
||||||
if (itemCount > 0) parts.push(`${itemCount} Link(s)`);
|
if (itemCount > 0) parts.push(`${itemCount} Link(s)`);
|
||||||
@ -2888,7 +2860,7 @@ export function App(): ReactElement {
|
|||||||
<button className="btn" onClick={() => setDeleteConfirm(null)}>Abbrechen</button>
|
<button className="btn" onClick={() => setDeleteConfirm(null)}>Abbrechen</button>
|
||||||
<button className="btn danger" onClick={() => {
|
<button className="btn danger" onClick={() => {
|
||||||
if (deleteConfirm.dontAsk) {
|
if (deleteConfirm.dontAsk) {
|
||||||
setSettingsDraft((prev) => ({ ...prev, confirmDeleteSelection: false }));
|
setBool("confirmDeleteSelection", false);
|
||||||
void window.rd.updateSettings({ confirmDeleteSelection: false }).catch(() => {});
|
void window.rd.updateSettings({ confirmDeleteSelection: false }).catch(() => {});
|
||||||
}
|
}
|
||||||
executeDeleteSelection(deleteConfirm.ids);
|
executeDeleteSelection(deleteConfirm.ids);
|
||||||
@ -3258,7 +3230,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
}
|
}
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
const dlProgress = Math.min(useExtractSplit ? 50 : 100, Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100)));
|
const dlProgress = Math.floor(((done + activeProgress) / total) * (useExtractSplit ? 50 : 100));
|
||||||
// Include fractional progress from items currently being extracted
|
// Include fractional progress from items currently being extracted
|
||||||
const extractingProgress = items.reduce((sum, item) => {
|
const extractingProgress = items.reduce((sum, item) => {
|
||||||
const fs = item.fullStatus || "";
|
const fs = item.fullStatus || "";
|
||||||
@ -3267,8 +3239,8 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
if (m) return sum + Number(m[1]) / 100;
|
if (m) return sum + Number(m[1]) / 100;
|
||||||
return sum;
|
return sum;
|
||||||
}, 0);
|
}, 0);
|
||||||
const exProgress = Math.min(50, Math.floor(((extracted + extractingProgress) / total) * 50));
|
const exProgress = Math.floor(((extracted + extractingProgress) / total) * 50);
|
||||||
const combinedProgress = Math.min(100, useExtractSplit ? dlProgress + exProgress : dlProgress);
|
const combinedProgress = useExtractSplit ? dlProgress + exProgress : dlProgress;
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>): void => {
|
||||||
if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); }
|
if (e.key === "Enter") { onFinishEdit(pkg.id, pkg.name, editingName); }
|
||||||
@ -3332,14 +3304,24 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "hoster": {
|
case "hoster": return (
|
||||||
const hosterText = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))].join(", ");
|
<span key={col} className="pkg-col pkg-col-hoster" title={(() => {
|
||||||
return <span key={col} className="pkg-col pkg-col-hoster" title={hosterText}>{hosterText}</span>;
|
const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))];
|
||||||
}
|
return hosters.join(", ");
|
||||||
case "account": {
|
})()}>{(() => {
|
||||||
const accountText = [...new Set(items.map((item) => item.provider).filter(Boolean))].map((p) => providerLabels[p!] || p).join(", ");
|
const hosters = [...new Set(items.map((item) => extractHoster(item.url)).filter(Boolean))];
|
||||||
return <span key={col} className="pkg-col pkg-col-account" title={accountText}>{accountText}</span>;
|
return hosters.length > 0 ? hosters.join(", ") : "";
|
||||||
}
|
})()}</span>
|
||||||
|
);
|
||||||
|
case "account": return (
|
||||||
|
<span key={col} className="pkg-col pkg-col-account" title={(() => {
|
||||||
|
const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))];
|
||||||
|
return providers.map((p) => providerLabels[p!] || p).join(", ");
|
||||||
|
})()}>{(() => {
|
||||||
|
const providers = [...new Set(items.map((item) => item.provider).filter(Boolean))];
|
||||||
|
return providers.length > 0 ? providers.map((p) => providerLabels[p!] || p).join(", ") : "";
|
||||||
|
})()}</span>
|
||||||
|
);
|
||||||
case "prio": return (
|
case "prio": return (
|
||||||
<span key={col} className={`pkg-col pkg-col-prio${pkg.priority === "high" ? " prio-high" : pkg.priority === "low" ? " prio-low" : ""}`}>{pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""}</span>
|
<span key={col} className={`pkg-col pkg-col-prio${pkg.priority === "high" ? " prio-high" : pkg.priority === "low" ? " prio-low" : ""}`}>{pkg.priority === "high" ? "Hoch" : pkg.priority === "low" ? "Niedrig" : ""}</span>
|
||||||
);
|
);
|
||||||
@ -3397,7 +3379,7 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
) : ""}
|
) : ""}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "hoster": { const h = extractHoster(item.url) || ""; return <span key={col} className="pkg-col pkg-col-hoster" title={h}>{h}</span>; }
|
case "hoster": return <span key={col} className="pkg-col pkg-col-hoster" title={extractHoster(item.url)}>{extractHoster(item.url) || ""}</span>;
|
||||||
case "account": return <span key={col} className="pkg-col pkg-col-account">{item.provider ? providerLabels[item.provider] : ""}</span>;
|
case "account": return <span key={col} className="pkg-col pkg-col-account">{item.provider ? providerLabels[item.provider] : ""}</span>;
|
||||||
case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>;
|
case "prio": return <span key={col} className="pkg-col pkg-col-prio"></span>;
|
||||||
case "status": return (
|
case "status": return (
|
||||||
@ -3458,7 +3440,6 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, isFirs
|
|||||||
}
|
}
|
||||||
if (a.id !== b.id
|
if (a.id !== b.id
|
||||||
|| a.updatedAt !== b.updatedAt
|
|| a.updatedAt !== b.updatedAt
|
||||||
|| a.url !== b.url
|
|
||||||
|| a.status !== b.status
|
|| a.status !== b.status
|
||||||
|| a.fileName !== b.fileName
|
|| a.fileName !== b.fileName
|
||||||
|| a.progressPercent !== b.progressPercent
|
|| a.progressPercent !== b.progressPercent
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
import type { PackageEntry } from "../shared/types";
|
|
||||||
|
|
||||||
export function reorderPackageOrderByDrop(order: string[], draggedPackageId: string, targetPackageId: string): string[] {
|
|
||||||
const fromIndex = order.indexOf(draggedPackageId);
|
|
||||||
const toIndex = order.indexOf(targetPackageId);
|
|
||||||
if (fromIndex < 0 || toIndex < 0 || fromIndex === toIndex) {
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
const next = [...order];
|
|
||||||
const [dragged] = next.splice(fromIndex, 1);
|
|
||||||
const insertIndex = Math.max(0, Math.min(next.length, toIndex));
|
|
||||||
next.splice(insertIndex, 0, dragged);
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function sortPackageOrderByName(order: string[], packages: Record<string, PackageEntry>, descending: boolean): string[] {
|
|
||||||
const sorted = [...order];
|
|
||||||
sorted.sort((a, b) => {
|
|
||||||
const nameA = (packages[a]?.name ?? "").toLowerCase();
|
|
||||||
const nameB = (packages[b]?.name ?? "").toLowerCase();
|
|
||||||
const cmp = nameA.localeCompare(nameB, undefined, { numeric: true, sensitivity: "base" });
|
|
||||||
return descending ? -cmp : cmp;
|
|
||||||
});
|
|
||||||
return sorted;
|
|
||||||
}
|
|
||||||
@ -670,22 +670,4 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
);
|
);
|
||||||
expect(result).toBe("Riviera.S02E02.GERMAN.DUBBED.DL.720p.WebHD.x264-TVP");
|
expect(result).toBe("Riviera.S02E02.GERMAN.DUBBED.DL.720p.WebHD.x264-TVP");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renames Room 104 abbreviated source r104.de.dl.web.7p-s04e02", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
["Room.104.S04.GERMAN.DL.720p.WEBRiP.x264-LAW"],
|
|
||||||
"r104.de.dl.web.7p-s04e02",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe("Room.104.S04E02.GERMAN.DL.720p.WEBRiP.x264-LAW");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renames Room 104 wayne source with episode", () => {
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
|
||||||
["Room.104.S04.GERMAN.DL.720p.WEBRiP.x264-LAW"],
|
|
||||||
"room.104.s04e01.german.dl.720p.web.h264-wayne",
|
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe("Room.104.S04E01.GERMAN.DL.720p.WEBRiP.x264-LAW");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user