Compare commits

...

9 Commits

Author SHA1 Message Date
Sucukdeluxe
0a7467a8b0 Release v2.0.0-beta.5 2026-03-08 19:39:07 +01:00
Sucukdeluxe
f25e61573d Fix downloads not starting: reset session.running on startup
Root cause: normalizeSessionStatuses() did not reset session.running
to false on startup. If the app was closed/crashed while downloads
were active, session.json retained running=true. On next launch,
start() checked if (this.session.running) return — silently refusing
to start any downloads.

Also improved normalizeSessionStatuses to match the old DM behavior:
- Reset session.running, paused, reconnectUntil, reconnectReason
- Recover cancelled/Gestoppt items back to queued
- Mark extracting/integrity_check items as completed (already downloaded)
- Handle paused and reconnect_wait items
- Cover all transient package statuses

Updated update tests for beta repo name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:38:22 +01:00
Sucukdeluxe
25b7104580 Release v2.0.0-beta.4 2026-03-08 19:19:32 +01:00
Sucukdeluxe
e0eab43763 Fix auto-updater pointing to stable repo instead of beta repo
DEFAULT_UPDATE_REPO was still set to Administrator/real-debrid-downloader
(the stable repo). Changed to Administrator/beta-real-debrid-downloader
so the beta app finds its own releases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:19:01 +01:00
Sucukdeluxe
4df0d40ece Release v2.0.0-beta.3 2026-03-08 19:16:38 +01:00
Sucukdeluxe
3567cc173c Fix cancelPackage not removing packages from session
cancelPackage only marked packages/items as cancelled but never removed
them from session.packages, session.items, or session.packageOrder.
The old download-manager called removePackageFromSession() which actually
deletes the entries. Now cancelPackage properly removes all items and the
package from the session, cleans up related state, and runs artifact
cleanup in the background.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:16:04 +01:00
Sucukdeluxe
ab08506361 Release v2.0.0-beta.2 2026-03-08 19:10:03 +01:00
Sucukdeluxe
6fd25a5447 Separate beta app identity to prevent overwriting stable installation
- appId: com.sucukdeluxe.realdebrid-beta
- productName: Real-Debrid-Downloader Beta
- name: real-debrid-downloader-beta
- Release script asset names updated for Beta suffix

Beta installs to separate directory and uses own userData path,
so both stable and beta can run side by side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:08:54 +01:00
Sucukdeluxe
d0885ba552 Fix 16 bugs found by code review across all download modules
Critical fixes:
- Post-processor: remove double attempts increment (onProgress + onArchiveFailure both counted)
- Post-processor: fix slot leak when signal aborted after acquireSlot
- Scheduler: reset global watchdog high-water mark after stall event (prevents permanent misfires)
- Pipeline/DM: fix isPathInsideDir path traversal (add trailing separator check)
- Retry-manager: check per-kind exhaustion before shelve threshold (prevents bypass)
- Retry-manager: add MAX_SHELVE_COUNT=5 cap to prevent infinite shelve cycling

Important fixes:
- Scheduler: clear retryDelays and providerCooldowns on start()
- Scheduler: skip already-aborting slots in stall detection
- Download-manager: fix cleanupAfterExtraction using extractDir instead of outputDir for link removal
- Download-manager: add "extracting" to package normalizeSessionStatuses
- Download-manager: clear activeTasks map on stop()
- Download-manager: remove useless cachedDirectUrls re-insertion after success
- Stream-writer: remove duplicate truncation code in error path
- Stream-writer: skip alignedFlush in finally when bodyError already set (avoids 5min drain wait)
- Stream-writer: re-read elapsed after speed limiter sleep for accurate window reset
- Error-classifier: add HTTP 401 (Forbidden) and 410 (NotFound) classification

Tests updated to match new shelve/kind-exhaustion priority and 401 classification.
All 216 tests pass, build verified.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 18:33:06 +01:00
13 changed files with 178 additions and 77 deletions

View File

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

View File

