Fix Debrid-Link key rotation cascade failure, case-sensitive rename, and sample filter
- notDebrid (host-level) no longer burns all keys: stops rotation immediately with 5min cooldown instead of cycling through all 9 keys pointlessly - Remove double provider-blockade: debrid_link_cooldown no longer stacks recordProviderFailure + applyProviderBusyBackoff on top of key cooldowns - Detect timeout cascades: 2+ consecutive transport failures trigger 3min cooldown instead of burning remaining keys - Case-sensitive rename: files with different casing (e.g. lowercase scene names) now get properly renamed instead of being skipped as "already matching" - Extended sample filter: detect -s.mkv suffix and \Sample\ subdirectories in auto-rename (already worked in MKV-move) - Add key status display with state pills in Debrid-Link key stats popup - Add parseDebridLinkTerminalFailure for fast-fail on exhausted keys Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1d0b2ee8e3
commit
38179881f5
@ -2280,6 +2280,7 @@ class DebridLinkClient {
|
|||||||
const cooldownFailures: string[] = [];
|
const cooldownFailures: string[] = [];
|
||||||
let earliestCooldownUntil = 0;
|
let earliestCooldownUntil = 0;
|
||||||
const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = [];
|
const attemptedKeyFailures: Array<{ message: string; cooldownMs: number; category?: DebridLinkCooldownCategory }> = [];
|
||||||
|
let consecutiveTransportFailures = 0;
|
||||||
|
|
||||||
// Always start from first key — use first available, skip disabled/limited/cooldown.
|
// Always start from first key — use first available, skip disabled/limited/cooldown.
|
||||||
// This ensures all parallel items use the same key until it's actually exhausted.
|
// This ensures all parallel items use the same key until it's actually exhausted.
|
||||||
@ -2333,6 +2334,22 @@ class DebridLinkClient {
|
|||||||
if (failure.fatal) {
|
if (failure.fatal) {
|
||||||
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
|
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
|
||||||
}
|
}
|
||||||
|
if (failure.providerWide) {
|
||||||
|
// Host-level issue (e.g. notDebrid) — rotating to other keys is pointless.
|
||||||
|
// Break immediately and apply a longer cooldown (5 min) to avoid burning all keys.
|
||||||
|
const providerWideCooldownMs = 5 * 60 * 1000;
|
||||||
|
logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
|
||||||
|
throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`);
|
||||||
|
}
|
||||||
|
// Track consecutive transport failures (timeout/network) to detect cascades.
|
||||||
|
const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
|
||||||
|
consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
|
||||||
|
if (consecutiveTransportFailures >= 2) {
|
||||||
|
// 2+ keys timed out in a row — likely a server/network issue, not key-specific.
|
||||||
|
const cascadeCooldownMs = 3 * 60 * 1000;
|
||||||
|
logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
|
||||||
|
throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
|
||||||
|
}
|
||||||
const cooldownInfo = failure.cooldownMs > 0
|
const cooldownInfo = failure.cooldownMs > 0
|
||||||
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
|
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
|
||||||
: "";
|
: "";
|
||||||
@ -2496,7 +2513,7 @@ class DebridLinkClient {
|
|||||||
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
|
apiKey: ReturnType<typeof parseDebridLinkApiKeys>[number],
|
||||||
link: string,
|
link: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory }> {
|
): Promise<{ fatal: boolean; cooldownMs: number; message: string; category?: DebridLinkCooldownCategory; providerWide?: boolean }> {
|
||||||
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||||
if (error instanceof DebridLinkApiError) {
|
if (error instanceof DebridLinkApiError) {
|
||||||
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
|
const code = String(error.code || "").trim() || `HTTP ${error.status}`;
|
||||||
@ -2529,12 +2546,13 @@ class DebridLinkClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
|
if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
|
||||||
// notDebrid = "host may be down" — transient, try next key before giving up.
|
// notDebrid = host-level issue — affects ALL keys equally, do NOT rotate.
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
||||||
message: `Link kann aktuell nicht generiert werden (${code}: ${description})`,
|
message: `Link kann aktuell nicht generiert werden (${code}: ${description})`,
|
||||||
category: "temporary"
|
category: "temporary",
|
||||||
|
providerWide: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (DEBRID_LINK_SKIP_KEY_ERRORS.has(code)) {
|
if (DEBRID_LINK_SKIP_KEY_ERRORS.has(code)) {
|
||||||
|
|||||||
@ -525,7 +525,8 @@ function isPermanentLinkError(errorText: string): boolean {
|
|||||||
|
|
||||||
function isUnrestrictFailure(errorText: string): boolean {
|
function isUnrestrictFailure(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return text.includes("unrestrict") || text.includes("mega-web") || text.includes("mega-debrid")
|
return text.includes("unrestrict") || text.includes("debrid-link") || text.includes("debrid_link_")
|
||||||
|
|| text.includes("mega-web") || text.includes("mega-debrid")
|
||||||
|| text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("kein debrid")
|
|| text.includes("bestdebrid") || text.includes("alldebrid") || text.includes("kein debrid")
|
||||||
|| text.includes("session-cookie") || text.includes("session cookie") || text.includes("session blockiert")
|
|| text.includes("session-cookie") || text.includes("session cookie") || text.includes("session blockiert")
|
||||||
|| text.includes("session expired") || text.includes("invalid session")
|
|| text.includes("session expired") || text.includes("invalid session")
|
||||||
@ -546,6 +547,26 @@ function parseDebridLinkCooldownRetry(errorText: string): { delayMs: number; det
|
|||||||
return { delayMs, detail };
|
return { delayMs, detail };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseDebridLinkTerminalFailure(errorText: string): { kind: "invalid_all" | "no_active_key"; detail: string } | null {
|
||||||
|
const raw = String(errorText || "");
|
||||||
|
const match = raw.match(/debrid_link_(invalid_all|no_active_key):(.*)$/i);
|
||||||
|
if (!match) {
|
||||||
|
if (/debrid-link.+(deaktiviert|ausgeschopft|kein aktiver api-key)/i.test(raw)) {
|
||||||
|
return {
|
||||||
|
kind: "no_active_key",
|
||||||
|
detail: raw.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const kind = String(match[1] || "").toLowerCase() === "invalid_all" ? "invalid_all" : "no_active_key";
|
||||||
|
const detail = String(match[2] || "").trim();
|
||||||
|
return {
|
||||||
|
kind,
|
||||||
|
detail: detail || "Debrid-Link ist aktuell nicht verfuegbar"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isProviderBusyUnrestrictError(errorText: string): boolean {
|
function isProviderBusyUnrestrictError(errorText: string): boolean {
|
||||||
const text = String(errorText || "").toLowerCase();
|
const text = String(errorText || "").toLowerCase();
|
||||||
return text.includes("too many active")
|
return text.includes("too many active")
|
||||||
@ -3310,6 +3331,9 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
const sampleTokenRe = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
||||||
|
const sampleDirNames = new Set(["sample", "samples"]);
|
||||||
|
// Short suffix pattern: scene groups often use "-s.mkv" for samples (e.g. itn-continuum.s01e10.720p-s.mkv)
|
||||||
|
const sampleSuffixRe = /[._\-]s$/i;
|
||||||
for (const sourcePath of videoFiles) {
|
for (const sourcePath of videoFiles) {
|
||||||
if (shouldAbort?.()) {
|
if (shouldAbort?.()) {
|
||||||
return renamed;
|
return renamed;
|
||||||
@ -3317,11 +3341,12 @@ export class DownloadManager extends EventEmitter {
|
|||||||
const sourceName = path.basename(sourcePath);
|
const sourceName = path.basename(sourcePath);
|
||||||
const sourceExt = path.extname(sourceName);
|
const sourceExt = path.extname(sourceName);
|
||||||
const sourceBaseName = path.basename(sourceName, sourceExt);
|
const sourceBaseName = path.basename(sourceName, sourceExt);
|
||||||
|
const parentDirName = path.basename(path.dirname(sourcePath)).toLowerCase();
|
||||||
|
|
||||||
// Skip sample files — renaming them strips the "-sample" suffix,
|
// Skip sample files — renaming them strips the "-sample" suffix,
|
||||||
// making them indistinguishable from the main MKV and causing (2)
|
// making them indistinguishable from the main MKV and causing (2)
|
||||||
// duplicates during MKV collection.
|
// duplicates during MKV collection.
|
||||||
if (sampleTokenRe.test(sourceBaseName)) {
|
if (sampleTokenRe.test(sourceBaseName) || sampleDirNames.has(parentDirName) || sampleSuffixRe.test(sourceBaseName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const folderCandidates: string[] = [];
|
const folderCandidates: string[] = [];
|
||||||
@ -3427,7 +3452,8 @@ export class DownloadManager extends EventEmitter {
|
|||||||
logger.warn(`Auto-Rename übersprungen (Zielpfad zu lang/ungültig): ${sourcePath}`);
|
logger.warn(`Auto-Rename übersprungen (Zielpfad zu lang/ungültig): ${sourcePath}`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (pathKey(targetPath) === pathKey(sourcePath)) {
|
if (targetPath === sourcePath) {
|
||||||
|
// Exact match (including casing) — truly nothing to do.
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
const resolved = resolveRenameItem(targetPath);
|
const resolved = resolveRenameItem(targetPath);
|
||||||
this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename übersprungen: Name bereits passend", {
|
this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename übersprungen: Name bereits passend", {
|
||||||
@ -3439,6 +3465,27 @@ export class DownloadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (pathKey(targetPath) === pathKey(sourcePath) && targetPath !== sourcePath) {
|
||||||
|
// Same file on case-insensitive FS but different casing — rename in-place.
|
||||||
|
// On Windows, fs.rename handles case-only renames correctly.
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(sourcePath, targetPath);
|
||||||
|
renamedCount += 1;
|
||||||
|
if (pkg) {
|
||||||
|
const resolved = resolveRenameItem(targetPath);
|
||||||
|
this.logRenameProcess(pkg, "INFO", "auto-rename", "Auto-Rename (Casing korrigiert)", {
|
||||||
|
sourcePath,
|
||||||
|
sourceName,
|
||||||
|
targetPath,
|
||||||
|
targetBaseName
|
||||||
|
}, resolved.item, resolved.matchedBy);
|
||||||
|
}
|
||||||
|
logger.info(`Auto-Rename Casing: ${sourcePath} -> ${targetPath}`);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Auto-Rename Casing fehlgeschlagen: ${sourcePath} -> ${targetPath}: ${compactErrorText(err as Error)}`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (await this.existsAsync(targetPath)) {
|
if (await this.existsAsync(targetPath)) {
|
||||||
if (pkg) {
|
if (pkg) {
|
||||||
this.logPackageForPackage(pkg, "WARN", "Auto-Rename übersprungen: Ziel existiert", {
|
this.logPackageForPackage(pkg, "WARN", "Auto-Rename übersprungen: Ziel existiert", {
|
||||||
@ -8055,14 +8102,28 @@ export class DownloadManager extends EventEmitter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const debridLinkTerminalFailure = parseDebridLinkTerminalFailure(errorText);
|
||||||
|
if (debridLinkTerminalFailure) {
|
||||||
|
item.status = "failed";
|
||||||
|
this.recordRunOutcome(item.id, "failed");
|
||||||
|
item.lastError = debridLinkTerminalFailure.detail;
|
||||||
|
item.fullStatus = `Debrid-Link: ${debridLinkTerminalFailure.detail}`;
|
||||||
|
item.speedBps = 0;
|
||||||
|
item.updatedAt = nowMs();
|
||||||
|
this.retryStateByItem.delete(item.id);
|
||||||
|
this.persistSoon();
|
||||||
|
this.emitState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
if (isUnrestrictFailure(errorText) && active.unrestrictRetries < maxUnrestrictRetries) {
|
||||||
const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText);
|
const debridLinkCooldown = parseDebridLinkCooldownRetry(errorText);
|
||||||
if (debridLinkCooldown) {
|
if (debridLinkCooldown) {
|
||||||
active.unrestrictRetries += 1;
|
active.unrestrictRetries += 1;
|
||||||
item.retries += 1;
|
item.retries += 1;
|
||||||
const failureProvider = this.getProviderFailureKeyForItem(item);
|
// Do NOT call recordProviderFailure/applyProviderBusyBackoff here —
|
||||||
this.recordProviderFailure(failureProvider);
|
// Debrid-Link key cooldowns are managed in debrid.ts per-key.
|
||||||
this.applyProviderBusyBackoff(failureProvider, debridLinkCooldown.delayMs);
|
// Adding a provider-wide cooldown on top causes double-blocking.
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Debrid-Link-Cooldown: item=${item.fileName || item.id}, ` +
|
`Debrid-Link-Cooldown: item=${item.fileName || item.id}, ` +
|
||||||
`retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${debridLinkCooldown.delayMs}ms, ` +
|
`retry=${active.unrestrictRetries}/${retryDisplayLimit}, delay=${debridLinkCooldown.delayMs}ms, ` +
|
||||||
|
|||||||
@ -1121,6 +1121,76 @@ function formatDebridLinkCountQuota(info: DebridLinkHostLimitInfo | null | undef
|
|||||||
return info.note || "Nicht verfügbar";
|
return info.note || "Nicht verfügbar";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDebridLinkKeyStatusDisplay(
|
||||||
|
key: DebridLinkAccountKeyEntry,
|
||||||
|
info: DebridLinkHostLimitInfo | null | undefined
|
||||||
|
): { label: string; tone: "ok" | "warn" | "bad" | "muted"; title: string } {
|
||||||
|
if (key.disabled) {
|
||||||
|
return {
|
||||||
|
label: "Deaktiviert",
|
||||||
|
tone: "muted",
|
||||||
|
title: "Key ist manuell deaktiviert."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (key.dailyLimitReached) {
|
||||||
|
return {
|
||||||
|
label: "Lokales Limit",
|
||||||
|
tone: "warn",
|
||||||
|
title: key.dailyLimitBytes > 0
|
||||||
|
? `Lokales Tageslimit erreicht (${humanSize(key.dailyUsedBytes)} / ${humanSize(key.dailyLimitBytes)}).`
|
||||||
|
: "Lokales Tageslimit erreicht."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!info) {
|
||||||
|
return {
|
||||||
|
label: "Pruefe...",
|
||||||
|
tone: "muted",
|
||||||
|
title: "Live-Status wird geladen."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = [info.stateDetail, info.note, info.hostNote]
|
||||||
|
.filter((value) => Boolean(String(value || "").trim()))
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
if (info.state === "ready") {
|
||||||
|
if (info.hostState === "down") {
|
||||||
|
return {
|
||||||
|
label: "Host offline",
|
||||||
|
tone: "warn",
|
||||||
|
title: title || "Der Hoster ist laut Debrid-Link aktuell offline."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "Bereit",
|
||||||
|
tone: "ok",
|
||||||
|
title: title || "Key ist nutzbar."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.state === "invalid" || info.state === "error") {
|
||||||
|
return {
|
||||||
|
label: info.stateLabel,
|
||||||
|
tone: "bad",
|
||||||
|
title: title || info.stateLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.state === "quota" || info.state === "rate_limit" || info.state === "cooldown") {
|
||||||
|
return {
|
||||||
|
label: info.stateLabel,
|
||||||
|
tone: "warn",
|
||||||
|
title: title || info.stateLabel
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: info.stateLabel || "Unbekannt",
|
||||||
|
tone: "muted",
|
||||||
|
title: title || info.stateLabel || "Unbekannt"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
interface BandwidthChartProps {
|
interface BandwidthChartProps {
|
||||||
items: Record<string, DownloadItem>;
|
items: Record<string, DownloadItem>;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@ -5913,7 +5983,13 @@ export function App(): ReactElement {
|
|||||||
const totalUsed = entry.debridLinkKeys.reduce((s, k) => s + k.dailyUsedBytes, 0);
|
const totalUsed = entry.debridLinkKeys.reduce((s, k) => s + k.dailyUsedBytes, 0);
|
||||||
const limitedCount = entry.debridLinkKeys.filter((k) => k.dailyLimitReached).length;
|
const limitedCount = entry.debridLinkKeys.filter((k) => k.dailyLimitReached).length;
|
||||||
const disabledCount = entry.debridLinkKeys.filter((k) => k.disabled).length;
|
const disabledCount = entry.debridLinkKeys.filter((k) => k.disabled).length;
|
||||||
|
const keyDiagnostics = entry.debridLinkKeys
|
||||||
|
.map((k) => debridLinkHostLimits[k.id])
|
||||||
|
.filter((info): info is DebridLinkHostLimitInfo => Boolean(info));
|
||||||
const loadedQuotaCount = entry.debridLinkKeys.filter((k) => Boolean(debridLinkHostLimits[k.id])).length;
|
const loadedQuotaCount = entry.debridLinkKeys.filter((k) => Boolean(debridLinkHostLimits[k.id])).length;
|
||||||
|
const invalidCount = keyDiagnostics.filter((info) => info.state === "invalid").length;
|
||||||
|
const cooldownCount = keyDiagnostics.filter((info) => info.state === "cooldown" || info.state === "quota" || info.state === "rate_limit").length;
|
||||||
|
const hostStatusLabel = keyDiagnostics.find((info) => info.hostState !== "unknown")?.hostStateLabel || "";
|
||||||
return (
|
return (
|
||||||
<div className="modal-backdrop" onClick={() => setKeyStatsPopup(null)}>
|
<div className="modal-backdrop" onClick={() => setKeyStatsPopup(null)}>
|
||||||
<div className="modal-card key-stats-popup" onClick={(e) => e.stopPropagation()}>
|
<div className="modal-card key-stats-popup" onClick={(e) => e.stopPropagation()}>
|
||||||
@ -5924,8 +6000,10 @@ export function App(): ReactElement {
|
|||||||
{entry.debridLinkKeys.length} Keys · Heute: {humanSize(totalUsed)}
|
{entry.debridLinkKeys.length} Keys · Heute: {humanSize(totalUsed)}
|
||||||
{limitedCount > 0 && <span className="key-stats-warn"> · {limitedCount} am Limit</span>}
|
{limitedCount > 0 && <span className="key-stats-warn"> · {limitedCount} am Limit</span>}
|
||||||
{disabledCount > 0 && <span className="key-stats-warn"> · {disabledCount} deaktiviert</span>}
|
{disabledCount > 0 && <span className="key-stats-warn"> · {disabledCount} deaktiviert</span>}
|
||||||
|
{invalidCount > 0 && <span className="key-stats-warn"> · {invalidCount} invalid</span>}
|
||||||
|
{cooldownCount > 0 && <span className="key-stats-warn"> · {cooldownCount} im Cooldown</span>}
|
||||||
{debridLinkHostLimitsLoading && <span> · Rapidgator-Quota wird geladen ({loadedQuotaCount}/{entry.debridLinkKeys.length})</span>}
|
{debridLinkHostLimitsLoading && <span> · Rapidgator-Quota wird geladen ({loadedQuotaCount}/{entry.debridLinkKeys.length})</span>}
|
||||||
{!debridLinkHostLimitsLoading && !debridLinkHostLimitsError && <span> · Rapidgator API-Quota</span>}
|
{!debridLinkHostLimitsLoading && !debridLinkHostLimitsError && <span> · Rapidgator {hostStatusLabel || "Status unbekannt"}</span>}
|
||||||
{debridLinkHostLimitsError && <span className="key-stats-warn"> · API-Quota konnte nicht geladen werden</span>}
|
{debridLinkHostLimitsError && <span className="key-stats-warn"> · API-Quota konnte nicht geladen werden</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -5937,14 +6015,16 @@ export function App(): ReactElement {
|
|||||||
<span className="col-masked">Key</span>
|
<span className="col-masked">Key</span>
|
||||||
<span className="col-usage">Heute</span>
|
<span className="col-usage">Heute</span>
|
||||||
<span className="col-limit">Lokal</span>
|
<span className="col-limit">Lokal</span>
|
||||||
|
<span className="col-status">Status</span>
|
||||||
<span className="col-traffic">RG Traffic</span>
|
<span className="col-traffic">RG Traffic</span>
|
||||||
<span className="col-links">RG Links</span>
|
<span className="col-links">RG Links</span>
|
||||||
<span className="col-action"></span>
|
<span className="col-action"></span>
|
||||||
</div>
|
</div>
|
||||||
{entry.debridLinkKeys.map((key, ki) => (
|
{entry.debridLinkKeys.map((key, ki) => (
|
||||||
<div key={key.id} className={`account-subkey-table-row${key.dailyLimitReached ? " warning" : ""}${key.disabled ? " disabled" : ""}`}>
|
<div key={key.id} className={`account-subkey-table-row${key.dailyLimitReached || (debridLinkHostLimits[key.id] && debridLinkHostLimits[key.id].state !== "ready") ? " warning" : ""}${key.disabled ? " disabled" : ""}`}>
|
||||||
{(() => {
|
{(() => {
|
||||||
const hostInfo = debridLinkHostLimits[key.id];
|
const hostInfo = debridLinkHostLimits[key.id];
|
||||||
|
const statusDisplay = getDebridLinkKeyStatusDisplay(key, hostInfo);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span className="col-key">{ki + 1}</span>
|
<span className="col-key">{ki + 1}</span>
|
||||||
@ -5961,6 +6041,7 @@ export function App(): ReactElement {
|
|||||||
</span>
|
</span>
|
||||||
<span className="col-usage">{humanSize(key.dailyUsedBytes)}</span>
|
<span className="col-usage">{humanSize(key.dailyUsedBytes)}</span>
|
||||||
<span className="col-limit">{key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span>
|
<span className="col-limit">{key.disabled ? "Deaktiviert" : key.dailyLimitBytes > 0 ? humanSize(key.dailyLimitBytes) : "Kein Limit"}</span>
|
||||||
|
<span className={`col-status status-pill status-pill-${statusDisplay.tone}`} title={statusDisplay.title}>{statusDisplay.label}</span>
|
||||||
<span className="col-traffic" title={hostInfo?.note || ""}>{formatDebridLinkTraffic(hostInfo)}</span>
|
<span className="col-traffic" title={hostInfo?.note || ""}>{formatDebridLinkTraffic(hostInfo)}</span>
|
||||||
<span className="col-links" title={hostInfo?.note || ""}>{formatDebridLinkCountQuota(hostInfo)}</span>
|
<span className="col-links" title={hostInfo?.note || ""}>{formatDebridLinkCountQuota(hostInfo)}</span>
|
||||||
<span className="col-action">
|
<span className="col-action">
|
||||||
|
|||||||
@ -1562,10 +1562,12 @@ body,
|
|||||||
|
|
||||||
.account-subkey-table-head .col-usage,
|
.account-subkey-table-head .col-usage,
|
||||||
.account-subkey-table-head .col-limit,
|
.account-subkey-table-head .col-limit,
|
||||||
|
.account-subkey-table-head .col-status,
|
||||||
.account-subkey-table-head .col-traffic,
|
.account-subkey-table-head .col-traffic,
|
||||||
.account-subkey-table-head .col-links,
|
.account-subkey-table-head .col-links,
|
||||||
.account-subkey-table-row .col-usage,
|
.account-subkey-table-row .col-usage,
|
||||||
.account-subkey-table-row .col-limit,
|
.account-subkey-table-row .col-limit,
|
||||||
|
.account-subkey-table-row .col-status,
|
||||||
.account-subkey-table-row .col-traffic,
|
.account-subkey-table-row .col-traffic,
|
||||||
.account-subkey-table-row .col-links {
|
.account-subkey-table-row .col-links {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@ -1615,6 +1617,10 @@ body,
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-subkey-table-row .col-status {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.account-subkey-table-row .col-traffic,
|
.account-subkey-table-row .col-traffic,
|
||||||
.account-subkey-table-row .col-links {
|
.account-subkey-table-row .col-links {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -1630,10 +1636,42 @@ body,
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.account-subkey-table-row .col-action .btn {
|
.account-subkey-table-row .col-action .btn {
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 88px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill-ok {
|
||||||
|
color: #166534;
|
||||||
|
background: color-mix(in srgb, #22c55e 16%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill-warn {
|
||||||
|
color: #92400e;
|
||||||
|
background: color-mix(in srgb, #f59e0b 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill-bad {
|
||||||
|
color: #991b1b;
|
||||||
|
background: color-mix(in srgb, #ef4444 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pill-muted {
|
||||||
|
color: var(--muted);
|
||||||
|
background: color-mix(in srgb, var(--field) 80%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
|
|||||||
@ -294,8 +294,10 @@ export interface UpdateInstallProgress {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
|
export type AllDebridHostState = "up" | "down" | "not_tracked" | "unknown";
|
||||||
export type AllDebridHostInfoSource = "api" | "web";
|
export type AllDebridHostInfoSource = "api" | "web";
|
||||||
|
export type DebridLinkHostState = "up" | "down" | "unknown";
|
||||||
|
export type DebridLinkKeyState = "ready" | "cooldown" | "invalid" | "quota" | "rate_limit" | "error" | "unknown";
|
||||||
|
|
||||||
export interface AllDebridHostInfo {
|
export interface AllDebridHostInfo {
|
||||||
host: string;
|
host: string;
|
||||||
@ -311,17 +313,26 @@ export interface AllDebridHostInfo {
|
|||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DebridLinkHostLimitInfo {
|
export interface DebridLinkHostLimitInfo {
|
||||||
keyId: string;
|
keyId: string;
|
||||||
keyLabel: string;
|
keyLabel: string;
|
||||||
host: string;
|
host: string;
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
trafficCurrentBytes: number | null;
|
trafficCurrentBytes: number | null;
|
||||||
trafficMaxBytes: number | null;
|
trafficMaxBytes: number | null;
|
||||||
linksCurrent: number | null;
|
linksCurrent: number | null;
|
||||||
linksMax: number | null;
|
linksMax: number | null;
|
||||||
note: string;
|
note: string;
|
||||||
}
|
state: DebridLinkKeyState;
|
||||||
|
stateLabel: string;
|
||||||
|
stateDetail: string;
|
||||||
|
cooldownUntil: number | null;
|
||||||
|
cooldownRemainingMs: number;
|
||||||
|
lastCheckedAt: number | null;
|
||||||
|
hostState: DebridLinkHostState;
|
||||||
|
hostStateLabel: string;
|
||||||
|
hostNote: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParsedHashEntry {
|
export interface ParsedHashEntry {
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|||||||
@ -525,7 +525,7 @@ describe("debrid service", () => {
|
|||||||
await expect(service.unrestrictLink("https://hoster.example/no-key-left.bin")).rejects.toThrow(/debrid-link nicht verfuegbar|kein aktiver api-key/i);
|
await expect(service.unrestrictLink("https://hoster.example/no-key-left.bin")).rejects.toThrow(/debrid-link nicht verfuegbar|kein aktiver api-key/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rotates through all keys on Debrid-Link notDebrid errors before failing", async () => {
|
it("stops rotation immediately on Debrid-Link notDebrid (provider-wide) — does NOT burn remaining keys", async () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
||||||
@ -551,9 +551,9 @@ describe("debrid service", () => {
|
|||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
|
|
||||||
const service = new DebridService(settings);
|
const service = new DebridService(settings);
|
||||||
await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/notDebrid/);
|
await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/);
|
||||||
// notDebrid is transient (host may be down) — both keys should be tried
|
// notDebrid is a host-level issue — only Key 1 should be tried, Key 2 must NOT be burned
|
||||||
expect(authHeaders).toEqual(["Bearer dl-key-one", "Bearer dl-key-two"]);
|
expect(authHeaders).toEqual(["Bearer dl-key-one"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("continues to the next Debrid-Link key for non-provider-wide skip errors without caching a cooldown", async () => {
|
it("continues to the next Debrid-Link key for non-provider-wide skip errors without caching a cooldown", async () => {
|
||||||
|
|||||||
@ -1798,6 +1798,54 @@ describe("download manager", () => {
|
|||||||
await manager.stop();
|
await manager.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails fast when Debrid-Link has no active api key left", async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const keys = parseDebridLinkApiKeys("dl-key-one\ndl-key-two");
|
||||||
|
|
||||||
|
const settings = {
|
||||||
|
...defaultSettings(),
|
||||||
|
debridLinkApiKeys: "dl-key-one\ndl-key-two",
|
||||||
|
providerOrder: ["debridlink"] as const,
|
||||||
|
providerPrimary: "debridlink" as const,
|
||||||
|
providerSecondary: "none" as const,
|
||||||
|
providerTertiary: "none" as const,
|
||||||
|
outputDir: path.join(root, "downloads"),
|
||||||
|
extractDir: path.join(root, "extract"),
|
||||||
|
retryLimit: 0,
|
||||||
|
autoExtract: false,
|
||||||
|
debridLinkApiKeyDailyLimitBytes: {
|
||||||
|
[keys[0].id]: 100,
|
||||||
|
[keys[1].id]: 100
|
||||||
|
},
|
||||||
|
debridLinkApiKeyDailyUsageBytes: {
|
||||||
|
[keys[0].id]: 100,
|
||||||
|
[keys[1].id]: 100
|
||||||
|
},
|
||||||
|
providerDailyUsageDay: getProviderUsageDayKey()
|
||||||
|
};
|
||||||
|
|
||||||
|
const manager = new DownloadManager(
|
||||||
|
settings,
|
||||||
|
emptySession(),
|
||||||
|
createStoragePaths(path.join(root, "state"))
|
||||||
|
);
|
||||||
|
|
||||||
|
manager.addPackages([{ name: "debridlink-no-key", links: ["https://rapidgator.net/file/no-active-key.part1.rar.html"] }]);
|
||||||
|
await manager.start();
|
||||||
|
await waitFor(() => {
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
return Boolean(item && item.status === "failed");
|
||||||
|
}, 12000);
|
||||||
|
|
||||||
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
|
expect(item?.status).toBe("failed");
|
||||||
|
expect(item?.fullStatus || "").toContain("Debrid-Link");
|
||||||
|
expect(item?.retries).toBe(0);
|
||||||
|
|
||||||
|
await manager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
it("restarts from zero after repeated resume underflow on fresh direct links", async () => {
|
it("restarts from zero after repeated resume underflow on fresh direct links", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user