Compare commits
9 Commits
v2.0.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a7467a8b0 | ||
|
|
f25e61573d | ||
|
|
25b7104580 | ||
|
|
e0eab43763 | ||
|
|
4df0d40ece | ||
|
|
3567cc173c | ||
|
|
ab08506361 | ||
|
|
6fd25a5447 | ||
|
|
d0885ba552 |
@ -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"
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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");
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user