@ -200,8 +200,8 @@ function updatePackageVersion(rootDir, version) {
function patchLatestYml(releaseDir, version) { function patchLatestYml(releaseDir, version) {
const ymlPath = path.join(releaseDir, "latest.yml"); const ymlPath = path.join(releaseDir, "latest.yml");
let content = fs.readFileSync(ymlPath, "utf8"); let content = fs.readFileSync(ymlPath, "utf8");
const setupName = `Real-Debrid-Downloader Setup ${version}.exe`; const setupName = `Real-Debrid-Downloader Beta Setup ${version}.exe`;
const dashedName = `Real-Debrid-Downloader-Setup-${version}.exe`; const dashedName = `Real-Debrid-Downloader-Beta-Setup-${version}.exe`;
if (content.includes(dashedName)) { if (content.includes(dashedName)) {
content = content.split(dashedName).join(setupName); content = content.split(dashedName).join(setupName);
fs.writeFileSync(ymlPath, content, "utf8"); fs.writeFileSync(ymlPath, content, "utf8");
@ -212,10 +212,10 @@ function patchLatestYml(releaseDir, version) {
function ensureAssetsExist(rootDir, version) { function ensureAssetsExist(rootDir, version) {
const releaseDir = path.join(rootDir, "release"); const releaseDir = path.join(rootDir, "release");
const files = [ const files = [
`Real-Debrid-Downloader Setup ${version}.exe`, `Real-Debrid-Downloader Beta Setup ${version}.exe`,
`Real-Debrid-Downloader ${version}.exe`, `Real-Debrid-Downloader Beta ${version}.exe`,
"latest.yml", "latest.yml",
`Real-Debrid-Downloader Setup ${version}.exe.blockmap` `Real-Debrid-Downloader Beta Setup ${version}.exe.blockmap`
]; ];
for (const fileName of files) { for (const fileName of files) {
const fullPath = path.join(releaseDir, fileName); 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 SPEED_WINDOW_SECONDS = 1;
export const CLIPBOARD_POLL_INTERVAL_MS = 2000; export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader"; export const DEFAULT_UPDATE_REPO = "Administrator/beta-real-debrid-downloader";
export function defaultSettings(): AppSettings { export function defaultSettings(): AppSettings {
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid"); const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");

View File

@ -88,7 +88,10 @@ function pathKey(p: string): string {
} }
function isPathInsideDir(filePath: string, dirPath: string): boolean { function isPathInsideDir(filePath: string, dirPath: string): boolean {
return path.resolve(filePath).toLowerCase().startsWith(path.resolve(dirPath).toLowerCase()); 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);
} }
function providerLabel(p: string | null): string { function providerLabel(p: string | null): string {
@ -520,23 +523,37 @@ export class DownloadManager extends EventEmitter {
pkg.cancelled = true; pkg.cancelled = true;
pkg.status = "cancelled"; pkg.status = "cancelled";
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
for (const itemId of pkg.itemIds) { const outputDir = pkg.outputDir;
const itemIds = [...pkg.itemIds];
for (const itemId of itemIds) {
const item = this.session.items[itemId]; const item = this.session.items[itemId];
if (!item) continue; if (!item) continue;
const slot = this.activeTasks.get(itemId); const slot = this.activeTasks.get(itemId);
if (slot) { slot.abortReason = "cancel"; slot.abortController.abort("cancel"); } if (slot) { slot.abortReason = "cancel"; slot.abortController.abort("cancel"); }
if (!isFinishedStatus(item.status)) { if (item.status !== "completed") {
item.status = "cancelled"; this.runOutcomes.set(itemId, "cancelled");
item.fullStatus = "Abgebrochen";
item.speedBps = 0;
item.updatedAt = nowMs();
} }
this.releaseTargetPath(itemId); this.releaseTargetPath(itemId);
this.retryManager.removeItem(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.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.persistSoon();
this.emitState(); this.emitState();
// Cleanup artifacts in background
void cleanupCancelledPackageArtifactsAsync(outputDir).catch(() => {});
} }
public resetPackage(packageId: string): void { public resetPackage(packageId: string): void {
@ -752,6 +769,7 @@ export class DownloadManager extends EventEmitter {
slot.abortReason = "stop"; slot.abortReason = "stop";
slot.abortController.abort("stop"); slot.abortController.abort("stop");
} }
this.activeTasks.clear();
// Reset non-finished items // Reset non-finished items
for (const item of Object.values(this.session.items)) { for (const item of Object.values(this.session.items)) {
@ -1069,16 +1087,6 @@ export class DownloadManager extends EventEmitter {
this.cachedDirectUrls.delete(slot.itemId); this.cachedDirectUrls.delete(slot.itemId);
this.retryManager.removeItem(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)})`); logger.info(`Download complete: ${item.fileName} (${humanSize(item.downloadedBytes)})`);
// Check if package is done → trigger post-processing // Check if package is done → trigger post-processing
@ -1331,7 +1339,8 @@ export class DownloadManager extends EventEmitter {
private async cleanupAfterExtraction(pkg: PackageEntry): Promise<void> { private async cleanupAfterExtraction(pkg: PackageEntry): Promise<void> {
if (this.settings.removeLinkFilesAfterExtract) { if (this.settings.removeLinkFilesAfterExtract) {
await removeDownloadLinkArtifacts(pkg.extractDir); // Link artifacts (.lnk files) are in the download directory, not extract dir
await removeDownloadLinkArtifacts(pkg.outputDir);
} }
if (this.settings.removeSamplesAfterExtract) { if (this.settings.removeSamplesAfterExtract) {
await removeSampleArtifacts(pkg.extractDir); await removeSampleArtifacts(pkg.extractDir);
@ -1585,16 +1594,47 @@ export class DownloadManager extends EventEmitter {
} }
private normalizeSessionStatuses(): void { 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)) { for (const item of Object.values(this.session.items)) {
if (item.status === "downloading" || item.status === "validating" || item.status === "integrity_check") { // Items that were stopped mid-run should be re-queued
if (item.status === "cancelled" && item.fullStatus === "Gestoppt") {
item.status = "queued"; item.status = "queued";
item.fullStatus = "Wartet"; 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.speedBps = 0;
item.updatedAt = nowMs(); item.updatedAt = nowMs();
} }
} }
for (const pkg of Object.values(this.session.packages)) { for (const pkg of Object.values(this.session.packages)) {
if (pkg.status === "downloading" || pkg.status === "validating") { if (pkg.status === "downloading" || pkg.status === "validating"
|| pkg.status === "extracting" || pkg.status === "integrity_check"
|| pkg.status === "paused" || pkg.status === "reconnect_wait") {
pkg.status = "queued"; pkg.status = "queued";
pkg.updatedAt = nowMs(); pkg.updatedAt = nowMs();
} }

View File

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

View File

@ -292,8 +292,10 @@ function filenameFromUrl(url: string): string {
function isPathInsideDir(filePath: string, dirPath: string): boolean { function isPathInsideDir(filePath: string, dirPath: string): boolean {
const normalizedFile = path.resolve(filePath).toLowerCase(); const normalizedFile = path.resolve(filePath).toLowerCase();
const normalizedDir = path.resolve(dirPath).toLowerCase(); let normalizedDir = path.resolve(dirPath).toLowerCase();
return normalizedFile.startsWith(normalizedDir); // 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);
} }
function providerDisplayName(provider: DebridProvider | null): string { function providerDisplayName(provider: DebridProvider | null): string {

View File

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

View File

@ -199,6 +199,7 @@ export interface RetryDecision {
const SHELVE_THRESHOLD = 15; const SHELVE_THRESHOLD = 15;
const SHELVE_DELAY_MS = 90_000; const SHELVE_DELAY_MS = 90_000;
const MAX_SHELVE_COUNT = 5;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// RetryManager // RetryManager
@ -248,12 +249,8 @@ export class RetryManager {
? Math.min(policy.maxRetries, this.userRetryLimit) ? Math.min(policy.maxRetries, this.userRetryLimit)
: policy.maxRetries; : policy.maxRetries;
// Check shelving threshold BEFORE individual kind limits // Check if this specific kind exhausted its retries FIRST
if (state.totalFailures >= SHELVE_THRESHOLD) { // (shelving must not bypass per-kind limits)
return this.shelve(state, kind);
}
// Check if this specific kind exhausted its retries
if (kindCount > effectiveMax) { if (kindCount > effectiveMax) {
return { return {
shouldRetry: false, shouldRetry: false,
@ -263,6 +260,19 @@ 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 // Retry — compute delay and actions
const delayMs = this.computeDelay(policy, kindCount); const delayMs = this.computeDelay(policy, kindCount);
const actions = this.computeActions(policy); const actions = this.computeActions(policy);

View File

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

View File

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

View File

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

View File

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

View File

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