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",
"version": "2.0.0-beta.1",
"name": "real-debrid-downloader-beta",
"version": "2.0.0-beta.5",
"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",
"productName": "Real-Debrid-Downloader",
"appId": "com.sucukdeluxe.realdebrid-beta",
"productName": "Real-Debrid-Downloader Beta",
"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 Setup ${version}.exe`;
const dashedName = `Real-Debrid-Downloader-Setup-${version}.exe`;
const setupName = `Real-Debrid-Downloader Beta Setup ${version}.exe`;
const dashedName = `Real-Debrid-Downloader-Beta-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 Setup ${version}.exe`,
`Real-Debrid-Downloader ${version}.exe`,
`Real-Debrid-Downloader Beta Setup ${version}.exe`,
`Real-Debrid-Downloader Beta ${version}.exe`,
"latest.yml",
`Real-Debrid-Downloader Setup ${version}.exe.blockmap`
`Real-Debrid-Downloader Beta 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/real-debrid-downloader";
export const DEFAULT_UPDATE_REPO = "Administrator/beta-real-debrid-downloader";
export function defaultSettings(): AppSettings {
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 {
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 {
@ -520,23 +523,37 @@ export class DownloadManager extends EventEmitter {
pkg.cancelled = true;
pkg.status = "cancelled";
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];
if (!item) continue;
const slot = this.activeTasks.get(itemId);
if (slot) { slot.abortReason = "cancel"; slot.abortController.abort("cancel"); }
if (!isFinishedStatus(item.status)) {
item.status = "cancelled";
item.fullStatus = "Abgebrochen";
item.speedBps = 0;
item.updatedAt = nowMs();
if (item.status !== "completed") {
this.runOutcomes.set(itemId, "cancelled");
}
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 {
@ -752,6 +769,7 @@ 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)) {
@ -1069,16 +1087,6 @@ 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
@ -1331,7 +1339,8 @@ export class DownloadManager extends EventEmitter {
private async cleanupAfterExtraction(pkg: PackageEntry): Promise<void> {
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) {
await removeSampleArtifacts(pkg.extractDir);
@ -1585,16 +1594,47 @@ 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)) {
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.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") {
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.updatedAt = nowMs();
}

View File

@ -241,6 +241,11 @@ 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,
@ -251,6 +256,11 @@ 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,8 +292,10 @@ function filenameFromUrl(url: string): string {
function isPathInsideDir(filePath: string, dirPath: string): boolean {
const normalizedFile = path.resolve(filePath).toLowerCase();
const normalizedDir = path.resolve(dirPath).toLowerCase();
return normalizedFile.startsWith(normalizedDir);
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);
}
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> {
// Acquire slot
await this.acquireSlot(options.signal);
if (options.signal.aborted) return;
if (options.signal.aborted) {
this.releaseSlot();
return;
}
const state: PackagePostProcessState = this.states.get(packageId) || {
packageId,
@ -335,13 +338,11 @@ export class PostProcessor extends EventEmitter {
// Track individual archive completion
if (update.archiveDone) {
const archiveState = state.archives.get(update.archiveName);
if (archiveState) {
if (archiveState && update.archiveSuccess) {
archiveState.attempts++;
if (update.archiveSuccess) {
archiveState.status = "done";
}
// If not success, onArchiveFailure will handle it
archiveState.status = "done";
}
// If not success, onArchiveFailure will handle it (and increment attempts)
}
},
onArchiveFailure: (failure: ExtractArchiveFailure) => {

View File

@ -199,6 +199,7 @@ export interface RetryDecision {
const SHELVE_THRESHOLD = 15;
const SHELVE_DELAY_MS = 90_000;
const MAX_SHELVE_COUNT = 5;
// ---------------------------------------------------------------------------
// RetryManager
@ -248,12 +249,8 @@ export class RetryManager {
? Math.min(policy.maxRetries, this.userRetryLimit)
: policy.maxRetries;
// 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
// Check if this specific kind exhausted its retries FIRST
// (shelving must not bypass per-kind limits)
if (kindCount > effectiveMax) {
return {
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
const delayMs = this.computeDelay(policy, kindCount);
const actions = this.computeActions(policy);

View File

@ -96,6 +96,8 @@ 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;
@ -430,6 +432,7 @@ 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 });
@ -460,7 +463,10 @@ export class Scheduler extends EventEmitter {
.filter(s => !s.blockedOnDiskWrite)
.map(s => s.itemId);
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
if (speedLimitBps > 0) {
speedLimitWindowBytes += buffer.length;
const elapsed = (nowMs() - speedLimitWindowStart) / 1000;
let 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();
@ -508,8 +510,10 @@ export async function streamToFile(opts: StreamOptions): Promise<StreamResult> {
bodyError = error;
log("WARN", "Download body error", { attempt, error: errorMessage(error) });
} finally {
// Flush remaining buffered data
try { await alignedFlush(true); } catch (e) { if (!bodyError) bodyError = e; }
// Flush remaining buffered data (skip if error already — avoid 5min drain wait)
if (!bodyError) {
try { await alignedFlush(true); } catch (e) { bodyError = e; }
}
// Close stream
try {
@ -538,13 +542,8 @@ 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
// On error: truncate pre-allocated sparse file to actual written bytes
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 Unknown (no special branch)", () => {
it("classifies 401 as Forbidden", () => {
const err = classifyHttpStatus({ status: 401 });
expect(err.kind).toBe(DownloadErrorKind.Unknown);
expect(err.kind).toBe(DownloadErrorKind.Forbidden);
expect(err.httpStatus).toBe(401);
});

View File

@ -441,18 +441,26 @@ describe("shelving", () => {
it("shelving increments shelveCount", () => {
const mgr = new RetryManager();
// Trigger shelve twice
// First round: 15 failures -> shelve (halves to ~7)
// 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)
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")!;
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;
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")!;
expect(state2.shelveCount).toBe(2);
@ -460,27 +468,45 @@ 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(DownloadErrorKind.Unknown));
mgr.evaluate("a", mkError(kinds[i % kinds.length]));
}
// 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(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.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();
// 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
// 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
const kinds = [
DownloadErrorKind.Timeout,
DownloadErrorKind.ServerError,
@ -728,9 +754,16 @@ describe("exportStates() and importStates()", () => {
it("shelveCount survives export/import roundtrip", () => {
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++) {
mgr.evaluate("a", mkError(DownloadErrorKind.Unknown));
mgr.evaluate("a", mkError(kinds[i % kinds.length]));
}
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/real-debrid-downloader");
expect(normalizeUpdateRepo("")).toBe("Administrator/beta-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/real-debrid-downloader");
expect(normalizeUpdateRepo(" ")).toBe("Administrator/real-debrid-downloader");
expect(normalizeUpdateRepo("just-one-part")).toBe("Administrator/beta-real-debrid-downloader");
expect(normalizeUpdateRepo(" ")).toBe("Administrator/beta-real-debrid-downloader");
});
it("rejects traversal-like owner or repo segments", () => {
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");
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");
});
it("handles www prefix", () => {