Detect dead links as permanent errors, fix last-episode extraction race
Some checks are pending
Build and Release / build (push) Waiting to run

Dead link detection:
- Mega-Web: parse hoster error messages (hosterNotAvailable, etc.) from HTML
  and throw specific error instead of returning null
- MegaDebridClient: stop retrying on permanent hoster errors
- download-manager: isPermanentLinkError() immediately fails items with dead
  links instead of retrying forever

Extraction race condition:
- package_done cleanup policy checked if all items were "completed" (downloaded)
  but not if they were "extracted" — removing the package before the last
  episode could be extracted
- Both applyCompletedCleanupPolicy and applyPackageDoneCleanup now guard
  against premature removal when autoExtract is enabled

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sucukdeluxe 2026-03-02 15:28:23 +01:00
parent 3ed31b7994
commit 09bc354c18
4 changed files with 102 additions and 1 deletions

View File

@ -1,6 +1,6 @@
{
"name": "real-debrid-downloader",
"version": "1.4.89",
"version": "1.4.90",
"description": "Real-Debrid Downloader Desktop (Electron + React + TypeScript)",
"main": "build/main/main/main.js",
"author": "Sucukdeluxe",

View File

@ -505,6 +505,10 @@ class MegaDebridClient {
if (!lastError) {
lastError = web ? "Mega-Web Antwort ohne Download-Link" : "Mega-Web Antwort leer";
}
// Don't retry permanent hoster errors (dead link, file removed, etc.)
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError)) {
break;
}
if (attempt < REQUEST_RETRIES) {
await sleepWithSignal(retryDelay(attempt), signal);
}

View File

@ -213,6 +213,20 @@ function isFetchFailure(errorText: string): boolean {
return text.includes("fetch failed") || text.includes("socket hang up") || text.includes("econnreset") || text.includes("network error");
}
function isPermanentLinkError(errorText: string): boolean {
const text = String(errorText || "").toLowerCase();
return text.includes("permanent ungültig")
|| text.includes("hosternotavailable")
|| /file.?not.?found/.test(text)
|| /file.?unavailable/.test(text)
|| /link.?is.?dead/.test(text)
|| text.includes("file has been removed")
|| text.includes("file has been deleted")
|| text.includes("file is no longer available")
|| text.includes("file was removed")
|| text.includes("file was deleted");
}
function isUnrestrictFailure(errorText: string): boolean {
const text = String(errorText || "").toLowerCase();
return text.includes("unrestrict") || text.includes("mega-web") || text.includes("mega-debrid")
@ -3780,6 +3794,21 @@ export class DownloadManager extends EventEmitter {
return;
}
// Permanent link errors (dead link, file removed, hoster unavailable) → fail immediately
if (isPermanentLinkError(errorText)) {
logger.error(`Link permanent ungültig: item=${item.fileName || item.id}, error=${errorText}, link=${item.url.slice(0, 80)}`);
item.status = "failed";
this.recordRunOutcome(item.id, "failed");
item.lastError = errorText;
item.fullStatus = `Link ungültig: ${errorText}`;
item.speedBps = 0;
item.updatedAt = nowMs();
this.retryStateByItem.delete(item.id);
this.persistSoon();
this.emitState();
return;
}
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
active.unrestrictRetries += 1;
item.retries += 1;
@ -5252,6 +5281,17 @@ export class DownloadManager extends EventEmitter {
return;
}
// With autoExtract: only remove once ALL items are extracted, not just downloaded
if (this.settings.autoExtract) {
const allExtracted = pkg.itemIds.every((itemId) => {
const item = this.session.items[itemId];
return !item || isExtractedLabel(item.fullStatus || "");
});
if (!allExtracted) {
return;
}
}
this.removePackageFromSession(packageId, [...pkg.itemIds]);
}
@ -5292,6 +5332,16 @@ export class DownloadManager extends EventEmitter {
return item != null && item.status !== "completed";
});
if (!hasOpen) {
// With autoExtract: only remove once ALL items are extracted, not just downloaded
if (this.settings.autoExtract) {
const allExtracted = pkg.itemIds.every((id) => {
const item = this.session.items[id];
return !item || isExtractedLabel(item.fullStatus || "");
});
if (!allExtracted) {
return;
}
}
this.removePackageFromSession(packageId, [...pkg.itemIds]);
}
}

View File

@ -42,6 +42,45 @@ function parseSetCookieFromHeaders(headers: Headers): string {
.join("; ");
}
const PERMANENT_HOSTER_ERRORS = [
"hosternotavailable",
"filenotfound",
"file_unavailable",
"file not found",
"link is dead",
"file has been removed",
"file has been deleted",
"file was deleted",
"file was removed",
"not available",
"file is no longer available"
];
function parsePageErrors(html: string): string[] {
const errors: string[] = [];
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
let m: RegExpExecArray | null;
while ((m = errorRegex.exec(html)) !== null) {
const text = m[1].replace(/^Fehler:\s*/i, "").trim();
if (text) {
errors.push(text);
}
}
return errors;
}
function isPermanentHosterError(errors: string[]): string | null {
for (const err of errors) {
const lower = err.toLowerCase();
for (const pattern of PERMANENT_HOSTER_ERRORS) {
if (lower.includes(pattern)) {
return err;
}
}
}
return null;
}
function parseCodes(html: string): CodeEntry[] {
const entries: CodeEntry[] = [];
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
@ -306,6 +345,14 @@ export class MegaWebFallback {
});
const html = await page.text();
// Check for permanent hoster errors before looking for debrid codes
const pageErrors = parsePageErrors(html);
const permanentError = isPermanentHosterError(pageErrors);
if (permanentError) {
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
}
const code = pickCode(parseCodes(html), link);
if (!code) {
return null;