Compare commits

..

No commits in common. "main" and "v2.0.0-beta.1" have entirely different histories.

13 changed files with 77 additions and 178 deletions

View File

@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader-beta",
"version": "2.0.0-beta.5",
"name": "real-debrid-downloader",
"version": "2.0.0-beta.1",
"description": "Desktop downloader",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",
@ -46,8 +46,8 @@
"wait-on": "^8.0.1"
},
"build": {
"appId": "com.sucukdeluxe.realdebrid-beta",
"productName": "Real-Debrid-Downloader Beta",
"appId": "com.sucukdeluxe.realdebrid",
"productName": "Real-Debrid-Downloader",
"directories": {
"buildResources": "assets",
"output": "release"

View File

@ -200,8 +200,8 @@ function updatePackageVersion(rootDir, version) {
function patchLatestYml(releaseDir, version) {
const ymlPath = path.join(releaseDir, "latest.yml");
let content = fs.readFileSync(ymlPath, "utf8");
const setupName = `Real-Debrid-Downloader Beta Setup ${version}.exe`;
const dashedName = `Real-Debrid-Downloader-Beta-Setup-${version}.exe`;
const setupName = `Real-Debrid-Downloader Setup ${version}.exe`;
const dashedName = `Real-Debrid-Downloader-Setup-${version}.exe`;
if (content.includes(dashedName)) {
content = content.split(dashedName).join(setupName);
fs.writeFileSync(ymlPath, content, "utf8");
@ -212,10 +212,10 @@ function patchLatestYml(releaseDir, version) {
function ensureAssetsExist(rootDir, version) {
const releaseDir = path.join(rootDir, "release");
const files = [
`Real-Debrid-Downloader Beta Setup ${version}.exe`,
`Real-Debrid-Downloader Beta ${version}.exe`,
`Real-Debrid-Downloader Setup ${version}.exe`,
`Real-Debrid-Downloader ${version}.exe`,
"latest.yml",
`Real-Debrid-Downloader Beta Setup ${version}.exe.blockmap`
`Real-Debrid-Downloader Setup ${version}.exe.blockmap`
];
for (const fileName of files) {
const fullPath = path.join(releaseDir, fileName);

View File

@ -36,7 +36,7 @@ export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
export const SPEED_WINDOW_SECONDS = 1;
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
export const DEFAULT_UPDATE_REPO = "Administrator/beta-real-debrid-downloader";
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader";
export function defaultSettings(): AppSettings {
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");

View File

@ -88,10 +88,7 @@ function pathKey(p: string): string {
}
function isPathInsideDir(filePath: string, dirPath: string): boolean {
const normalizedFile = path.resolve(filePath).toLowerCase();
let normalizedDir = path.resolve(dirPath).toLowerCase();
if (!normalizedDir.endsWith(path.sep)) normalizedDir += path.sep;
return normalizedFile.startsWith(normalizedDir) || normalizedFile === normalizedDir.slice(0, -1);
return path.resolve(filePath).toLowerCase().startsWith(path.resolve(dirPath).toLowerCase());
}
function providerLabel(p: string | null): string {
@ -523,37 +520,23 @@ export class DownloadManager extends EventEmitter {
pkg.cancelled = true;
pkg.status = "cancelled";
pkg.updatedAt = nowMs();
const outputDir = pkg.outputDir;
const itemIds = [...pkg.itemIds];
for (const itemId of itemIds) {
for (const itemId of pkg.itemIds) {
const item = this.session.items[itemId];
if (!item) continue;
const slot = this.activeTasks.get(itemId);
if (slot) { slot.abortReason = "cancel"; slot.abortController.abort("cancel"); }
if (item.status !== "completed") {
this.runOutcomes.set(itemId, "cancelled");
if (!isFinishedStatus(item.status)) {
item.status = "cancelled";
item.fullStatus = "Abgebrochen";
item.speedBps = 0;
item.updatedAt = nowMs();
}
this.releaseTargetPath(itemId);
this.retryManager.removeItem(itemId);
this.cachedDirectUrls.delete(itemId);
this.itemContributedBytes.delete(itemId);
delete this.session.items[itemId];
this.itemCount = Math.max(0, this.itemCount - 1);
}
this.postProcessor.abortPackage(packageId);
this.historyRecordedPackages.delete(packageId);
this.hybridExtractRequeue.delete(packageId);
this.packagePostProcessTasks.delete(packageId);
delete this.session.packages[packageId];
this.session.packageOrder = this.session.packageOrder.filter(id => id !== packageId);
this.persistSoon();
this.emitState();
// Cleanup artifacts in background
void cleanupCancelledPackageArtifactsAsync(outputDir).catch(() => {});
}
public resetPackage(packageId: string): void {
@ -769,7 +752,6 @@ export class DownloadManager extends EventEmitter {
slot.abortReason = "stop";
slot.abortController.abort("stop");
}
this.activeTasks.clear();
// Reset non-finished items
for (const item of Object.values(this.session.items)) {
@ -1087,6 +1069,16 @@ export class DownloadManager extends EventEmitter {
this.cachedDirectUrls.delete(slot.itemId);
this.retryManager.removeItem(slot.itemId);
// Cache the direct URL in case another item from same package needs it
if (result.directUrl) {
this.cachedDirectUrls.set(slot.itemId, {
url: result.directUrl,
provider: result.provider,
label: result.providerLabel || "",
skipTls: result.skipTlsVerify || false,
});
}
logger.info(`Download complete: ${item.fileName} (${humanSize(item.downloadedBytes)})`);
// Check if package is done → trigger post-processing
@ -1339,8 +1331,7 @@ export class DownloadManager extends EventEmitter {
private async cleanupAfterExtraction(pkg: PackageEntry): Promise<void> {
if (this.settings.removeLinkFilesAfterExtract) {
// Link artifacts (.lnk files) are in the download directory, not extract dir
await removeDownloadLinkArtifacts(pkg.outputDir);
await removeDownloadLinkArtifacts(pkg.extractDir);
}
if (this.settings.removeSamplesAfterExtract) {
await removeSampleArtifacts(pkg.extractDir);
@ -1594,47 +1585,16 @@ export class DownloadManager extends EventEmitter {
}
private normalizeSessionStatuses(): void {
// Critical: reset session-level running state on startup.
// Without this, if the app crashes while running, session.running stays true
// in the persisted JSON and start() silently returns on next launch.
this.session.running = false;
this.session.paused = false;
this.session.reconnectUntil = 0;
this.session.reconnectReason = "";
for (const item of Object.values(this.session.items)) {
// Items that were stopped mid-run should be re-queued
if (item.status === "cancelled" && item.fullStatus === "Gestoppt") {
if (item.status === "downloading" || item.status === "validating" || item.status === "integrity_check") {
item.status = "queued";
item.fullStatus = "Wartet";
item.lastError = "";
item.speedBps = 0;
item.provider = null;
item.updatedAt = nowMs();
continue;
}
// Items that were extracting/checking integrity are already fully downloaded
if (item.status === "extracting" || item.status === "integrity_check") {
item.status = "completed";
item.fullStatus = `Fertig (${humanSize(item.downloadedBytes)})`;
item.speedBps = 0;
item.updatedAt = nowMs();
continue;
}
// Active/paused/reconnecting items → re-queue
if (item.status === "downloading" || item.status === "validating"
|| item.status === "paused" || item.status === "reconnect_wait") {
item.status = "queued";
const pkg = this.session.packages[item.packageId];
item.fullStatus = (pkg && pkg.enabled === false) ? "Paket gestoppt" : "Wartet";
item.speedBps = 0;
item.updatedAt = nowMs();
}
}
for (const pkg of Object.values(this.session.packages)) {
if (pkg.status === "downloading" || pkg.status === "validating"
|| pkg.status === "extracting" || pkg.status === "integrity_check"
|| pkg.status === "paused" || pkg.status === "reconnect_wait") {
if (pkg.status === "downloading" || pkg.status === "validating") {
pkg.status = "queued";
pkg.updatedAt = nowMs();
}

View File

@ -241,11 +241,6 @@ export function classifyHttpStatus(ctx: HttpClassifyContext): DownloadError {
httpStatus: status,
});
case status === 401:
return new DownloadError(DownloadErrorKind.Forbidden, msg, {
httpStatus: status,
});
case status === 403:
return new DownloadError(DownloadErrorKind.Forbidden, msg, {
httpStatus: status,
@ -256,11 +251,6 @@ export function classifyHttpStatus(ctx: HttpClassifyContext): DownloadError {
httpStatus: status,
});
case status === 410:
return new DownloadError(DownloadErrorKind.NotFound, msg, {
httpStatus: status,
});
case status >= 500:
return new DownloadError(DownloadErrorKind.ServerError, msg, {
httpStatus: status,

View File

@ -292,10 +292,8 @@ function filenameFromUrl(url: string): string {
function isPathInsideDir(filePath: string, dirPath: string): boolean {
const normalizedFile = path.resolve(filePath).toLowerCase();
let normalizedDir = path.resolve(dirPath).toLowerCase();
// Ensure trailing separator to prevent "C:\downloads\pack-evil" matching "C:\downloads\pack"
if (!normalizedDir.endsWith(path.sep)) normalizedDir += path.sep;
return normalizedFile.startsWith(normalizedDir) || normalizedFile === normalizedDir.slice(0, -1);
const normalizedDir = path.resolve(dirPath).toLowerCase();
return normalizedFile.startsWith(normalizedDir);
}
function providerDisplayName(provider: DebridProvider | null): string {

View File

@ -207,10 +207,7 @@ export class PostProcessor extends EventEmitter {
private async runPostProcessing(packageId: string, options: PostProcessOptions): Promise<void> {
// Acquire slot
await this.acquireSlot(options.signal);
if (options.signal.aborted) {
this.releaseSlot();
return;
}
if (options.signal.aborted) return;
const state: PackagePostProcessState = this.states.get(packageId) || {
packageId,
@ -338,11 +335,13 @@ export class PostProcessor extends EventEmitter {
// Track individual archive completion
if (update.archiveDone) {
const archiveState = state.archives.get(update.archiveName);
if (archiveState && update.archiveSuccess) {
if (archiveState) {
archiveState.attempts++;
archiveState.status = "done";
if (update.archiveSuccess) {
archiveState.status = "done";
}
// If not success, onArchiveFailure will handle it
}
// If not success, onArchiveFailure will handle it (and increment attempts)
}
},
onArchiveFailure: (failure: ExtractArchiveFailure) => {

View File

@ -199,7 +199,6 @@ export interface RetryDecision {
const SHELVE_THRESHOLD = 15;
const SHELVE_DELAY_MS = 90_000;
const MAX_SHELVE_COUNT = 5;
// ---------------------------------------------------------------------------
// RetryManager
@ -249,8 +248,12 @@ export class RetryManager {
? Math.min(policy.maxRetries, this.userRetryLimit)
: policy.maxRetries;
// Check if this specific kind exhausted its retries FIRST
// (shelving must not bypass per-kind limits)
// Check shelving threshold BEFORE individual kind limits
if (state.totalFailures >= SHELVE_THRESHOLD) {
return this.shelve(state, kind);
}
// Check if this specific kind exhausted its retries
if (kindCount > effectiveMax) {
return {
shouldRetry: false,
@ -260,19 +263,6 @@ export class RetryManager {
};
}
// Shelving threshold: too many total failures across all kinds
if (state.totalFailures >= SHELVE_THRESHOLD) {
if (state.shelveCount >= MAX_SHELVE_COUNT) {
return {
shouldRetry: false,
delayMs: 0,
actions: [],
reason: `Maximale Shelve-Zyklen erreicht (${MAX_SHELVE_COUNT})`,
};
}
return this.shelve(state, kind);
}
// Retry — compute delay and actions
const delayMs = this.computeDelay(policy, kindCount);
const actions = this.computeActions(policy);

View File

@ -96,8 +96,6 @@ export class Scheduler extends EventEmitter {
this.scopedPackageIds = new Set(scopedIds || []);
this.lastGlobalProgressBytes = 0;
this.lastGlobalProgressAt = Date.now();
this.retryDelays.clear();
this.providerCooldowns.clear();
const myGeneration = this.generation;
const loopIntervalMs = 120;
@ -432,7 +430,6 @@ export class Scheduler extends EventEmitter {
for (const slot of this.slots.values()) {
if (slot.blockedOnDiskWrite) continue; // Don't count disk waits
if (slot.abortReason !== "none") continue; // Already aborting
const idleMs = now - slot.lastHeartbeatAt;
if (idleMs > this.config.stallTimeoutMs) {
this.emit("stall-detected", { itemId: slot.itemId, idleMs });
@ -463,10 +460,7 @@ export class Scheduler extends EventEmitter {
.filter(s => !s.blockedOnDiskWrite)
.map(s => s.itemId);
this.emit("global-stall", { itemIds: stalledIds });
// Reset both timestamp and high-water mark so after retry
// (where bytesAtHeartbeat resets to 0) the watchdog doesn't misfire
this.lastGlobalProgressAt = now;
this.lastGlobalProgressBytes = totalBytes;
this.lastGlobalProgressAt = now; // Reset to avoid rapid-fire events
}
}

View File

@ -424,14 +424,12 @@ export async function streamToFile(opts: StreamOptions): Promise<StreamResult> {
// Speed limiting
if (speedLimitBps > 0) {
speedLimitWindowBytes += buffer.length;
let elapsed = (nowMs() - speedLimitWindowStart) / 1000;
const elapsed = (nowMs() - speedLimitWindowStart) / 1000;
if (elapsed > 0.1) {
const currentRate = speedLimitWindowBytes / elapsed;
if (currentRate > speedLimitBps) {
const sleepMs = Math.floor(((speedLimitWindowBytes / speedLimitBps) - elapsed) * 1000);
if (sleepMs > 10) await sleep(Math.min(sleepMs, 1000));
// Re-read elapsed after sleep so window reset check is accurate
elapsed = (nowMs() - speedLimitWindowStart) / 1000;
}
if (elapsed >= 1) {
speedLimitWindowStart = nowMs();
@ -510,10 +508,8 @@ export async function streamToFile(opts: StreamOptions): Promise<StreamResult> {
bodyError = error;
log("WARN", "Download body error", { attempt, error: errorMessage(error) });
} finally {
// Flush remaining buffered data (skip if error already — avoid 5min drain wait)
if (!bodyError) {
try { await alignedFlush(true); } catch (e) { bodyError = e; }
}
// Flush remaining buffered data
try { await alignedFlush(true); } catch (e) { if (!bodyError) bodyError = e; }
// Close stream
try {
@ -542,8 +538,13 @@ export async function streamToFile(opts: StreamOptions): Promise<StreamResult> {
} catch { /* best-effort */ }
}
// Truncate pre-allocated file to actual written bytes on error
if (bodyError && preAllocated && totalBytes && written < totalBytes) {
try { await fs.promises.truncate(effectiveTargetPath, written); } catch {}
}
if (bodyError) {
// On error: truncate pre-allocated sparse file to actual written bytes
// On error: truncate pre-allocated sparse file
if (preAllocated && totalBytes && written < totalBytes) {
try { await fs.promises.truncate(effectiveTargetPath, written); } catch {}
}

View File

@ -284,9 +284,9 @@ describe("classifyHttpStatus", () => {
expect(err.httpStatus).toBe(503);
});
it("classifies 401 as Forbidden", () => {
it("classifies 401 as Unknown (no special branch)", () => {
const err = classifyHttpStatus({ status: 401 });
expect(err.kind).toBe(DownloadErrorKind.Forbidden);
expect(err.kind).toBe(DownloadErrorKind.Unknown);
expect(err.httpStatus).toBe(401);
});

View File

@ -441,26 +441,18 @@ describe("shelving", () => {
it("shelving increments shelveCount", () => {
const mgr = new RetryManager();
// Use mixed kinds to avoid per-kind exhaustion before shelve threshold
// Timeout(10), RateLimited(8), ProviderBusy(8), ServerError(5), Unknown(5)
const kinds = [
DownloadErrorKind.Timeout,
DownloadErrorKind.RateLimited,
DownloadErrorKind.ProviderBusy,
DownloadErrorKind.ServerError,
DownloadErrorKind.Unknown,
];
// First round: 15 failures -> shelve (3 per kind, all within limits)
// Trigger shelve twice
// First round: 15 failures -> shelve (halves to ~7)
for (let i = 0; i < 15; i++) {
mgr.evaluate("a", mkError(kinds[i % kinds.length]));
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
}
const state1 = mgr.getState("a")!;
expect(state1.shelveCount).toBe(1);
// After halving, totalFailures is ~7. Need more to reach 15 again.
// After halving, totalFailures is ~7. Need 8 more to reach 15 again.
const remaining = SHELVE_THRESHOLD - state1.totalFailures;
for (let i = 0; i < remaining; i++) {
mgr.evaluate("a", mkError(kinds[i % kinds.length]));
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
}
const state2 = mgr.getState("a")!;
expect(state2.shelveCount).toBe(2);
@ -468,45 +460,27 @@ describe("shelving", () => {
it("shelve decision always has shouldRetry=true", () => {
const mgr = new RetryManager();
// Use mixed kinds to reach shelve threshold
const kinds = [
DownloadErrorKind.Timeout,
DownloadErrorKind.RateLimited,
DownloadErrorKind.ProviderBusy,
DownloadErrorKind.ServerError,
DownloadErrorKind.Unknown,
];
for (let i = 0; i < 15; i++) {
mgr.evaluate("a", mkError(kinds[i % kinds.length]));
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
}
// The 15th call itself triggers shelve
// Let's re-check: the state now has halved counters.
// One more batch to trigger shelve again
const state = mgr.getState("a")!;
const needed = SHELVE_THRESHOLD - state.totalFailures;
for (let i = 0; i < needed - 1; i++) {
mgr.evaluate("a", mkError(kinds[i % kinds.length]));
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
}
const d = mgr.evaluate("a", mkError(kinds[0]));
const d = mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
expect(d.shouldRetry).toBe(true);
expect(d.delayMs).toBe(SHELVE_DELAY_MS);
});
it("per-kind exhaustion is checked before shelve", () => {
it("shelve is checked before per-kind exhaustion", () => {
const mgr = new RetryManager();
// NetworkReset has maxRetries=3. If we use only NetworkReset errors,
// kind exhaustion hits at 4 (before shelve at 15).
for (let i = 0; i < 3; i++) {
mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
}
// 4th NetworkReset -> kind exhausted (maxRetries=3)
const d = mgr.evaluate("a", mkError(DownloadErrorKind.NetworkReset));
expect(d.shouldRetry).toBe(false);
expect(d.reason).toContain("erschöpft");
});
it("shelve triggers when mixed kinds reach threshold without any kind exhausted", () => {
const mgr = new RetryManager();
// Use 5 kinds with high maxRetries so no single kind is exhausted
// NetworkReset has maxRetries=3. If we mix kinds to reach 15 total
// without exhausting any single kind, shelve takes priority.
// Use 5 kinds, 3 each = 15
const kinds = [
DownloadErrorKind.Timeout,
DownloadErrorKind.ServerError,
@ -754,16 +728,9 @@ describe("exportStates() and importStates()", () => {
it("shelveCount survives export/import roundtrip", () => {
const mgr = new RetryManager();
// Trigger shelve using mixed kinds to avoid per-kind exhaustion
const kinds = [
DownloadErrorKind.Timeout,
DownloadErrorKind.RateLimited,
DownloadErrorKind.ProviderBusy,
DownloadErrorKind.ServerError,
DownloadErrorKind.Unknown,
];
// Trigger shelve
for (let i = 0; i < 15; i++) {
mgr.evaluate("a", mkError(kinds[i % kinds.length]));
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
}
const originalShelve = mgr.getState("a")!.shelveCount;
expect(originalShelve).toBeGreaterThan(0);

View File

@ -46,7 +46,7 @@ afterEach(() => {
describe("update", () => {
it("normalizes update repo input", () => {
expect(normalizeUpdateRepo("")).toBe("Administrator/beta-real-debrid-downloader");
expect(normalizeUpdateRepo("")).toBe("Administrator/real-debrid-downloader");
expect(normalizeUpdateRepo("owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://codeberg.org/owner/repo")).toBe("owner/repo");
expect(normalizeUpdateRepo("https://www.codeberg.org/owner/repo")).toBe("owner/repo");
@ -518,14 +518,14 @@ describe("normalizeUpdateRepo extended", () => {
});
it("returns default for malformed inputs", () => {
expect(normalizeUpdateRepo("just-one-part")).toBe("Administrator/beta-real-debrid-downloader");
expect(normalizeUpdateRepo(" ")).toBe("Administrator/beta-real-debrid-downloader");
expect(normalizeUpdateRepo("just-one-part")).toBe("Administrator/real-debrid-downloader");
expect(normalizeUpdateRepo(" ")).toBe("Administrator/real-debrid-downloader");
});
it("rejects traversal-like owner or repo segments", () => {
expect(normalizeUpdateRepo("../owner/repo")).toBe("Administrator/beta-real-debrid-downloader");
expect(normalizeUpdateRepo("owner/../repo")).toBe("Administrator/beta-real-debrid-downloader");
expect(normalizeUpdateRepo("https://codeberg.org/owner/../../repo")).toBe("Administrator/beta-real-debrid-downloader");
expect(normalizeUpdateRepo("../owner/repo")).toBe("Administrator/real-debrid-downloader");
expect(normalizeUpdateRepo("owner/../repo")).toBe("Administrator/real-debrid-downloader");
expect(normalizeUpdateRepo("https://codeberg.org/owner/../../repo")).toBe("Administrator/real-debrid-downloader");
});
it("handles www prefix", () => {