Compare commits

..

No commits in common. "main" and "v1.7.183" have entirely different histories.

104 changed files with 111863 additions and 25912 deletions

13
.gitignore vendored
View File

@ -19,6 +19,7 @@ apply_update.cmd
.claude/ .claude/
.github/ .github/
docs/plans/
CHANGELOG.md CHANGELOG.md
node_modules/ node_modules/
@ -28,6 +29,7 @@ npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# Forgejo deployment runtime files
deploy/forgejo/.env deploy/forgejo/.env
deploy/forgejo/forgejo/ deploy/forgejo/forgejo/
deploy/forgejo/postgres/ deploy/forgejo/postgres/
@ -36,14 +38,3 @@ deploy/forgejo/caddy/config/
deploy/forgejo/caddy/logs/ deploy/forgejo/caddy/logs/
deploy/forgejo/backups/ deploy/forgejo/backups/
.secrets .secrets
*.log.old
*.bak
rust-postprocess/
electron-postprocess/
python-postprocess/
scripts/*.py
scripts/*.ps1
scripts/*.md
scripts/fix-library-renames.mjs

View File

@ -0,0 +1,361 @@
<!DOCTYPE html>
<html lang="de" data-flavor="ember">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aurora Flavors — Real-Debrid-Downloader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">
<style>
/* ===== BASELINE = TEAL ===== */
:root {
--bg: #060b18; --bg-2: #0a1326;
--surface: rgba(18, 30, 54, 0.66);
--card: rgba(22, 36, 64, 0.72);
--field: #0a1426;
--border: rgba(86, 124, 178, 0.22);
--border-strong: rgba(96, 140, 200, 0.4);
--text: #eaf1fb; --muted: #93a8c8; --faint: #6982a6;
--accent: #3ad4ce; --accent-2: #4aa8ff; --accent-glow: rgba(58, 212, 206, 0.55);
--green: #43e08a; --amber: #ffb13d; --red: #ff5d76; --violet: #9d7bff;
--bg-grad:
radial-gradient(900px 520px at 84% -8%, rgba(74,168,255,0.16), transparent 60%),
radial-gradient(820px 480px at 8% 0%, rgba(58,212,206,0.13), transparent 55%),
radial-gradient(1200px 700px at 50% 120%, rgba(157,123,255,0.10), transparent 60%),
linear-gradient(180deg, var(--bg-2), var(--bg));
}
/* ===== INDIGO ===== */
html[data-flavor="indigo"] {
--bg: #0a0a1c; --bg-2: #100f28;
--accent: #8f7bff; --accent-2: #5b8cff; --accent-glow: rgba(143,123,255,0.55);
--border: rgba(120,110,210,0.24); --border-strong: rgba(140,120,240,0.42);
--bg-grad:
radial-gradient(900px 520px at 84% -8%, rgba(91,140,255,0.18), transparent 60%),
radial-gradient(820px 480px at 8% 0%, rgba(143,123,255,0.15), transparent 55%),
radial-gradient(1200px 700px at 50% 120%, rgba(120,90,255,0.12), transparent 60%),
linear-gradient(180deg, var(--bg-2), var(--bg));
}
/* ===== EMERALD ===== */
html[data-flavor="emerald"] {
--bg: #04130e; --bg-2: #08211a;
--accent: #34e0a1; --accent-2: #2dd4bf; --accent-glow: rgba(52,224,161,0.5);
--border: rgba(70,160,130,0.24); --border-strong: rgba(80,200,160,0.4);
--bg-grad:
radial-gradient(900px 520px at 84% -8%, rgba(45,212,191,0.16), transparent 60%),
radial-gradient(820px 480px at 8% 0%, rgba(52,224,161,0.14), transparent 55%),
radial-gradient(1200px 700px at 50% 120%, rgba(34,197,150,0.10), transparent 60%),
linear-gradient(180deg, var(--bg-2), var(--bg));
}
/* ===== EMBER (warm accent / cool base) ===== */
html[data-flavor="ember"] {
--bg: #090b18; --bg-2: #0e1124;
--accent: #ff9e57; --accent-2: #ff6f91; --accent-glow: rgba(255,158,87,0.5);
--border: rgba(150,120,150,0.22); --border-strong: rgba(255,158,87,0.36);
--bg-grad:
radial-gradient(900px 520px at 84% -8%, rgba(255,111,145,0.15), transparent 60%),
radial-gradient(820px 480px at 8% 0%, rgba(255,158,87,0.13), transparent 55%),
radial-gradient(1200px 700px at 50% 120%, rgba(91,140,255,0.10), transparent 60%),
linear-gradient(180deg, var(--bg-2), var(--bg));
}
/* ===== ICE (desaturated steel, low glow) ===== */
html[data-flavor="ice"] {
--bg: #080d16; --bg-2: #0d1622;
--accent: #82d4ff; --accent-2: #abc0db; --accent-glow: rgba(130,212,255,0.32);
--border: rgba(100,130,165,0.22); --border-strong: rgba(130,160,195,0.4);
--bg-grad:
radial-gradient(900px 520px at 84% -8%, rgba(130,212,255,0.12), transparent 60%),
radial-gradient(820px 480px at 8% 0%, rgba(171,192,219,0.08), transparent 55%),
radial-gradient(1200px 700px at 50% 120%, rgba(90,120,160,0.08), transparent 60%),
linear-gradient(180deg, var(--bg-2), var(--bg));
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: "Hanken Grotesk", system-ui, sans-serif;
background: var(--bg-grad); color: var(--text); font-size: 14px;
-webkit-font-smoothing: antialiased; height: 100vh; overflow: hidden;
transition: background .4s ease;
}
.app { display: grid; grid-template-rows: auto auto auto auto 1fr auto; height: 100vh; padding: 10px 16px 10px; gap: 10px; }
/* flavor switcher */
.flavors { display: flex; align-items: center; gap: 10px; padding: 7px 12px; border-radius: 12px;
background: var(--surface); border: 1px solid var(--border); backdrop-filter: blur(16px); }
.flavors .lbl { font-family: "JetBrains Mono"; font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--faint); margin-right: 4px; }
.fbtn { display: flex; align-items: center; gap: 8px; padding: 6px 13px; border-radius: 999px; cursor: pointer;
border: 1px solid var(--border); background: var(--field); color: var(--muted); font-weight: 600; font-size: 12.5px; transition: all .15s; }
.fbtn:hover { color: var(--text); transform: translateY(-1px); }
.fbtn.on { color: var(--text); border-color: var(--accent); box-shadow: 0 0 14px var(--accent-glow); }
.sw { width: 13px; height: 13px; border-radius: 50%; }
.sw.teal { background: linear-gradient(135deg,#3ad4ce,#4aa8ff); }
.sw.indigo { background: linear-gradient(135deg,#8f7bff,#5b8cff); }
.sw.emerald { background: linear-gradient(135deg,#34e0a1,#2dd4bf); }
.sw.ember { background: linear-gradient(135deg,#ff9e57,#ff6f91); }
.sw.ice { background: linear-gradient(135deg,#82d4ff,#abc0db); }
.flavors .hint { margin-left: auto; font-size: 12px; color: var(--faint); }
.menubar { display: flex; align-items: center; gap: 4px; }
.brand { display: flex; align-items: center; gap: 10px; margin-right: 18px; }
.brand .logo { width: 30px; height: 30px; border-radius: 9px;
background: conic-gradient(from 200deg, var(--accent), var(--accent-2), var(--violet), var(--accent));
box-shadow: 0 0 18px var(--accent-glow), inset 0 0 12px rgba(255,255,255,0.18); display: grid; place-items: center; }
.brand .logo::after { content: ""; width: 13px; height: 13px; border-radius: 4px; background: var(--bg); }
.brand .name { font-family: "Bricolage Grotesque"; font-weight: 800; font-size: 16px; letter-spacing: -0.02em; }
.brand .ver { font-size: 11px; color: var(--faint); font-family: "JetBrains Mono"; margin-top: 3px; text-transform: capitalize; }
.menu-item { padding: 6px 12px; border-radius: 8px; color: var(--muted); font-weight: 600; font-size: 13px; cursor: pointer; transition: all .15s; }
.menu-item:hover { background: var(--surface); color: var(--text); }
.menubar .spacer { flex: 1; }
.conn-pill { display: flex; align-items: center; gap: 8px; padding: 6px 13px; border-radius: 999px; background: var(--surface); border: 1px solid var(--border); font-size: 12px; color: var(--muted); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 10px var(--green); }
.control { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-radius: 16px;
background: var(--surface); border: 1px solid var(--border); backdrop-filter: blur(18px);
box-shadow: 0 10px 30px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.05); }
.ctrl-group { display: flex; gap: 7px; }
.ic { width: 38px; height: 38px; border-radius: 11px; border: 1px solid var(--border); background: var(--field);
display: grid; place-items: center; cursor: pointer; transition: all .15s; color: var(--muted); }
.ic:hover { transform: translateY(-1px); border-color: var(--border-strong); color: var(--text); }
.ic svg { width: 17px; height: 17px; }
.ic.play { color: var(--green); } .ic.play:hover { box-shadow: 0 0 16px rgba(67,224,138,0.35); border-color: rgba(67,224,138,0.5); }
.ic.pause { color: var(--amber); } .ic.stop { color: var(--red); } .ic.sched { color: var(--accent); }
.sep { width: 1px; height: 26px; background: var(--border); margin: 0 2px; }
.control .spacer { flex: 1; }
.live-stats { display: flex; gap: 22px; padding-right: 6px; }
.ls { text-align: right; }
.ls .lbl { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--faint); }
.ls .val { font-family: "JetBrains Mono"; font-weight: 600; font-size: 16px; margin-top: 2px; }
.ls .val .u { font-size: 11px; color: var(--muted); }
.ls .val.accent { color: var(--accent); }
.tabs { display: flex; gap: 6px; }
.tab { display: flex; align-items: center; gap: 8px; padding: 9px 16px; border-radius: 11px 11px 0 0; cursor: pointer;
color: var(--muted); font-weight: 600; font-size: 13px; border: 1px solid transparent; border-bottom: none; transition: all .15s; }
.tab svg { width: 15px; height: 15px; opacity: 0.8; }
.tab:hover { color: var(--text); background: var(--surface); }
.tab.active { color: var(--text); background: var(--card); border-color: var(--border); position: relative; }
.tab.active::after { content: ""; position: absolute; left: 14px; right: 14px; top: 0; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-2)); box-shadow: 0 0 12px var(--accent-glow); }
.tab .count { font-family: "JetBrains Mono"; font-size: 11px; background: var(--field); padding: 1px 7px; border-radius: 999px; color: var(--muted); }
.panel { background: var(--card); border: 1px solid var(--border); border-radius: 0 14px 14px 14px;
backdrop-filter: blur(20px); overflow: hidden; display: flex; flex-direction: column;
box-shadow: 0 16px 44px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.04); }
.thead { display: grid; grid-template-columns: minmax(0,1fr) 168px 96px 116px 130px 80px 168px 96px;
padding: 11px 16px; border-bottom: 1px solid var(--border); font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em; color: var(--faint); }
.thead > div:not(:first-child) { text-align: center; }
.tbody { overflow-y: auto; flex: 1; }
.tbody::-webkit-scrollbar { width: 10px; }
.tbody::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; border: 3px solid transparent; background-clip: padding-box; }
.pkg { border-bottom: 1px solid rgba(86,124,178,0.1); }
.pkg-row { display: grid; grid-template-columns: minmax(0,1fr) 168px 96px 116px 130px 80px 168px 96px;
align-items: center; padding: 12px 16px; cursor: pointer; transition: background .15s; }
.pkg-row:hover { background: rgba(127,170,220,0.06); }
.pkg-name { display: flex; align-items: center; gap: 10px; min-width: 0; }
.chev { color: var(--faint); transition: transform .2s; flex-shrink: 0; }
.pkg.open .chev { transform: rotate(90deg); }
.pkg-icon { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; flex-shrink: 0;
background: linear-gradient(140deg, var(--accent-glow), transparent); border: 1px solid var(--border); }
.pkg-icon svg { width: 15px; height: 15px; color: var(--accent); }
.pkg-title { min-width: 0; }
.pkg-title .t { font-weight: 600; font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pkg-title .s { font-size: 11px; color: var(--faint); margin-top: 1px; font-family: "JetBrains Mono"; }
.cell { text-align: center; font-size: 12.5px; color: var(--muted); }
.cell.mono { font-family: "JetBrains Mono"; }
.pbar { height: 22px; border-radius: 7px; background: var(--field); position: relative; overflow: hidden; border: 1px solid var(--border); }
.pbar .fill { position: absolute; inset: 0; width: var(--p, 0%); border-radius: 6px;
background: linear-gradient(90deg, var(--accent-2), var(--accent)); box-shadow: 0 0 14px var(--accent-glow); }
.pbar .fill.ext { background: linear-gradient(90deg, #2fbf6e, var(--green)); box-shadow: 0 0 14px rgba(67,224,138,0.4); }
.pbar .txt { position: absolute; inset: 0; display: grid; place-items: center; font-size: 11px; font-weight: 700;
font-family: "JetBrains Mono"; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.6); z-index: 2; }
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.b-dl { background: color-mix(in srgb, var(--accent) 16%, transparent); color: color-mix(in srgb, var(--accent) 80%, white); }
.b-ext { background: rgba(67,224,138,0.14); color: var(--green); }
.b-done { background: rgba(67,224,138,0.12); color: var(--green); }
.b-err { background: rgba(255,93,118,0.14); color: var(--red); }
.b-queue { background: rgba(147,168,200,0.12); color: var(--muted); }
.hoster { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
.hoster .hd { width: 7px; height: 7px; border-radius: 2px; }
.svc { font-size: 11.5px; color: var(--muted); }
.prio-high { color: var(--amber); font-weight: 700; font-size: 11px; }
.items { background: rgba(6,12,24,0.45); display: none; }
.pkg.open .items { display: block; }
.item { display: grid; grid-template-columns: minmax(0,1fr) 168px 96px 116px 130px 80px 168px 96px;
align-items: center; padding: 7px 16px; font-size: 12px; border-top: 1px solid rgba(86,124,178,0.07); }
.item:hover { background: rgba(127,170,220,0.04); }
.item-name { display: flex; align-items: center; gap: 9px; padding-left: 40px; min-width: 0; }
.ldot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.ldot.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
.ldot.off { background: var(--red); box-shadow: 0 0 8px var(--red); }
.item-name .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-family: "JetBrains Mono"; font-size: 11.5px; }
.mini { height: 6px; border-radius: 3px; background: var(--field); overflow: hidden; position: relative; }
.mini .mf { position: absolute; inset: 0; width: var(--p,0%); background: linear-gradient(90deg, var(--accent-2), var(--accent)); }
.statusbar { display: flex; align-items: center; gap: 18px; padding: 8px 16px; border-radius: 12px;
background: var(--surface); border: 1px solid var(--border); font-size: 12px; color: var(--muted); backdrop-filter: blur(14px); }
.statusbar .sb-item { display: flex; align-items: center; gap: 7px; }
.statusbar .spacer { flex: 1; }
.statusbar b { color: var(--text); font-family: "JetBrains Mono"; font-weight: 600; }
.qbar { width: 120px; height: 6px; border-radius: 3px; background: var(--field); overflow: hidden; }
.qbar > div { height: 100%; width: 62%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
</style>
</head>
<body>
<div class="app">
<!-- FLAVOR SWITCHER -->
<div class="flavors">
<span class="lbl">Aurora Flavor</span>
<div class="fbtn" data-f="teal"><span class="sw teal"></span> Teal</div>
<div class="fbtn" data-f="indigo"><span class="sw indigo"></span> Indigo</div>
<div class="fbtn" data-f="emerald"><span class="sw emerald"></span> Emerald</div>
<div class="fbtn on" data-f="ember"><span class="sw ember"></span> Ember</div>
<div class="fbtn" data-f="ice"><span class="sw ice"></span> Ice</div>
<span class="hint">Klick zum Wechseln · Tasten 15 · Pakete ausklappbar</span>
</div>
<!-- MENU BAR -->
<div class="menubar">
<div class="brand">
<div class="logo"></div>
<div><div class="name">Debrid Downloader</div><div class="ver" id="verLabel">v1.7.156 · Aurora Ember</div></div>
</div>
<div class="menu-item">Datei</div>
<div class="menu-item">Einstellungen</div>
<div class="menu-item">Hilfe</div>
<div class="spacer"></div>
<div class="conn-pill"><span class="dot"></span> 4 Provider aktiv · Mega-Debrid bereit</div>
</div>
<!-- CONTROL STRIP -->
<div class="control">
<div class="ctrl-group">
<div class="ic play" title="Start"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></div>
<div class="ic pause" title="Pause"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg></div>
<div class="ic stop" title="Stop"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></div>
</div>
<div class="sep"></div>
<div class="ctrl-group">
<div class="ic sched" title="Zeitplan"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></div>
<div class="ic" title="Hoch"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg></div>
<div class="ic" title="Runter"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg></div>
</div>
<div class="spacer"></div>
<div class="live-stats">
<div class="ls"><div class="lbl">Geschwindigkeit</div><div class="val accent">48.2<span class="u"> MB/s</span></div></div>
<div class="ls"><div class="lbl">Verbleibend</div><div class="val">02:14<span class="u"> min</span></div></div>
<div class="ls"><div class="lbl">Aktiv</div><div class="val">8<span class="u"> / 24</span></div></div>
</div>
</div>
<!-- TABS -->
<div class="tabs">
<div class="tab active"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"/></svg> Downloads <span class="count">5</span></div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1m-2 9a5 5 0 01-7 0 5 5 0 010-7l1-1"/></svg> Linksammler</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 00.3 1.8M4.6 9a1.6 1.6 0 00-.3-1.8"/></svg> Einstellungen</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 109-9 9 9 0 00-9 9zm9-5v5l3 2"/></svg> Verlauf</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg> Statistiken</div>
</div>
<!-- TABLE -->
<div class="panel">
<div class="thead"><div>Name</div><div>Geladen / Größe</div><div>Fortschritt</div><div>Hoster</div><div>Service</div><div>Priorität</div><div>Status</div><div>Tempo</div></div>
<div class="tbody">
<div class="pkg open">
<div class="pkg-row">
<div class="pkg-name"><svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg><div class="pkg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div><div class="pkg-title"><div class="t">Ugly.Americans.S02.COMPLETE.German.DL.720p.WEB-DL.h264-NERDS</div><div class="s">10 Dateien · 14.2 GB</div></div></div>
<div class="cell mono">8.9 / 14.2 GB</div>
<div class="cell"><div class="pbar"><div class="fill" style="--p:63%"></div><div class="txt">63%</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#ff5722"></span>mega</span></div>
<div class="cell"><span class="svc">Mega-Debrid</span></div>
<div class="cell"><span class="prio-high">HOCH</span></div>
<div class="cell"><span class="badge b-dl">↓ 6/10 läuft</span></div>
<div class="cell mono" style="color:var(--accent)">48 MB/s</div>
</div>
<div class="items">
<div class="item"><div class="item-name"><span class="ldot on"></span><span class="nm">…h264-NERDS.part1.rar</span></div><div class="cell mono">2.0 / 2.0 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:100%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-done">✓ Fertig</span></div><div class="cell mono"></div></div>
<div class="item"><div class="item-name"><span class="ldot on"></span><span class="nm">…h264-NERDS.part2.rar</span></div><div class="cell mono">2.0 / 2.0 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:100%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-done">✓ Fertig</span></div><div class="cell mono"></div></div>
<div class="item"><div class="item-name"><span class="ldot on"></span><span class="nm">…h264-NERDS.part3.rar</span></div><div class="cell mono">1.4 / 2.0 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:70%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-dl">↓ 70%</span></div><div class="cell mono" style="color:var(--accent)">26 MB/s</div></div>
<div class="item"><div class="item-name"><span class="ldot on"></span><span class="nm">…h264-NERDS.part4.rar</span></div><div class="cell mono">0.8 / 2.0 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:40%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-dl">↓ 40%</span></div><div class="cell mono" style="color:var(--accent)">22 MB/s</div></div>
<div class="item"><div class="item-name"><span class="ldot off"></span><span class="nm">…h264-NERDS.part5.rar</span></div><div class="cell mono">0 / 1.1 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:0%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-queue">Wartet</span></div><div class="cell mono"></div></div>
</div>
</div>
<div class="pkg">
<div class="pkg-row">
<div class="pkg-name"><svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg><div class="pkg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div><div class="pkg-title"><div class="t">The.Bear.S03.German.DL.1080p.WEB.h264-WvF</div><div class="s">8 Dateien · 11.6 GB</div></div></div>
<div class="cell mono">11.6 / 11.6 GB</div>
<div class="cell"><div class="pbar"><div class="fill ext" style="--p:82%"></div><div class="txt">Entpacken 82%</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#22c55e"></span>rapidgator</span></div>
<div class="cell"><span class="svc">Real-Debrid</span></div>
<div class="cell"></div>
<div class="cell"><span class="badge b-ext">⚙ Entpacken</span></div>
<div class="cell mono"></div>
</div>
</div>
<div class="pkg">
<div class="pkg-row">
<div class="pkg-name"><svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg><div class="pkg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div><div class="pkg-title"><div class="t">Dune.Part.Two.2024.German.DL.2160p.UHD.BluRay.x265-NERDS</div><div class="s">1 Datei · 18.4 GB → Library</div></div></div>
<div class="cell mono">18.4 / 18.4 GB</div>
<div class="cell"><div class="pbar"><div class="fill ext" style="--p:100%"></div><div class="txt">100%</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#3b82f6"></span>ddownload</span></div>
<div class="cell"><span class="svc">AllDebrid</span></div>
<div class="cell"></div>
<div class="cell"><span class="badge b-done">✓ Abgeschlossen</span></div>
<div class="cell mono"></div>
</div>
</div>
<div class="pkg">
<div class="pkg-row">
<div class="pkg-name"><svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg><div class="pkg-icon" style="border-color:rgba(255,93,118,0.4)"><svg viewBox="0 0 24 24" fill="none" stroke="var(--red)" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/></svg></div><div class="pkg-title"><div class="t">Shogun.S01E05.German.DL.1080p.WEB.h264-EDITION</div><div class="s" style="color:var(--red)">Hoster nicht verfügbar · Link tot</div></div></div>
<div class="cell mono">0 / 2.4 GB</div>
<div class="cell"><div class="pbar"><div class="fill" style="--p:0%"></div><div class="txt" style="color:var(--red);text-shadow:none">Fehler</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#a855f7"></span>uploaded</span></div>
<div class="cell"><span class="svc"></span></div>
<div class="cell"></div>
<div class="cell"><span class="badge b-err">✗ Fehlgeschlagen</span></div>
<div class="cell mono"></div>
</div>
</div>
<div class="pkg">
<div class="pkg-row">
<div class="pkg-name"><svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg><div class="pkg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div><div class="pkg-title"><div class="t">Severance.S02.COMPLETE.German.DL.1080p.ATVP.WEB.h265-NERDS</div><div class="s">10 Dateien · 22.1 GB</div></div></div>
<div class="cell mono">0 / 22.1 GB</div>
<div class="cell"><div class="pbar"><div class="fill" style="--p:0%"></div><div class="txt" style="color:var(--faint);text-shadow:none">In Warteschlange</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#ff5722"></span>mega</span></div>
<div class="cell"><span class="svc">Mega-Debrid</span></div>
<div class="cell"></div>
<div class="cell"><span class="badge b-queue">Wartet</span></div>
<div class="cell mono"></div>
</div>
</div>
</div>
</div>
<!-- STATUS BAR -->
<div class="statusbar">
<div class="sb-item"><span class="dot"></span> Läuft</div>
<div class="sb-item">Queue: <b>5 Pakete</b> · <b>34 Dateien</b></div>
<div class="sb-item">Heute geladen: <b>147 GB</b></div>
<div class="spacer"></div>
<div class="sb-item">Gesamt-Fortschritt <div class="qbar"><div></div></div> <b>62%</b></div>
</div>
</div>
<script>
const names = { teal:"Teal", indigo:"Indigo", emerald:"Emerald", ember:"Ember", ice:"Ice" };
const order = ["teal","indigo","emerald","ember","ice"];
function setFlavor(f) {
document.documentElement.dataset.flavor = f;
document.querySelectorAll('.fbtn').forEach(b => b.classList.toggle('on', b.dataset.f === f));
document.getElementById('verLabel').textContent = "v1.7.156 · Aurora " + names[f];
}
document.querySelectorAll('.fbtn').forEach(b => b.addEventListener('click', () => setFlavor(b.dataset.f)));
document.querySelectorAll('.pkg-row').forEach(r => r.addEventListener('click', () => r.parentElement.classList.toggle('open')));
window.addEventListener('keydown', e => { const i = parseInt(e.key,10); if (i>=1 && i<=5) setFlavor(order[i-1]); });
</script>
</body>
</html>

345
design-mockups/aurora.html Normal file
View File

@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Aurora — Real-Debrid-Downloader Mockup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,600;12..96,700;12..96,800&family=Hanken+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #060b18;
--bg-2: #0a1326;
--surface: rgba(18, 30, 54, 0.66);
--surface-solid: #111e36;
--card: rgba(22, 36, 64, 0.72);
--field: #0a1426;
--border: rgba(86, 124, 178, 0.22);
--border-strong: rgba(96, 140, 200, 0.4);
--text: #eaf1fb;
--muted: #93a8c8;
--faint: #6982a6;
--accent: #3ad4ce;
--accent-2: #4aa8ff;
--accent-glow: rgba(58, 212, 206, 0.55);
--green: #43e08a;
--amber: #ffb13d;
--red: #ff5d76;
--violet: #9d7bff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: "Hanken Grotesk", system-ui, sans-serif;
background:
radial-gradient(900px 520px at 84% -8%, rgba(74, 168, 255, 0.16), transparent 60%),
radial-gradient(820px 480px at 8% 0%, rgba(58, 212, 206, 0.13), transparent 55%),
radial-gradient(1200px 700px at 50% 120%, rgba(157, 123, 255, 0.10), transparent 60%),
linear-gradient(180deg, var(--bg-2), var(--bg));
color: var(--text);
font-size: 14px;
-webkit-font-smoothing: antialiased;
height: 100vh;
overflow: hidden;
}
.app { display: grid; grid-template-rows: auto auto auto 1fr auto; height: 100vh; padding: 12px 16px 10px; gap: 10px; }
/* Menu bar */
.menubar { display: flex; align-items: center; gap: 4px; }
.brand { display: flex; align-items: center; gap: 10px; margin-right: 18px; }
.brand .logo {
width: 30px; height: 30px; border-radius: 9px;
background: conic-gradient(from 200deg, var(--accent), var(--accent-2), var(--violet), var(--accent));
box-shadow: 0 0 18px var(--accent-glow), inset 0 0 12px rgba(255,255,255,0.18);
display: grid; place-items: center;
}
.brand .logo::after { content: ""; width: 13px; height: 13px; border-radius: 4px; background: #06101f; }
.brand .name { font-family: "Bricolage Grotesque"; font-weight: 800; font-size: 16px; letter-spacing: -0.02em; }
.brand .ver { font-size: 11px; color: var(--faint); font-family: "JetBrains Mono"; margin-top: 3px; }
.menu-item { padding: 6px 12px; border-radius: 8px; color: var(--muted); font-weight: 600; font-size: 13px; cursor: pointer; transition: all .15s; }
.menu-item:hover { background: var(--surface); color: var(--text); }
.menubar .spacer { flex: 1; }
.conn-pill { display: flex; align-items: center; gap: 8px; padding: 6px 13px; border-radius: 999px; background: var(--surface); border: 1px solid var(--border); font-size: 12px; color: var(--muted); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 10px var(--green); }
/* Control strip */
.control { display: flex; align-items: center; gap: 12px; padding: 11px 14px; border-radius: 16px;
background: var(--surface); border: 1px solid var(--border); backdrop-filter: blur(18px);
box-shadow: 0 10px 30px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.05); }
.ctrl-group { display: flex; gap: 7px; }
.ic { width: 38px; height: 38px; border-radius: 11px; border: 1px solid var(--border); background: var(--field);
display: grid; place-items: center; cursor: pointer; transition: all .15s; color: var(--muted); }
.ic:hover { transform: translateY(-1px); border-color: var(--border-strong); color: var(--text); }
.ic svg { width: 17px; height: 17px; }
.ic.play { color: var(--green); } .ic.play:hover { box-shadow: 0 0 16px rgba(67,224,138,0.35); border-color: rgba(67,224,138,0.5); }
.ic.pause { color: var(--amber); }
.ic.stop { color: var(--red); }
.ic.sched { color: var(--accent); }
.sep { width: 1px; height: 26px; background: var(--border); margin: 0 2px; }
.control .spacer { flex: 1; }
.live-stats { display: flex; gap: 22px; padding-right: 6px; }
.ls { text-align: right; }
.ls .lbl { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--faint); }
.ls .val { font-family: "JetBrains Mono"; font-weight: 600; font-size: 16px; margin-top: 2px; }
.ls .val .u { font-size: 11px; color: var(--muted); }
.ls .val.accent { color: var(--accent); }
/* Tabs */
.tabs { display: flex; gap: 6px; }
.tab { display: flex; align-items: center; gap: 8px; padding: 9px 16px; border-radius: 11px 11px 0 0; cursor: pointer;
color: var(--muted); font-weight: 600; font-size: 13px; border: 1px solid transparent; border-bottom: none; transition: all .15s; }
.tab svg { width: 15px; height: 15px; opacity: 0.8; }
.tab:hover { color: var(--text); background: var(--surface); }
.tab.active { color: var(--text); background: var(--card);
border-color: var(--border); position: relative; }
.tab.active::after { content: ""; position: absolute; left: 14px; right: 14px; top: 0; height: 2px; border-radius: 2px;
background: linear-gradient(90deg, var(--accent), var(--accent-2)); box-shadow: 0 0 12px var(--accent-glow); }
.tab .count { font-family: "JetBrains Mono"; font-size: 11px; background: var(--field); padding: 1px 7px; border-radius: 999px; color: var(--muted); }
/* Table panel */
.panel { background: var(--card); border: 1px solid var(--border); border-radius: 0 14px 14px 14px;
backdrop-filter: blur(20px); overflow: hidden; display: flex; flex-direction: column;
box-shadow: 0 16px 44px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.04); }
.thead { display: grid; grid-template-columns: minmax(0,1fr) 168px 96px 116px 130px 80px 168px 96px;
padding: 11px 16px; border-bottom: 1px solid var(--border); font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em; color: var(--faint); }
.thead > div:not(:first-child) { text-align: center; }
.tbody { overflow-y: auto; flex: 1; }
.tbody::-webkit-scrollbar { width: 10px; }
.tbody::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; border: 3px solid transparent; background-clip: padding-box; }
/* Package row */
.pkg { border-bottom: 1px solid rgba(86,124,178,0.1); }
.pkg-row { display: grid; grid-template-columns: minmax(0,1fr) 168px 96px 116px 130px 80px 168px 96px;
align-items: center; padding: 12px 16px; cursor: pointer; transition: background .15s; }
.pkg-row:hover { background: rgba(74,168,255,0.05); }
.pkg-name { display: flex; align-items: center; gap: 10px; min-width: 0; }
.chev { color: var(--faint); transition: transform .2s; flex-shrink: 0; }
.pkg.open .chev { transform: rotate(90deg); }
.pkg-icon { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; flex-shrink: 0;
background: linear-gradient(140deg, rgba(74,168,255,0.18), rgba(58,212,206,0.12)); border: 1px solid var(--border); }
.pkg-icon svg { width: 15px; height: 15px; color: var(--accent); }
.pkg-title { min-width: 0; }
.pkg-title .t { font-weight: 600; font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.pkg-title .s { font-size: 11px; color: var(--faint); margin-top: 1px; font-family: "JetBrains Mono"; }
.cell { text-align: center; font-size: 12.5px; color: var(--muted); }
.cell.mono { font-family: "JetBrains Mono"; }
/* progress */
.pbar { height: 22px; border-radius: 7px; background: var(--field); position: relative; overflow: hidden; border: 1px solid var(--border); }
.pbar .fill { position: absolute; inset: 0; width: var(--p, 0%); border-radius: 6px;
background: linear-gradient(90deg, var(--accent-2), var(--accent)); box-shadow: 0 0 14px var(--accent-glow); }
.pbar .fill.ext { background: linear-gradient(90deg, #2fbf6e, var(--green)); box-shadow: 0 0 14px rgba(67,224,138,0.4); }
.pbar .txt { position: absolute; inset: 0; display: grid; place-items: center; font-size: 11px; font-weight: 700;
font-family: "JetBrains Mono"; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,0.6); z-index: 2; }
.pct { font-family: "JetBrains Mono"; font-weight: 600; }
/* badges */
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.b-dl { background: rgba(74,168,255,0.14); color: #8ec5ff; }
.b-ext { background: rgba(67,224,138,0.14); color: var(--green); }
.b-done { background: rgba(67,224,138,0.12); color: var(--green); }
.b-err { background: rgba(255,93,118,0.14); color: var(--red); }
.b-queue { background: rgba(147,168,200,0.12); color: var(--muted); }
.hoster { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
.hoster .hd { width: 7px; height: 7px; border-radius: 2px; }
.svc { font-size: 11.5px; color: var(--muted); }
.prio-high { color: var(--amber); font-weight: 700; font-size: 11px; }
/* items */
.items { background: rgba(6,12,24,0.45); display: none; }
.pkg.open .items { display: block; }
.item { display: grid; grid-template-columns: minmax(0,1fr) 168px 96px 116px 130px 80px 168px 96px;
align-items: center; padding: 7px 16px 7px 16px; font-size: 12px; border-top: 1px solid rgba(86,124,178,0.07); }
.item:hover { background: rgba(74,168,255,0.04); }
.item-name { display: flex; align-items: center; gap: 9px; padding-left: 40px; min-width: 0; }
.ldot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.ldot.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
.ldot.off { background: var(--red); box-shadow: 0 0 8px var(--red); }
.item-name .nm { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-family: "JetBrains Mono"; font-size: 11.5px; }
.mini { height: 6px; border-radius: 3px; background: var(--field); overflow: hidden; position: relative; }
.mini .mf { position: absolute; inset: 0; width: var(--p,0%); background: linear-gradient(90deg, var(--accent-2), var(--accent)); }
/* statusbar */
.statusbar { display: flex; align-items: center; gap: 18px; padding: 8px 16px; border-radius: 12px;
background: var(--surface); border: 1px solid var(--border); font-size: 12px; color: var(--muted); backdrop-filter: blur(14px); }
.statusbar .sb-item { display: flex; align-items: center; gap: 7px; }
.statusbar .spacer { flex: 1; }
.statusbar b { color: var(--text); font-family: "JetBrains Mono"; font-weight: 600; }
.qbar { width: 120px; height: 6px; border-radius: 3px; background: var(--field); overflow: hidden; }
.qbar > div { height: 100%; width: 62%; background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
</style>
</head>
<body>
<div class="app">
<!-- MENU BAR -->
<div class="menubar">
<div class="brand">
<div class="logo"></div>
<div>
<div class="name">Debrid Downloader</div>
<div class="ver">v1.7.156 · Aurora</div>
</div>
</div>
<div class="menu-item">Datei</div>
<div class="menu-item">Einstellungen</div>
<div class="menu-item">Hilfe</div>
<div class="spacer"></div>
<div class="conn-pill"><span class="dot"></span> 4 Provider aktiv · Mega-Debrid bereit</div>
</div>
<!-- CONTROL STRIP -->
<div class="control">
<div class="ctrl-group">
<div class="ic play" title="Start"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></div>
<div class="ic pause" title="Pause"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg></div>
<div class="ic stop" title="Stop"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></div>
</div>
<div class="sep"></div>
<div class="ctrl-group">
<div class="ic sched" title="Zeitplan"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></div>
<div class="ic" title="Hoch"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg></div>
<div class="ic" title="Runter"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg></div>
</div>
<div class="spacer"></div>
<div class="live-stats">
<div class="ls"><div class="lbl">Geschwindigkeit</div><div class="val accent">48.2<span class="u"> MB/s</span></div></div>
<div class="ls"><div class="lbl">Verbleibend</div><div class="val">02:14<span class="u"> min</span></div></div>
<div class="ls"><div class="lbl">Aktiv</div><div class="val">8<span class="u"> / 24</span></div></div>
</div>
</div>
<!-- TABS -->
<div class="tabs">
<div class="tab active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"/></svg>
Downloads <span class="count">5</span>
</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1m-2 9a5 5 0 01-7 0 5 5 0 010-7l1-1"/></svg> Linksammler</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 00-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 00-2-1.2l-.4-2.6h-4l-.4 2.6a7 7 0 00-2 1.2l-2.4-1-2 3.4 2 1.6a7 7 0 000 2.4l-2 1.6 2 3.4 2.4-1a7 7 0 002 1.2l.4 2.6h4l.4-2.6a7 7 0 002-1.2l2.4 1 2-3.4-2-1.6c.06-.4.1-.8.1-1.2z"/></svg> Einstellungen</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 109-9 9 9 0 00-9 9zm9-5v5l3 2"/></svg> Verlauf</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg> Statistiken</div>
</div>
<!-- TABLE -->
<div class="panel">
<div class="thead">
<div>Name</div><div>Geladen / Größe</div><div>Fortschritt</div><div>Hoster</div><div>Service</div><div>Priorität</div><div>Status</div><div>Tempo</div>
</div>
<div class="tbody">
<!-- Package 1: downloading, open -->
<div class="pkg open">
<div class="pkg-row">
<div class="pkg-name">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pkg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div>
<div class="pkg-title"><div class="t">Ugly.Americans.S02.COMPLETE.German.DL.720p.WEB-DL.h264-NERDS</div><div class="s">10 Dateien · 14.2 GB</div></div>
</div>
<div class="cell mono">8.9 / 14.2 GB</div>
<div class="cell"><div class="pbar"><div class="fill" style="--p:63%"></div><div class="txt">63%</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#ff5722"></span>mega</span></div>
<div class="cell"><span class="svc">Mega-Debrid</span></div>
<div class="cell"><span class="prio-high">HOCH</span></div>
<div class="cell"><span class="badge b-dl">↓ 6/10 läuft</span></div>
<div class="cell mono" style="color:var(--accent)">48 MB/s</div>
</div>
<div class="items">
<div class="item"><div class="item-name"><span class="ldot on"></span><span class="nm">…h264-NERDS.part1.rar</span></div><div class="cell mono">2.0 / 2.0 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:100%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-done">✓ Fertig</span></div><div class="cell mono"></div></div>
<div class="item"><div class="item-name"><span class="ldot on"></span><span class="nm">…h264-NERDS.part2.rar</span></div><div class="cell mono">2.0 / 2.0 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:100%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-done">✓ Fertig</span></div><div class="cell mono"></div></div>
<div class="item"><div class="item-name"><span class="ldot on"></span><span class="nm">…h264-NERDS.part3.rar</span></div><div class="cell mono">1.4 / 2.0 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:70%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-dl">↓ 70%</span></div><div class="cell mono" style="color:var(--accent)">26 MB/s</div></div>
<div class="item"><div class="item-name"><span class="ldot on"></span><span class="nm">…h264-NERDS.part4.rar</span></div><div class="cell mono">0.8 / 2.0 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:40%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-dl">↓ 40%</span></div><div class="cell mono" style="color:var(--accent)">22 MB/s</div></div>
<div class="item"><div class="item-name"><span class="ldot off"></span><span class="nm">…h264-NERDS.part5.rar</span></div><div class="cell mono">0 / 1.1 GB</div><div class="cell"><div class="mini"><div class="mf" style="--p:0%"></div></div></div><div class="cell">mega</div><div class="cell"></div><div class="cell"></div><div class="cell"><span class="badge b-queue">Wartet</span></div><div class="cell mono"></div></div>
</div>
</div>
<!-- Package 2: extracting -->
<div class="pkg">
<div class="pkg-row">
<div class="pkg-name">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pkg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div>
<div class="pkg-title"><div class="t">The.Bear.S03.German.DL.1080p.WEB.h264-WvF</div><div class="s">8 Dateien · 11.6 GB</div></div>
</div>
<div class="cell mono">11.6 / 11.6 GB</div>
<div class="cell"><div class="pbar"><div class="fill ext" style="--p:82%"></div><div class="txt">Entpacken 82%</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#22c55e"></span>rapidgator</span></div>
<div class="cell"><span class="svc">Real-Debrid</span></div>
<div class="cell"></div>
<div class="cell"><span class="badge b-ext">⚙ Entpacken</span></div>
<div class="cell mono"></div>
</div>
</div>
<!-- Package 3: completed -->
<div class="pkg">
<div class="pkg-row">
<div class="pkg-name">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pkg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div>
<div class="pkg-title"><div class="t">Dune.Part.Two.2024.German.DL.2160p.UHD.BluRay.x265-NERDS</div><div class="s">1 Datei · 18.4 GB → Library</div></div>
</div>
<div class="cell mono">18.4 / 18.4 GB</div>
<div class="cell"><div class="pbar"><div class="fill ext" style="--p:100%"></div><div class="txt">100%</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#3b82f6"></span>ddownload</span></div>
<div class="cell"><span class="svc">AllDebrid</span></div>
<div class="cell"></div>
<div class="cell"><span class="badge b-done">✓ Abgeschlossen</span></div>
<div class="cell mono"></div>
</div>
</div>
<!-- Package 4: error -->
<div class="pkg">
<div class="pkg-row">
<div class="pkg-name">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pkg-icon" style="border-color:rgba(255,93,118,0.4)"><svg viewBox="0 0 24 24" fill="none" stroke="var(--red)" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/></svg></div>
<div class="pkg-title"><div class="t">Shogun.S01E05.German.DL.1080p.WEB.h264-EDITION</div><div class="s" style="color:var(--red)">Hoster nicht verfügbar · Link tot</div></div>
</div>
<div class="cell mono">0 / 2.4 GB</div>
<div class="cell"><div class="pbar"><div class="fill" style="--p:0%"></div><div class="txt" style="color:var(--red);text-shadow:none">Fehler</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#a855f7"></span>uploaded</span></div>
<div class="cell"><span class="svc"></span></div>
<div class="cell"></div>
<div class="cell"><span class="badge b-err">✗ Fehlgeschlagen</span></div>
<div class="cell mono"></div>
</div>
</div>
<!-- Package 5: queued -->
<div class="pkg">
<div class="pkg-row">
<div class="pkg-name">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pkg-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div>
<div class="pkg-title"><div class="t">Severance.S02.COMPLETE.German.DL.1080p.ATVP.WEB.h265-NERDS</div><div class="s">10 Dateien · 22.1 GB</div></div>
</div>
<div class="cell mono">0 / 22.1 GB</div>
<div class="cell"><div class="pbar"><div class="fill" style="--p:0%"></div><div class="txt" style="color:var(--faint);text-shadow:none">In Warteschlange</div></div></div>
<div class="cell"><span class="hoster"><span class="hd" style="background:#ff5722"></span>mega</span></div>
<div class="cell"><span class="svc">Mega-Debrid</span></div>
<div class="cell"></div>
<div class="cell"><span class="badge b-queue">Wartet</span></div>
<div class="cell mono"></div>
</div>
</div>
</div>
</div>
<!-- STATUS BAR -->
<div class="statusbar">
<div class="sb-item"><span class="dot"></span> Läuft</div>
<div class="sb-item">Queue: <b>5 Pakete</b> · <b>34 Dateien</b></div>
<div class="sb-item">Heute geladen: <b>147 GB</b></div>
<div class="spacer"></div>
<div class="sb-item">Gesamt-Fortschritt <div class="qbar"><div></div></div> <b>62%</b></div>
</div>
</div>
<script>
document.querySelectorAll('.pkg-row').forEach(r => r.addEventListener('click', () => r.parentElement.classList.toggle('open')));
</script>
</body>
</html>

297
design-mockups/command.html Normal file
View File

@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Command — Real-Debrid-Downloader Mockup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #07090d;
--panel: #0c0f15;
--panel-2: #0f131b;
--line: #1c2230;
--line-bright: #2a3343;
--text: #d4dae6;
--muted: #7a8699;
--faint: #515c6e;
--green: #38e07b;
--amber: #ffc14d;
--red: #ff5c5c;
--cyan: #45e0e0;
--blue: #5a9dff;
--violet: #b18bff;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "IBM Plex Sans", system-ui, sans-serif;
background: var(--bg);
background-image:
linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 44px 44px;
background-position: -1px -1px;
color: var(--text);
font-size: 13px;
height: 100vh; overflow: hidden;
-webkit-font-smoothing: antialiased;
}
.mono { font-family: "IBM Plex Mono", monospace; }
.app { display: grid; grid-template-columns: 56px 1fr; height: 100vh; }
/* Left rail */
.rail { background: var(--panel); border-right: 1px solid var(--line-bright); display: flex; flex-direction: column;
align-items: center; padding: 12px 0; gap: 6px; }
.rail .logo { width: 32px; height: 32px; border: 1px solid var(--green); color: var(--green); display: grid; place-items: center;
font-family: "IBM Plex Mono"; font-weight: 700; font-size: 15px; margin-bottom: 14px; box-shadow: 0 0 12px rgba(56,224,123,0.3); }
.rail .ri { width: 38px; height: 38px; display: grid; place-items: center; color: var(--faint); cursor: pointer; border-left: 2px solid transparent; }
.rail .ri:hover { color: var(--text); background: var(--panel-2); }
.rail .ri.active { color: var(--green); border-left-color: var(--green); background: var(--panel-2); }
.rail .ri svg { width: 19px; height: 19px; }
.rail .spacer { flex: 1; }
/* Main */
.main { display: grid; grid-template-rows: auto auto 1fr auto; min-width: 0; }
/* Topbar */
.topbar { display: flex; align-items: center; gap: 16px; padding: 0 16px; height: 46px; background: var(--panel);
border-bottom: 1px solid var(--line-bright); }
.topbar .title { font-family: "IBM Plex Mono"; font-weight: 700; font-size: 13px; letter-spacing: 0.04em; }
.topbar .title .accent { color: var(--green); }
.topbar .crumb { color: var(--faint); font-family: "IBM Plex Mono"; font-size: 12px; }
.topbar .spacer { flex: 1; }
.stat-chip { font-family: "IBM Plex Mono"; font-size: 12px; color: var(--muted); display: flex; align-items: center; gap: 7px; padding: 0 12px; border-left: 1px solid var(--line); height: 46px; line-height: 46px; }
.stat-chip b { color: var(--text); }
.stat-chip .k { color: var(--faint); }
.led { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.led.g { background: var(--green); box-shadow: 0 0 8px var(--green); animation: blink 2s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.5} }
/* Toolbar */
.toolbar { display: flex; align-items: center; gap: 8px; padding: 8px 16px; background: var(--panel-2); border-bottom: 1px solid var(--line); }
.btn { font-family: "IBM Plex Mono"; font-size: 12px; font-weight: 600; padding: 6px 12px; border: 1px solid var(--line-bright);
background: var(--panel); color: var(--muted); cursor: pointer; display: flex; align-items: center; gap: 7px; transition: all .12s; }
.btn:hover { color: var(--text); border-color: var(--faint); }
.btn svg { width: 13px; height: 13px; }
.btn.start { color: var(--green); border-color: rgba(56,224,123,0.4); }
.btn.start:hover { background: rgba(56,224,123,0.08); }
.btn.pause { color: var(--amber); }
.btn.stop { color: var(--red); }
.toolbar .sep { width: 1px; height: 22px; background: var(--line-bright); }
.toolbar .spacer { flex: 1; }
.search { font-family: "IBM Plex Mono"; font-size: 12px; background: var(--panel); border: 1px solid var(--line-bright); color: var(--text);
padding: 6px 10px 6px 30px; width: 220px; }
.search-wrap { position: relative; }
.search-wrap svg { position: absolute; left: 9px; top: 8px; width: 14px; height: 14px; color: var(--faint); }
/* Table */
.table-wrap { overflow: auto; background: var(--bg); }
.table-wrap::-webkit-scrollbar { width: 12px; height: 12px; }
.table-wrap::-webkit-scrollbar-thumb { background: var(--line-bright); border: 3px solid var(--bg); }
table { width: 100%; border-collapse: collapse; }
thead th { position: sticky; top: 0; background: var(--panel); z-index: 3; text-align: left; font-family: "IBM Plex Mono";
font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--faint);
padding: 9px 12px; border-bottom: 1px solid var(--line-bright); border-right: 1px solid var(--line); white-space: nowrap; }
thead th.num { text-align: right; }
tbody td { padding: 0; border-bottom: 1px solid var(--line); border-right: 1px solid var(--line); }
.cell { padding: 8px 12px; }
.num { text-align: right; }
.pkgrow { cursor: pointer; }
.pkgrow:hover td { background: rgba(90,157,255,0.04); }
.pkgrow td { background: var(--panel-2); }
.pname { display: flex; align-items: center; gap: 9px; }
.tw { color: var(--faint); font-family: "IBM Plex Mono"; font-size: 12px; width: 12px; }
.pkg.open .tw::before { content: "▾"; } .tw::before { content: "▸"; }
.ptype { color: var(--cyan); font-family: "IBM Plex Mono"; font-size: 12px; }
.pname .nm { font-weight: 600; font-size: 12.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 460px; }
.sub { color: var(--faint); font-family: "IBM Plex Mono"; font-size: 10.5px; margin-top: 1px; }
/* progress bar - segmented terminal style */
.seg { display: flex; gap: 2px; align-items: center; }
.seg .blocks { display: flex; gap: 1.5px; }
.seg .blk { width: 5px; height: 13px; background: var(--line-bright); }
.seg .blk.on { background: var(--green); box-shadow: 0 0 4px rgba(56,224,123,0.5); }
.seg .blk.dl { background: var(--cyan); box-shadow: 0 0 4px rgba(69,224,224,0.5); }
.seg .p { font-family: "IBM Plex Mono"; font-size: 11px; color: var(--muted); margin-left: 6px; min-width: 32px; }
.tag { font-family: "IBM Plex Mono"; font-size: 11px; font-weight: 600; padding: 2px 7px; display: inline-flex; align-items: center; gap: 5px; }
.t-dl { color: var(--cyan); background: rgba(69,224,224,0.1); border: 1px solid rgba(69,224,224,0.3); }
.t-ext { color: var(--green); background: rgba(56,224,123,0.1); border: 1px solid rgba(56,224,123,0.3); }
.t-done { color: var(--green); background: rgba(56,224,123,0.08); border: 1px solid rgba(56,224,123,0.25); }
.t-err { color: var(--red); background: rgba(255,92,92,0.1); border: 1px solid rgba(255,92,92,0.35); }
.t-q { color: var(--muted); border: 1px solid var(--line-bright); }
.host { font-family: "IBM Plex Mono"; font-size: 11.5px; display: flex; align-items: center; gap: 6px; }
.host .hd { width: 6px; height: 6px; }
.prio { font-family: "IBM Plex Mono"; font-size: 10.5px; color: var(--amber); font-weight: 700; }
.speed { font-family: "IBM Plex Mono"; font-size: 11.5px; }
.speed.live { color: var(--cyan); }
.item td { background: var(--bg); }
.pkg:not(.open) .item { display: none; }
.iname { display: flex; align-items: center; gap: 8px; padding-left: 34px; }
.ld { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.ld.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
.ld.off { background: var(--red); box-shadow: 0 0 6px var(--red); }
.iname .nm { font-family: "IBM Plex Mono"; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 420px; }
/* statusbar */
.statusbar { display: flex; align-items: center; height: 30px; background: var(--panel); border-top: 1px solid var(--line-bright);
font-family: "IBM Plex Mono"; font-size: 11px; color: var(--muted); }
.statusbar .si { padding: 0 12px; height: 30px; line-height: 30px; border-right: 1px solid var(--line); display: flex; align-items: center; gap: 7px; }
.statusbar b { color: var(--text); }
.statusbar .spacer { flex: 1; }
.statusbar .si.green { color: var(--green); }
</style>
</head>
<body>
<div class="app">
<!-- LEFT RAIL -->
<div class="rail">
<div class="logo">D</div>
<div class="ri active" title="Downloads"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"/></svg></div>
<div class="ri" title="Linksammler"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1m-2 9a5 5 0 01-7 0 5 5 0 010-7l1-1"/></svg></div>
<div class="ri" title="Einstellungen"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 00.3 1.8l.1.1a2 2 0 11-2.8 2.8l-.1-.1a1.6 1.6 0 00-2.7 1.1V21a2 2 0 01-4 0v-.1A1.6 1.6 0 007 19.4a1.6 1.6 0 00-1.8.3l-.1.1a2 2 0 11-2.8-2.8l.1-.1a1.6 1.6 0 00-1.1-2.7H1a2 2 0 010-4h.1A1.6 1.6 0 002.6 7a1.6 1.6 0 00-.3-1.8l-.1-.1a2 2 0 112.8-2.8l.1.1a1.6 1.6 0 001.8.3H7a1.6 1.6 0 001-1.5V1a2 2 0 014 0v.1a1.6 1.6 0 001 1.5 1.6 1.6 0 001.8-.3l.1-.1a2 2 0 112.8 2.8l-.1.1a1.6 1.6 0 00-.3 1.8V7a1.6 1.6 0 001.5 1H23a2 2 0 010 4h-.1a1.6 1.6 0 00-1.5 1z"/></svg></div>
<div class="ri" title="Verlauf"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 109-9 9 9 0 00-9 9zm9-5v5l3 2"/></svg></div>
<div class="ri" title="Statistiken"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg></div>
<div class="spacer"></div>
<div class="ri" title="Hilfe"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 015 0c0 2-2.5 2-2.5 4M12 17h.01"/></svg></div>
</div>
<div class="main">
<!-- TOPBAR -->
<div class="topbar">
<div class="title"><span class="accent">rdd</span> ::downloads</div>
<div class="crumb">~/queue · 5 pkg</div>
<div class="spacer"></div>
<div class="stat-chip"><span class="k">DL</span> <b class="mono" style="color:var(--cyan)">48.2 MB/s</b></div>
<div class="stat-chip"><span class="k">ETA</span> <b>02:14</b></div>
<div class="stat-chip"><span class="k">SLOTS</span> <b>8/24</b></div>
<div class="stat-chip"><span class="led g"></span> running</div>
</div>
<!-- TOOLBAR -->
<div class="toolbar">
<div class="btn start"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg> START</div>
<div class="btn pause"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg> PAUSE</div>
<div class="btn stop"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12"/></svg> STOP</div>
<div class="sep"></div>
<div class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg> SCHED</div>
<div class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg></div>
<div class="btn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg></div>
<div class="spacer"></div>
<div class="search-wrap"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg><input class="search mono" placeholder="filter pakete…"></div>
</div>
<!-- TABLE -->
<div class="table-wrap">
<table>
<thead>
<tr>
<th>NAME</th>
<th class="num">GELADEN/GRÖSSE</th>
<th>FORTSCHRITT</th>
<th>HOSTER</th>
<th>SERVICE</th>
<th>PRIO</th>
<th>STATUS</th>
<th class="num">TEMPO</th>
</tr>
</thead>
<tbody>
<!-- pkg1 open -->
<tbody class="pkg open">
<tr class="pkgrow">
<td><div class="cell pname"><span class="tw"></span><span class="ptype">[S]</span><div><div class="nm">Ugly.Americans.S02.COMPLETE.German.DL.720p.WEB-DL.h264-NERDS</div><div class="sub">10 files · 14.2 GB</div></div></div></td>
<td><div class="cell num mono">8.9/14.2 GB</div></td>
<td><div class="cell seg"><div class="blocks"><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk dl"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span></div><span class="p">63%</span></div></td>
<td><div class="cell host"><span class="hd" style="background:#ff5722"></span>mega</div></td>
<td><div class="cell mono" style="font-size:11.5px;color:var(--muted)">Mega-Debrid</div></td>
<td><div class="cell prio">HIGH</div></td>
<td><div class="cell"><span class="tag t-dl">● 6/10 DL</span></div></td>
<td><div class="cell num speed live">48 MB/s</div></td>
</tr>
<tr class="item"><td><div class="cell iname"><span class="ld on"></span><span class="nm">…NERDS.part1.rar</span></div></td><td><div class="cell num mono">2.0/2.0</div></td><td><div class="cell seg"><div class="blocks"><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span></div><span class="p">100%</span></div></td><td><div class="cell host">mega</div></td><td><div class="cell"></div></td><td><div class="cell"></div></td><td><div class="cell"><span class="tag t-done">✓ ok</span></div></td><td><div class="cell num speed"></div></td></tr>
<tr class="item"><td><div class="cell iname"><span class="ld on"></span><span class="nm">…NERDS.part2.rar</span></div></td><td><div class="cell num mono">2.0/2.0</div></td><td><div class="cell seg"><div class="blocks"><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span></div><span class="p">100%</span></div></td><td><div class="cell host">mega</div></td><td><div class="cell"></div></td><td><div class="cell"></div></td><td><div class="cell"><span class="tag t-done">✓ ok</span></div></td><td><div class="cell num speed"></div></td></tr>
<tr class="item"><td><div class="cell iname"><span class="ld on"></span><span class="nm">…NERDS.part3.rar</span></div></td><td><div class="cell num mono">1.4/2.0</div></td><td><div class="cell seg"><div class="blocks"><span class="blk dl"></span><span class="blk dl"></span><span class="blk dl"></span><span class="blk dl"></span><span class="blk dl"></span><span class="blk dl"></span><span class="blk dl"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span></div><span class="p">70%</span></div></td><td><div class="cell host">mega</div></td><td><div class="cell"></div></td><td><div class="cell"></div></td><td><div class="cell"><span class="tag t-dl">● DL</span></div></td><td><div class="cell num speed live">26 MB/s</div></td></tr>
<tr class="item"><td><div class="cell iname"><span class="ld on"></span><span class="nm">…NERDS.part4.rar</span></div></td><td><div class="cell num mono">0.8/2.0</div></td><td><div class="cell seg"><div class="blocks"><span class="blk dl"></span><span class="blk dl"></span><span class="blk dl"></span><span class="blk dl"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span></div><span class="p">40%</span></div></td><td><div class="cell host">mega</div></td><td><div class="cell"></div></td><td><div class="cell"></div></td><td><div class="cell"><span class="tag t-dl">● DL</span></div></td><td><div class="cell num speed live">22 MB/s</div></td></tr>
<tr class="item"><td><div class="cell iname"><span class="ld off"></span><span class="nm">…NERDS.part5.rar</span></div></td><td><div class="cell num mono">0/1.1</div></td><td><div class="cell seg"><div class="blocks"><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span></div><span class="p">0%</span></div></td><td><div class="cell host">mega</div></td><td><div class="cell"></div></td><td><div class="cell"></div></td><td><div class="cell"><span class="tag t-q">queued</span></div></td><td><div class="cell num speed"></div></td></tr>
</tbody>
<!-- pkg2 extracting -->
<tbody class="pkg">
<tr class="pkgrow">
<td><div class="cell pname"><span class="tw"></span><span class="ptype">[S]</span><div><div class="nm">The.Bear.S03.German.DL.1080p.WEB.h264-WvF</div><div class="sub">8 files · 11.6 GB</div></div></div></td>
<td><div class="cell num mono">11.6/11.6 GB</div></td>
<td><div class="cell seg"><div class="blocks"><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk"></span><span class="blk"></span></div><span class="p" style="color:var(--green)">EXT 82%</span></div></td>
<td><div class="cell host"><span class="hd" style="background:#22c55e"></span>rapidgator</div></td>
<td><div class="cell mono" style="font-size:11.5px;color:var(--muted)">Real-Debrid</div></td>
<td><div class="cell"></div></td>
<td><div class="cell"><span class="tag t-ext">⚙ entpacken</span></div></td>
<td><div class="cell num speed"></div></td>
</tr>
</tbody>
<!-- pkg3 done -->
<tbody class="pkg">
<tr class="pkgrow">
<td><div class="cell pname"><span class="tw"></span><span class="ptype">[M]</span><div><div class="nm">Dune.Part.Two.2024.German.DL.2160p.UHD.BluRay.x265-NERDS</div><div class="sub">1 file · 18.4 GB → library</div></div></div></td>
<td><div class="cell num mono">18.4/18.4 GB</div></td>
<td><div class="cell seg"><div class="blocks"><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span><span class="blk on"></span></div><span class="p" style="color:var(--green)">100%</span></div></td>
<td><div class="cell host"><span class="hd" style="background:#3b82f6"></span>ddownload</div></td>
<td><div class="cell mono" style="font-size:11.5px;color:var(--muted)">AllDebrid</div></td>
<td><div class="cell"></div></td>
<td><div class="cell"><span class="tag t-done">✓ done</span></div></td>
<td><div class="cell num speed"></div></td>
</tr>
</tbody>
<!-- pkg4 error -->
<tbody class="pkg">
<tr class="pkgrow">
<td><div class="cell pname"><span class="tw"></span><span class="ptype" style="color:var(--red)">[!]</span><div><div class="nm">Shogun.S01E05.German.DL.1080p.WEB.h264-EDITION</div><div class="sub" style="color:var(--red)">hoster_unavailable · link tot</div></div></div></td>
<td><div class="cell num mono">0/2.4 GB</div></td>
<td><div class="cell seg"><div class="blocks"><span class="blk" style="background:rgba(255,92,92,0.25)"></span><span class="blk" style="background:rgba(255,92,92,0.25)"></span><span class="blk" style="background:rgba(255,92,92,0.25)"></span><span class="blk" style="background:rgba(255,92,92,0.25)"></span><span class="blk" style="background:rgba(255,92,92,0.25)"></span><span class="blk" style="background:rgba(255,92,92,0.25)"></span><span class="blk" style="background:rgba(255,92,92,0.25)"></span><span class="blk" style="background:rgba(255,92,92,0.25)"></span><span class="blk" style="background:rgba(255,92,92,0.25)"></span><span class="blk" style="background:rgba(255,92,92,0.25)"></span></div><span class="p" style="color:var(--red)">ERR</span></div></td>
<td><div class="cell host"><span class="hd" style="background:#a855f7"></span>uploaded</div></td>
<td><div class="cell mono" style="font-size:11.5px;color:var(--faint)"></div></td>
<td><div class="cell"></div></td>
<td><div class="cell"><span class="tag t-err">✗ failed</span></div></td>
<td><div class="cell num speed"></div></td>
</tr>
</tbody>
<!-- pkg5 queued -->
<tbody class="pkg">
<tr class="pkgrow">
<td><div class="cell pname"><span class="tw"></span><span class="ptype">[S]</span><div><div class="nm">Severance.S02.COMPLETE.German.DL.1080p.ATVP.WEB.h265-NERDS</div><div class="sub">10 files · 22.1 GB</div></div></div></td>
<td><div class="cell num mono">0/22.1 GB</div></td>
<td><div class="cell seg"><div class="blocks"><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span><span class="blk"></span></div><span class="p">0%</span></div></td>
<td><div class="cell host"><span class="hd" style="background:#ff5722"></span>mega</div></td>
<td><div class="cell mono" style="font-size:11.5px;color:var(--muted)">Mega-Debrid</div></td>
<td><div class="cell"></div></td>
<td><div class="cell"><span class="tag t-q">queued</span></div></td>
<td><div class="cell num speed"></div></td>
</tr>
</tbody>
</tbody>
</table>
</div>
<!-- STATUSBAR -->
<div class="statusbar">
<div class="si green"><span class="led g"></span> RUNNING</div>
<div class="si">queue: <b>5</b> pkg / <b>34</b> files</div>
<div class="si">today: <b>147 GB</b></div>
<div class="si">disk: <b>1.54 TB</b> free</div>
<div class="spacer"></div>
<div class="si">total <b>62%</b></div>
</div>
</div>
</div>
<script>
document.querySelectorAll('.pkgrow').forEach(r => r.addEventListener('click', () => r.closest('.pkg').classList.toggle('open')));
</script>
</body>
</html>

289
design-mockups/forge.html Normal file
View File

@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Forge — Real-Debrid-Downloader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
/* FORGE — warmes, ehrliches Werkzeug. Keine Glas/Glow/Gradient-Tropes.
Flache opake Flächen, präzise 1px-Linien, eine Amber-Signalfarbe. */
:root {
--bg: #141210;
--panel: #1a1713;
--row: #1e1a16;
--row-alt: #211d18;
--sunk: #100e0b; /* recessed: tracks, fields */
--line: #322c23; /* hairline */
--line-2: #453c30;
--line-strong: #574b3b;
--text: #ece3d3;
--muted: #9d907d;
--faint: #6c6253;
--amber: #e8973a; /* signal */
--amber-deep: #c97c24;
--amber-ink: #1a1206; /* text on amber */
--green: #84a85f; /* muted sage = done */
--red: #cf5e47; /* terracotta = error */
--steel: #6f93a3; /* sparingly = info/extract alt */
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: "Archivo", system-ui, sans-serif;
background: var(--bg); color: var(--text); font-size: 13.5px;
-webkit-font-smoothing: antialiased; height: 100vh; overflow: hidden;
}
.mono { font-family: "JetBrains Mono", monospace; }
.lbl { font-weight: 700; text-transform: uppercase; letter-spacing: 0.1em; }
.app { display: grid; grid-template-rows: auto auto auto 1fr auto; height: 100vh; }
/* ── Menu bar ── */
.menubar { display: flex; align-items: center; height: 46px; padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 11px; margin-right: 22px; }
.mark { width: 30px; height: 30px; border: 2px solid var(--amber); display: grid; place-items: center; }
.mark svg { width: 16px; height: 16px; color: var(--amber); }
.brand .nm { font-weight: 800; font-size: 15px; letter-spacing: 0.02em; }
.brand .nm .accent { color: var(--amber); }
.menu-item { padding: 7px 13px; color: var(--muted); font-weight: 600; font-size: 13px; cursor: pointer; }
.menu-item:hover { color: var(--text); }
.menubar .spacer { flex: 1; }
.conn { display: flex; align-items: center; gap: 9px; font-size: 12px; color: var(--muted); font-weight: 500; }
.conn .sq { width: 8px; height: 8px; background: var(--green); }
/* ── Control strip ── */
.control { display: flex; align-items: center; gap: 14px; height: 56px; padding: 0 16px; background: var(--bg); border-bottom: 1px solid var(--line); }
.grp { display: flex; gap: 0; border: 1px solid var(--line-strong); }
.grp .ic { width: 40px; height: 38px; display: grid; place-items: center; cursor: pointer; color: var(--muted); background: var(--panel); border-right: 1px solid var(--line-strong); }
.grp .ic:last-child { border-right: none; }
.grp .ic:hover { background: var(--row-alt); color: var(--text); }
.grp .ic svg { width: 16px; height: 16px; }
.grp .ic.play { background: var(--amber); color: var(--amber-ink); }
.grp .ic.play:hover { background: var(--amber-deep); }
.grp .ic.pause { color: var(--amber); } .grp .ic.stop { color: var(--red); }
.control .spacer { flex: 1; }
.gauges { display: flex; }
.gauge { padding: 0 18px; border-left: 1px solid var(--line); text-align: right; }
.gauge .g-l { font-size: 9.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--faint); font-weight: 700; }
.gauge .g-v { font-family: "JetBrains Mono"; font-weight: 700; font-size: 17px; margin-top: 1px; }
.gauge .g-v .u { font-size: 11px; color: var(--muted); font-weight: 500; }
.gauge .g-v.amber { color: var(--amber); }
/* ── Tabs ── */
.tabs { display: flex; align-items: stretch; height: 40px; padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
.tab { display: flex; align-items: center; gap: 8px; padding: 0 17px; cursor: pointer; color: var(--faint);
font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; border-bottom: 2px solid transparent; }
.tab svg { width: 14px; height: 14px; }
.tab:hover { color: var(--muted); }
.tab.active { color: var(--text); border-bottom-color: var(--amber); }
.tab .ct { font-family: "JetBrains Mono"; font-size: 11px; color: var(--amber); letter-spacing: 0; }
/* ── Table ── */
.panel { background: var(--bg); overflow: hidden; display: flex; flex-direction: column; }
.thead { display: grid; grid-template-columns: minmax(0,1fr) 158px 132px 118px 128px 78px 158px 92px;
height: 32px; align-items: center; padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--line-strong);
font-size: 10px; font-weight: 800; letter-spacing: 0.1em; text-transform: uppercase; color: var(--faint); }
.thead > div:not(:first-child) { text-align: center; }
.tbody { overflow-y: auto; flex: 1; }
.tbody::-webkit-scrollbar { width: 12px; }
.tbody::-webkit-scrollbar-thumb { background: var(--line-strong); border: 4px solid var(--bg); }
.tbody::-webkit-scrollbar-track { background: var(--bg); }
.pkg { border-bottom: 1px solid var(--line); }
.prow { display: grid; grid-template-columns: minmax(0,1fr) 158px 132px 118px 128px 78px 158px 92px;
align-items: center; height: 52px; padding: 0 16px; cursor: pointer; }
.prow:hover { background: var(--row); }
.pkg.open .prow { background: var(--row); }
.pn { display: flex; align-items: center; gap: 11px; min-width: 0; }
.tw { width: 14px; color: var(--faint); font-family: "JetBrains Mono"; font-size: 13px; flex-shrink: 0; text-align: center; }
.pkg.open .tw::before { content: ""; } .tw::before { content: "+"; }
.pic { width: 28px; height: 28px; border: 1px solid var(--line-strong); display: grid; place-items: center; flex-shrink: 0; color: var(--amber); }
.pic svg { width: 14px; height: 14px; }
.pic.err { border-color: var(--red); color: var(--red); }
.ptt { min-width: 0; }
.ptt .t { font-weight: 600; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ptt .s { font-family: "JetBrains Mono"; font-size: 10.5px; color: var(--faint); margin-top: 2px; }
.ptt .s.err { color: var(--red); }
.c { text-align: center; font-size: 12.5px; color: var(--muted); }
.c.mono { font-family: "JetBrains Mono"; font-size: 11.5px; }
.c.amber { color: var(--amber); }
/* progress: solid, recessed, crisp. no gradient/glow. */
.bar { height: 18px; background: var(--sunk); border: 1px solid var(--line); position: relative; overflow: hidden; }
.bar .fill { position: absolute; left: 0; top: 0; bottom: 0; width: var(--p,0%); background: var(--amber); }
.bar .fill.gr { background: var(--green); }
.bar .fill.rd { background: var(--red); }
.bar .tx { position: absolute; inset: 0; display: grid; place-items: center; font-family: "JetBrains Mono"; font-size: 10.5px; font-weight: 600; color: var(--text); mix-blend-mode: difference; }
.tag { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; font-family: "JetBrains Mono"; font-size: 10.5px; font-weight: 600; border: 1px solid; text-transform: uppercase; letter-spacing: 0.04em; }
.t-dl { color: var(--amber); border-color: var(--amber-deep); }
.t-ext { color: var(--green); border-color: #5e7a44; }
.t-done { color: var(--green); border-color: #4a6135; }
.t-err { color: var(--red); border-color: #93412f; }
.t-q { color: var(--faint); border-color: var(--line-strong); }
.host { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; }
.host .hd { width: 7px; height: 7px; }
.prio { font-family: "JetBrains Mono"; font-size: 10px; font-weight: 700; color: var(--amber); letter-spacing: 0.05em; }
.items { display: none; background: var(--sunk); }
.pkg.open .items { display: block; }
.item { display: grid; grid-template-columns: minmax(0,1fr) 158px 132px 118px 128px 78px 158px 92px; align-items: center; height: 34px; padding: 0 16px; border-top: 1px solid var(--line); }
.item:hover { background: var(--row); }
.in { display: flex; align-items: center; gap: 10px; padding-left: 39px; min-width: 0; }
.ld { width: 7px; height: 7px; flex-shrink: 0; }
.ld.on { background: var(--green); } .ld.off { background: var(--red); }
.in .nm { font-family: "JetBrains Mono"; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.minibar { height: 5px; background: var(--bg); border: 1px solid var(--line); position: relative; }
.minibar .mf { position: absolute; left:0; top:0; bottom:0; width: var(--p,0%); background: var(--amber); }
/* statusbar */
.statusbar { display: flex; align-items: center; height: 30px; background: var(--panel); border-top: 1px solid var(--line-strong); font-family: "JetBrains Mono"; font-size: 11px; color: var(--muted); }
.statusbar .si { padding: 0 14px; height: 30px; line-height: 30px; border-right: 1px solid var(--line); display: flex; align-items: center; gap: 8px; }
.statusbar .si.first { color: var(--green); font-weight: 600; }
.statusbar b { color: var(--text); }
.statusbar .spacer { flex: 1; }
.qtrack { width: 130px; height: 6px; background: var(--sunk); border: 1px solid var(--line); position: relative; }
.qtrack > i { position: absolute; left:0; top:0; bottom:0; width: 62%; background: var(--amber); }
</style>
</head>
<body>
<div class="app">
<!-- MENU BAR -->
<div class="menubar">
<div class="brand">
<div class="mark"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><path d="M12 4v11m0 0l-4.5-4.5M12 15l4.5-4.5M5 20h14"/></svg></div>
<div class="nm">DEBRID<span class="accent">·</span>DOWNLOADER</div>
</div>
<div class="menu-item">Datei</div>
<div class="menu-item">Einstellungen</div>
<div class="menu-item">Hilfe</div>
<div class="spacer"></div>
<div class="conn"><span class="sq"></span> 4 PROVIDER AKTIV · MEGA-DEBRID BEREIT</div>
</div>
<!-- CONTROL STRIP -->
<div class="control">
<div class="grp">
<div class="ic play" title="Start"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></div>
<div class="ic pause" title="Pause"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg></div>
<div class="ic stop" title="Stop"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12"/></svg></div>
</div>
<div class="grp">
<div class="ic" title="Zeitplan"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></div>
<div class="ic" title="Hoch"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg></div>
<div class="ic" title="Runter"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg></div>
</div>
<div class="spacer"></div>
<div class="gauges">
<div class="gauge"><div class="g-l">Tempo</div><div class="g-v amber">48.2<span class="u"> MB/s</span></div></div>
<div class="gauge"><div class="g-l">Verbleibend</div><div class="g-v">02:14</div></div>
<div class="gauge"><div class="g-l">Aktiv</div><div class="g-v">8<span class="u">/24</span></div></div>
</div>
</div>
<!-- TABS -->
<div class="tabs">
<div class="tab active"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"/></svg> Downloads <span class="ct">5</span></div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1"/></svg> Linksammler</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 00.3 1.8M4.6 9a1.6 1.6 0 00-.3-1.8"/></svg> Einstellungen</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 109-9 9 9 0 00-9 9zm9-5v5l3 2"/></svg> Verlauf</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg> Statistiken</div>
</div>
<!-- TABLE -->
<div class="panel">
<div class="thead"><div>Name</div><div>Geladen / Größe</div><div>Fortschritt</div><div>Hoster</div><div>Service</div><div>Prio</div><div>Status</div><div>Tempo</div></div>
<div class="tbody">
<div class="pkg open">
<div class="prow">
<div class="pn"><span class="tw"></span><div class="pic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4"/></svg></div><div class="ptt"><div class="t">Ugly.Americans.S02.COMPLETE.German.DL.720p.WEB-DL.h264-NERDS</div><div class="s">10 Dateien · 14.2 GB</div></div></div>
<div class="c mono">8.9 / 14.2 GB</div>
<div class="c"><div class="bar"><div class="fill" style="--p:63%"></div><div class="tx">63 %</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#d2693e"></span>mega</span></div>
<div class="c">Mega-Debrid</div>
<div class="c"><span class="prio">HOCH</span></div>
<div class="c"><span class="tag t-dl">6/10 lädt</span></div>
<div class="c mono amber">48 MB/s</div>
</div>
<div class="items">
<div class="item"><div class="in"><span class="ld on"></span><span class="nm">…h264-NERDS.part1.rar</span></div><div class="c mono">2.0 / 2.0</div><div class="c"><div class="minibar"><div class="mf gr" style="--p:100%;background:var(--green)"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="tag t-done">fertig</span></div><div class="c mono"></div></div>
<div class="item"><div class="in"><span class="ld on"></span><span class="nm">…h264-NERDS.part2.rar</span></div><div class="c mono">2.0 / 2.0</div><div class="c"><div class="minibar"><div class="mf" style="--p:100%;background:var(--green)"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="tag t-done">fertig</span></div><div class="c mono"></div></div>
<div class="item"><div class="in"><span class="ld on"></span><span class="nm">…h264-NERDS.part3.rar</span></div><div class="c mono">1.4 / 2.0</div><div class="c"><div class="minibar"><div class="mf" style="--p:70%"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="tag t-dl">70 %</span></div><div class="c mono amber">26 MB/s</div></div>
<div class="item"><div class="in"><span class="ld on"></span><span class="nm">…h264-NERDS.part4.rar</span></div><div class="c mono">0.8 / 2.0</div><div class="c"><div class="minibar"><div class="mf" style="--p:40%"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="tag t-dl">40 %</span></div><div class="c mono amber">22 MB/s</div></div>
<div class="item"><div class="in"><span class="ld off"></span><span class="nm">…h264-NERDS.part5.rar</span></div><div class="c mono">0 / 1.1</div><div class="c"><div class="minibar"><div class="mf" style="--p:0%"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="tag t-q">wartet</span></div><div class="c mono"></div></div>
</div>
</div>
<div class="pkg">
<div class="prow">
<div class="pn"><span class="tw"></span><div class="pic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4"/></svg></div><div class="ptt"><div class="t">The.Bear.S03.German.DL.1080p.WEB.h264-WvF</div><div class="s">8 Dateien · 11.6 GB</div></div></div>
<div class="c mono">11.6 / 11.6 GB</div>
<div class="c"><div class="bar"><div class="fill gr" style="--p:82%"></div><div class="tx">Entpacken 82 %</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#6f9a52"></span>rapidgator</span></div>
<div class="c">Real-Debrid</div>
<div class="c"></div>
<div class="c"><span class="tag t-ext">entpacken</span></div>
<div class="c mono"></div>
</div>
</div>
<div class="pkg">
<div class="prow">
<div class="pn"><span class="tw"></span><div class="pic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4"/></svg></div><div class="ptt"><div class="t">Dune.Part.Two.2024.German.DL.2160p.UHD.BluRay.x265-NERDS</div><div class="s">1 Datei · 18.4 GB → Library</div></div></div>
<div class="c mono">18.4 / 18.4 GB</div>
<div class="c"><div class="bar"><div class="fill gr" style="--p:100%"></div><div class="tx">100 %</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#5683b0"></span>ddownload</span></div>
<div class="c">AllDebrid</div>
<div class="c"></div>
<div class="c"><span class="tag t-done">abgeschlossen</span></div>
<div class="c mono"></div>
</div>
</div>
<div class="pkg">
<div class="prow">
<div class="pn"><span class="tw"></span><div class="pic err"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 8v5m0 3h.01M10.3 3.9l-8 14A2 2 0 004 21h16a2 2 0 001.7-3l-8-14a2 2 0 00-3.4 0z"/></svg></div><div class="ptt"><div class="t">Shogun.S01E05.German.DL.1080p.WEB.h264-EDITION</div><div class="s err">Hoster nicht verfügbar · Link tot · kein Fallback</div></div></div>
<div class="c mono">0 / 2.4 GB</div>
<div class="c"><div class="bar"><div class="fill rd" style="--p:8%"></div><div class="tx">Fehler</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#9a6fb0"></span>uploaded</span></div>
<div class="c"></div>
<div class="c"></div>
<div class="c"><span class="tag t-err">fehler</span></div>
<div class="c mono"></div>
</div>
</div>
<div class="pkg">
<div class="prow">
<div class="pn"><span class="tw"></span><div class="pic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4"/></svg></div><div class="ptt"><div class="t">Severance.S02.COMPLETE.German.DL.1080p.ATVP.WEB.h265-NERDS</div><div class="s">10 Dateien · 22.1 GB</div></div></div>
<div class="c mono">0 / 22.1 GB</div>
<div class="c"><div class="bar"><div class="fill" style="--p:0%"></div><div class="tx" style="color:var(--faint);mix-blend-mode:normal">Warteschlange</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#d2693e"></span>mega</span></div>
<div class="c">Mega-Debrid</div>
<div class="c"></div>
<div class="c"><span class="tag t-q">wartet</span></div>
<div class="c mono"></div>
</div>
</div>
</div>
</div>
<!-- STATUSBAR -->
<div class="statusbar">
<div class="si first"><span style="width:8px;height:8px;background:var(--green);display:inline-block"></span> LÄUFT</div>
<div class="si">Queue: <b>5</b> Pakete · <b>34</b> Dateien</div>
<div class="si">Heute: <b>147 GB</b></div>
<div class="si">Platte: <b>1.54 TB</b> frei</div>
<div class="spacer"></div>
<div class="si">Gesamt <div class="qtrack"><i></i></div> <b>62 %</b></div>
</div>
</div>
<script>
document.querySelectorAll('.prow').forEach(r => r.addEventListener('click', () => r.parentElement.classList.toggle('open')));
</script>
</body>
</html>

95
design-mockups/index.html Normal file
View File

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Design-Varianten — Real-Debrid-Downloader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,600;12..96,700;12..96,800&family=Hanken+Grotesk:wght@400;500;600&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet">
<style>
:root { --bg:#070b14; --card:#0f1726; --line:#22324d; --text:#e7eefb; --muted:#8ea2c2; --accent:#3ad4ce; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "Hanken Grotesk", system-ui, sans-serif; background:
radial-gradient(900px 500px at 85% -10%, rgba(74,168,255,0.12), transparent 60%),
radial-gradient(700px 500px at 5% 0, rgba(58,212,206,0.1), transparent 55%), var(--bg);
color: var(--text); min-height: 100vh; padding: 32px 28px 48px; }
.head { max-width: 1400px; margin: 0 auto 26px; }
.eyebrow { font-family: "JetBrains Mono"; font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent); }
h1 { font-family: "Bricolage Grotesque"; font-weight: 800; font-size: 34px; letter-spacing: -0.02em; margin: 8px 0 6px; }
.sub { color: var(--muted); font-size: 15px; max-width: 720px; line-height: 1.5; }
.grid { max-width: 1400px; margin: 0 auto; display: grid; grid-template-columns: repeat(2, 1fr); gap: 22px; }
.variant { background: var(--card); border: 1px solid var(--line); border-radius: 18px; overflow: hidden; transition: transform .2s, box-shadow .2s; }
.variant:hover { transform: translateY(-3px); box-shadow: 0 22px 50px rgba(0,0,0,0.45); border-color: var(--accent); }
.v-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--line); }
.v-head .meta { display: flex; align-items: center; gap: 14px; }
.v-num { font-family: "Bricolage Grotesque"; font-weight: 800; font-size: 22px; color: var(--accent); }
.v-name { font-family: "Bricolage Grotesque"; font-weight: 700; font-size: 19px; }
.v-tag { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
.v-open { font-family: "JetBrains Mono"; font-size: 12px; color: var(--accent); text-decoration: none; padding: 8px 14px; border: 1px solid var(--line); border-radius: 9px; transition: all .15s; }
.v-open:hover { background: var(--accent); color: #061018; }
.frame-wrap { position: relative; height: 420px; overflow: hidden; background: #000; cursor: pointer; }
.frame-wrap iframe { position: absolute; top: 0; left: 0; width: 1600px; height: 1000px; border: 0;
transform: scale(0.5); transform-origin: top left; pointer-events: none; }
.frame-wrap::after { content: "Klicken zum Öffnen"; position: absolute; inset: 0; display: grid; place-items: center;
background: rgba(7,11,20,0.0); color: transparent; font-family: "JetBrains Mono"; font-size: 13px; transition: all .2s; }
.frame-wrap:hover::after { background: rgba(7,11,20,0.55); color: var(--text); }
.desc { padding: 14px 20px 18px; color: var(--muted); font-size: 13.5px; line-height: 1.5; border-top: 1px solid var(--line); }
.desc b { color: var(--text); font-weight: 600; }
.foot { max-width: 1400px; margin: 30px auto 0; padding: 18px 22px; background: var(--card); border: 1px solid var(--line);
border-radius: 14px; color: var(--muted); font-size: 13.5px; line-height: 1.6; }
.foot b { color: var(--text); }
@media (max-width: 1000px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="head">
<div class="eyebrow">Real-Debrid-Downloader · Redesign</div>
<h1>Vier Design-Richtungen</h1>
<div class="sub">Jede Variante zeigt denselben Downloads-Screen mit identischen Daten — so vergleichst du fair. Klick auf eine Vorschau, um sie in voller Größe zu öffnen. Sag mir danach welche Richtung (oder welche Mischung) dir gefällt.</div>
</div>
<div class="grid">
<div class="variant">
<div class="v-head">
<div class="meta"><span class="v-num">01</span><div><div class="v-name">Aurora</div><div class="v-tag">Verfeinerte Dark-Evolution</div></div></div>
<a class="v-open" href="aurora.html" target="_blank">Öffnen ↗</a>
</div>
<a href="aurora.html" target="_blank"><div class="frame-wrap"><iframe src="aurora.html" scrolling="no"></iframe></div></a>
<div class="desc"><b>Premium &amp; vertraut.</b> Baut auf deinem aktuellen Cyan-Dark auf, aber edler: Glas-Effekt, Aurora-Verlauf, weiche Glows, klarere Status-Badges, Bricolage-Grotesque-Typo. Geringstes Risiko — alles bleibt erkennbar, wirkt nur hochwertiger.</div>
</div>
<div class="variant">
<div class="v-head">
<div class="meta"><span class="v-num">02</span><div><div class="v-name">Command</div><div class="v-tag">Terminal / Ops-Dashboard</div></div></div>
<a class="v-open" href="command.html" target="_blank">Öffnen ↗</a>
</div>
<a href="command.html" target="_blank"><div class="frame-wrap"><iframe src="command.html" scrolling="no"></iframe></div></a>
<div class="desc"><b>Maximale Dichte für Power-User.</b> Linke Icon-Leiste, Monospace, Grid-Linien, segmentierte Block-Progress-Bars, Status-LEDs, scharfe Kanten. Wirkt wie ein NOC/Server-Tool — viel Info auf einen Blick.</div>
</div>
<div class="variant">
<div class="v-head">
<div class="meta"><span class="v-num">03</span><div><div class="v-name">Vellum</div><div class="v-tag">Light Editorial</div></div></div>
<a class="v-open" href="vellum.html" target="_blank">Öffnen ↗</a>
</div>
<a href="vellum.html" target="_blank"><div class="frame-wrap"><iframe src="vellum.html" scrolling="no"></iframe></div></a>
<div class="desc"><b>Mutige helle Alternative.</b> Warmes Papier, Serif-Display (Fraunces), großzügige Abstände, dezente Teal-Akzente. Hebt sich von jedem anderen Downloader ab (die sind alle dunkel). Ruhig, edel, „App-Store-Premium".</div>
</div>
<div class="variant">
<div class="v-head">
<div class="meta"><span class="v-num">04</span><div><div class="v-name">Nebula</div><div class="v-tag">Neon / Synthwave</div></div></div>
<a class="v-open" href="nebula.html" target="_blank">Öffnen ↗</a>
</div>
<a href="nebula.html" target="_blank"><div class="frame-wrap"><iframe src="nebula.html" scrolling="no"></iframe></div></a>
<div class="desc"><b>Auffällig &amp; vibrant.</b> Tiefes Violett-Schwarz, Magenta-Cyan-Verläufe, glühende Progress-Bars, Unbounded-Display-Font, animierte Akzente. Für wenn's knallen soll.</div>
</div>
</div>
<div class="foot">
<b>Hinweis:</b> Die Vorschauen sind live (skaliert auf 50%). Pakete sind anklickbar/ausklappbar wenn du sie in voller Größe öffnest. &nbsp;·&nbsp;
Alle vier nutzen dieselbe Spaltenstruktur (Name · Geladen/Größe · Fortschritt · Hoster · Service · Priorität · Status · Tempo) und Beispieldaten (Ugly Americans S02 lädt, The Bear entpackt, Dune fertig, Shōgun Fehler, Severance wartet).
</div>
</body>
</html>

333
design-mockups/nebula.html Normal file
View File

@ -0,0 +1,333 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Nebula — Real-Debrid-Downloader Mockup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;600;700;800&family=Outfit:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0a0612;
--bg-2: #0f0820;
--panel: rgba(26, 16, 48, 0.6);
--card: rgba(34, 20, 60, 0.5);
--field: rgba(14, 8, 28, 0.8);
--line: rgba(140, 90, 220, 0.22);
--line-bright: rgba(160, 110, 240, 0.42);
--text: #f0e9ff;
--muted: #a797c9;
--faint: #6f5f93;
--magenta: #ff3d9a;
--cyan: #2ee6ff;
--violet: #9d5cff;
--green: #3dffa8;
--amber: #ffcc44;
--red: #ff4d6d;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Outfit", system-ui, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px; height: 100vh; overflow: hidden;
-webkit-font-smoothing: antialiased;
position: relative;
}
body::before {
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0;
background:
radial-gradient(680px 420px at 88% -6%, rgba(255,61,154,0.18), transparent 60%),
radial-gradient(620px 420px at 6% 4%, rgba(46,230,255,0.14), transparent 58%),
radial-gradient(900px 600px at 50% 116%, rgba(157,92,255,0.18), transparent 60%);
}
body::after {
content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0; opacity: 0.4;
background-image: linear-gradient(rgba(157,92,255,0.05) 1px, transparent 1px), linear-gradient(90deg, rgba(157,92,255,0.05) 1px, transparent 1px);
background-size: 50px 50px;
mask-image: linear-gradient(180deg, transparent, #000 30%, #000 70%, transparent);
}
.mono { font-family: "Space Mono", monospace; }
.disp { font-family: "Unbounded", sans-serif; }
.app { position: relative; z-index: 1; display: grid; grid-template-rows: auto auto auto 1fr auto; height: 100vh; padding: 14px 20px 12px; gap: 12px; }
/* topbar */
.topbar { display: flex; align-items: center; gap: 16px; }
.brand { display: flex; align-items: center; gap: 12px; }
.orb { width: 36px; height: 36px; border-radius: 50%;
background: radial-gradient(circle at 30% 30%, var(--cyan), var(--violet) 50%, var(--magenta));
box-shadow: 0 0 22px rgba(157,92,255,0.7), 0 0 8px rgba(255,61,154,0.6); }
.brand .nm { font-family: "Unbounded"; font-weight: 700; font-size: 15px; letter-spacing: -0.01em;
background: linear-gradient(90deg, var(--cyan), var(--magenta)); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
.brand .vr { font-family: "Space Mono"; font-size: 10.5px; color: var(--faint); margin-top: 2px; }
.topbar .menu { display: flex; gap: 2px; margin-left: 10px; }
.topbar .menu a { padding: 6px 12px; border-radius: 8px; color: var(--muted); font-weight: 500; font-size: 13px; cursor: pointer; }
.topbar .menu a:hover { background: var(--panel); color: var(--text); }
.topbar .spacer { flex: 1; }
.status-led { display: flex; align-items: center; gap: 9px; padding: 7px 15px; border-radius: 999px;
background: var(--panel); border: 1px solid var(--line); font-size: 12px; color: var(--muted); }
.pulse { width: 9px; height: 9px; border-radius: 50%; background: var(--green); box-shadow: 0 0 12px var(--green); animation: pulse 1.6s infinite; }
@keyframes pulse { 0%,100%{box-shadow:0 0 12px var(--green)} 50%{box-shadow:0 0 4px var(--green)} }
/* control */
.control { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-radius: 18px;
background: var(--panel); border: 1px solid var(--line); backdrop-filter: blur(20px); }
.runner { display: flex; gap: 8px; }
.rb { width: 40px; height: 40px; border-radius: 12px; border: 1px solid var(--line); background: var(--field); display: grid; place-items: center; cursor: pointer; color: var(--muted); transition: all .18s; }
.rb svg { width: 17px; height: 17px; }
.rb:hover { transform: translateY(-2px); }
.rb.play { color: #0a0612; background: linear-gradient(135deg, var(--cyan), var(--green)); border: none; box-shadow: 0 0 20px rgba(46,230,255,0.5); }
.rb.pause { color: var(--amber); } .rb.pause:hover { box-shadow: 0 0 16px rgba(255,204,68,0.4); border-color: var(--amber); }
.rb.stop { color: var(--red); } .rb.stop:hover { box-shadow: 0 0 16px rgba(255,77,109,0.4); border-color: var(--red); }
.rb.ghost:hover { color: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 14px rgba(46,230,255,0.3); }
.sep { width: 1px; height: 28px; background: var(--line-bright); }
.control .spacer { flex: 1; }
.stats { display: flex; gap: 10px; }
.stat { padding: 8px 16px; border-radius: 12px; background: var(--field); border: 1px solid var(--line); text-align: center; min-width: 92px; }
.stat .l { font-size: 9.5px; text-transform: uppercase; letter-spacing: 0.14em; color: var(--faint); }
.stat .v { font-family: "Unbounded"; font-weight: 600; font-size: 17px; margin-top: 3px; }
.stat .v .u { font-size: 10px; font-family: "Outfit"; color: var(--muted); }
.stat.hl .v { background: linear-gradient(90deg, var(--cyan), var(--magenta)); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; }
/* tabs */
.tabs { display: flex; gap: 8px; }
.tab { padding: 9px 18px; border-radius: 11px; cursor: pointer; color: var(--muted); font-weight: 600; font-size: 13px;
border: 1px solid transparent; transition: all .15s; display: flex; align-items: center; gap: 8px; }
.tab svg { width: 15px; height: 15px; }
.tab:hover { color: var(--text); background: var(--panel); }
.tab.active { color: var(--text); background: linear-gradient(135deg, rgba(255,61,154,0.18), rgba(46,230,255,0.14));
border-color: var(--line-bright); box-shadow: 0 0 20px rgba(157,92,255,0.2); }
.tab .ct { font-family: "Space Mono"; font-size: 11px; padding: 1px 7px; border-radius: 999px; background: rgba(157,92,255,0.25); }
/* panel */
.panel-c { background: var(--card); border: 1px solid var(--line); border-radius: 18px; overflow: hidden;
backdrop-filter: blur(22px); display: flex; flex-direction: column; box-shadow: 0 18px 50px rgba(0,0,0,0.5); }
.thead { display: grid; grid-template-columns: minmax(0,1fr) 160px 130px 116px 124px 74px 150px 88px;
padding: 13px 18px; border-bottom: 1px solid var(--line); font-family: "Space Mono"; font-size: 10.5px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em; color: var(--faint); }
.thead > div:not(:first-child) { text-align: center; }
.tbody { overflow-y: auto; flex: 1; }
.tbody::-webkit-scrollbar { width: 10px; }
.tbody::-webkit-scrollbar-thumb { background: var(--line-bright); border-radius: 10px; }
.pkg { border-bottom: 1px solid rgba(140,90,220,0.12); }
.prow { display: grid; grid-template-columns: minmax(0,1fr) 160px 130px 116px 124px 74px 150px 88px; align-items: center; padding: 13px 18px; cursor: pointer; transition: all .15s; }
.prow:hover { background: rgba(157,92,255,0.07); }
.pn { display: flex; align-items: center; gap: 11px; min-width: 0; }
.chev { color: var(--faint); transition: transform .2s; flex-shrink: 0; }
.pkg.open .chev { transform: rotate(90deg); }
.picon { width: 32px; height: 32px; border-radius: 10px; display: grid; place-items: center; flex-shrink: 0;
background: linear-gradient(135deg, rgba(255,61,154,0.2), rgba(46,230,255,0.16)); border: 1px solid var(--line-bright); }
.picon svg { width: 16px; height: 16px; color: var(--cyan); }
.ptt { min-width: 0; }
.ptt .t { font-weight: 600; font-size: 13.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ptt .s { font-family: "Space Mono"; font-size: 10.5px; color: var(--faint); margin-top: 2px; }
.c { text-align: center; font-size: 12.5px; color: var(--muted); }
.c.mono { font-family: "Space Mono"; font-size: 11.5px; }
/* glowing progress */
.pbar { height: 24px; border-radius: 8px; background: var(--field); position: relative; overflow: hidden; border: 1px solid var(--line); }
.pbar .fill { position: absolute; inset: 0; width: var(--p,0%); border-radius: 7px;
background: linear-gradient(90deg, var(--violet), var(--magenta)); box-shadow: 0 0 18px rgba(255,61,154,0.6); }
.pbar .fill.cy { background: linear-gradient(90deg, var(--cyan), var(--violet)); box-shadow: 0 0 18px rgba(46,230,255,0.55); }
.pbar .fill.gr { background: linear-gradient(90deg, var(--green), var(--cyan)); box-shadow: 0 0 18px rgba(61,255,168,0.5); }
.pbar .fill::after { content: ""; position: absolute; top: 0; right: 0; width: 30px; height: 100%; background: rgba(255,255,255,0.35); filter: blur(8px); }
.pbar .txt { position: absolute; inset: 0; display: grid; place-items: center; font-family: "Space Mono"; font-size: 11px; font-weight: 700; color: #fff; z-index: 2; text-shadow: 0 1px 3px rgba(0,0,0,0.7); }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 11px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.c-dl { background: rgba(46,230,255,0.14); color: var(--cyan); border: 1px solid rgba(46,230,255,0.3); }
.c-ext { background: rgba(61,255,168,0.13); color: var(--green); border: 1px solid rgba(61,255,168,0.3); }
.c-done { background: rgba(61,255,168,0.1); color: var(--green); border: 1px solid rgba(61,255,168,0.25); }
.c-err { background: rgba(255,77,109,0.14); color: var(--red); border: 1px solid rgba(255,77,109,0.35); }
.c-q { background: rgba(167,151,201,0.1); color: var(--muted); border: 1px solid var(--line); }
.host { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
.host .hd { width: 7px; height: 7px; border-radius: 2px; }
.prio { font-family: "Space Mono"; font-size: 10px; font-weight: 700; color: var(--magenta); }
.items { display: none; background: rgba(10,6,18,0.5); }
.pkg.open .items { display: block; }
.item { display: grid; grid-template-columns: minmax(0,1fr) 160px 130px 116px 124px 74px 150px 88px; align-items: center; padding: 8px 18px; font-size: 12px; border-top: 1px solid rgba(140,90,220,0.08); }
.item:hover { background: rgba(46,230,255,0.04); }
.in { display: flex; align-items: center; gap: 9px; padding-left: 43px; min-width: 0; }
.ld { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.ld.on { background: var(--green); box-shadow: 0 0 8px var(--green); }
.ld.off { background: var(--red); box-shadow: 0 0 8px var(--red); }
.in .nm { font-family: "Space Mono"; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mini { height: 7px; border-radius: 4px; background: var(--field); overflow: hidden; position: relative; }
.mini .mf { position: absolute; inset: 0; width: var(--p,0%); background: linear-gradient(90deg, var(--violet), var(--magenta)); box-shadow: 0 0 8px rgba(255,61,154,0.5); }
/* statusbar */
.statusbar { display: flex; align-items: center; gap: 18px; padding: 9px 18px; border-radius: 14px;
background: var(--panel); border: 1px solid var(--line); font-size: 12px; color: var(--muted); backdrop-filter: blur(16px); }
.statusbar .si { display: flex; align-items: center; gap: 8px; }
.statusbar b { color: var(--text); font-family: "Space Mono"; font-weight: 700; }
.statusbar .spacer { flex: 1; }
.gtrack { width: 130px; height: 7px; border-radius: 99px; background: var(--field); overflow: hidden; }
.gtrack > i { display: block; height: 100%; width: 62%; background: linear-gradient(90deg, var(--cyan), var(--magenta)); box-shadow: 0 0 10px rgba(255,61,154,0.5); }
</style>
</head>
<body>
<div class="app">
<!-- TOPBAR -->
<div class="topbar">
<div class="brand">
<div class="orb"></div>
<div><div class="nm disp">DEBRID DOWNLOADER</div><div class="vr">v1.7.156 · nebula</div></div>
</div>
<div class="menu"><a>Datei</a><a>Einstellungen</a><a>Hilfe</a></div>
<div class="spacer"></div>
<div class="status-led"><span class="pulse"></span> 4 Provider aktiv · Mega-Debrid bereit</div>
</div>
<!-- CONTROL -->
<div class="control">
<div class="runner">
<div class="rb play" title="Start"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></div>
<div class="rb pause" title="Pause"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg></div>
<div class="rb stop" title="Stop"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></div>
</div>
<div class="sep"></div>
<div class="runner">
<div class="rb ghost" title="Zeitplan"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg></div>
<div class="rb ghost" title="Hoch"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg></div>
<div class="rb ghost" title="Runter"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg></div>
</div>
<div class="spacer"></div>
<div class="stats">
<div class="stat hl"><div class="l">Tempo</div><div class="v">48.2<span class="u"> MB/s</span></div></div>
<div class="stat"><div class="l">Verbleibend</div><div class="v">02:14</div></div>
<div class="stat"><div class="l">Aktiv</div><div class="v">8<span class="u">/24</span></div></div>
</div>
</div>
<!-- TABS -->
<div class="tabs">
<div class="tab active"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M5 21h14"/></svg> Downloads <span class="ct">5</span></div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007 0l3-3a5 5 0 00-7-7l-1 1"/></svg> Linksammler</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.6 1.6 0 00.3 1.8M4.6 9a1.6 1.6 0 00-.3-1.8"/></svg> Einstellungen</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12a9 9 0 109-9 9 9 0 00-9 9zm9-5v5l3 2"/></svg> Verlauf</div>
<div class="tab"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 20V10M10 20V4M16 20v-8M22 20H2"/></svg> Statistiken</div>
</div>
<!-- PANEL -->
<div class="panel-c">
<div class="thead">
<div>NAME</div><div>GELADEN / GRÖSSE</div><div>FORTSCHRITT</div><div>HOSTER</div><div>SERVICE</div><div>PRIO</div><div>STATUS</div><div>TEMPO</div>
</div>
<div class="tbody">
<!-- pkg1 open -->
<div class="pkg open">
<div class="prow">
<div class="pn">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="picon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div>
<div class="ptt"><div class="t">Ugly.Americans.S02.COMPLETE.German.DL.720p.WEB-DL.h264-NERDS</div><div class="s">10 Dateien · 14.2 GB</div></div>
</div>
<div class="c mono">8.9 / 14.2 GB</div>
<div class="c"><div class="pbar"><div class="fill" style="--p:63%"></div><div class="txt">63%</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#ff5722"></span>mega</span></div>
<div class="c">Mega-Debrid</div>
<div class="c"><span class="prio">HIGH</span></div>
<div class="c"><span class="chip c-dl">◢ 6/10</span></div>
<div class="c mono" style="color:var(--cyan)">48 MB/s</div>
</div>
<div class="items">
<div class="item"><div class="in"><span class="ld on"></span><span class="nm">…NERDS.part1.rar</span></div><div class="c mono">2.0/2.0</div><div class="c"><div class="mini"><div class="mf" style="--p:100%"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="chip c-done"></span></div><div class="c mono"></div></div>
<div class="item"><div class="in"><span class="ld on"></span><span class="nm">…NERDS.part2.rar</span></div><div class="c mono">2.0/2.0</div><div class="c"><div class="mini"><div class="mf" style="--p:100%"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="chip c-done"></span></div><div class="c mono"></div></div>
<div class="item"><div class="in"><span class="ld on"></span><span class="nm">…NERDS.part3.rar</span></div><div class="c mono">1.4/2.0</div><div class="c"><div class="mini"><div class="mf" style="--p:70%"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="chip c-dl">◢ 70%</span></div><div class="c mono" style="color:var(--cyan)">26 MB/s</div></div>
<div class="item"><div class="in"><span class="ld on"></span><span class="nm">…NERDS.part4.rar</span></div><div class="c mono">0.8/2.0</div><div class="c"><div class="mini"><div class="mf" style="--p:40%"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="chip c-dl">◢ 40%</span></div><div class="c mono" style="color:var(--cyan)">22 MB/s</div></div>
<div class="item"><div class="in"><span class="ld off"></span><span class="nm">…NERDS.part5.rar</span></div><div class="c mono">0/1.1</div><div class="c"><div class="mini"><div class="mf" style="--p:0%"></div></div></div><div class="c">mega</div><div class="c"></div><div class="c"></div><div class="c"><span class="chip c-q">Wartet</span></div><div class="c mono"></div></div>
</div>
</div>
<!-- pkg2 extracting -->
<div class="pkg">
<div class="prow">
<div class="pn">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="picon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div>
<div class="ptt"><div class="t">The.Bear.S03.German.DL.1080p.WEB.h264-WvF</div><div class="s">8 Dateien · 11.6 GB</div></div>
</div>
<div class="c mono">11.6 / 11.6 GB</div>
<div class="c"><div class="pbar"><div class="fill gr" style="--p:82%"></div><div class="txt">ENTPACKEN 82%</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#22c55e"></span>rapidgator</span></div>
<div class="c">Real-Debrid</div>
<div class="c"></div>
<div class="c"><span class="chip c-ext">⚙ Entpacken</span></div>
<div class="c mono"></div>
</div>
</div>
<!-- pkg3 done -->
<div class="pkg">
<div class="prow">
<div class="pn">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="picon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div>
<div class="ptt"><div class="t">Dune.Part.Two.2024.German.DL.2160p.UHD.BluRay.x265-NERDS</div><div class="s">1 Datei · 18.4 GB → Library</div></div>
</div>
<div class="c mono">18.4 / 18.4 GB</div>
<div class="c"><div class="pbar"><div class="fill gr" style="--p:100%"></div><div class="txt">100%</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#3b82f6"></span>ddownload</span></div>
<div class="c">AllDebrid</div>
<div class="c"></div>
<div class="c"><span class="chip c-done">✓ Fertig</span></div>
<div class="c mono"></div>
</div>
</div>
<!-- pkg4 error -->
<div class="pkg">
<div class="prow">
<div class="pn">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="picon" style="border-color:rgba(255,77,109,0.5)"><svg viewBox="0 0 24 24" fill="none" stroke="var(--red)" stroke-width="2"><path d="M12 9v4m0 4h.01M10.3 3.9l-8 14A2 2 0 004 21h16a2 2 0 001.7-3l-8-14a2 2 0 00-3.4 0z"/></svg></div>
<div class="ptt"><div class="t">Shogun.S01E05.German.DL.1080p.WEB.h264-EDITION</div><div class="s" style="color:var(--red)">Hoster nicht verfügbar · Link tot</div></div>
</div>
<div class="c mono">0 / 2.4 GB</div>
<div class="c"><div class="pbar"><div class="fill" style="--p:6%;background:var(--red);box-shadow:0 0 14px rgba(255,77,109,0.5)"></div><div class="txt">FEHLER</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#a855f7"></span>uploaded</span></div>
<div class="c"></div>
<div class="c"></div>
<div class="c"><span class="chip c-err">✗ Fehler</span></div>
<div class="c mono"></div>
</div>
</div>
<!-- pkg5 queued -->
<div class="pkg">
<div class="prow">
<div class="pn">
<svg class="chev" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="picon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 7l9-4 9 4v10l-9 4-9-4z"/><path d="M3 7l9 4 9-4M12 21V11"/></svg></div>
<div class="ptt"><div class="t">Severance.S02.COMPLETE.German.DL.1080p.ATVP.WEB.h265-NERDS</div><div class="s">10 Dateien · 22.1 GB</div></div>
</div>
<div class="c mono">0 / 22.1 GB</div>
<div class="c"><div class="pbar"><div class="fill" style="--p:0%"></div><div class="txt" style="color:var(--faint)">WARTESCHLANGE</div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#ff5722"></span>mega</span></div>
<div class="c">Mega-Debrid</div>
<div class="c"></div>
<div class="c"><span class="chip c-q">Wartet</span></div>
<div class="c mono"></div>
</div>
</div>
</div>
</div>
<!-- STATUSBAR -->
<div class="statusbar">
<div class="si"><span class="pulse"></span> Läuft</div>
<div class="si">Queue: <b>5</b> Pakete · <b>34</b> Dateien</div>
<div class="si">Heute: <b>147 GB</b></div>
<div class="spacer"></div>
<div class="si">Gesamt <div class="gtrack"><i></i></div> <b>62%</b></div>
</div>
</div>
<script>
document.querySelectorAll('.prow').forEach(r => r.addEventListener('click', () => r.parentElement.classList.toggle('open')));
</script>
</body>
</html>

298
design-mockups/vellum.html Normal file
View File

@ -0,0 +1,298 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Vellum — Real-Debrid-Downloader Mockup</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Spline+Sans:wght@400;500;600;700&family=Spline+Sans+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--paper: #f6f1e7;
--paper-2: #f1eadc;
--card: #fdfbf6;
--ink: #2a2520;
--ink-soft: #6b6256;
--faint: #9c9284;
--line: #e3dac8;
--line-soft: #ece4d6;
--accent: #0f6b5f; /* deep teal */
--accent-soft: #d7e9e4;
--rust: #c0562f; /* burnt sienna */
--gold: #b8860b;
--green: #3f7d52;
--red: #b23a3a;
--shadow: rgba(60, 48, 30, 0.1);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Spline Sans", system-ui, sans-serif;
background:
radial-gradient(1000px 600px at 90% -10%, rgba(15,107,95,0.05), transparent 60%),
radial-gradient(800px 500px at 0% 100%, rgba(192,86,47,0.05), transparent 55%),
var(--paper);
color: var(--ink);
font-size: 14px;
height: 100vh; overflow: hidden;
-webkit-font-smoothing: antialiased;
}
.serif { font-family: "Fraunces", Georgia, serif; }
.mono { font-family: "Spline Sans Mono", monospace; }
.app { display: grid; grid-template-rows: auto auto 1fr auto; height: 100vh; padding: 16px 24px 14px; gap: 14px; }
/* Header */
.header { display: flex; align-items: flex-end; gap: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.brand { display: flex; align-items: center; gap: 13px; }
.mark { width: 40px; height: 40px; border-radius: 50%; background: var(--ink); color: var(--paper); display: grid; place-items: center;
font-family: "Fraunces"; font-weight: 600; font-size: 21px; }
.brand .tt { font-family: "Fraunces"; font-weight: 600; font-size: 23px; letter-spacing: -0.01em; line-height: 1; }
.brand .ss { font-size: 11.5px; color: var(--faint); margin-top: 4px; letter-spacing: 0.04em; text-transform: uppercase; }
.header .spacer { flex: 1; }
.nav { display: flex; gap: 4px; align-items: center; }
.nav a { padding: 7px 15px; border-radius: 999px; color: var(--ink-soft); font-weight: 600; font-size: 13.5px; cursor: pointer; transition: all .15s; text-decoration: none; }
.nav a:hover { background: var(--line-soft); color: var(--ink); }
.nav a.active { background: var(--ink); color: var(--paper); }
.menu-min { display: flex; gap: 2px; margin-left: 10px; }
.menu-min a { font-size: 12.5px; color: var(--faint); padding: 7px 10px; }
/* Action bar */
.actionbar { display: flex; align-items: center; gap: 16px; }
.runner { display: flex; align-items: center; gap: 4px; padding: 5px; background: var(--card); border: 1px solid var(--line); border-radius: 999px; box-shadow: 0 2px 8px var(--shadow); }
.rbtn { width: 36px; height: 36px; border-radius: 50%; border: none; background: transparent; display: grid; place-items: center; cursor: pointer; color: var(--ink-soft); transition: all .15s; }
.rbtn:hover { background: var(--line-soft); color: var(--ink); }
.rbtn svg { width: 16px; height: 16px; }
.rbtn.play { background: var(--accent); color: #fff; } .rbtn.play:hover { background: #0c574d; }
.rbtn.stop { color: var(--red); }
.chip-actions { display: flex; gap: 8px; }
.chip { display: inline-flex; align-items: center; gap: 7px; padding: 8px 14px; border-radius: 999px; background: var(--card); border: 1px solid var(--line);
font-size: 12.5px; font-weight: 600; color: var(--ink-soft); cursor: pointer; box-shadow: 0 1px 4px var(--shadow); }
.chip svg { width: 14px; height: 14px; }
.actionbar .spacer { flex: 1; }
.metrics { display: flex; gap: 26px; }
.metric { text-align: right; }
.metric .m-l { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--faint); }
.metric .m-v { font-family: "Fraunces"; font-weight: 600; font-size: 22px; line-height: 1.1; margin-top: 2px; }
.metric .m-v .u { font-size: 12px; color: var(--ink-soft); font-family: "Spline Sans"; }
.metric .m-v.accent { color: var(--accent); }
/* List */
.listcard { background: var(--card); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; display: flex; flex-direction: column;
box-shadow: 0 6px 22px var(--shadow); }
.lhead { display: grid; grid-template-columns: minmax(0,1fr) 150px 150px 120px 120px 150px 84px;
padding: 13px 22px; border-bottom: 1px solid var(--line); font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.07em; color: var(--faint); background: var(--paper-2); }
.lhead > div:not(:first-child) { text-align: center; }
.lbody { overflow-y: auto; flex: 1; }
.lbody::-webkit-scrollbar { width: 12px; }
.lbody::-webkit-scrollbar-thumb { background: var(--line); border-radius: 12px; border: 4px solid var(--card); background-clip: padding-box; }
.pkg { border-bottom: 1px solid var(--line-soft); }
.pkg:last-child { border-bottom: none; }
.prow { display: grid; grid-template-columns: minmax(0,1fr) 150px 150px 120px 120px 150px 84px; align-items: center; padding: 16px 22px; cursor: pointer; transition: background .15s; }
.prow:hover { background: var(--paper-2); }
.pn { display: flex; align-items: center; gap: 13px; min-width: 0; }
.chev { color: var(--faint); transition: transform .2s; flex-shrink: 0; }
.pkg.open .chev { transform: rotate(90deg); }
.pnum { width: 30px; height: 30px; border-radius: 8px; background: var(--accent-soft); color: var(--accent); display: grid; place-items: center;
font-family: "Fraunces"; font-weight: 600; font-size: 14px; flex-shrink: 0; }
.ptt { min-width: 0; }
.ptt .t { font-family: "Fraunces"; font-weight: 600; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; letter-spacing: -0.005em; }
.ptt .s { font-size: 11.5px; color: var(--faint); margin-top: 1px; }
.c { text-align: center; font-size: 13px; color: var(--ink-soft); }
.c.mono { font-family: "Spline Sans Mono"; font-size: 12px; }
/* progress: refined thin bar with number */
.prog { display: flex; flex-direction: column; gap: 5px; align-items: center; }
.prog .pp { font-family: "Fraunces"; font-weight: 600; font-size: 14px; }
.bar { width: 100%; height: 5px; border-radius: 99px; background: var(--line); overflow: hidden; }
.bar > i { display: block; height: 100%; width: var(--p,0%); border-radius: 99px; background: var(--accent); }
.bar > i.ext { background: var(--green); }
.bar > i.err { background: var(--red); }
.pill { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 999px; font-size: 11.5px; font-weight: 600; }
.p-dl { background: var(--accent-soft); color: var(--accent); }
.p-ext { background: #dcecdf; color: var(--green); }
.p-done { background: #dcecdf; color: var(--green); }
.p-err { background: #f4dede; color: var(--red); }
.p-q { background: var(--line-soft); color: var(--ink-soft); }
.host { display: inline-flex; align-items: center; gap: 7px; font-size: 12.5px; }
.host .hd { width: 8px; height: 8px; border-radius: 50%; }
.prio { font-size: 11px; font-weight: 700; color: var(--rust); letter-spacing: 0.04em; }
.items { display: none; background: var(--paper-2); padding: 4px 0; }
.pkg.open .items { display: block; }
.item { display: grid; grid-template-columns: minmax(0,1fr) 150px 150px 120px 120px 150px 84px; align-items: center; padding: 9px 22px; font-size: 12.5px; }
.item:hover { background: rgba(15,107,95,0.04); }
.in { display: flex; align-items: center; gap: 10px; padding-left: 43px; min-width: 0; }
.ldot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.ldot.on { background: var(--green); } .ldot.off { background: var(--red); }
.in .nm { font-family: "Spline Sans Mono"; font-size: 11.5px; color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* footer */
.footer { display: flex; align-items: center; gap: 22px; font-size: 12.5px; color: var(--ink-soft); padding: 0 6px; }
.footer .fi { display: flex; align-items: center; gap: 8px; }
.footer b { color: var(--ink); font-family: "Fraunces"; font-weight: 600; }
.footer .spacer { flex: 1; }
.gdot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); }
.track { width: 140px; height: 6px; border-radius: 99px; background: var(--line); overflow: hidden; }
.track > i { display: block; height: 100%; width: 62%; background: linear-gradient(90deg, var(--accent), var(--green)); }
</style>
</head>
<body>
<div class="app">
<!-- HEADER -->
<div class="header">
<div class="brand">
<div class="mark">D</div>
<div>
<div class="tt">Debrid Downloader</div>
<div class="ss">Vellum Edition · v1.7.156</div>
</div>
</div>
<div class="spacer"></div>
<nav class="nav">
<a class="active">Downloads</a>
<a>Linksammler</a>
<a>Verlauf</a>
<a>Statistiken</a>
<span class="menu-min"><a>Datei</a><a>Einstellungen</a><a>Hilfe</a></span>
</nav>
</div>
<!-- ACTION BAR -->
<div class="actionbar">
<div class="runner">
<button class="rbtn play" title="Start"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg></button>
<button class="rbtn" title="Pause"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg></button>
<button class="rbtn stop" title="Stop"><svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg></button>
</div>
<div class="chip-actions">
<div class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg> Zeitplan</div>
<div class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 19V5M5 12l7-7 7 7"/></svg> Hoch</div>
<div class="chip"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg> Runter</div>
</div>
<div class="spacer"></div>
<div class="metrics">
<div class="metric"><div class="m-l">Tempo</div><div class="m-v accent">48.2<span class="u"> MB/s</span></div></div>
<div class="metric"><div class="m-l">Verbleibend</div><div class="m-v">02:14</div></div>
<div class="metric"><div class="m-l">Aktiv</div><div class="m-v">8<span class="u"> / 24</span></div></div>
</div>
</div>
<!-- LIST -->
<div class="listcard">
<div class="lhead">
<div>Paket</div><div>Geladen / Größe</div><div>Fortschritt</div><div>Hoster</div><div>Service</div><div>Status</div><div>Tempo</div>
</div>
<div class="lbody">
<!-- pkg1 open downloading -->
<div class="pkg open">
<div class="prow">
<div class="pn">
<svg class="chev" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pnum">1</div>
<div class="ptt"><div class="t">Ugly Americans · Staffel 2</div><div class="s">Ugly.Americans.S02.COMPLETE.German.DL.720p.WEB-DL.h264-NERDS · 10 Dateien · 14.2 GB</div></div>
</div>
<div class="c mono">8.9 / 14.2 GB</div>
<div class="c"><div class="prog"><div class="pp">63%</div><div class="bar"><i style="--p:63%"></i></div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#ff5722"></span>mega</span></div>
<div class="c">Mega-Debrid</div>
<div class="c"><span class="pill p-dl">Lädt · 6/10</span></div>
<div class="c mono" style="color:var(--accent)">48 MB/s</div>
</div>
<div class="items">
<div class="item"><div class="in"><span class="ldot on"></span><span class="nm">…h264-NERDS.part1.rar</span></div><div class="c mono">2.0 / 2.0</div><div class="c"><div class="bar"><i class="ext" style="--p:100%"></i></div></div><div class="c">mega</div><div class="c"></div><div class="c"><span class="pill p-done">Fertig</span></div><div class="c mono"></div></div>
<div class="item"><div class="in"><span class="ldot on"></span><span class="nm">…h264-NERDS.part2.rar</span></div><div class="c mono">2.0 / 2.0</div><div class="c"><div class="bar"><i class="ext" style="--p:100%"></i></div></div><div class="c">mega</div><div class="c"></div><div class="c"><span class="pill p-done">Fertig</span></div><div class="c mono"></div></div>
<div class="item"><div class="in"><span class="ldot on"></span><span class="nm">…h264-NERDS.part3.rar</span></div><div class="c mono">1.4 / 2.0</div><div class="c"><div class="bar"><i style="--p:70%"></i></div></div><div class="c">mega</div><div class="c"></div><div class="c"><span class="pill p-dl">70%</span></div><div class="c mono" style="color:var(--accent)">26 MB/s</div></div>
<div class="item"><div class="in"><span class="ldot on"></span><span class="nm">…h264-NERDS.part4.rar</span></div><div class="c mono">0.8 / 2.0</div><div class="c"><div class="bar"><i style="--p:40%"></i></div></div><div class="c">mega</div><div class="c"></div><div class="c"><span class="pill p-dl">40%</span></div><div class="c mono" style="color:var(--accent)">22 MB/s</div></div>
<div class="item"><div class="in"><span class="ldot off"></span><span class="nm">…h264-NERDS.part5.rar</span></div><div class="c mono">0 / 1.1</div><div class="c"><div class="bar"><i style="--p:0%"></i></div></div><div class="c">mega</div><div class="c"></div><div class="c"><span class="pill p-q">Wartet</span></div><div class="c mono"></div></div>
</div>
</div>
<!-- pkg2 extracting -->
<div class="pkg">
<div class="prow">
<div class="pn">
<svg class="chev" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pnum">2</div>
<div class="ptt"><div class="t">The Bear · Staffel 3</div><div class="s">The.Bear.S03.German.DL.1080p.WEB.h264-WvF · 8 Dateien · 11.6 GB</div></div>
</div>
<div class="c mono">11.6 / 11.6 GB</div>
<div class="c"><div class="prog"><div class="pp" style="color:var(--green)">82%</div><div class="bar"><i class="ext" style="--p:82%"></i></div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#22c55e"></span>rapidgator</span></div>
<div class="c">Real-Debrid</div>
<div class="c"><span class="pill p-ext">Entpacken</span></div>
<div class="c mono"></div>
</div>
</div>
<!-- pkg3 done -->
<div class="pkg">
<div class="prow">
<div class="pn">
<svg class="chev" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pnum">3</div>
<div class="ptt"><div class="t">Dune · Part Two</div><div class="s">Dune.Part.Two.2024.German.DL.2160p.UHD.BluRay.x265-NERDS · in Library übernommen</div></div>
</div>
<div class="c mono">18.4 / 18.4 GB</div>
<div class="c"><div class="prog"><div class="pp" style="color:var(--green)">100%</div><div class="bar"><i class="ext" style="--p:100%"></i></div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#3b82f6"></span>ddownload</span></div>
<div class="c">AllDebrid</div>
<div class="c"><span class="pill p-done">Abgeschlossen</span></div>
<div class="c mono"></div>
</div>
</div>
<!-- pkg4 error -->
<div class="pkg">
<div class="prow">
<div class="pn">
<svg class="chev" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pnum" style="background:#f4dede;color:var(--red)">!</div>
<div class="ptt"><div class="t">Shōgun · S01E05</div><div class="s" style="color:var(--red)">Hoster nicht verfügbar — Link tot, kein Fallback erfolgreich</div></div>
</div>
<div class="c mono">0 / 2.4 GB</div>
<div class="c"><div class="prog"><div class="pp" style="color:var(--red)"></div><div class="bar"><i class="err" style="--p:8%"></i></div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#a855f7"></span>uploaded</span></div>
<div class="c"></div>
<div class="c"><span class="pill p-err">Fehlgeschlagen</span></div>
<div class="c mono"></div>
</div>
</div>
<!-- pkg5 queued -->
<div class="pkg">
<div class="prow">
<div class="pn">
<svg class="chev" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M9 6l6 6-6 6"/></svg>
<div class="pnum">5</div>
<div class="ptt"><div class="t">Severance · Staffel 2</div><div class="s">Severance.S02.COMPLETE.German.DL.1080p.ATVP.WEB.h265-NERDS · 10 Dateien · 22.1 GB</div></div>
</div>
<div class="c mono">0 / 22.1 GB</div>
<div class="c"><div class="prog"><div class="pp" style="color:var(--faint)">0%</div><div class="bar"><i style="--p:0%"></i></div></div></div>
<div class="c"><span class="host"><span class="hd" style="background:#ff5722"></span>mega</span></div>
<div class="c">Mega-Debrid</div>
<div class="c"><span class="pill p-q">Wartet</span></div>
<div class="c mono"></div>
</div>
</div>
</div>
</div>
<!-- FOOTER -->
<div class="footer">
<div class="fi"><span class="gdot"></span> Läuft</div>
<div class="fi">Queue <b>5 Pakete</b> · <b>34 Dateien</b></div>
<div class="fi">Heute <b>147 GB</b></div>
<div class="spacer"></div>
<div class="fi">Gesamt <div class="track"><i></i></div> <b>62 %</b></div>
</div>
</div>
<script>
document.querySelectorAll('.prow').forEach(r => r.addEventListener('click', () => r.parentElement.classList.toggle('open')));
</script>
</body>
</html>

View File

@ -0,0 +1,183 @@
# Intensive Analyse: Pausen zwischen Pack-Entpackungen (1015 Sekunden)
**Nur Analyse keine Code-Änderungen.**
---
## 1. Problem
Nach dem Entpacken eines Packs (z.B. 3 Parts einer Serie) passiert ca. 1015 Sekunden lang scheinbar nichts, bevor das nächste Pack mit dem Entpacken beginnt.
---
## 2. Steuerungslogik: Ein Slot für alle Packs
- **Nur ein Pack** darf gleichzeitig Post-Processing (inkl. Entpacken) machen.
- Steuerung: `acquirePostProcessSlot(packageId)` / `releasePostProcessSlot()` in `download-manager.ts`.
- Weitere Packs warten in `packagePostProcessWaiters` und kommen erst dran, wenn der aktive Task im **finally**-Block `releasePostProcessSlot()` aufruft.
```3761:3804:src/main/download-manager.ts
private async acquirePostProcessSlot(packageId: string): Promise<void> {
const maxConcurrent = 1;
// ...
}
private releasePostProcessSlot(): void {
// ...
}
```
- Der Slot wird **erst** freigegeben, wenn die gesamte `runPackagePostProcessing`-Task-Funktion durch ist genauer: wenn ihr **finally**-Block läuft (dort `releasePostProcessSlot()`). Alles, was vorher im gleichen Task **synchron** (await) läuft, blockiert den Slot und damit das nächste Pack.
---
## 3. Zwei relevante Code-Pfade
### 3.1 Pfad A: Hybrid-Extract (Pack noch nicht fertig)
- Bedingung: `!allDone && settings.hybridExtract && autoExtract && failed === 0 && success > 0`.
- Es werden nur die **bereits fertigen** Archive des Packs entpackt (`onlyArchives: readyArchives`), mit `skipPostCleanup: true` (kein Post-Cleanup im Extractor).
- Ablauf:
1. `handlePackagePostProcessing``runHybridExtraction`.
2. `await extractPackageArchives(..., onlyArchives, skipPostCleanup: true)`.
3. **Direkt danach (im gleichen Callstack, vor Rückkehr):**
`await this.autoRenameExtractedVideoFiles(pkg.extractDir, pkg)` (Zeile 6490).
4. Dann return aus `handlePackagePostProcessing`**finally**`releasePostProcessSlot()`.
**Folge:** Im Hybrid-Pfad blockiert **Auto-Rename** den Slot. Solange Rename läuft (rekursives Scannen + Umbenennen), kann das nächste Pack nicht starten. Das kann gut 1015 Sekunden ausmachen.
---
### 3.2 Pfad B: Finales Post-Processing (Pack komplett, alle Items fertig)
- Bedingung: `allDone` (alle Items completed/failed/cancelled).
- Es wird das **gesamte** Pack entpackt (`extractPackageArchives` ohne `onlyArchives`), **ohne** `skipPostCleanup`.
- Ablauf:
1. `await extractPackageArchives(...)` **inklusive allem, was der Extractor danach noch macht** (siehe Abschnitt 4).
2. Status-Updates, `recordPackageHistory(...)` (synchron, schnell).
3. `void this.runDeferredPostExtraction(...)` wird **nicht** awaitet; Rename, MKV-Sammlung, Cleanup laufen im Hintergrund.
4. `handlePackagePostProcessing` kehrt zurück → **finally**`releasePostProcessSlot()`.
**Folge:** Im Final-Pfad blockieren **nicht** mehr Rename/MKV/Cleanup im Download-Manager den Slot die sind in `runDeferredPostExtraction` ausgelagert. Was den Slot aber **noch** blockiert, ist alles, was **innerhalb** von `extractPackageArchives` **nach** dem eigentlichen Entpacken passiert (Post-Cleanup und ggf. Nested-Extraction im Extractor).
---
## 4. Was passiert INNERHALB von `extractPackageArchives` (Extractor) und blockiert
Nach dem Durchlauf über alle Kandidaten-Archive folgt im Extractor (`extractor.ts`) noch:
### 4.1 Nested-Extraction (Zeilen 22082284)
- Wenn `extracted > 0 && !skipPostCleanup && !onlyArchives`: Es werden Archive **im Zielordner** gesucht (`findArchiveCandidates(options.targetDir)`) und nacheinander entpackt.
- Pro nested-Archiv: Entpacken, ggf. `cleanupArchives([nestedArchive], ...)`.
- Kann bei vielen/vollen Archiven deutlich Zeit kosten und den Slot blockieren.
### 4.2 Post-Cleanup (Zeilen 22862328)
- Nur wenn `!options.skipPostCleanup`:
- **cleanupArchives(cleanupSources, cleanupMode):** Entfernen/Trash der entpackten Quell-Archive (readdir pro Verzeichnis, ggf. viele `rm`/rename).
- **removeDownloadLinkArtifacts(targetDir):** Link-Artefakte im Zielordner entfernen.
- **removeSampleArtifacts(targetDir):** Rekursives Durchlaufen des kompletten Extract-Ordners, Erkennung von Sample-Dateien/Ordnern, Löschen.
- **removeEmptyDirectoryTree(packageDir):** Rekursives Auflisten aller Unterordner, dann sortiert leere Ordner von tief nach flach löschen.
All das läuft **vor** dem Return von `extractPackageArchives`. Erst danach kommt im Download-Manager noch `recordPackageHistory` und `void runDeferredPostExtraction`. Der Slot wird also erst nach dem **gesamten** `extractPackageArchives`-Lauf (inkl. Nested + Post-Cleanup) freigegeben.
**Typische Zeitfresser (1015 s):**
- `cleanupArchives`: viele Dateien/Archive → viele I/O-Ops.
- `removeSampleArtifacts`: vollständiger rekursiver Scan des Extract-Ordners.
- `removeEmptyDirectoryTree`: rekursives readdir über die ganze Verzeichnisstruktur.
- Nested-Extraction: zusätzliches Entpacken und ggf. weiteres Cleanup.
---
## 5. Was im Download-Manager NACH dem Extractor noch passiert (Final-Pfad)
- **recordPackageHistory:** synchron, in-memory + Callback vernachlässigbar.
- **runDeferredPostExtraction:** wird mit `void` gestartet, blockiert den Slot **nicht**. Darin laufen (im Hintergrund):
- `autoRenameExtractedVideoFiles`
- `cleanupRemainingArchiveArtifacts` (bei Hybrid-Szenario/cleanupMode)
- `collectMkvFilesToLibrary`
- `applyPackageDoneCleanup`
Diese Schritte verursachen **keine** Pause mehr zwischen zwei Packs im Final-Pfad, weil der Slot schon vorher freigegeben wird.
---
## 6. Zusammenfassung: Wo entstehen die 1015 Sekunden Pause?
| Szenario | Was blockiert den Slot (Pause bis zum nächsten Pack)? |
|----------|--------------------------------------------------------|
| **Hybrid-Extract** (Pack hat noch offene Items) | `await autoRenameExtractedVideoFiles` **direkt nach** `extractPackageArchives` in `runHybridExtraction` (Zeile 6490). Rekursives Scannen + Umbenennen aller Video-Dateien. |
| **Finales Post-Processing** (Pack fertig) | Alles **innerhalb** von `extractPackageArchives`: Nested-Extraction (falls vorhanden) + Post-Cleanup (`cleanupArchives`, `removeDownloadLinkArtifacts`, `removeSampleArtifacts`, `removeEmptyDirectoryTree`). Rekursive Scans und viele I/O-Ops. |
In beiden Fällen ist die Pause also die Zeit **vor** `releasePostProcessSlot()` einmal durch Rename im Manager (Hybrid), einmal durch Post-Cleanup und Nested-Extraction im Extractor (Final).
---
## 7. Mögliche Verbesserungen (nur Konzept, keine Änderung)
- **Hybrid-Pfad:**
`autoRenameExtractedVideoFiles` nach dem Hybrid-Extract **nicht** mehr awaiten, sondern (analog zu `runDeferredPostExtraction`) im Hintergrund starten und sofort aus `runHybridExtraction` zurückkehren. Dann wird der Slot direkt nach `extractPackageArchives` freigegeben; Rename läuft parallel.
- **Final-Pfad / Extractor:**
Post-Cleanup (und ggf. Nested-Extraction) **nicht** mehr synchron am Ende von `extractPackageArchives` ausführen, sondern:
- Entweder: Extractor gibt nach dem letzten „eigentlichen“ Entpacken sofort zurück und eine andere Komponente (z.B. Download-Manager oder eine Queue) übernimmt Cleanup/Nested im Hintergrund; oder
- Extractor bekommt eine Option (z.B. `deferPostCleanup: true`), liefert die nötigen Daten (z.B. Liste der zu löschenden Archive) zurück, und der Aufrufer führt Cleanup/Nested asynchron aus.
- **Slot-Logik unverändert:**
Ein Slot bleibt sinnvoll, um I/O und CPU beim Entpacken zu bündeln. Durch die Entkopplung der „teuren“ Schritte (Rename, Cleanup, Nested) von der Slot-Holding-Zeit verkürzt sich die Pause zwischen zwei Packs ohne Parallel-Entpacken mehrerer Packs.
---
## 8. Relevante Stellen im Code (Orientierung)
- Slot: `acquirePostProcessSlot` / `releasePostProcessSlot` (download-manager.ts, ca. 37613804).
- Post-Processing-Task: `runPackagePostProcessing``handlePackagePostProcessing` (ca. 38063854, 65446916).
- Hybrid: `runHybridExtraction` (ca. 63746542), inkl. `await autoRenameExtractedVideoFiles` (6490).
- Final: `handlePackagePostProcessing` nach `extractPackageArchives` (66976916): `recordPackageHistory`, `void runDeferredPostExtraction`, dann return.
- Extractor: `extractPackageArchives` (extractor.ts, ca. 18802353), Nested 22082284, Post-Cleanup 22862328.
- Rename: `autoRenameExtractedVideoFiles` (download-manager.ts, 21732312), nutzt `collectVideoFiles` (rekursiv).
- MKV/Cleanup: `collectMkvFilesToLibrary` (2448), `cleanupRemainingArchiveArtifacts` (2353), `runDeferredPostExtraction` (69226965).
---
## 9. Vergleich: JDownloader (jdownloader-source)
Im JDownloader-Quellcode (z.B. `C:\Users\ploet\Desktop\jdownloader-source`) ist das Entpacken so aufgebaut, dass **pro Pack (3 Parts = 1 Folge) kaum schwere Arbeit nach dem eigentlichen Entpacken** im gleichen Queue-Job läuft deshalb wirkt es „ohne Pause“.
### Ablauf bei JDownloader
- **Ein Archiv = ein Pack** (z.B. 3 RAR-Parts = 1 Archive mit `archive.getArchiveFiles()`).
- **Eine Queue** (`ExtractionQueue`), ein Job pro Archiv (`ExtractionController` extends `QueueAction`).
- Pro Job passiert in `ExtractionController.run()`:
1. `extractor.extract(this)` reines Entpacken.
2. `extractor.close()`.
3. Je nach Exit-Code: `fireEvent(ExtractionEvent.Type.FINISHED)` (inkl. `FileCreationEvent(NEW_FILES, files)` die Dateiliste kommt vom Extractor, **kein** rekursives Scannen).
4. Im **finally**: `fireEvent(Type.CLEANUP)``archive.onCleanUp()`.
5. Listener bei `CLEANUP`: `controller.removeArchiveFiles()`.
### Was `removeArchiveFiles()` bei JDownloader macht
- Holt die **bereits bekannten** Archive-Dateien: `archive.getArchiveFiles()` (die 3 Parts sind dem Archiv von Anfang an zugeordnet).
- Löscht nur diese Dateien (z.B. `link.deleteFile(remove)` pro Part).
- **Kein** rekursives Durchsuchen von Ordnern, **kein** `findArchiveCandidates`, **kein** Scannen des Extract-Ordners.
- Aufwand: O(Anzahl Parts) Datei-Löschungen, typisch sehr schnell.
### Was JDownloader in diesem Pfad nicht macht
- **Kein** Auto-Rename der entpackten Dateien im Extraction-Queue-Job (LinknameCleaner wird an anderer Stelle für Pfadsegmente genutzt, nicht als Blockierung nach Extract).
- **Kein** „Collect MKV to Library“ (rekursives Scannen + Verschieben) im gleichen Job.
- **Kein** `removeSampleArtifacts` (rekursiver Scan des Extract-Ordners).
- **Kein** `removeEmptyDirectoryTree` (rekursives Auflisten aller Unterordner).
- Nested-Archive (Deep-Extraction) werden als **neue** Archive in die Queue gestellt (`addToQueue(..., newArchive, false)`), also **separate Jobs**, die nacheinander laufen der aktuelle Job ist sofort fertig.
### Warum es sich „flawless“ anfühlt
- Der kritische Pfad pro Pack ist: **Entpacken → Event FINISHED → Event CLEANUP → nur die bekannten Archive-Dateien löschen → `run()` endet**.
- Keine rechen- oder I/O-intensiven Schritte (keine rekursiven Scans, kein Rename, keine MKV-Sammlung) im gleichen Queue-Job.
- Das nächste Pack (nächster `ExtractionController` in der Queue) startet direkt nach `run()` return die spürbare Pause entfällt.
### Übertrag auf unser Projekt
- Um ein ähnlich „flüssiges“ Verhalten zu erreichen, sollten **alle** zeitaufwändigen Schritte (Rename, MKV-Sammlung, Sample-Cleanup, leere Ordner entfernen, ggf. Post-Cleanup im Extractor) **nicht** den Post-Process-Slot blockieren.
- Konkret: Sie entweder **nach** `releasePostProcessSlot()` im Hintergrund ausführen (wie beim Final-Pfad bereits für Rename/MKV/Cleanup im Manager) **oder** den Extractor so auslegen, dass er direkt nach dem letzten eigentlichen Entpacken zurückkehrt und Cleanup/Nested in einem separaten, asynchronen Schritt erledigt wird (siehe Abschnitt 7).

View File

@ -0,0 +1,91 @@
# Mega-Debrid Multi-Account Support
> **For agentic workers:** Use superpowers:subagent-driven-development to implement this plan.
**Goal:** Multiple Mega-Debrid accounts with automatic fallback when an account hits Fair-Use limits or errors.
**Architecture:** Follow the existing Debrid-Link multi-key pattern. Store credentials as newline-separated `login:password` pairs. Account rotation uses linear iteration with cooldown/disable/daily-limit checks.
**Tech Stack:** TypeScript, Electron, React
---
### Task 1: Create mega-debrid-accounts.ts parser module
**Files:**
- Create: `src/shared/mega-debrid-accounts.ts`
- [ ] Create `MegaDebridAccountEntry` interface (id, login, password, index, label, maskedLogin)
- [ ] Create `parseMegaDebridAccounts(raw: string): MegaDebridAccountEntry[]` - split by newlines, parse `login:password` pairs, deduplicate by login, generate stable IDs via FNV-1a hash (`mda_` prefix)
- [ ] Create `getMegaDebridAccountId(login: string): string`
- [ ] Create `maskMegaDebridLogin(login: string): string`
- [ ] Create `getMegaDebridAccountLabel(index: number): string` - "Account 1", "Account 2"
- [ ] Create `serializeMegaDebridAccounts(accounts: {login: string, password: string}[]): string` - back to newline-separated format
- [ ] Backward compat: if raw string has no `:` separator, treat as legacy single-login (use megaPassword from settings)
### Task 2: Extend AppSettings with multi-account fields
**Files:**
- Modify: `src/shared/types.ts`
- [ ] Replace `megaLogin: string``megaCredentials: string` (newline-separated `login:password` pairs)
- [ ] Keep `megaPassword: string` for backward compat (migration reads it once)
- [ ] Add `megaDebridDisabledAccountIds: string[]`
- [ ] Add `megaDebridAccountDailyLimitBytes: Record<string, number>`
- [ ] Add `megaDebridAccountDailyUsageBytes: Record<string, number>`
- [ ] Add `megaDebridAccountTotalUsageBytes: Record<string, number>`
### Task 3: Add per-account daily limit functions
**Files:**
- Modify: `src/shared/provider-daily-limits.ts`
- [ ] Add `getMegaDebridAccountDailyLimitBytes(settings, accountId)`
- [ ] Add `getMegaDebridAccountDailyUsageBytes(settings, accountId, epochMs)`
- [ ] Add `isMegaDebridAccountDailyLimitReached(settings, accountId, epochMs)`
- [ ] Add `addMegaDebridAccountDailyUsageBytes(settings, accountId, bytes, epochMs)`
- [ ] Add `addMegaDebridAccountTotalUsageBytes(settings, accountId, bytes)`
- [ ] Add `isMegaDebridAccountDisabled(settings, accountId)`
### Task 4: Migrate storage from single to multi-account
**Files:**
- Modify: `src/main/storage.ts`
- [ ] In `normalizeSettings`: migrate old `megaLogin`+`megaPassword` → `megaCredentials` format (`login:password`)
- [ ] Normalize new fields with defaults
### Task 5: Implement account rotation in debrid.ts
**Files:**
- Modify: `src/main/debrid.ts`
- [ ] Add in-memory cooldown cache for Mega accounts (like `debridLinkKeyCooldowns`)
- [ ] Update `hasMegaDebridCredentials()` to check `parseMegaDebridAccounts().length > 0`
- [ ] Update Mega-Debrid API unrestrict to iterate accounts (skip disabled/limited/cooldown)
- [ ] Update Mega-Debrid Web unrestrict to iterate accounts
- [ ] Return `sourceAccountId` and `sourceAccountLabel` on success
- [ ] On failure: classify error, apply cooldown, try next account
### Task 6: Update download-manager usage tracking
**Files:**
- Modify: `src/main/download-manager.ts`
- [ ] Track per-account bytes for Mega-Debrid (like Debrid-Link key tracking)
- [ ] Update `isProviderDailyLimited` to check if ANY Mega account is available
### Task 7: Update UI for multi-account management
**Files:**
- Modify: `src/renderer/App.tsx`
- [ ] Update Mega-Debrid account dialog: textarea for credentials (`login:password` per line)
- [ ] Display account list with masked logins, enable/disable toggle, per-account daily limits
- [ ] Update account summary display to show individual accounts
### Task 8: Tests
- [ ] Unit tests for `parseMegaDebridAccounts` (parse, deduplicate, legacy compat)
- [ ] Unit tests for per-account daily limits
- [ ] Run full test suite: `npx vitest run`

View File

@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "1.7.188", "version": "1.7.183",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",

79822
rd_downloader.log.old Normal file

File diff suppressed because it is too large Load Diff

View File

@ -98,12 +98,13 @@ public final class JBindExtractorMain {
System.out.flush(); System.out.flush();
} }
} catch (IOException ignored) { } catch (IOException ignored) {
// stdin closed parent process exited
} }
} }
private static ExtractionRequest parseDaemonRequest(String jsonLine) { private static ExtractionRequest parseDaemonRequest(String jsonLine) {
// Minimal JSON parsing without external dependencies.
// Expected format: {"archive":"...","target":"...","conflict":"...","backend":"...","passwords":["...","..."]}
ExtractionRequest request = new ExtractionRequest(); ExtractionRequest request = new ExtractionRequest();
request.archiveFile = new File(extractJsonString(jsonLine, "archive")); request.archiveFile = new File(extractJsonString(jsonLine, "archive"));
request.targetDir = new File(extractJsonString(jsonLine, "target")); request.targetDir = new File(extractJsonString(jsonLine, "target"));
@ -115,7 +116,7 @@ public final class JBindExtractorMain {
if (backend.length() > 0) { if (backend.length() > 0) {
request.backend = Backend.fromValue(backend); request.backend = Backend.fromValue(backend);
} }
// Parse passwords array
int pwStart = jsonLine.indexOf("\"passwords\""); int pwStart = jsonLine.indexOf("\"passwords\"");
if (pwStart >= 0) { if (pwStart >= 0) {
int arrStart = jsonLine.indexOf('[', pwStart); int arrStart = jsonLine.indexOf('[', pwStart);
@ -160,7 +161,7 @@ public final class JBindExtractorMain {
for (int i = from; i < s.length(); i++) { for (int i = from; i < s.length(); i++) {
char c = s.charAt(i); char c = s.charAt(i);
if (c == '\\') { if (c == '\\') {
i++; i++; // skip escaped character
continue; continue;
} }
if (c == '"') return i; if (c == '"') return i;
@ -366,6 +367,7 @@ public final class JBindExtractorMain {
throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath()); throw new IOException("Archiv enthalt keine Eintrage oder konnte nicht gelesen werden: " + request.archiveFile.getAbsolutePath());
} }
// Pre-scan: collect file indices, sizes, output paths, and detect encryption
long totalUnits = 0; long totalUnits = 0;
boolean encrypted = false; boolean encrypted = false;
List<Integer> fileIndices = new ArrayList<Integer>(); List<Integer> fileIndices = new ArrayList<Integer>();
@ -389,7 +391,7 @@ public final class JBindExtractorMain {
Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED); Boolean isEncrypted = (Boolean) archive.getProperty(i, PropID.ENCRYPTED);
encrypted = encrypted || Boolean.TRUE.equals(isEncrypted); encrypted = encrypted || Boolean.TRUE.equals(isEncrypted);
} catch (Throwable ignored) { } catch (Throwable ignored) {
// ignore encrypted flag read issues
} }
Long rawSize = (Long) archive.getProperty(i, PropID.SIZE); Long rawSize = (Long) archive.getProperty(i, PropID.SIZE);
@ -398,12 +400,12 @@ public final class JBindExtractorMain {
File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved); File output = resolveOutputFile(request.targetDir, entryName, request.conflictMode, reserved);
fileIndices.add(i); fileIndices.add(i);
outputFiles.add(output); outputFiles.add(output); // null if skipped
fileSizes.add(itemSize); fileSizes.add(itemSize);
} }
if (fileIndices.isEmpty()) { if (fileIndices.isEmpty()) {
// All items are folders or skipped
ProgressTracker progress = new ProgressTracker(1); ProgressTracker progress = new ProgressTracker(1);
progress.emitStart(); progress.emitStart();
progress.emitDone(); progress.emitDone();
@ -413,16 +415,19 @@ public final class JBindExtractorMain {
ProgressTracker progress = new ProgressTracker(totalUnits); ProgressTracker progress = new ProgressTracker(totalUnits);
progress.emitStart(); progress.emitStart();
// Build index array for bulk extract
int[] indices = new int[fileIndices.size()]; int[] indices = new int[fileIndices.size()];
for (int i = 0; i < fileIndices.size(); i++) { for (int i = 0; i < fileIndices.size(); i++) {
indices[i] = fileIndices.get(i); indices[i] = fileIndices.get(i);
} }
// Map from archive index to our position in fileIndices/outputFiles
Map<Integer, Integer> indexToPos = new HashMap<Integer, Integer>(); Map<Integer, Integer> indexToPos = new HashMap<Integer, Integer>();
for (int i = 0; i < fileIndices.size(); i++) { for (int i = 0; i < fileIndices.size(); i++) {
indexToPos.put(fileIndices.get(i), i); indexToPos.put(fileIndices.get(i), i);
} }
// Bulk extraction state
final boolean encryptedFinal = encrypted; final boolean encryptedFinal = encrypted;
final String effectivePassword = password == null ? "" : password; final String effectivePassword = password == null ? "" : password;
final File[] currentOutput = new File[1]; final File[] currentOutput = new File[1];
@ -669,7 +674,7 @@ public final class JBindExtractorMain {
if (entry.length() == 0) { if (entry.length() == 0) {
return fallback; return fallback;
} }
// Sanitize Windows special characters from each path segment
String[] segments = entry.split("/", -1); String[] segments = entry.split("/", -1);
StringBuilder sanitized = new StringBuilder(); StringBuilder sanitized = new StringBuilder();
for (int i = 0; i < segments.length; i++) { for (int i = 0; i < segments.length; i++) {
@ -703,7 +708,7 @@ public final class JBindExtractorMain {
if (Files.isSymbolicLink(file.toPath())) { if (Files.isSymbolicLink(file.toPath())) {
throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath()); throw new IOException("Zieldatei ist ein Symlink, Schreiben verweigert: " + file.getAbsolutePath());
} }
// Also check parent directories for symlinks
File parent = file.getParentFile(); File parent = file.getParentFile();
while (parent != null) { while (parent != null) {
if (Files.isSymbolicLink(parent.toPath())) { if (Files.isSymbolicLink(parent.toPath())) {
@ -874,6 +879,12 @@ public final class JBindExtractorMain {
private final List<String> passwords = new ArrayList<String>(); private final List<String> passwords = new ArrayList<String>();
} }
/**
* Bulk extraction callback that implements both IArchiveExtractCallback and
* ICryptoGetTextPassword. Using the bulk IInArchive.extract() API instead of
* per-item extractSlow() is critical for performance solid RAR archives
* otherwise re-decode from the beginning for every single item.
*/
private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword { private static final class BulkExtractCallback implements IArchiveExtractCallback, ICryptoGetTextPassword {
private final IInArchive archive; private final IInArchive archive;
private final Map<Integer, Integer> indexToPos; private final Map<Integer, Integer> indexToPos;
@ -919,12 +930,12 @@ public final class JBindExtractorMain {
@Override @Override
public void setTotal(long total) { public void setTotal(long total) {
// 7z reports total compressed bytes; we track uncompressed via ProgressTracker
} }
@Override @Override
public void setCompleted(long complete) { public void setCompleted(long complete) {
// Not used we track per-write progress
} }
@Override @Override
@ -979,7 +990,7 @@ public final class JBindExtractorMain {
@Override @Override
public void prepareOperation(ExtractAskMode extractAskMode) { public void prepareOperation(ExtractAskMode extractAskMode) {
// no-op
} }
@Override @Override
@ -1000,7 +1011,7 @@ public final class JBindExtractorMain {
currentOutput[0].setLastModified(modified.getTime()); currentOutput[0].setLastModified(modified.getTime());
} }
} catch (Throwable ignored) { } catch (Throwable ignored) {
// best effort
} }
} }
} else { } else {
@ -1168,12 +1179,12 @@ public final class JBindExtractorMain {
@Override @Override
public void setTotal(Long files, Long bytes) { public void setTotal(Long files, Long bytes) {
// no-op
} }
@Override @Override
public void setCompleted(Long files, Long bytes) { public void setCompleted(Long files, Long bytes) {
// no-op
} }
@Override @Override
@ -1185,7 +1196,8 @@ public final class JBindExtractorMain {
if (filename == null || filename.trim().length() == 0) { if (filename == null || filename.trim().length() == 0) {
return null; return null;
} }
// Always resolve relative to the archive's parent directory.
// Never accept absolute paths to prevent path traversal.
String baseName = new File(filename).getName(); String baseName = new File(filename).getName();
if (archiveDir != null) { if (archiveDir != null) {
File relative = new File(archiveDir, baseName); File relative = new File(archiveDir, baseName);

View File

@ -66,6 +66,8 @@ async function callRealDebrid(link) {
}; };
} }
// megaCookie is intentionally cached at module scope so that multiple
// callMegaDebrid() invocations reuse the same session cookie.
async function callMegaDebrid(link) { async function callMegaDebrid(link) {
if (!megaCookie) { if (!megaCookie) {
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", { const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {

View File

@ -116,6 +116,7 @@ function getGiteaRepo() {
} }
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` }; return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
} catch { } catch {
// try next remote
} }
} }
@ -255,6 +256,9 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
target_commitish: "main", target_commitish: "main",
name: tag, name: tag,
body: notes || `Release ${tag}`, body: notes || `Release ${tag}`,
// Als Draft anlegen — der Auto-Updater ueberspringt Drafts. So wird das Release erst
// NACH dem Asset-Upload (unten via PATCH draft:false) "latest"; ein Update-Check kann
// nie ein Release ohne Setup/latest.yml sehen ("Setup-Asset nicht gefunden").
draft: true, draft: true,
prerelease: false prerelease: false
}; };
@ -262,6 +266,8 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
if (created.ok) { if (created.ok) {
return created.body; return created.body;
} }
// Gitea can return 409/422/500 UNIQUE when the release was already partially created.
// Retry the GET — it may now exist.
if (created.status === 409 || created.status === 422 || created.status === 500) { if (created.status === 409 || created.status === 422 || created.status === 500) {
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader); const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
if (retry.ok) { if (retry.ok) {
@ -279,6 +285,11 @@ async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, f
const fileSize = fs.statSync(filePath).size; const fileSize = fs.statSync(filePath).size;
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`; const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
// Grosse Assets (~80MB Setup/portable) brechen gelegentlich mitten im Upload ab
// (Netzwerk-Reset oder 5xx). Da das Release vorher als Draft angelegt wird, bleibt ein
// Fehlschlag hier unsichtbar — aber der Release ist dann unvollstaendig. Deshalb je Asset
// bis zu MAX_ATTEMPTS Versuche mit Backoff; ein konsumierter Stream laesst sich nicht
// erneut senden, also pro Versuch einen FRISCHEN createReadStream.
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) { for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
const fileStream = fs.createReadStream(filePath); const fileStream = fs.createReadStream(filePath);
let response; let response;
@ -320,6 +331,7 @@ async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, f
process.stdout.write(`Skipped existing asset: ${fileName}\n`); process.stdout.write(`Skipped existing asset: ${fileName}\n`);
break; break;
} }
// 5xx = transient -> neu versuchen; 4xx (ausser 409/422) = echter Fehler -> abbrechen.
if (response.status >= 500 && attempt < MAX_ATTEMPTS) { if (response.status >= 500 && attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`); process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt)); await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
@ -378,6 +390,9 @@ async function main() {
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes); const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files); await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files);
// Erst JETZT veroeffentlichen (draft:false), nachdem ALLE Assets oben hochgeladen sind.
// Davor war das Release den ganzen Upload ueber sichtbar, aber ohne latest.yml/Setup →
// Auto-Update-Checks in diesem Fenster scheiterten mit "Setup-Asset nicht gefunden".
const published = await apiRequest("PATCH", `${baseApi}/releases/${release.id}`, authHeader, JSON.stringify({ draft: false })); const published = await apiRequest("PATCH", `${baseApi}/releases/${release.id}`, authHeader, JSON.stringify({ draft: false }));
if (!published.ok) { if (!published.ok) {
throw new Error(`Failed to publish release (${published.status}): ${JSON.stringify(published.body)}`); throw new Error(`Failed to publish release (${published.status}): ${JSON.stringify(published.body)}`);

View File

@ -4,6 +4,18 @@ import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/de
import { logger } from "./logger"; import { logger } from "./logger";
import { compactErrorText } from "./utils"; import { compactErrorText } from "./utils";
/**
* Account-Validity + Premium-Check fuer Multi-Account-Provider.
*
* Standalone (eigene fetch-Calls, kein Import aus debrid.ts) damit es ohne
* Zirkular-Abhaengigkeit von der "Check all"-IPC und beim Programmstart genutzt
* werden kann.
*
* Verifizierte API-Felder (Live-Probe):
* - Mega-Debrid connectUser -> { response_code:"ok", token, vip_end (Unix-ts), email }
* - Debrid-Link /account/infos -> { success, value: { accountType, premiumLeft (s), username } }
*/
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php"; const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
const DEBRID_LINK_API = "https://debrid-link.com/api/v2"; const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
const CHECK_USER_AGENT = const CHECK_USER_AGENT =
@ -43,6 +55,7 @@ function formatRemaining(premiumUntilMs: number | null, now: number): string {
return `Premium noch ${hours} Std`; return `Premium noch ${hours} Std`;
} }
/** Check a single Mega-Debrid account via connectUser. */
export async function checkMegaDebridAccount( export async function checkMegaDebridAccount(
account: MegaDebridAccountEntry, account: MegaDebridAccountEntry,
signal?: AbortSignal, signal?: AbortSignal,
@ -74,6 +87,7 @@ export async function checkMegaDebridAccount(
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt"); const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
return { ...base, message: `Ungueltiger Login: ${reason}` }; return { ...base, message: `Ungueltiger Login: ${reason}` };
} }
// vip_end is a Unix timestamp (seconds). 0 / missing => no premium.
const vipEndRaw = Number(payload.vip_end || 0); const vipEndRaw = Number(payload.vip_end || 0);
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0; const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
const isPremium = premiumUntilMs > now; const isPremium = premiumUntilMs > now;
@ -96,6 +110,7 @@ export async function checkMegaDebridAccount(
} }
} }
/** Check a single Debrid-Link API key via /account/infos. */
export async function checkDebridLinkKey( export async function checkDebridLinkKey(
key: DebridLinkApiKeyEntry, key: DebridLinkApiKeyEntry,
signal?: AbortSignal, signal?: AbortSignal,
@ -123,6 +138,7 @@ export async function checkDebridLinkKey(
const text = await response.text(); const text = await response.text();
const payload = parseJsonSafe(text); const payload = parseJsonSafe(text);
if (!response.ok || !payload) { if (!response.ok || !payload) {
// 401 = bad/expired token
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" }; return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
} }
@ -133,6 +149,7 @@ export async function checkDebridLinkKey(
return { ...base, message: `Ungueltiger API-Key: ${reason}` }; return { ...base, message: `Ungueltiger API-Key: ${reason}` };
} }
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>; const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
// premiumLeft = seconds of premium remaining. accountType>0 also indicates premium.
const premiumLeftSec = Number(value.premiumLeft || 0); const premiumLeftSec = Number(value.premiumLeft || 0);
const accountType = Number(value.accountType || 0); const accountType = Number(value.accountType || 0);
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0; const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
@ -158,6 +175,8 @@ export async function checkDebridLinkKey(
} }
} }
/** Check ALL configured multi-account credentials (Mega-Debrid accounts +
* Debrid-Link keys) concurrently. Returns one status per account id. */
export async function checkAllDebridAccounts( export async function checkAllDebridAccounts(
settings: AppSettings, settings: AppSettings,
signal?: AbortSignal signal?: AbortSignal
@ -166,6 +185,9 @@ export async function checkAllDebridAccounts(
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || ""); const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || ""); const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
// Each task is a thunk so we can throttle concurrency. Firing all accounts at
// once (e.g. 9+ Debrid-Link keys) can trip provider rate-limits and produce
// false "invalid" badges, so cap at CHECK_CONCURRENCY parallel checks.
const taskFns: Array<() => Promise<DebridAccountStatus>> = [ const taskFns: Array<() => Promise<DebridAccountStatus>> = [
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)), ...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now)) ...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
@ -181,6 +203,7 @@ export async function checkAllDebridAccounts(
const CHECK_CONCURRENCY = 4; const CHECK_CONCURRENCY = 4;
/** Run thunks with a bounded number in flight, preserving result order. */
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> { async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
const results: T[] = new Array(taskFns.length); const results: T[] = new Array(taskFns.length);
let nextIndex = 0; let nextIndex = 0;

View File

@ -4,30 +4,51 @@ import path from "node:path";
import { AsyncLocalStorage } from "node:async_hooks"; import { AsyncLocalStorage } from "node:async_hooks";
import type { RotationEvent } from "../shared/types"; import type { RotationEvent } from "../shared/types";
/** Item-scoped sink: while a single item's link-unrestrict runs, the
* download-manager wraps it in runWithRotationItemSink() so EVERY rotation
* event for that item (Account 1 wird versucht, fehlgeschlagen, Account 2)
* lands in that item's own log exactly where the user looks. AsyncLocalStorage
* keeps this correct even with 8 items unrestricting in parallel: each runs in
* its own async context, so events never cross-attribute. */
export type RotationItemSink = (event: RotationEvent) => void; export type RotationItemSink = (event: RotationEvent) => void;
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>(); const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
/** Run `fn` with an item-scoped rotation sink active for its whole async chain. */
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> { export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
return rotationItemContext.run(sink, fn); return rotationItemContext.run(sink, fn);
} }
/** Dedicated log file for multi-account/key rotation events:
* Mega-Debrid account selection, Debrid-Link key selection, per-attempt
* test result, cooldown set, fallback to next account/key, etc.
* Separate from rd_downloader.log so the user can see the rotation flow
* without the noise of normal download activity. */
type RotationLevel = "INFO" | "WARN" | "ERROR"; type RotationLevel = "INFO" | "WARN" | "ERROR";
/** In-memory ring buffer of the most recent rotation events so the UI can show
* a live "which account was tried and why it failed" panel the same events
* written to account-rotation.log, but surfaced to the renderer via snapshot. */
const ROTATION_EVENT_RING_MAX = 60; const ROTATION_EVENT_RING_MAX = 60;
const rotationEventRing: RotationEvent[] = []; const rotationEventRing: RotationEvent[] = [];
let rotationEventSeq = 0; let rotationEventSeq = 0;
let rotationEventListener: ((event: RotationEvent) => void) | null = null; let rotationEventListener: ((event: RotationEvent) => void) | null = null;
/** Register a callback fired whenever a new rotation event is recorded (used by
* the download-manager to push a fresh snapshot to the UI immediately). */
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void { export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
rotationEventListener = listener; rotationEventListener = listener;
} }
/** Returns the recent rotation events, newest first. */
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] { export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
const slice = rotationEventRing.slice(-limit); const slice = rotationEventRing.slice(-limit);
slice.reverse(); slice.reverse();
return slice; return slice;
} }
/** Events that are noise for the UI panel (per-attempt TEST markers). The panel
* focuses on outcomes: OK / FAILED / FATAL / skips. */
function isUiRelevantRotationEvent(event: string): boolean { function isUiRelevantRotationEvent(event: string): boolean {
return event !== "TEST"; return event !== "TEST";
} }
@ -54,14 +75,20 @@ function pushRotationEvent(
next: fields && fields.next != null ? String(fields.next) : undefined next: fields && fields.next != null ? String(fields.next) : undefined
}; };
// Always route to the item-scoped sink (if any) — the per-item log wants the
// FULL trail including "TEST" (Account X wird versucht), so the user sees the
// rotation right where they look.
const itemSink = rotationItemContext.getStore(); const itemSink = rotationItemContext.getStore();
if (itemSink) { if (itemSink) {
try { try {
itemSink(entry); itemSink(entry);
} catch { } catch {
// never let item logging break the rotation flow
} }
} }
// The global UI panel ring + live push skip noisy per-attempt TEST markers;
// it focuses on outcomes (OK / FAILED / FATAL / skips).
if (!isUiRelevantRotationEvent(event)) { if (!isUiRelevantRotationEvent(event)) {
return; return;
} }
@ -73,6 +100,7 @@ function pushRotationEvent(
try { try {
rotationEventListener(entry); rotationEventListener(entry);
} catch { } catch {
// never let a UI push break the rotation flow
} }
} }
} }
@ -119,9 +147,11 @@ function rotateIfNeeded(filePath: string): void {
try { try {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} catch { } catch {
// ignore
} }
fs.renameSync(filePath, backup); fs.renameSync(filePath, backup);
} catch { } catch {
// ignore
} }
} }
@ -134,6 +164,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} }
} catch { } catch {
// ignore
} }
} }
@ -159,6 +190,13 @@ export function initAccountRotationLog(baseDir: string): void {
} }
} }
/** Record an account/key rotation event. The format is intentionally compact
* and grep-friendly: timestamp + level + provider + accountLabel + event + fields.
* Example output:
* 2026-04-19T20:48:50.000Z [INFO] Mega-Debrid Web | Account 2 (fa**david@...) | TEST | link=https://...
* 2026-04-19T20:48:52.000Z [WARN] Mega-Debrid Web | Account 2 (fa**david@...) | FAILED reason="Antwort leer" cooldownSec=30 | link=https://...
* 2026-04-19T20:48:53.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | TEST | link=https://...
* 2026-04-19T20:48:55.000Z [INFO] Mega-Debrid Web | Account 3 (am**@example.com) | OK directLink=https://... | link=https://... */
export function logAccountRotation( export function logAccountRotation(
level: RotationLevel, level: RotationLevel,
provider: string, provider: string,
@ -166,6 +204,7 @@ export function logAccountRotation(
event: string, event: string,
fields?: Record<string, unknown> fields?: Record<string, unknown>
): void { ): void {
// Surface to the UI ring buffer regardless of whether the file log is ready.
pushRotationEvent(level, provider, accountLabel, event, fields); pushRotationEvent(level, provider, accountLabel, event, fields);
if (!rotationLogPath) { if (!rotationLogPath) {
return; return;
@ -178,6 +217,7 @@ export function logAccountRotation(
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`; const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8"); fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
} catch { } catch {
// ignore write errors
} }
} }
@ -199,6 +239,7 @@ export function shutdownAccountRotationLog(): void {
"utf8" "utf8"
); );
} catch { } catch {
// ignore
} }
rotationLogPath = null; rotationLogPath = null;
} }

View File

@ -243,10 +243,12 @@ export class AllDebridWebFallback {
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
}); });
} catch { } catch {
// ignore
} }
try { try {
await currentSession.clearCache(); await currentSession.clearCache();
} catch { } catch {
// ignore
} }
} }
} }

View File

@ -1,5 +1,4 @@
import path from "node:path"; import path from "node:path";
import v8 from "node:v8";
import { app } from "electron"; import { app } from "electron";
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys"; import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import { import {
@ -40,7 +39,6 @@ import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves,
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update"; import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto"; import { encryptBackup, decryptBackup } from "./backup-crypto";
import { buildBackupPayload, planBackupImport } from "./backup-payload";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log"; import { initAccountRotationLog, shutdownAccountRotationLog } from "./account-rotation-log";
import { runStartupHealthCheck } from "./startup-health-check"; import { runStartupHealthCheck } from "./startup-health-check";
@ -85,7 +83,6 @@ export class AppController {
private autoResumePending = false; private autoResumePending = false;
private runtimeStatsTimer: NodeJS.Timeout | null = null; private runtimeStatsTimer: NodeJS.Timeout | null = null;
private lastMemoryWarnAt = 0;
public constructor() { public constructor() {
configureLogger(this.storagePaths.baseDir); configureLogger(this.storagePaths.baseDir);
@ -95,6 +92,12 @@ export class AppController {
initAuditLog(this.storagePaths.baseDir); initAuditLog(this.storagePaths.baseDir);
initAccountRotationLog(this.storagePaths.baseDir); initAccountRotationLog(this.storagePaths.baseDir);
initRenameLog(this.storagePaths.baseDir); initRenameLog(this.storagePaths.baseDir);
// Session-eigenes Rename-Protokoll auf dem Desktop (eigener Ordner Downloader-Log,
// selbstheilend) — luekenlose Uebersicht + Post-Rename-Verifikation fuer kuenftige
// Renaming-Diagnose, getrennt von den userData-Logs. app.getPath("desktop") wird vom
// Aufrufer ausgewertet (ausserhalb der modul-internen try/catch) — der "desktop"-
// Known-Folder kann in Headless-/Service-Account-Setups scheitern, daher hier gegen
// einen Startup-Crash absichern (initDesktopRenameLog behandelt null als no-op).
let desktopDir: string | null = null; let desktopDir: string | null = null;
try { try {
desktopDir = app.getPath("desktop"); desktopDir = app.getPath("desktop");
@ -132,6 +135,10 @@ export class AppController {
appVersion: APP_VERSION, appVersion: APP_VERSION,
runtimeDir: this.storagePaths.baseDir runtimeDir: this.storagePaths.baseDir
}); });
// Startup Health-Check: surface problematic state early (missing download
// dir, low disk space, no provider configured, corrupted state file).
// Never blocks startup — findings go into the normal log + audit log so
// the user can diagnose issues before hitting them mid-download.
try { try {
const report = runStartupHealthCheck(this.settings, this.storagePaths); const report = runStartupHealthCheck(this.settings, this.storagePaths);
if (report.errorCount > 0 || report.warnCount > 0) { if (report.errorCount > 0 || report.warnCount > 0) {
@ -164,7 +171,6 @@ export class AppController {
this.runtimeStatsTimer = setInterval(() => { this.runtimeStatsTimer = setInterval(() => {
this.manager.persistRuntimeStats(); this.manager.persistRuntimeStats();
this.settings = this.manager.getSettings(); this.settings = this.manager.getSettings();
this.checkMemoryPressure();
}, 60_000); }, 60_000);
this.runtimeStatsTimer.unref?.(); this.runtimeStatsTimer.unref?.();
@ -175,6 +181,8 @@ export class AppController {
void this.manager.getStartConflicts().then((conflicts) => { void this.manager.getStartConflicts().then((conflicts) => {
const hasConflicts = conflicts.length > 0; const hasConflicts = conflicts.length > 0;
if (this.hasAnyProviderToken(this.settings) && !hasConflicts) { if (this.hasAnyProviderToken(this.settings) && !hasConflicts) {
// If the onState handler is already set (renderer connected), start immediately.
// Otherwise mark as pending so the onState setter triggers the start.
if (this.onStateHandler) { if (this.onStateHandler) {
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)"); logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`)); void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
@ -190,34 +198,6 @@ export class AppController {
} }
} }
// Early-warning for OOM on a long-running process. Measured against the V8
// heap_size_limit (the real ceiling at which the process is killed), NOT against
// heapTotal: V8 routinely runs near-full of its current heapTotal just before it
// grows it, so a heapUsed/heapTotal ratio would cry wolf and — since every WARN
// now feeds the error ring — crowd real failures out. Throttled to 1 warning per
// 5 min so a genuine sustained-pressure run does not spam the log/ring.
private checkMemoryPressure(): void {
try {
const mem = process.memoryUsage();
const heapLimit = v8.getHeapStatistics().heap_size_limit;
const ratio = heapLimit > 0 ? mem.heapUsed / heapLimit : 0;
if (ratio < 0.9) {
return;
}
const now = Date.now();
if (now - this.lastMemoryWarnAt < 5 * 60_000) {
return;
}
this.lastMemoryWarnAt = now;
const mb = (bytes: number): number => Math.round(bytes / 1048576);
logger.warn(
`Speicherdruck: heapUsed=${mb(mem.heapUsed)}MB von Limit ${mb(heapLimit)}MB ` +
`(${Math.round(ratio * 100)}%), heapTotal=${mb(mem.heapTotal)}MB, rss=${mb(mem.rss)}MB, external=${mb(mem.external)}MB`
);
} catch {
}
}
private hasAnyProviderToken(settings: AppSettings): boolean { private hasAnyProviderToken(settings: AppSettings): boolean {
return Boolean( return Boolean(
settings.token.trim() settings.token.trim()
@ -245,6 +225,7 @@ export class AppController {
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`)); void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
logger.info("Auto-Resume beim Start aktiviert"); logger.info("Auto-Resume beim Start aktiviert");
} else { } else {
// Trigger pending extractions without starting the session
this.manager.triggerIdleExtractions(); this.manager.triggerIdleExtractions();
} }
} }
@ -315,6 +296,7 @@ export class AppController {
return previousSettings; return previousSettings;
} }
// Preserve the live all-time counters from the download manager
const liveSettings = this.manager.getSettings(); const liveSettings = this.manager.getSettings();
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0); nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0); nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
@ -328,6 +310,11 @@ export class AppController {
nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries( nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId)) Object.entries(liveSettings.debridLinkApiKeyTotalUsageBytes || {}).filter(([keyId]) => getDebridLinkApiKeyIds(nextSettings.debridLinkApiKeys).includes(keyId))
); );
// debridAccountStatuses ist main-owned Runtime-State (wird NUR von
// applyDebridAccountStatuses gesetzt). Der Renderer schickt in seinem Settings-
// Patch eine evtl. veraltete Kopie mit; die Live-Version bewahren, damit ein
// Settings-Save (z.B. direkt nach Hinzufuegen+Pruefen eines Mega-Accounts im
// Dialog) einen frisch geprueften Status nicht ueberschreibt.
nextSettings.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) }; nextSettings.debridAccountStatuses = { ...(liveSettings.debridAccountStatuses || {}) };
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode; const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
this.settings = nextSettings; this.settings = nextSettings;
@ -414,6 +401,9 @@ export class AppController {
return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host); return fetchDebridLinkHostLimits(this.settings.debridLinkApiKeys, host);
} }
/** Check login validity + premium expiry for ALL configured multi-account
* credentials (Mega-Debrid accounts + Debrid-Link keys), persist the result
* into settings (so badges survive restart), and return the statuses. */
public async checkDebridAccounts(): Promise<DebridAccountStatus[]> { public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
const statuses = await checkAllDebridAccounts(this.settings); const statuses = await checkAllDebridAccounts(this.settings);
this.manager.applyDebridAccountStatuses(statuses); this.manager.applyDebridAccountStatuses(statuses);
@ -425,6 +415,9 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
return statuses; return statuses;
} }
/** Check a SINGLE Mega-Debrid account by raw credentials (used when an account
* is added in the dialog, before it is saved) and merge the result so the
* validity/premium badge updates immediately without a full "Alle pruefen". */
public async checkSingleMegaDebridAccount(login: string, password: string): Promise<DebridAccountStatus | null> { public async checkSingleMegaDebridAccount(login: string, password: string): Promise<DebridAccountStatus | null> {
const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0]; const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0];
if (!entry) { if (!entry) {
@ -445,9 +438,20 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
} }
public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> { public async installUpdate(onProgress?: (progress: UpdateInstallProgress) => void): Promise<UpdateInstallResult> {
// Stop active downloads before installing. Extractions may continue briefly
// until prepareForShutdown() is called during app quit.
// parkForRestart MUST stay true here: it keeps in-flight items as "queued"
// (not "cancelled") so the updated app auto-resumes them after the silent
// install relaunch. A plain stop() marks them "cancelled"/"Gestoppt", which
// autoResumeOnStart does NOT pick up — the downloads then silently fail to
// continue after the update (the reported "packages gone after update" bug).
// Regression coverage: tests/update-restart-resume.test.ts asserts this exact
// stop({parkForRestart:true}) + persistNowSync() sequence reloads as "queued".
if (this.manager.isSessionRunning()) { if (this.manager.isSessionRunning()) {
this.manager.stop({ parkForRestart: true }); this.manager.stop({ parkForRestart: true });
} }
// Flush any pending async saves BEFORE the update process starts.
// This ensures the queue is fully persisted to disk so it survives the restart.
this.manager.persistNowSync(); this.manager.persistNowSync();
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt; const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
@ -630,21 +634,23 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
} }
public exportBackup(): Buffer { public exportBackup(): Buffer {
const includeDownloads = Boolean(this.settings.backupIncludeDownloads); const settings = { ...this.settings };
const payloadObj = buildBackupPayload({ const session = this.manager.getSession();
settings: { ...this.settings }, const history = loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
const payload = JSON.stringify({
version: 2,
appVersion: APP_VERSION, appVersion: APP_VERSION,
exportedAt: new Date().toISOString(), exportedAt: new Date().toISOString(),
session: this.manager.getSession(), settings,
history: loadHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode) session,
history
}); });
this.audit("INFO", "Backup exportiert", { this.audit("INFO", "Backup exportiert", {
kind: payloadObj.kind, historyEntries: history.length,
historyEntries: payloadObj.history ? payloadObj.history.length : 0, sessionItems: Object.keys(session.items).length,
sessionItems: payloadObj.session ? Object.keys(payloadObj.session.items).length : 0, sessionPackages: Object.keys(session.packages).length
sessionPackages: payloadObj.session ? Object.keys(payloadObj.session.packages).length : 0
}); });
return encryptBackup(JSON.stringify(payloadObj)); return encryptBackup(payload);
} }
public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } { public exportSupportBundle(): { buffer: Buffer; defaultFileName: string } {
@ -663,28 +669,30 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
return getSupportBundleDefaultFileName(); return getSupportBundleDefaultFileName();
} }
public importBackup(data: Buffer): { restored: boolean; relaunch: boolean; message: string } { public importBackup(data: Buffer): { restored: boolean; message: string } {
let parsed: Record<string, unknown>; let parsed: Record<string, unknown>;
try { try {
// Try encrypted MDD format first
const json = decryptBackup(data); const json = decryptBackup(data);
parsed = JSON.parse(json) as Record<string, unknown>; parsed = JSON.parse(json) as Record<string, unknown>;
} catch { } catch {
// Fallback: try legacy plaintext JSON (old backups)
try { try {
const json = data.toString("utf8"); const json = data.toString("utf8");
parsed = JSON.parse(json) as Record<string, unknown>; parsed = JSON.parse(json) as Record<string, unknown>;
} catch { } catch {
return { restored: false, relaunch: false, message: "Backup-Datei konnte nicht entschlüsselt werden" }; return { restored: false, message: "Backup-Datei konnte nicht entschlüsselt werden" };
} }
} }
const plan = planBackupImport(parsed); if (!parsed || typeof parsed !== "object" || !parsed.settings || !parsed.session) {
if (!plan.valid) { return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" };
return { restored: false, relaunch: false, message: plan.message };
} }
const hasSession = plan.restoreDownloads;
// Restore settings — ALL credentials are included (no more masking)
const importedSettings = parsed.settings as AppSettings; const importedSettings = parsed.settings as AppSettings;
const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>; const importedSettingsRecord = importedSettings as unknown as Record<string, unknown>;
const currentSettingsRecord = this.settings as unknown as Record<string, unknown>; const currentSettingsRecord = this.settings as unknown as Record<string, unknown>;
// Legacy backup compatibility: if credentials were masked with ***, keep current values
const SENSITIVE_KEYS: (keyof AppSettings)[] = [ const SENSITIVE_KEYS: (keyof AppSettings)[] = [
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken", "token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey", "ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
@ -701,30 +709,19 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
saveSettings(this.storagePaths, this.settings); saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings); this.manager.setSettings(this.settings);
// Settings-only backup: settings are already applied live (same path as the // Full stop including extraction abort
// normal updateSettings flow). Do NOT stop the manager, wipe the session,
// block persistence or relaunch — the running queue stays untouched.
if (!hasSession) {
this.audit("INFO", "Backup importiert (nur Einstellungen)", {
accountSummary: buildAccountSummary(this.settings)
});
return {
restored: true,
relaunch: false,
message: "Einstellungen wiederhergestellt"
};
}
this.manager.stop(); this.manager.stop();
this.manager.abortAllPostProcessing(); this.manager.abortAllPostProcessing();
this.manager.clearPersistTimer(); this.manager.clearPersistTimer();
cancelPendingAsyncSaves(); cancelPendingAsyncSaves();
// Restore session
const restoredSession = normalizeLoadedSessionTransientFields( const restoredSession = normalizeLoadedSessionTransientFields(
normalizeLoadedSession(parsed.session) normalizeLoadedSession(parsed.session)
); );
saveSession(this.storagePaths, restoredSession); saveSession(this.storagePaths, restoredSession);
// Restore history (if present in backup)
if (Array.isArray(parsed.history) && parsed.history.length > 0) { if (Array.isArray(parsed.history) && parsed.history.length > 0) {
const normalizedHistory = (parsed.history as unknown[]) const normalizedHistory = (parsed.history as unknown[])
.map((raw, idx) => normalizeHistoryEntry(raw, idx)) .map((raw, idx) => normalizeHistoryEntry(raw, idx))
@ -737,6 +734,15 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode); resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode);
// Block runtime + shutdown persistence so the STALE in-memory session (the
// manager still holds the PRE-import session — importBackup only wrote to disk)
// cannot overwrite the restored data in the brief window before the auto-relaunch.
// The relaunch (triggered in main.ts when restored===true) starts a fresh process
// that loads the restored session cleanly via the normal startup path, so these
// flags never linger in a live session.
// M2-Fix: vorher blieb blockAllPersistence dauerhaft true wenn der User die
// manuelle "Bitte neustarten"-Aufforderung ignorierte → stille Persistenz-Blockade,
// alle weiteren Änderungen gingen bei hartem Crash verloren. Jetzt: Auto-Relaunch.
this.manager.skipShutdownPersist = true; this.manager.skipShutdownPersist = true;
this.manager.blockAllPersistence = true; this.manager.blockAllPersistence = true;
logger.info("Backup wiederhergestellt — App startet automatisch neu"); logger.info("Backup wiederhergestellt — App startet automatisch neu");
@ -744,7 +750,7 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0, historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
accountSummary: buildAccountSummary(this.settings) accountSummary: buildAccountSummary(this.settings)
}); });
return { restored: true, relaunch: true, message: "Backup wiederhergestellt App startet automatisch neu…" }; return { restored: true, message: "Backup wiederhergestellt App startet automatisch neu…" };
} }
public getSessionLogPath(): string | null { public getSessionLogPath(): string | null {

View File

@ -46,9 +46,11 @@ function rotateIfNeeded(filePath: string): void {
try { try {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} catch { } catch {
// ignore
} }
fs.renameSync(filePath, backup); fs.renameSync(filePath, backup);
} catch { } catch {
// ignore
} }
} }
@ -61,6 +63,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} }
} catch { } catch {
// ignore
} }
} }
@ -97,6 +100,7 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
"utf8" "utf8"
); );
} catch { } catch {
// ignore write errors
} }
} }
@ -114,6 +118,7 @@ export function shutdownAuditLog(): void {
try { try {
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
auditLogPath = null; auditLogPath = null;
} }

View File

@ -1,15 +1,22 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
// Fixed app key — like JDownloader 2: deterministic, works on any machine.
// Not meant to protect against reverse-engineering, just prevents casual
// plaintext snooping when someone opens the backup file.
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026"; const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
const ALGORITHM = "aes-256-gcm"; const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12; const IV_LENGTH = 12; // 96-bit IV for GCM
const AUTH_TAG_LENGTH = 16; const AUTH_TAG_LENGTH = 16;
const MAGIC = Buffer.from("MDD1"); const MAGIC = Buffer.from("MDD1"); // file signature
function deriveKey(): Buffer { function deriveKey(): Buffer {
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest(); return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
} }
/**
* Encrypt a UTF-8 string into an MDD backup buffer.
* Format: MAGIC(4) | IV(12) | AUTH_TAG(16) | CIPHERTEXT()
*/
export function encryptBackup(plaintext: string): Buffer { export function encryptBackup(plaintext: string): Buffer {
const key = deriveKey(); const key = deriveKey();
const iv = crypto.randomBytes(IV_LENGTH); const iv = crypto.randomBytes(IV_LENGTH);
@ -19,6 +26,10 @@ export function encryptBackup(plaintext: string): Buffer {
return Buffer.concat([MAGIC, iv, authTag, encrypted]); return Buffer.concat([MAGIC, iv, authTag, encrypted]);
} }
/**
* Decrypt an MDD backup buffer back to a UTF-8 string.
* Throws on invalid/corrupted data.
*/
export function decryptBackup(data: Buffer): string { export function decryptBackup(data: Buffer): string {
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) { if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error("Backup-Datei zu kurz oder ungültig"); throw new Error("Backup-Datei zu kurz oder ungültig");

View File

@ -1,77 +0,0 @@
import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
export type BackupKind = "full" | "settings-only";
export interface BackupPayload {
version: 2;
kind: BackupKind;
appVersion: string;
exportedAt: string;
settings: AppSettings;
session?: SessionState;
history?: HistoryEntry[];
}
export interface BuildBackupInput {
settings: AppSettings;
appVersion: string;
exportedAt: string;
/** Only bundled when includeDownloads is true. */
session: SessionState;
history: HistoryEntry[];
}
/**
* Build the backup payload. By default ("Download-Liste mitsichern" off) the
* payload contains ONLY settings no session, no history. The download list is
* bundled solely when settings.backupIncludeDownloads is true. An explicit kind
* marker makes the import side unambiguous and survives hand-edited files.
*/
export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
const includeDownloads = Boolean(input.settings.backupIncludeDownloads);
const base: BackupPayload = {
version: 2,
kind: includeDownloads ? "full" : "settings-only",
appVersion: input.appVersion,
exportedAt: input.exportedAt,
settings: input.settings
};
if (includeDownloads) {
base.session = input.session;
base.history = input.history;
}
return base;
}
export interface ImportPlan {
valid: boolean;
/** Restore the download list (session + history) and relaunch. */
restoreDownloads: boolean;
message: string;
}
/**
* Decide how to apply an imported backup based on what the FILE physically
* contains NOT the local toggle. A backup without a session restores settings
* only (no queue wipe, no relaunch); a full backup (with session) restores the
* queue too. This way an old full backup still restores fully even if the local
* toggle is currently off, and a settings-only backup never disturbs a running
* queue.
*/
export function planBackupImport(parsed: unknown): ImportPlan {
if (!parsed || typeof parsed !== "object") {
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
}
const record = parsed as Record<string, unknown>;
if (!record.settings || typeof record.settings !== "object") {
return { valid: false, restoreDownloads: false, message: "Kein gültiges Backup (settings fehlen)" };
}
const hasSession = Boolean(record.session) && typeof record.session === "object";
return {
valid: true,
restoreDownloads: hasSession,
message: hasSession
? "Backup wiederhergestellt App startet automatisch neu…"
: "Einstellungen wiederhergestellt"
};
}

View File

@ -212,15 +212,18 @@ export class BestDebridWebFallback {
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
}); });
} catch { } catch {
// ignore
} }
try { try {
await currentSession.clearCache(); await currentSession.clearCache();
} catch { } catch {
// ignore
} }
} }
} }
public dispose(): void { public dispose(): void {
// nothing to clean up
} }
private getPartition(): string { private getPartition(): string {
@ -341,6 +344,7 @@ export class BestDebridWebFallback {
try { try {
await currentSession.clearCache(); await currentSession.clearCache();
} catch { } catch {
// ignore cache clear failures
} }
} }
} }

View File

@ -39,6 +39,7 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
fs.rmSync(full, { force: true }); fs.rmSync(full, { force: true });
removed += 1; removed += 1;
} catch { } catch {
// ignore
} }
} }
} }
@ -83,6 +84,7 @@ export async function cleanupCancelledPackageArtifactsAsync(
await fs.promises.rm(full, { force: true }); await fs.promises.rm(full, { force: true });
removed += 1; removed += 1;
} catch { } catch {
// ignore
} }
} }
@ -148,6 +150,7 @@ export async function removeDownloadLinkArtifacts(
await fs.promises.rm(full, { force: true }); await fs.promises.rm(full, { force: true });
removed += 1; removed += 1;
} catch { } catch {
// ignore
} }
} }
} }
@ -237,6 +240,7 @@ export async function removeSampleArtifacts(
await fs.promises.rm(full, { force: true }); await fs.promises.rm(full, { force: true });
removedFiles += 1; removedFiles += 1;
} catch { } catch {
// ignore
} }
} }
} }
@ -259,6 +263,7 @@ export async function removeSampleArtifacts(
removedFiles += filesInDir; removedFiles += filesInDir;
removedDirs += 1; removedDirs += 1;
} catch { } catch {
// ignore
} }
} }

View File

@ -17,12 +17,12 @@ export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
export const REQUEST_RETRIES = 3; export const REQUEST_RETRIES = 3;
export const CHUNK_SIZE = 512 * 1024; export const CHUNK_SIZE = 512 * 1024;
export const WRITE_BUFFER_SIZE = 512 * 1024; export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDownloader: 500 KB)
export const WRITE_FLUSH_TIMEOUT_MS = 2000; export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout
export const ALLOCATION_UNIT_SIZE = 4096; export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment
export const STREAM_HIGH_WATER_MARK = 512 * 1024; export const STREAM_HIGH_WATER_MARK = 512 * 1024; // 512 KB stream buffer — lower than before (2 MB) so backpressure triggers sooner when disk is slow
export const DISK_BUSY_THRESHOLD_MS = 300; export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]); export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]); export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
@ -72,8 +72,6 @@ export function defaultSettings(): AppSettings {
packageName: "", packageName: "",
autoExtract: true, autoExtract: true,
autoRename4sf4sj: false, autoRename4sf4sj: false,
keepGermanAudioOnly: false,
germanAudioMode: "tag",
extractDir: path.join(baseDir, "_entpackt"), extractDir: path.join(baseDir, "_entpackt"),
collectMkvToLibrary: false, collectMkvToLibrary: false,
mkvLibraryDir: path.join(baseDir, "_mkv"), mkvLibraryDir: path.join(baseDir, "_mkv"),
@ -106,7 +104,6 @@ export function defaultSettings(): AppSettings {
autoSkipExtracted: false, autoSkipExtracted: false,
hideExtractedItems: true, hideExtractedItems: true,
confirmDeleteSelection: true, confirmDeleteSelection: true,
backupIncludeDownloads: false,
totalDownloadedAllTime: 0, totalDownloadedAllTime: 0,
totalCompletedFilesAllTime: 0, totalCompletedFilesAllTime: 0,
totalRuntimeAllTimeMs: 0, totalRuntimeAllTimeMs: 0,

View File

@ -113,11 +113,13 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
try { try {
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim(); fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
} catch { } catch {
// ignore
} }
} }
links.push(url); links.push(url);
fileNames.push(sanitizeFilename(fileName)); fileNames.push(sanitizeFilename(fileName));
} catch { } catch {
// skip broken entries
} }
} }
@ -130,6 +132,7 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
links.push(url); links.push(url);
} }
} catch { } catch {
// skip broken entries
} }
} }
} }

View File

@ -24,13 +24,20 @@ const ONEFICHIER_API_BASE = "https://api.1fichier.com/v1";
const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i; const ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2"; const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
/** Truly key-wide quota errors: the whole key is exhausted regardless of host. */
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]); const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
/** Per-(key, host) quota errors: only this host is exhausted for this key the
* key remains usable for other hosters. */
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]); const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
/** Backward-compat union includes BOTH key-wide and per-host quota codes.
* Use this only for "is it a quota error of any kind?" checks; for behavior
* branches use the more specific sets above. */
const DEBRID_LINK_QUOTA_ERRORS = new Set([...DEBRID_LINK_KEY_QUOTA_ERRORS, ...DEBRID_LINK_HOST_QUOTA_ERRORS]); const DEBRID_LINK_QUOTA_ERRORS = new Set([...DEBRID_LINK_KEY_QUOTA_ERRORS, ...DEBRID_LINK_HOST_QUOTA_ERRORS]);
const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]); const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]);
const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]); const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]);
const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]); const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]);
const DEBRID_LINK_PROVIDER_WIDE_ERRORS = new Set(["notDebrid"]); const DEBRID_LINK_PROVIDER_WIDE_ERRORS = new Set(["notDebrid"]);
/** Errors where the key can't handle this link — skip to next key immediately, no retries */
const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([ const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
"disabledServerHost", "disabledServerHost",
"notFreeHost", "notFreeHost",
@ -41,6 +48,7 @@ const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
"fileNotAvailable" "fileNotAvailable"
]); ]);
const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "badFilePassword", "fileNotFound", "hostNotValid"]); const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "badFilePassword", "fileNotFound", "hostNotValid"]);
/** Per-key cooldown cache: keyId → expiry timestamp. Parallel items skip keys that recently failed. */
const debridLinkKeyCooldowns = new Map<string, number>(); const debridLinkKeyCooldowns = new Map<string, number>();
type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip"; type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory }; type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory };
@ -52,7 +60,7 @@ type DebridLinkRuntimeStatus = {
}; };
const debridLinkKeyCooldownDetails = new Map<string, DebridLinkCooldownDetail>(); const debridLinkKeyCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
const debridLinkKeyRuntimeStatuses = new Map<string, DebridLinkRuntimeStatus>(); const debridLinkKeyRuntimeStatuses = new Map<string, DebridLinkRuntimeStatus>();
const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000; const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000; // 2 min cooldown per failed key
const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000; const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000;
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000; const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;
@ -64,6 +72,9 @@ export function resetDebridLinkRuntimeStateForTests(): void {
debridLinkKeyHostCooldownDetails.clear(); debridLinkKeyHostCooldownDetails.clear();
} }
/** Drop all Debrid-Link cooldown/runtime entries for key IDs that are no
* longer in the active key set. Called when settings change so removed
* keys don't keep blocking the system if they're re-added later. */
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void { export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
for (const keyId of debridLinkKeyCooldowns.keys()) { for (const keyId of debridLinkKeyCooldowns.keys()) {
if (!activeKeyIds.has(keyId)) { if (!activeKeyIds.has(keyId)) {
@ -76,6 +87,9 @@ export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): v
debridLinkKeyRuntimeStatuses.delete(keyId); debridLinkKeyRuntimeStatuses.delete(keyId);
} }
} }
// Per-(key, host) cooldown keys have format `${keyId}|${hoster}` — drop any
// whose keyId is no longer in the active set so removed keys don't keep
// memory state around if they're re-added later.
for (const stateKey of debridLinkKeyHostCooldowns.keys()) { for (const stateKey of debridLinkKeyHostCooldowns.keys()) {
const sepIdx = stateKey.indexOf("|"); const sepIdx = stateKey.indexOf("|");
const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey; const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey;
@ -86,9 +100,12 @@ export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): v
} }
} }
/** Periodic cleanup of expired Debrid-Link cooldown/runtime entries.
* Without this, module-level Maps grow unbounded over 24/7 operation.
* Removes entries whose cooldown expired more than 1 hour ago. */
export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number { export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number {
let removed = 0; let removed = 0;
const grace = 60 * 60 * 1000; const grace = 60 * 60 * 1000; // keep 1h grace for debugging
for (const [keyId, until] of debridLinkKeyCooldowns) { for (const [keyId, until] of debridLinkKeyCooldowns) {
if (until + grace < now) { if (until + grace < now) {
debridLinkKeyCooldowns.delete(keyId); debridLinkKeyCooldowns.delete(keyId);
@ -161,6 +178,13 @@ function setDebridLinkKeyCooldownState(
clearDebridLinkKeyCooldownState(keyId); clearDebridLinkKeyCooldownState(keyId);
return; return;
} }
// Cooldown set: max-wins. When 8 parallel items hit floodDetected on the
// same key, each computes its own retry-after and calls setDebridLinkKey
// CooldownState. Without max-wins, the LAST setter could shorten the
// cooldown (e.g. one item got a 1h Retry-After header, another got the
// default 2 min — without max-wins the 2 min would overwrite the 1h).
// Quota and rate_limit categories take priority over generic temporary
// cooldowns regardless of duration to preserve the more-specific signal.
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs)); const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
const existingUntil = Number(debridLinkKeyCooldowns.get(keyId) || 0); const existingUntil = Number(debridLinkKeyCooldowns.get(keyId) || 0);
const existingDetail = debridLinkKeyCooldownDetails.get(keyId); const existingDetail = debridLinkKeyCooldownDetails.get(keyId);
@ -168,6 +192,7 @@ function setDebridLinkKeyCooldownState(
const existingIsStrongCategory = existingDetail const existingIsStrongCategory = existingDetail
? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid") ? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
: false; : false;
// Keep existing if it's still active and either lasts longer or has a stronger category
if (existingUntil > Date.now()) { if (existingUntil > Date.now()) {
if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) { if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
return; return;
@ -202,6 +227,9 @@ function getDebridLinkKeyCooldownState(
}; };
} }
/** Per-(key, host) cooldown cache. When a key hits maxLinkHost / maxDataHost
* for a specific host, only that combination should be blocked the key
* itself stays usable for other hosters. Map key format: `${keyId}|${hoster}`. */
const debridLinkKeyHostCooldowns = new Map<string, number>(); const debridLinkKeyHostCooldowns = new Map<string, number>();
const debridLinkKeyHostCooldownDetails = new Map<string, DebridLinkCooldownDetail>(); const debridLinkKeyHostCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
@ -223,6 +251,8 @@ function setDebridLinkKeyHostCooldownState(
category: DebridLinkCooldownCategory category: DebridLinkCooldownCategory
): void { ): void {
if (!hoster) { if (!hoster) {
// Fall back to key-wide cooldown when we can't determine the hoster — better
// a slightly broader block than letting the key thrash on the same failure.
setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category); setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category);
return; return;
} }
@ -231,6 +261,10 @@ function setDebridLinkKeyHostCooldownState(
return; return;
} }
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster); const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
// Same max-wins semantics as setDebridLinkKeyCooldownState — parallel items
// hitting maxDataHost on the same (key, host) shouldn't shorten an existing
// longer cooldown. Strong categories (quota / rate_limit / invalid) win over
// generic temporary regardless of duration.
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs)); const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
const existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0); const existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey); const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey);
@ -248,6 +282,8 @@ function setDebridLinkKeyHostCooldownState(
} }
debridLinkKeyHostCooldowns.set(stateKey, newUntil); debridLinkKeyHostCooldowns.set(stateKey, newUntil);
debridLinkKeyHostCooldownDetails.set(stateKey, { message, category }); debridLinkKeyHostCooldownDetails.set(stateKey, { message, category });
// Intentionally NOT updating setDebridLinkKeyRuntimeStatus here — the key
// is still healthy for other hosters, only this (key, host) is blocked.
} }
function getDebridLinkKeyHostCooldownState( function getDebridLinkKeyHostCooldownState(
@ -277,31 +313,37 @@ function getDebridLinkKeyHostCooldownState(
}; };
} }
/** Per-account cooldown cache for Mega-Debrid: accountId expiry timestamp.
* untilRestart: ein Tageslimit-Account wird fuer den REST der Laufzeit uebersprungen
* (nicht alle 20s/2min neu getestet) und kommt erst nach einem Neustart zurueck die
* Map liegt nur im RAM, ein Neustart loescht sie also automatisch. */
type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip"; type MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean }; type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>(); const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; // 2 min cooldown per failed account
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000; const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
// A Mega-Web account abort (the shared unrestrict timeout firing while this /** Zaehlt aufeinanderfolgende "Antwort leer"-Fehlversuche je Account-Key. Ein
// account ran) only cools the account down — so the next attempt rotates on — * tageslimitierter Mega-Debrid-Account liefert im Web-Pfad KEINE unterscheidbare
// if it actually ran this long. Below this, it's treated as a quick user-cancel * Meldung ("Kein Server" taucht in echten Logs nie auf immer nur "Antwort leer"),
// (no cooldown). Env-overridable for tests. * ist aber daran erkennbar, dass er PERSISTENT leer antwortet. Nach
const MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT = 8000; * MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART aufeinanderfolgenden Leer-Antworten wird der
function getMegaDebridAbortMinRunMs(): number { * Account bis Neustart geparkt; ein einzelner transienter Blip (Streak < Schwelle)
const fromEnv = Number(process.env.RD_MEGA_ABORT_MIN_RUN_MS ?? NaN); * behaelt den kurzen 20s-Cooldown. Ein Erfolg oder ein anderer Fehlertyp setzt den
return Number.isFinite(fromEnv) && fromEnv >= 0 ? Math.floor(fromEnv) : MEGA_DEBRID_ABORT_MIN_RUN_MS_DEFAULT; * Zaehler zurueck. */
}
const megaDebridEmptyResponseStreaks = new Map<string, number>(); const megaDebridEmptyResponseStreaks = new Map<string, number>();
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3; export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
/** Verbucht eine "Antwort leer"-Antwort fuer den Account-Key und liefert die neue
* Streak-Laenge. Ab MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART parkt der Aufrufer den
* Account bis Neustart. Exportiert fuer deterministische Tests. */
export function recordMegaDebridEmptyResponseStreak(accountId: string): number { export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1; const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
megaDebridEmptyResponseStreaks.set(accountId, streak); megaDebridEmptyResponseStreaks.set(accountId, streak);
return streak; return streak;
} }
/** Setzt die "Antwort leer"-Streak zurueck (bei Erfolg oder einem anderen Fehlertyp). */
export function clearMegaDebridEmptyResponseStreak(accountId: string): void { export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
megaDebridEmptyResponseStreaks.delete(accountId); megaDebridEmptyResponseStreaks.delete(accountId);
} }
@ -311,6 +353,7 @@ export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridEmptyResponseStreaks.clear(); megaDebridEmptyResponseStreaks.clear();
} }
/** Periodic cleanup of expired Mega-Debrid cooldown entries. */
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number { export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
let removed = 0; let removed = 0;
const grace = 60 * 60 * 1000; const grace = 60 * 60 * 1000;
@ -327,6 +370,7 @@ export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldo
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary"); setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
} }
/** Parkt einen Account-Key bis Neustart (Tageslimit). Exportiert fuer Tests. */
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis Neustart gesperrt"): void { export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis Neustart gesperrt"): void {
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true); setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
} }
@ -343,6 +387,9 @@ function setMegaDebridAccountCooldownState(
untilRestart = false untilRestart = false
): void { ): void {
if (untilRestart) { if (untilRestart) {
// Bis-Neustart-Sperre: never expires in-process (Number.MAX_SAFE_INTEGER liegt
// ausserhalb des gueltigen Date-Bereichs → Anzeige wird via untilRestart-Flag
// gesondert behandelt, nicht ueber new Date(until)).
megaDebridAccountCooldowns.set(accountId, { megaDebridAccountCooldowns.set(accountId, {
until: Number.MAX_SAFE_INTEGER, until: Number.MAX_SAFE_INTEGER,
message, message,
@ -453,17 +500,21 @@ export function getAvailableDebridLinkApiKeys(settings: AppSettings, epochMs = D
); );
} }
/** Returns Mega-Debrid accounts that are not disabled and not daily-limited. */
export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] { export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] {
return getMegaDebridAccountList(settings).filter( return getMegaDebridAccountList(settings).filter(
(entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs) (entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
); );
} }
/** Resolves the full list of Mega-Debrid accounts from settings (multi-account or legacy single). */
function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] { function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] {
// Multi-account format: newline-separated "login:password" pairs in megaCredentials
const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || ""); const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
if (multiAccounts.length > 0) { if (multiAccounts.length > 0) {
return multiAccounts; return multiAccounts;
} }
// Backward compat: single legacy megaLogin/megaPassword
if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) { if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim()); return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
} }
@ -524,6 +575,7 @@ function parseRetryAfterMs(value: string | null): number {
return 0; return 0;
} }
// Cap at 1 hour — floodDetected can mandate "retry after 1 hour"
const maxRetryMs = 60 * 60 * 1000; const maxRetryMs = 60 * 60 * 1000;
const asSeconds = Number(text); const asSeconds = Number(text);
if (Number.isFinite(asSeconds) && asSeconds >= 0) { if (Number.isFinite(asSeconds) && asSeconds >= 0) {
@ -1364,6 +1416,8 @@ export function extractRapidgatorFilenameFromHtml(html: string): string {
for (const pattern of patterns) { for (const pattern of patterns) {
const match = html.match(pattern); const match = html.match(pattern);
// Some patterns have multiple capture groups for attribute-order independence;
// pick the first non-empty group.
const raw = match?.[1] || match?.[2] || ""; const raw = match?.[1] || match?.[2] || "";
const normalized = normalizeResolvedFilename(raw); const normalized = normalizeResolvedFilename(raw);
if (normalized) { if (normalized) {
@ -1445,10 +1499,12 @@ async function readResponseTextLimited(response: Response, maxBytes: number, sig
try { try {
await reader.cancel(); await reader.cancel();
} catch { } catch {
// ignore
} }
try { try {
reader.releaseLock(); reader.releaseLock();
} catch { } catch {
// ignore
} }
} }
@ -1480,7 +1536,7 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
signal: withTimeoutSignal(signal, API_TIMEOUT_MS) signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
}); });
if (!response.ok) { if (!response.ok) {
try { await response.body?.cancel(); } catch { } try { await response.body?.cancel(); } catch { /* drain socket */ }
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) { if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal); await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue; continue;
@ -1496,11 +1552,11 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
&& !contentType.includes("text/plain") && !contentType.includes("text/plain")
&& !contentType.includes("text/xml") && !contentType.includes("text/xml")
&& !contentType.includes("application/xml")) { && !contentType.includes("application/xml")) {
try { await response.body?.cancel(); } catch { } try { await response.body?.cancel(); } catch { /* drain socket */ }
return ""; return "";
} }
if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) { if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
try { await response.body?.cancel(); } catch { } try { await response.body?.cancel(); } catch { /* drain socket */ }
return ""; return "";
} }
@ -1557,6 +1613,7 @@ export async function checkRapidgatorOnline(
"Accept-Language": "en-US,en;q=0.9,de;q=0.8" "Accept-Language": "en-US,en;q=0.9,de;q=0.8"
}; };
// Fast path: HEAD request (no body download, much faster)
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) { for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
try { try {
if (signal?.aborted) throw new Error("aborted:debrid"); if (signal?.aborted) throw new Error("aborted:debrid");
@ -1577,26 +1634,30 @@ export async function checkRapidgatorOnline(
if (!finalUrl.includes(fileId)) { if (!finalUrl.includes(fileId)) {
return { online: false, fileName: "", fileSize: null }; return { online: false, fileName: "", fileSize: null };
} }
// HEAD 200 + URL still contains file ID → online
const fileName = filenameFromRapidgatorUrlPath(link); const fileName = filenameFromRapidgatorUrlPath(link);
return { online: true, fileName, fileSize: null }; return { online: true, fileName, fileSize: null };
} }
// Non-OK, non-404: retry or give up
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) { if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal); await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue; continue;
} }
// HEAD inconclusive — fall through to GET
break; break;
} catch (error) { } catch (error) {
const errorText = compactErrorText(error); const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error; if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) { if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
break; break; // fall through to GET
} }
await sleepWithSignal(retryDelay(attempt), signal); await sleepWithSignal(retryDelay(attempt), signal);
} }
} }
// Slow path: GET request (downloads HTML, more thorough)
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) { for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
try { try {
if (signal?.aborted) throw new Error("aborted:debrid"); if (signal?.aborted) throw new Error("aborted:debrid");
@ -1609,12 +1670,12 @@ export async function checkRapidgatorOnline(
}); });
if (response.status === 404) { if (response.status === 404) {
try { await response.body?.cancel(); } catch { } try { await response.body?.cancel(); } catch { /* drain socket */ }
return { online: false, fileName: "", fileSize: null }; return { online: false, fileName: "", fileSize: null };
} }
if (!response.ok) { if (!response.ok) {
try { await response.body?.cancel(); } catch { } try { await response.body?.cancel(); } catch { /* drain socket */ }
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) { if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal); await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue; continue;
@ -1624,7 +1685,7 @@ export async function checkRapidgatorOnline(
const finalUrl = response.url || link; const finalUrl = response.url || link;
if (!finalUrl.includes(fileId)) { if (!finalUrl.includes(fileId)) {
try { await response.body?.cancel(); } catch { } try { await response.body?.cancel(); } catch { /* drain socket */ }
return { online: false, fileName: "", fileSize: null }; return { online: false, fileName: "", fileSize: null };
} }
@ -1678,10 +1739,14 @@ class MegaDebridClient {
private allowApiFallback: boolean; private allowApiFallback: boolean;
/** Per-account API token cache: login (lowercase) → { token, timestamp } */
private static cachedApiTokens = new Map<string, { token: string; at: number }>(); private static cachedApiTokens = new Map<string, { token: string; at: number }>();
/** Per-account pending connect deduplication: login (lowercase) → promise */
private static pendingConnects = new Map<string, Promise<string | null>>(); private static pendingConnects = new Map<string, Promise<string | null>>();
/** Clear cached tokens for accounts whose login is no longer in the given set.
* Called when settings change so removed accounts don't keep stale tokens. */
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void { public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
const keep = new Set<string>(); const keep = new Set<string>();
for (const login of activeLogins) { for (const login of activeLogins) {
@ -1699,6 +1764,8 @@ class MegaDebridClient {
} }
} }
/** Force-clear the API token for a specific login (e.g. when its password
* changes same login, but cached token is now invalid for new password). */
public static clearCachedApiToken(login: string): void { public static clearCachedApiToken(login: string): void {
const key = String(login || "").toLowerCase(); const key = String(login || "").toLowerCase();
MegaDebridClient.cachedApiTokens.delete(key); MegaDebridClient.cachedApiTokens.delete(key);
@ -1719,11 +1786,13 @@ class MegaDebridClient {
private async connectApi(signal?: AbortSignal): Promise<string | null> { private async connectApi(signal?: AbortSignal): Promise<string | null> {
const key = this.cacheKey; const key = this.cacheKey;
// Return cached token if fresh (max 20 min)
const cached = MegaDebridClient.cachedApiTokens.get(key); const cached = MegaDebridClient.cachedApiTokens.get(key);
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) { if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
return cached.token; return cached.token;
} }
// Deduplicate parallel connectUser calls — only one in-flight request per account
const pending = MegaDebridClient.pendingConnects.get(key); const pending = MegaDebridClient.pendingConnects.get(key);
if (pending) { if (pending) {
return pending; return pending;
@ -1786,6 +1855,7 @@ class MegaDebridClient {
}); });
const text = await response.text(); const text = await response.text();
if (!response.ok) { if (!response.ok) {
// Token might be invalid, clear cache
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
this.clearTokenCache(); this.clearTokenCache();
} }
@ -1793,6 +1863,7 @@ class MegaDebridClient {
} }
const payload = parseJsonSafe(text); const payload = parseJsonSafe(text);
if (!payload || payload.response_code !== "ok") { if (!payload || payload.response_code !== "ok") {
// Token expired — clear cache for next attempt
if (payload && String(payload.response_code || "").includes("token")) { if (payload && String(payload.response_code || "").includes("token")) {
this.clearTokenCache(); this.clearTokenCache();
} }
@ -1844,6 +1915,10 @@ class MegaDebridClient {
if (!lastError) { if (!lastError) {
lastError = "Mega-Web Antwort leer"; lastError = "Mega-Web Antwort leer";
} }
// Don't retry permanent hoster errors (dead link, file removed, etc.) — and
// don't hammer a "Kein Server für diesen Hoster" (account hoster quota) message:
// immediate retries are futile (the limit persists) and waste the shared
// rotation budget, so break and let the rotation move to the next account.
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.test(lastError)) { if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.test(lastError)) {
break; break;
} }
@ -1879,6 +1954,12 @@ class MegaDebridClient {
return this.unrestrictViaWeb(link, signal); return this.unrestrictViaWeb(link, signal);
} }
/**
* Multi-account rotation for Mega-Debrid, following the same pattern as Debrid-Link multi-key rotation.
* Iterates through all configured accounts, skipping disabled/daily-limited/cooldown accounts.
* On success: clears cooldown, returns result with sourceAccountId/sourceAccountLabel.
* On failure: classifies error, sets cooldown, tries next account.
*/
public static async unrestrictWithAccounts( public static async unrestrictWithAccounts(
settings: AppSettings, settings: AppSettings,
mode: "api" | "web", mode: "api" | "web",
@ -1905,8 +1986,11 @@ class MegaDebridClient {
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`; const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
const linkShort = String(link || "").slice(0, 80); const linkShort = String(link || "").slice(0, 80);
// Always start from first account — use first available, skip disabled/limited/cooldown.
for (let idx = 0; idx < accounts.length; idx += 1) { for (let idx = 0; idx < accounts.length; idx += 1) {
const account = accounts[idx]; const account = accounts[idx];
// Always show account number — even with 1 account — so user can tell at a
// glance which account is in play. Format: "(Account 2/3, fa**david@...)"
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`; const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`; const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
@ -1920,6 +2004,7 @@ class MegaDebridClient {
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" }); logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
continue; continue;
} }
// Cooldown key includes mode so API failures don't block Web attempts
const cooldownKey = `${account.id}:${mode}`; const cooldownKey = `${account.id}:${mode}`;
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey); const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
if (accountCooldownState) { if (accountCooldownState) {
@ -1936,6 +2021,9 @@ class MegaDebridClient {
until: untilStr until: untilStr
}); });
cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`); cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`);
// Eine Bis-Neustart-Sperre traegt NICHT zu earliestCooldownUntil bei: es gibt
// keinen sinnvollen endlichen Retry-Zeitpunkt (der Account kommt erst nach
// Neustart zurueck). Sonst wuerde MAX_SAFE_INTEGER einen absurden Retry-Timer setzen.
if (accountCooldownState.untilRestart) { if (accountCooldownState.untilRestart) {
parkedUntilRestartSeen = true; parkedUntilRestartSeen = true;
} else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) { } else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
@ -1944,6 +2032,9 @@ class MegaDebridClient {
continue; continue;
} }
// CLEAR per-account TEST log line BEFORE the network call, so the user
// can always see exactly which account is currently being tested for
// link generation — even if the call hangs or times out.
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`); logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort }); logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
const testStartedAt = Date.now(); const testStartedAt = Date.now();
@ -1968,31 +2059,15 @@ class MegaDebridClient {
sourceAccountLabel: account.label sourceAccountLabel: account.label
}; };
} catch (error) { } catch (error) {
const elapsedMs = Date.now() - testStartedAt;
const abortText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Timeout/abort on THIS account (the shared unrestrict signal fired). Cool
// the account down — if it actually ran, not a quick user-cancel — so the
// download-manager's retry rotates to the NEXT account instead of hammering
// this one. The shared signal is now aborted, so we stop this pass; the
// retry runs the rotation fresh with this account skipped. A genuine cancel
// is not retried by the caller, so the cooldown is harmless there.
if (/aborted/i.test(abortText) && !/timeout/i.test(abortText)) {
const ranLongEnough = elapsedMs >= getMegaDebridAbortMinRunMs();
if (ranLongEnough) {
setMegaDebridAccountCooldownState(cooldownKey, MEGA_DEBRID_ACCOUNT_COOLDOWN_MS, `Abbruch/Timeout nach ${Math.ceil(elapsedMs / 1000)}s`, "temporary");
}
failures.push(`Mega-Debrid${accountLabel}: ${abortText}`);
logAccountRotation("WARN", providerName, rotationLabel, "TIMEOUT_COOLDOWN", {
elapsedMs,
reason: abortText,
cooldownSec: ranLongEnough ? Math.ceil(MEGA_DEBRID_ACCOUNT_COOLDOWN_MS / 1000) : 0,
next: "naechster Account beim Retry"
});
throw new Error(`Mega-Debrid${accountLabel}: ${abortText}`);
}
const failure = MegaDebridClient.classifyAccountFailure(error); const failure = MegaDebridClient.classifyAccountFailure(error);
const elapsedMs = Date.now() - testStartedAt;
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`); failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
// "Antwort leer"-Streak fuehren: ein tageslimitierter Account antwortet
// PERSISTENT leer (siehe Kommentar an megaDebridEmptyResponseStreaks). Erreicht
// der Account die Schwelle, wird er bis Neustart geparkt — statt alle 20s neu
// getestet zu werden. failure.untilRestart deckt zusaetzlich den Fall ab, dass
// generate() die "Kein Server"-Meldung doch mal direkt liefert.
let parkUntilRestart = false; let parkUntilRestart = false;
let parkMessage = failure.message; let parkMessage = failure.message;
if (failure.limitSignal) { if (failure.limitSignal) {
@ -2026,6 +2101,7 @@ class MegaDebridClient {
: failure.cooldownMs > 0 : failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s` ? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: ""; : "";
// Find the next account that will be tried (for clearer log)
let nextLabel = "ENDE"; let nextLabel = "ENDE";
for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) { for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) {
const nextAcc = accounts[nextIdx]; const nextAcc = accounts[nextIdx];
@ -2052,6 +2128,9 @@ class MegaDebridClient {
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`); throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
} }
if (parkedUntilRestartSeen) { if (parkedUntilRestartSeen) {
// Alle (verbliebenen) Accounts haben ihr Tageslimit erreicht und sind bis
// Neustart gesperrt. KEIN mega_debrid_cooldown:<ms> — es gibt keinen sinnvollen
// Retry-Zeitpunkt; ein endlicher Timer wuerde nur erneut leer pollen.
throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis Neustart gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`); throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis Neustart gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
} }
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar"); throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
@ -2059,15 +2138,21 @@ class MegaDebridClient {
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar"); throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
} }
/**
* Classify error from a single Mega-Debrid account attempt.
* Returns whether the error is fatal (stop all accounts) and how long to cool down.
*/
private static classifyAccountFailure( private static classifyAccountFailure(
error: unknown error: unknown
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } { ): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, ""); const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Abort — don't retry other accounts
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) { if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" }; return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
} }
// Auth/login failures — long cooldown, try next account
if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) { if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) {
return { return {
fatal: false, fatal: false,
@ -2077,10 +2162,12 @@ class MegaDebridClient {
}; };
} }
// Permanent hoster errors — fatal, don't try other accounts
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) { if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" }; return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
} }
// Quota/limit errors — cooldown, try next account
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) { if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
return { return {
fatal: false, fatal: false,
@ -2090,6 +2177,11 @@ class MegaDebridClient {
}; };
} }
// "Kein Server für diesen Hoster verfügbar" = Account-Tageslimit erschöpft (oder der
// Hoster ist kurz nicht bedient — laut Kommentar an MEGA_DEBRID_NO_SERVER_RE moeglich).
// Wie "Antwort leer" ein Limit-Signal: feedet die Streak (limitSignal). Erst nach
// mehreren Treffern wird der Account bis Neustart geparkt — ein einzelner (evtl.
// transienter) Treffer erzwingt KEINEN Neustart, behaelt aber den 2-Min-Cooldown.
if (MEGA_DEBRID_NO_SERVER_RE.test(errorText)) { if (MEGA_DEBRID_NO_SERVER_RE.test(errorText)) {
return { return {
fatal: false, fatal: false,
@ -2100,6 +2192,7 @@ class MegaDebridClient {
}; };
} }
// Rate limit
if (/rate.?limit|too.?many|429/i.test(errorText)) { if (/rate.?limit|too.?many|429/i.test(errorText)) {
return { return {
fatal: false, fatal: false,
@ -2109,6 +2202,12 @@ class MegaDebridClient {
}; };
} }
// Mega-Web "Antwort leer" / empty body — kann zweierlei sein: (a) ein transienter
// Blip (erholt sich in Sekunden → kurzer 20s-Cooldown reicht) ODER (b) ein
// tageslimitierter Account, der PERSISTENT leer antwortet. Da beide Faelle auf
// Message-Ebene identisch aussehen (in echten Logs taucht "Kein Server" nie auf,
// immer nur "Antwort leer"), markieren wir emptyResponse=true; der Aufrufer zaehlt
// die Streak und parkt den Account erst nach mehreren Leer-Antworten bis Neustart.
if (/antwort\s+leer|empty\s+response|leere\s+antwort/i.test(errorText)) { if (/antwort\s+leer|empty\s+response|leere\s+antwort/i.test(errorText)) {
return { return {
fatal: false, fatal: false,
@ -2119,6 +2218,8 @@ class MegaDebridClient {
}; };
} }
// Temporary/transport errors — short cooldown, try next account.
// Plain network blips deserve a much shorter cooldown than 2 min.
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) { if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
return { return {
fatal: false, fatal: false,
@ -2128,6 +2229,7 @@ class MegaDebridClient {
}; };
} }
// Unknown errors — short cooldown, try next account (non-fatal)
return { return {
fatal: false, fatal: false,
cooldownMs: 30_000, cooldownMs: 30_000,
@ -2558,6 +2660,8 @@ export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapi
return results; return results;
} }
// ── Debrid-Link Client ──
class DebridLinkClient { class DebridLinkClient {
private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>; private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>;
@ -2585,8 +2689,12 @@ class DebridLinkClient {
const linkShort = String(link || "").slice(0, 80); const linkShort = String(link || "").slice(0, 80);
const linkHoster = extractHosterFromUrl(link); const linkHoster = extractHosterFromUrl(link);
// 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.
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) { for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
const apiKey = this.apiKeys[keyIdx]; const apiKey = this.apiKeys[keyIdx];
// Always show key number — even with 1 key — so user can tell at a
// glance which key is in play. Format: "(Key 2/3, abc***xyz)"
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`; const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`; const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) { if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
@ -2614,6 +2722,9 @@ class DebridLinkClient {
} }
continue; continue;
} }
// Per-(key, host) cooldown — set when a previous attempt for THIS host
// returned maxLinkHost / maxDataHost. The key itself is healthy for other
// hosters, so we only skip it for this specific link.
const hostCooldownState = linkHoster ? getDebridLinkKeyHostCooldownState(apiKey.id, linkHoster) : null; const hostCooldownState = linkHoster ? getDebridLinkKeyHostCooldownState(apiKey.id, linkHoster) : null;
if (hostCooldownState) { if (hostCooldownState) {
const untilStr = new Date(hostCooldownState.until).toLocaleTimeString(); const untilStr = new Date(hostCooldownState.until).toLocaleTimeString();
@ -2631,6 +2742,9 @@ class DebridLinkClient {
continue; continue;
} }
// CLEAR per-key TEST log line BEFORE the network call, so the user
// can always see exactly which key is currently being tested for
// link generation — even if the call hangs or times out.
logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`); logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort }); logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
const testStartedAt = Date.now(); const testStartedAt = Date.now();
@ -2664,6 +2778,10 @@ class DebridLinkClient {
failures.push(`Debrid-Link${keyLabel}: ${failure.message}`); failures.push(`Debrid-Link${keyLabel}: ${failure.message}`);
if (failure.cooldownMs > 0) { if (failure.cooldownMs > 0) {
if (failure.hostOnly) { if (failure.hostOnly) {
// Per-(key, host) quota — block only this combination, not the
// whole key. The key remains "ready" for other hosters. If the
// hoster couldn't be parsed from the URL, the helper falls back
// to a key-wide cooldown (better safe than thrashing).
setDebridLinkKeyHostCooldownState( setDebridLinkKeyHostCooldownState(
apiKey.id, apiKey.id,
failure.hoster || "", failure.hoster || "",
@ -2679,6 +2797,10 @@ class DebridLinkClient {
if (failure.category === "invalid") { if (failure.category === "invalid") {
setDebridLinkKeyRuntimeStatus(apiKey.id, "invalid", failure.message); setDebridLinkKeyRuntimeStatus(apiKey.id, "invalid", failure.message);
} else if (failure.category !== "skip") { } else if (failure.category !== "skip") {
// "skip" means the LINK or HOST is unavailable (fileNotAvailable,
// disabledServerHost, notFreeHost, freeServerOverload, ...), NOT
// that the key is broken. The key responded normally — leave its
// runtime status alone so the UI doesn't flag it as errored.
setDebridLinkKeyRuntimeStatus(apiKey.id, "error", failure.message); setDebridLinkKeyRuntimeStatus(apiKey.id, "error", failure.message);
} }
} }
@ -2692,6 +2814,8 @@ class DebridLinkClient {
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`); throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
} }
if (failure.providerWide) { 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; const providerWideCooldownMs = 5 * 60 * 1000;
logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`); logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", { logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", {
@ -2703,9 +2827,11 @@ class DebridLinkClient {
}); });
throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`); 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); const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0; consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
if (consecutiveTransportFailures >= 2) { if (consecutiveTransportFailures >= 2) {
// 2+ keys timed out in a row — likely a server/network issue, not key-specific.
const cascadeCooldownMs = 3 * 60 * 1000; const cascadeCooldownMs = 3 * 60 * 1000;
logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`); logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", { logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", {
@ -2716,6 +2842,7 @@ class DebridLinkClient {
}); });
throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`); throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
} }
// Find the next key that will be tried (for clearer log)
let nextLabel = "ENDE"; let nextLabel = "ENDE";
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) { for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
const nextKey = this.apiKeys[nextIdx]; const nextKey = this.apiKeys[nextIdx];
@ -2841,6 +2968,8 @@ class DebridLinkClient {
return chosen; return chosen;
} }
// Poll up to 5 times with 2s delay — Debrid-Link sometimes needs a few
// seconds to generate the download URL after /downloader/add.
const maxPolls = 5; const maxPolls = 5;
for (let poll = 0; poll < maxPolls; poll++) { for (let poll = 0; poll < maxPolls; poll++) {
if (signal?.aborted) { if (signal?.aborted) {
@ -2858,6 +2987,7 @@ class DebridLinkClient {
} }
} }
} }
// Return last fetched entry (caller will detect missing URL and throw)
return (await this.fetchDownloaderEntry(apiKey, id, signal)) || chosen; return (await this.fetchDownloaderEntry(apiKey, id, signal)) || chosen;
} }
@ -2915,6 +3045,9 @@ class DebridLinkClient {
}; };
} }
if (DEBRID_LINK_HOST_QUOTA_ERRORS.has(code)) { if (DEBRID_LINK_HOST_QUOTA_ERRORS.has(code)) {
// Per-(key, host) quota — only this host is exhausted for this key.
// The key remains usable for other hosters, so we mark the failure
// hostOnly and let the rotation loop apply a per-(key, host) cooldown.
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal); const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
const hosterRaw = extractHosterFromUrl(link); const hosterRaw = extractHosterFromUrl(link);
const hosterLabel = hosterRaw || "host"; const hosterLabel = hosterRaw || "host";
@ -2928,6 +3061,7 @@ class DebridLinkClient {
}; };
} }
if (DEBRID_LINK_KEY_QUOTA_ERRORS.has(code)) { if (DEBRID_LINK_KEY_QUOTA_ERRORS.has(code)) {
// Key-wide quota — whole key is exhausted, blocks all hosters.
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal); const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
return { return {
fatal: false, fatal: false,
@ -2937,6 +3071,7 @@ class DebridLinkClient {
}; };
} }
if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) { if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
// 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,
@ -2975,6 +3110,8 @@ class DebridLinkClient {
}; };
} }
// Treat missing/expired download URLs as temporary — the server may need
// more time or another key might succeed immediately.
if (/keine gueltige download-url/i.test(errorText)) { if (/keine gueltige download-url/i.test(errorText)) {
return { return {
fatal: false, fatal: false,
@ -2985,6 +3122,11 @@ class DebridLinkClient {
} }
if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) { if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) {
// Distinguish a single transient transport error (timeout, network blip,
// ECONNRESET) from a real API/server problem. Single timeouts shouldn't
// park a key for 2 full minutes — that just delays parallel work for
// no reason. Use a short 15s cooldown for transport, full 2min only
// for things that look like server-side faults (5xx HTML pages, etc).
const isTransport = /timeout|network|fetch failed|aborted|econnreset|enotfound|etimedout|socket/i.test(errorText) const isTransport = /timeout|network|fetch failed|aborted|econnreset|enotfound|etimedout|socket/i.test(errorText)
&& !(error instanceof DebridLinkApiError); && !(error instanceof DebridLinkApiError);
return { return {
@ -2994,6 +3136,9 @@ class DebridLinkClient {
}; };
} }
// HTTP 200 with success:false but no recognizable error code: don't kill
// the item permanently. Treat as a temporary blip — same key can be tried
// again after a short cooldown, or another key picked up.
if (errorText && /success.*false|kein.*json|empty.*response/i.test(errorText)) { if (errorText && /success.*false|kein.*json|empty.*response/i.test(errorText)) {
return { return {
fatal: false, fatal: false,
@ -3011,6 +3156,8 @@ class DebridLinkClient {
} }
} }
// ── LinkSnappy Client ──
class LinkSnappyClient { class LinkSnappyClient {
private username: string; private username: string;
private password: string; private password: string;
@ -3102,6 +3249,7 @@ class LinkSnappyClient {
if (!directUrl) { if (!directUrl) {
throw new Error("LinkSnappy: Keine Download-URL in Antwort"); throw new Error("LinkSnappy: Keine Download-URL in Antwort");
} }
// LinkSnappy liefert http:// URLs auf https:// upgraden (deren Server unterstützt beides)
if (directUrl.startsWith("http://")) { if (directUrl.startsWith("http://")) {
directUrl = directUrl.replace("http://", "https://"); directUrl = directUrl.replace("http://", "https://");
} }
@ -3152,6 +3300,8 @@ function parseFileSizeString(s: string): number {
return Math.floor(num * (multipliers[unit] || 1)); return Math.floor(num * (multipliers[unit] || 1));
} }
// ── 1Fichier Client ──
class OneFichierClient { class OneFichierClient {
private apiKey: string; private apiKey: string;
@ -3225,6 +3375,7 @@ class DdownloadClient {
} }
private async webLogin(signal?: AbortSignal): Promise<void> { private async webLogin(signal?: AbortSignal): Promise<void> {
// Step 1: GET login page to extract form token
const loginPageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/login.html`, { const loginPageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/login.html`, {
headers: { "User-Agent": DDOWNLOAD_WEB_UA }, headers: { "User-Agent": DDOWNLOAD_WEB_UA },
redirect: "manual", redirect: "manual",
@ -3234,6 +3385,7 @@ class DdownloadClient {
const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/); const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/);
const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; "); const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; ");
// Step 2: POST login
const body = new URLSearchParams({ const body = new URLSearchParams({
op: "login", op: "login",
token: tokenMatch?.[1] || "", token: tokenMatch?.[1] || "",
@ -3254,7 +3406,8 @@ class DdownloadClient {
signal: withTimeoutSignal(signal, API_TIMEOUT_MS) signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
}); });
try { await loginRes.text(); } catch { } // Drain body
try { await loginRes.text(); } catch { /* ignore */ }
const setCookies = loginRes.headers.getSetCookie?.() || []; const setCookies = loginRes.headers.getSetCookie?.() || [];
const xfss = setCookies.find((c: string) => c.startsWith("xfss=")); const xfss = setCookies.find((c: string) => c.startsWith("xfss="));
@ -3277,10 +3430,12 @@ class DdownloadClient {
try { try {
if (signal?.aborted) throw new Error("aborted:debrid"); if (signal?.aborted) throw new Error("aborted:debrid");
// Login if no session yet
if (!this.cookies) { if (!this.cookies) {
await this.webLogin(signal); await this.webLogin(signal);
} }
// Step 1: GET file page to extract form fields
const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, { const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, {
headers: { headers: {
"User-Agent": DDOWNLOAD_WEB_UA, "User-Agent": DDOWNLOAD_WEB_UA,
@ -3290,9 +3445,10 @@ class DdownloadClient {
signal: withTimeoutSignal(signal, API_TIMEOUT_MS) signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
}); });
// Premium with direct downloads enabled → redirect immediately
if (filePageRes.status >= 300 && filePageRes.status < 400) { if (filePageRes.status >= 300 && filePageRes.status < 400) {
const directUrl = filePageRes.headers.get("location") || ""; const directUrl = filePageRes.headers.get("location") || "";
try { await filePageRes.text(); } catch { } try { await filePageRes.text(); } catch { /* drain */ }
if (directUrl) { if (directUrl) {
return { return {
fileName: filenameFromUrl(directUrl) || filenameFromUrl(link), fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
@ -3306,15 +3462,18 @@ class DdownloadClient {
const html = await filePageRes.text(); const html = await filePageRes.text();
// Check for file not found
if (/File Not Found|file was removed|file was banned/i.test(html)) { if (/File Not Found|file was removed|file was banned/i.test(html)) {
throw new Error("DDownload: Datei nicht gefunden"); throw new Error("DDownload: Datei nicht gefunden");
} }
// Extract form fields
const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode; const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode;
const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || ""; const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || "";
const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+)</); const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+)</);
const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link); const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link);
// Step 2: POST download2 for premium download
const dlBody = new URLSearchParams({ const dlBody = new URLSearchParams({
op: "download2", op: "download2",
id: idVal, id: idVal,
@ -3339,7 +3498,7 @@ class DdownloadClient {
if (dlRes.status >= 300 && dlRes.status < 400) { if (dlRes.status >= 300 && dlRes.status < 400) {
const directUrl = dlRes.headers.get("location") || ""; const directUrl = dlRes.headers.get("location") || "";
try { await dlRes.text(); } catch { } try { await dlRes.text(); } catch { /* drain */ }
if (directUrl) { if (directUrl) {
return { return {
fileName: fileName || filenameFromUrl(directUrl), fileName: fileName || filenameFromUrl(directUrl),
@ -3352,6 +3511,7 @@ class DdownloadClient {
} }
const dlHtml = await dlRes.text(); const dlHtml = await dlRes.text();
// Try to find direct URL in response HTML
const directMatch = dlHtml.match(/https?:\/\/[a-z0-9]+\.(?:dstorage\.org|ddownload\.com|ddl\.to|ucdn\.to)[^\s"'<>]+/i); const directMatch = dlHtml.match(/https?:\/\/[a-z0-9]+\.(?:dstorage\.org|ddownload\.com|ddl\.to|ucdn\.to)[^\s"'<>]+/i);
if (directMatch) { if (directMatch) {
return { return {
@ -3363,6 +3523,7 @@ class DdownloadClient {
}; };
} }
// Check for error messages
const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)</i); const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)</i);
if (errMatch) { if (errMatch) {
throw new Error(`DDownload: ${errMatch[1].trim()}`); throw new Error(`DDownload: ${errMatch[1].trim()}`);
@ -3374,6 +3535,7 @@ class DdownloadClient {
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) { if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
break; break;
} }
// Re-login on auth errors
if (/login|session|cookie/i.test(lastError)) { if (/login|session|cookie/i.test(lastError)) {
this.cookies = ""; this.cookies = "";
} }
@ -3409,6 +3571,10 @@ export class DebridService {
const prev = this.settings; const prev = this.settings;
this.settings = cloneSettings(next); this.settings = cloneSettings(next);
// Invalidate cached provider clients whose credentials/keys changed.
// Without this, switching API keys or session-cookie-bound accounts
// (LinkSnappy, Ddownload) would keep using the previous Client instance
// — which holds the OLD session cookies — until the app is restarted.
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) { if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) {
this.cachedDebridLinkClient = null; this.cachedDebridLinkClient = null;
this.cachedDebridLinkKey = ""; this.cachedDebridLinkKey = "";
@ -3422,6 +3588,12 @@ export class DebridService {
this.cachedDdownloadKey = ""; this.cachedDdownloadKey = "";
} }
// Mega-Debrid token cache (static, module-level): tokens are keyed by
// login (lowercase). When credentials change, drop tokens for logins
// that are no longer in the active account list, AND force-clear any
// login whose password changed. Otherwise stale tokens linger up to
// 20 minutes and the new credentials won't be tried until the cached
// token starts returning 401/403.
const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || ""); const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || "");
const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || ""); const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || "");
const nextLogins = new Set<string>(); const nextLogins = new Set<string>();
@ -3430,13 +3602,17 @@ export class DebridService {
nextLogins.add(acc.login.toLowerCase()); nextLogins.add(acc.login.toLowerCase());
nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password); nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password);
} }
// Drop tokens for logins no longer present
MegaDebridClient.pruneCachedTokensNotIn(nextLogins); MegaDebridClient.pruneCachedTokensNotIn(nextLogins);
// For logins still present but with a changed password, force-clear the token
for (const prevAcc of prevAccounts) { for (const prevAcc of prevAccounts) {
const loginKey = prevAcc.login.toLowerCase(); const loginKey = prevAcc.login.toLowerCase();
if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) { if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) {
MegaDebridClient.clearCachedApiToken(prevAcc.login); MegaDebridClient.clearCachedApiToken(prevAcc.login);
} }
} }
// Also prune module-level Debrid-Link cooldowns for keys that no longer exist —
// otherwise a key removed and re-added later would still show its old cooldown.
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id)); const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds); pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
} }
@ -3507,9 +3683,14 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error; throw error;
} }
// ignore and continue with host page fallback
} }
} }
// Mega.nz Pre-Resolve via Public API (kein Mega-Debrid-Quota-Verbrauch).
// Liefert echten Filename sobald Links in die Queue kommen, anstatt erst
// beim Unrestrict. Concurrency 4 — Mega's Public API ist tolerant gegen
// kleine Bursts.
const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link)); const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link));
if (megaLinks.length > 0) { if (megaLinks.length > 0) {
await runWithConcurrency(megaLinks, 4, async (link) => { await runWithConcurrency(megaLinks, 4, async (link) => {
@ -3523,6 +3704,8 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error; throw error;
} }
// Schluck — Public API kann fehlen oder rate-limiten; faellt auf
// den normalen Mega-Debrid Unrestrict-Pfad zurueck.
} }
}); });
} }
@ -3583,6 +3766,7 @@ export class DebridService {
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> { public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings); const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
// Hoster-Zuordnung: prüfe ob für diesen Hoster ein bestimmter Provider konfiguriert ist
const routing = settings.hosterRouting || {}; const routing = settings.hosterRouting || {};
const hosterKey = extractHosterFromUrl(link); const hosterKey = extractHosterFromUrl(link);
if (hosterKey && routing[hosterKey]) { if (hosterKey && routing[hosterKey]) {
@ -3611,6 +3795,7 @@ export class DebridService {
throw new Error(`Hoster-Zuordnung fehlgeschlagen (${hosterKey}${PROVIDER_LABELS[routedProvider]}): ${errorText}`); throw new Error(`Hoster-Zuordnung fehlgeschlagen (${hosterKey}${PROVIDER_LABELS[routedProvider]}): ${errorText}`);
} }
logger.warn(`Hoster-Zuordnung ${hosterKey}${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`); logger.warn(`Hoster-Zuordnung ${hosterKey}${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`);
// Fall through to normal provider chain
} }
} else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) { } else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) {
logger.info(`Hoster-Zuordnung ${hosterKey}${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`); logger.info(`Hoster-Zuordnung ${hosterKey}${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`);
@ -3619,6 +3804,8 @@ export class DebridService {
} }
} }
// 1Fichier is a direct file hoster. If the link is a 1fichier.com URL
// and the API key is configured, use 1Fichier directly before debrid providers.
if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) { if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
try { try {
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal); const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
@ -3632,9 +3819,13 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error; throw error;
} }
// Fall through to normal provider chain
} }
} }
// DDownload is a direct file hoster, not a debrid service.
// If the link is a ddownload.com/ddl.to URL and the account is configured,
// use DDownload directly before trying any debrid providers.
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) { if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
try { try {
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal); const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
@ -3648,9 +3839,11 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) { if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error; throw error;
} }
// Fall through to normal provider chain (debrid services may also support ddownload links)
} }
} }
// Dynamische Reihenfolge: providerOrder hat Vorrang, Fallback auf altes primary/secondary/tertiary
const order: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0) const order: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
? uniqueProviderOrder(settings.providerOrder) ? uniqueProviderOrder(settings.providerOrder)
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary); : toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);

View File

@ -6,7 +6,6 @@ import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log"; import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup"; import { getDebugSetupCheck } from "./debug-setup";
import { logger, getLogFilePath } from "./logger"; import { logger, getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getItemLogPath as getPersistedItemLogPath } from "./item-log"; import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
import { getSessionLogPath } from "./session-log"; import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log"; import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
@ -45,7 +44,6 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
{ method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
{ method: "GET", path: "/trace/config", queryExample: "enable=1&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/trace/config", queryExample: "enable=1&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
@ -118,6 +116,7 @@ function getPort(baseDir: string): number {
return n; return n;
} }
} catch { } catch {
// ignore
} }
return DEFAULT_PORT; return DEFAULT_PORT;
} }
@ -136,6 +135,7 @@ function getHost(baseDir: string): string {
return raw; return raw;
} }
} catch { } catch {
// ignore
} }
return DEFAULT_HOST; return DEFAULT_HOST;
} }
@ -530,18 +530,6 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
if (pathname === "/errors") {
const levelFilter = (url.searchParams.get("level") || "").toUpperCase();
const limit = normalizeLinesParam(url.searchParams.get("limit"), 100);
let entries = getRecentErrors();
if (levelFilter === "ERROR" || levelFilter === "WARN") {
entries = entries.filter((entry) => entry.level === levelFilter);
}
const limited = entries.slice(-limit);
jsonResponse(res, 200, { count: limited.length, total: entries.length, entries: limited });
return;
}
if (pathname === "/logs/audit") { if (pathname === "/logs/audit") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100); const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || ""; const grep = url.searchParams.get("grep") || "";

View File

@ -53,6 +53,7 @@ function readPort(baseDir: string): number {
return raw; return raw;
} }
} catch { } catch {
// ignore
} }
return DEFAULT_PORT; return DEFAULT_PORT;
} }
@ -70,6 +71,7 @@ function readHost(baseDir: string): string {
return raw; return raw;
} }
} catch { } catch {
// ignore
} }
return DEFAULT_HOST; return DEFAULT_HOST;
} }
@ -156,6 +158,7 @@ function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): Suppor
bytes += fs.statSync(fullPath).size; bytes += fs.statSync(fullPath).size;
fileCount += 1; fileCount += 1;
} catch { } catch {
// ignore unreadable files
} }
} }
} }

View File

@ -2,6 +2,26 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
/**
* Session-eigenes Rename-Protokoll auf dem DESKTOP des Nutzers.
*
* Ziel (User-Anforderung): bei zukuenftigen Renaming-Problemen eine luekenlose,
* sofort auffindbare Uebersicht haben JEDER Umbenenn-/Verschiebevorgang wird
* protokolliert UND danach verifiziert (liegt die Datei wirklich unter dem
* Zielnamen auf der Platte? ist die Quelle weg?). Nur weil fs.rename "ok" meldet,
* heisst das nicht, dass das Ergebnis stimmt (Gross-/Kleinschreibung, Unicode-
* Normalisierung, halb-fertiger EXDEV-Copy ohne geloeschte Quelle, ...).
*
* - Pro Programm-Sitzung eine eigene Datei: <Desktop>/Downloader-Log/rename-session_<ts>.txt
* - Der Ordner wird beim Start angelegt UND vor JEDEM Schreibvorgang selbstheilend
* neu angelegt (mkdir recursive) wird er zur Laufzeit geloescht, ist er beim
* naechsten Rename sofort wieder da, inkl. neu geschriebenem Session-Header.
* - Synchroner Append (wie rename-log.ts), kein gepufferter Flush: Renames sind
* selten genug, und so gibt es kein "geloescht-waehrend-Flush"-Zeitfenster.
* - Schlaegt das Logging fehl, wird der Fehler verschluckt Logging darf einen
* Download niemals abbrechen.
*/
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR"; type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
const FOLDER_NAME = "Downloader-Log"; const FOLDER_NAME = "Downloader-Log";
@ -10,6 +30,8 @@ let logDir: string | null = null;
let logFilePath: string | null = null; let logFilePath: string | null = null;
let sessionHeader = ""; let sessionHeader = "";
/** Lokaler Zeitstempel fuer den DATEINAMEN (keine Doppelpunkte unter Windows
* in Dateinamen verboten): YYYY-MM-DD_HH-MM-SS in lokaler Zeit. */
function fileTimestamp(date: Date = new Date()): string { function fileTimestamp(date: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, "0"); const pad = (value: number): string => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_` return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
@ -43,6 +65,9 @@ function formatFields(fields?: Record<string, unknown>): string {
return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
} }
/** Stellt sicher, dass Ordner UND Session-Datei existieren (selbstheilend, auch
* wenn beides zur Laufzeit geloescht wurde). Gibt false zurueck, wenn das
* Logging nicht initialisiert ist oder das Anlegen scheitert. */
function ensureWritable(): boolean { function ensureWritable(): boolean {
if (!logDir || !logFilePath) { if (!logDir || !logFilePath) {
return false; return false;
@ -58,6 +83,9 @@ function ensureWritable(): boolean {
} }
} }
/** Initialisiert das Desktop-Rename-Log fuer diese Sitzung. `desktopDir` ist der
* Desktop-Pfad (app.getPath("desktop")). Faellt still auf no-op zurueck, wenn der
* Pfad fehlt oder nicht beschreibbar ist. */
export function initDesktopRenameLog(desktopDir: string | null | undefined): void { export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
try { try {
const base = String(desktopDir || "").trim(); const base = String(desktopDir || "").trim();
@ -80,6 +108,9 @@ export function initDesktopRenameLog(desktopDir: string | null | undefined): voi
} }
} }
/** Schreibt eine Zeile ins Desktop-Rename-Log. Tut nichts, wenn nicht
* initialisiert; verschluckt jeden Schreibfehler (darf nie einen Download
* abbrechen). */
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void { export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
if (!ensureWritable() || !logFilePath) { if (!ensureWritable() || !logFilePath) {
return; return;
@ -87,6 +118,7 @@ export function logDesktopRename(level: DesktopRenameLevel, message: string, fie
try { try {
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8"); fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
} catch { } catch {
// Logging darf einen Download niemals abbrechen.
} }
} }
@ -106,6 +138,7 @@ export function shutdownDesktopRenameLog(): void {
try { try {
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
} }
logDir = null; logDir = null;
@ -113,16 +146,34 @@ export function shutdownDesktopRenameLog(): void {
} }
export interface RenameVerification { export interface RenameVerification {
/** Gesamtergebnis: Datei liegt unter dem EXAKT erwarteten Namen vor und (sofern kein
* In-Place-Rename) die Quelle ist verschwunden. */
ok: boolean; ok: boolean;
/** Empfohlenes Log-Level: ERROR (Rename nicht vollzogen / falscher Name),
* WARN (vollzogen, aber Schreibweise nicht pruefbar), INFO (alles ok). */
level: "INFO" | "WARN" | "ERROR"; level: "INFO" | "WARN" | "ERROR";
/** Zieldatei (egal welche Schreibweise) auf der Platte vorhanden? */
targetExists: boolean; targetExists: boolean;
/** Tatsaechlicher Name auf der Platte (Gross-/Kleinschreibung wie wirklich
* gespeichert), oder null wenn nicht gefunden / Verzeichnis nicht lesbar. */
onDiskName: string | null; onDiskName: string | null;
/** onDiskName === erwarteter Zielname (exakt, case-sensitive)? */
nameMatches: boolean; nameMatches: boolean;
/** Quelldatei verschwunden (Rename wirklich vollzogen, kein halb-fertiger Copy)? */
sourceGone: boolean; sourceGone: boolean;
/** Groesse der Zieldatei in Bytes, oder null. */
targetSize: number | null; targetSize: number | null;
/** Menschenlesbarer Grund, wenn nicht sauber INFO. */
reason: string; reason: string;
} }
/** Repliziert download-manager.toWindowsLongPathIfNeeded (ein Import waere zirkulaer:
* download-manager -> desktop-rename-log). Node fs-Aufrufe scheitern unter Windows fuer
* absolute Pfade >=248 Zeichen, sofern nicht mit \\?\ / \\?\UNC\ praefixiert und genau
* solche langen Scene-Release-Pfade benennt diese App um. OHNE dieses Prefix wuerden
* statSync/readdirSync in der Verifikation auf langen Pfaden faelschlich scheitern
* (falsches "Ziel nicht gefunden" UND falsches "Quelle weg" -> falsches OK, das einen
* halb-fertigen Verschiebevorgang maskiert). */
function toLongPath(filePath: string): string { function toLongPath(filePath: string): string {
const absolute = path.resolve(String(filePath || "")); const absolute = path.resolve(String(filePath || ""));
if (process.platform !== "win32") { if (process.platform !== "win32") {
@ -140,6 +191,9 @@ function toLongPath(filePath: string): string {
return `\\\\?\\${absolute}`; return `\\\\?\\${absolute}`;
} }
/** Echter On-Disk-Name (korrekte Schreibweise) fuer `requested` aus den
* Verzeichnis-Eintraegen, oder null wenn das Verzeichnis nicht lesbar war
* (entries===null) bzw. nichts passt. */
function resolveOnDiskName(requested: string, entries: string[] | null): string | null { function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
if (entries === null) { if (entries === null) {
return null; return null;
@ -150,6 +204,8 @@ function resolveOnDiskName(requested: string, entries: string[] | null): string
|| requested; || requested;
} }
/** Baut das Verifikations-Ergebnis aus den (sync ODER async) erhobenen Roh-Fakten.
* `dirEntries`=null bedeutet "Zielverzeichnis war nicht lesbar". */
function buildVerification( function buildVerification(
sourcePath: string, sourcePath: string,
targetPath: string, targetPath: string,
@ -159,6 +215,8 @@ function buildVerification(
const dirReadFailed = facts.targetExists && facts.dirEntries === null; const dirReadFailed = facts.targetExists && facts.dirEntries === null;
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null; const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
// In-Place-Rename (reine Gross-/Kleinschreibungs-Korrektur auf case-insensitivem FS):
// Quelle == Ziel -> "Quelle weg" gilt nicht.
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase(); const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
const sourceGone = samePath ? true : !facts.sourceExists; const sourceGone = samePath ? true : !facts.sourceExists;
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested; const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
@ -177,6 +235,7 @@ function buildVerification(
level = "ERROR"; level = "ERROR";
} }
if (level === "INFO" && dirReadFailed) { if (level === "INFO" && dirReadFailed) {
// Datei da + Quelle weg, aber Schreibweise ungeprueft — KEIN stilles OK.
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert"); problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
level = "WARN"; level = "WARN";
} }
@ -193,6 +252,10 @@ function buildVerification(
}; };
} }
/** Verifiziert NACH einem Rename SYNCHRON, ob das Ergebnis wirklich stimmt der Kern
* der User-Anforderung ("nur weil er renaming sagt heisst es nicht das es klappt").
* Fuer die synchronen Rename-Sites (startup-Dedup, Suffix-Fix, Deobfuskation). Rein
* lesend, wirft nie. fs-Aufrufe ueber toLongPath (lange Windows-Pfade!). */
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification { export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
const longTarget = toLongPath(targetPath); const longTarget = toLongPath(targetPath);
let targetExists = false; let targetExists = false;
@ -222,6 +285,9 @@ export function verifyRename(sourcePath: string, targetPath: string): RenameVeri
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists }); return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
} }
/** Asynchrone Verifikation fuer den Media-Rename-Hot-Path (renamePathWithExdevFallback),
* damit KEIN synchrones statSync/readdirSync den Electron-Main-Loop in Saison-Pack-
* Rename-Schleifen blockiert (Projekt-Regel: kein sync I/O in Hot Paths). Wirft nie. */
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> { export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
const longTarget = toLongPath(targetPath); const longTarget = toLongPath(targetPath);
let targetExists = false; let targetExists = false;

View File

@ -127,6 +127,11 @@ export function validateDownloadedFileCompletion(args: {
} }
if (args.plan.source === "stream-end") { if (args.plan.source === "stream-end") {
// H3: Kein Content-Length, keine Provider-Größe UND 0 Bytes empfangen → der
// Hoster hat die Verbindung sofort geschlossen. Das ist ein fehlgeschlagener
// Download, kein gültiges "fertig" — sonst gilt eine leere Datei als komplett
// und es gibt keinen Auto-Redownload. Verhält sich jetzt wie der bereits
// behandelte Fall actualBytes<=0 mit bekannter Größe (oben).
if (actualBytes <= 0) { if (actualBytes <= 0) {
return { return {
ok: false, ok: false,

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +0,0 @@
export interface ErrorRingEntry {
ts: string;
level: string;
message: string;
}
export interface ErrorRing {
push: (entry: ErrorRingEntry) => void;
snapshot: () => ErrorRingEntry[];
clear: () => void;
size: () => number;
}
export function createErrorRing(capacity: number): ErrorRing {
const limit = Math.max(1, Math.floor(capacity));
const buffer: ErrorRingEntry[] = [];
return {
push(entry: ErrorRingEntry): void {
buffer.push(entry);
while (buffer.length > limit) {
buffer.shift();
}
},
snapshot(): ErrorRingEntry[] {
return buffer.slice();
},
clear(): void {
buffer.length = 0;
},
size(): number {
return buffer.length;
}
};
}
const RECENT_ERROR_CAPACITY = 200;
const recentErrors = createErrorRing(RECENT_ERROR_CAPACITY);
export function recordRecentError(level: string, message: string, ts: string): void {
recentErrors.push({ level, message, ts });
}
export function getRecentErrors(): ErrorRingEntry[] {
return recentErrors.snapshot();
}

View File

@ -1,3 +1,7 @@
// ════════════════════════════════════════════════════════════════════════════
// Sektion 1 — Imports & Konstanten
// ════════════════════════════════════════════════════════════════════════════
import fs from "node:fs"; import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import os from "node:os"; import os from "node:os";
@ -43,6 +47,10 @@ const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80; const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
let currentExtractCpuPriority: string | undefined; let currentExtractCpuPriority: string | undefined;
// ════════════════════════════════════════════════════════════════════════════
// Sektion 2 — Types & Interfaces
// ════════════════════════════════════════════════════════════════════════════
export interface ExtractOptions { export interface ExtractOptions {
packageDir: string; packageDir: string;
targetDir: string; targetDir: string;
@ -161,15 +169,20 @@ interface DaemonRequest {
passwordCount: number; passwordCount: number;
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 3 — Subst Drive Mapping (Windows long-path workaround)
// ════════════════════════════════════════════════════════════════════════════
const activeSubstDrives = new Set<string>(); const activeSubstDrives = new Set<string>();
function findFreeSubstDrive(): string | null { function findFreeSubstDrive(): string | null {
if (process.platform !== "win32") return null; if (process.platform !== "win32") return null;
for (let code = 90; code >= 71; code--) { for (let code = 90; code >= 71; code--) { // Z to G
const letter = String.fromCharCode(code); const letter = String.fromCharCode(code);
if (activeSubstDrives.has(letter)) continue; if (activeSubstDrives.has(letter)) continue;
try { try {
fs.accessSync(`${letter}:\\`); fs.accessSync(`${letter}:\\`);
// Drive exists, skip
} catch { } catch {
return letter; return letter;
} }
@ -213,9 +226,14 @@ export function cleanupStaleSubstDrives(): void {
} }
} }
} catch { } catch {
// ignore — subst cleanup is best-effort
} }
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 4 — Archiv-Erkennung & Kandidaten
// ════════════════════════════════════════════════════════════════════════════
export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> { export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> {
let fd: fs.promises.FileHandle | null = null; let fd: fs.promises.FileHandle | null = null;
try { try {
@ -350,6 +368,7 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
return !fileNamesLower.has(`${fileName}.001`.toLowerCase()); return !fileNamesLower.has(`${fileName}.001`.toLowerCase());
}); });
const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath)); const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath));
// Generic .001 splits (HJSplit etc.) — exclude already-recognized .zip.001 and .7z.001
const genericSplit = files.filter((filePath) => { const genericSplit = files.filter((filePath) => {
const fileName = archiveDetectionName(filePath).toLowerCase(); const fileName = archiveDetectionName(filePath).toLowerCase();
if (!/\.001$/.test(fileName)) return false; if (!/\.001$/.test(fileName)) return false;
@ -387,6 +406,10 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
return unique; return unique;
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 5 — Cleanup & Dateisystem
// ════════════════════════════════════════════════════════════════════════════
function escapeRegex(value: string): string { function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
} }
@ -415,6 +438,8 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
} }
}; };
// Companion metadata files (.sfv, .nfo, .md5, etc.) share the same base stem
// as the archive and should be cleaned up together with the archive parts.
const COMPANION_EXTS_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|srr)$/i; const COMPANION_EXTS_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|srr)$/i;
const addCompanions = (stemRe: string): void => { const addCompanions = (stemRe: string): void => {
for (const candidate of filesInDir) { for (const candidate of filesInDir) {
@ -479,10 +504,12 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
return Array.from(targets); return Array.from(targets);
} }
// Tar compound archives (.tar.gz, .tar.bz2, .tar.xz, .tgz, .tbz2, .txz)
if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) { if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) {
return Array.from(targets); return Array.from(targets);
} }
// Generic .NNN split files (HJSplit etc.)
const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i); const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i);
if (genericSplit) { if (genericSplit) {
const stem = escapeRegex(genericSplit[1]); const stem = escapeRegex(genericSplit[1]);
@ -545,6 +572,7 @@ export async function cleanupArchives(
index += 1; index += 1;
} }
} catch { } catch {
// ignore
} }
return false; return false;
}; };
@ -567,6 +595,7 @@ export async function cleanupArchives(
await fs.promises.rm(filePath, { force: true }); await fs.promises.rm(filePath, { force: true });
removed += 1; removed += 1;
} catch { } catch {
// ignore
} }
} }
return removed; return removed;
@ -655,11 +684,16 @@ export async function removeEmptyDirectoryTree(rootDir: string): Promise<number>
removed += 1; removed += 1;
} }
} catch { } catch {
// ignore
} }
} }
return removed; return removed;
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 6 — Passwort-Management (LRU-Cache & Kandidaten)
// ════════════════════════════════════════════════════════════════════════════
function packagePasswordCacheKey(packageDir: string, packageId?: string): string { function packagePasswordCacheKey(packageDir: string, packageId?: string): string {
const normalizedPackageId = String(packageId || "").trim(); const normalizedPackageId = String(packageId || "").trim();
if (normalizedPackageId) { if (normalizedPackageId) {
@ -681,6 +715,7 @@ function readCachedPackagePassword(cacheKey: string): string {
if (!cached) { if (!cached) {
return ""; return "";
} }
// Refresh insertion order to keep recently used package caches alive.
packageLearnedPasswords.delete(cacheKey); packageLearnedPasswords.delete(cacheKey);
packageLearnedPasswords.set(cacheKey, cached); packageLearnedPasswords.set(cacheKey, cached);
return cached; return cached;
@ -707,6 +742,24 @@ function clearCachedPackagePassword(cacheKey: string): void {
packageLearnedPasswords.delete(cacheKey); packageLearnedPasswords.delete(cacheKey);
} }
/**
* Setzt den Extractor-Zustand zurück, wenn der User die Archiv-Passwortliste
* ändert. Repliziert, was ein App-Neustart am Extractor-Subsystem tut:
* - leert den In-Memory Learned-Password-Cache (gelernte Passwörter aller Pakete)
* - fährt den langlebigen JVM-Daemon herunter (sofern nicht gerade beschäftigt),
* damit die nächste Extraktion mit einem frischen Prozess + frischen Passwörtern
* startet.
*
* Hintergrund: User-Report ein neu hinzugefügtes Passwort griff bei "Jetzt
* entpacken" erst NACH App-Neustart. Die gesamte TS/Java-Kette propagiert die
* Liste pro Request korrekt; die einzige zustandsbehaftete Komponente, die ein
* Neustart zurücksetzt (und dieser Aufruf ebenfalls), ist der Daemon-Prozess.
*
* Bewusst KEIN Shutdown eines beschäftigten Daemons: läuft gerade eine Extraktion
* (z.B. weil Settings während des Entpackens gespeichert werden), bleibt sie
* unangetastet der nächste Lauf bekommt dann ggf. noch den alten Daemon, aber
* der häufige Fall (Liste im Leerlauf ändern) wird sauber abgedeckt.
*/
export function resetExtractorCachesForPasswordChange(): { learnedCleared: number; daemonRestarted: boolean } { export function resetExtractorCachesForPasswordChange(): { learnedCleared: number; daemonRestarted: boolean } {
const learnedCleared = packageLearnedPasswords.size; const learnedCleared = packageLearnedPasswords.size;
packageLearnedPasswords.clear(); packageLearnedPasswords.clear();
@ -767,6 +820,10 @@ function prioritizePassword(passwords: string[], successful: string): string[] {
return next; return next;
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 7 — Fehler-Klassifizierung
// ════════════════════════════════════════════════════════════════════════════
export function cleanErrorText(text: string): string { export function cleanErrorText(text: string): string {
const normalized = String(text || "").replace(/\s+/g, " ").trim(); const normalized = String(text || "").replace(/\s+/g, " ").trim();
if (normalized.length <= 500) { if (normalized.length <= 500) {
@ -907,6 +964,10 @@ function isJvmRuntimeMissingError(errorText: string): boolean {
|| text.includes("enoent"); || text.includes("enoent");
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 8 — Backend-Modus (auto / jvm / legacy)
// ════════════════════════════════════════════════════════════════════════════
export function resolveExtractorBackendMode( export function resolveExtractorBackendMode(
rawValue?: string | null, rawValue?: string | null,
isVitestEnv = Boolean(process.env.VITEST) isVitestEnv = Boolean(process.env.VITEST)
@ -932,6 +993,9 @@ export function resolveExtractorBackendModeForArchive(
if (requestedMode !== "auto") { if (requestedMode !== "auto") {
return requestedMode; return requestedMode;
} }
// On Windows, multipart RAR extraction feels significantly snappier with the
// native CLI path than with the JVM backend, and we already harden that path
// with subst + flat-mode fallback.
if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) { if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) {
return "legacy"; return "legacy";
} }
@ -950,6 +1014,10 @@ function isRarArchivePath(filePath: string): boolean {
return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || "")); return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || ""));
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 9 — Native Extractor Resolution (7-Zip / WinRAR)
// ════════════════════════════════════════════════════════════════════════════
function is7zCommand(command: string): boolean { function is7zCommand(command: string): boolean {
const lower = command.toLowerCase(); const lower = command.toLowerCase();
return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar"); return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar");
@ -1161,6 +1229,12 @@ async function findAlternativeExtractor(currentCommand: string, archivePath = ""
return null; return null;
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 10 — CPU / Thread / Priority
// ════════════════════════════════════════════════════════════════════════════
/** Compute a safe JVM -Xmx value based on available physical RAM.
* Reserves 4 GB for Windows + Electron + other processes, caps at 16 GB. */
function jvmMaxHeapArg(): string { function jvmMaxHeapArg(): string {
const totalGb = os.totalmem() / (1024 ** 3); const totalGb = os.totalmem() / (1024 ** 3);
const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16)); const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16));
@ -1227,15 +1301,21 @@ function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?:
try { try {
os.setPriority(pid, extractOsPriority(cpuPriority)); os.setPriority(pid, extractOsPriority(cpuPriority));
} catch { } catch {
// ignore: priority lowering is best-effort
} }
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 11 — Prozess-Ausführung (spawn, kill, progress parsing)
// ════════════════════════════════════════════════════════════════════════════
function killProcessTree(child: { pid?: number; kill: () => void }): void { function killProcessTree(child: { pid?: number; kill: () => void }): void {
const pid = Number(child.pid || 0); const pid = Number(child.pid || 0);
if (!Number.isFinite(pid) || pid <= 0) { if (!Number.isFinite(pid) || pid <= 0) {
try { try {
child.kill(); child.kill();
} catch { } catch {
// ignore
} }
return; return;
} }
@ -1250,12 +1330,14 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
try { try {
child.kill(); child.kill();
} catch { } catch {
// ignore
} }
}); });
} catch { } catch {
try { try {
child.kill(); child.kill();
} catch { } catch {
// ignore
} }
} }
return; return;
@ -1264,6 +1346,7 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
try { try {
child.kill(); child.kill();
} catch { } catch {
// ignore
} }
} }
@ -1420,6 +1503,10 @@ function runExtractCommand(
}); });
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 12 — JVM Backend & Daemon
// ════════════════════════════════════════════════════════════════════════════
let cachedJvmLayout: JvmExtractorLayout | null | undefined; let cachedJvmLayout: JvmExtractorLayout | null | undefined;
let cachedJvmLayoutNullSince = 0; let cachedJvmLayoutNullSince = 0;
const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000; const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000;
@ -1541,6 +1628,10 @@ function parseJvmLine(
} }
} }
// ── Persistent JVM Daemon ──
// Keeps a single JVM process alive across multiple extraction requests,
// eliminating the ~5s JVM boot overhead per archive.
let daemonProcess: ChildProcess | null = null; let daemonProcess: ChildProcess | null = null;
let daemonReady = false; let daemonReady = false;
let daemonBusy = false; let daemonBusy = false;
@ -1554,8 +1645,8 @@ let daemonLayout: JvmExtractorLayout | null = null;
export function shutdownDaemon(): void { export function shutdownDaemon(): void {
if (daemonProcess) { if (daemonProcess) {
try { daemonProcess.stdin?.end(); } catch { } try { daemonProcess.stdin?.end(); } catch { /* ignore */ }
try { killProcessTree(daemonProcess); } catch { } try { killProcessTree(daemonProcess); } catch { /* ignore */ }
daemonProcess = null; daemonProcess = null;
} }
daemonReady = false; daemonReady = false;
@ -1731,6 +1822,7 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
}); });
} }
// Clean up tmp dir
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {}); fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
daemonProcess = null; daemonProcess = null;
daemonReady = false; daemonReady = false;
@ -1753,6 +1845,7 @@ function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
return Boolean(daemonProcess && daemonReady && !daemonBusy); return Boolean(daemonProcess && daemonReady && !daemonBusy);
} }
/** Wait for the daemon to become ready (boot phase) or free (busy phase), with timeout. */
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> { function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const start = Date.now(); const start = Date.now();
@ -1865,12 +1958,14 @@ async function runJvmExtractCommand(
}); });
} }
// Try persistent daemon first — saves ~5s JVM boot per archive
if (isDaemonAvailable(layout)) { if (isDaemonAvailable(layout)) {
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority); lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`); logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs); return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
} }
// Daemon exists but is still booting or busy — wait up to 15s for it
if (daemonProcess) { if (daemonProcess) {
const reason = !daemonReady ? "booting" : "busy"; const reason = !daemonReady ? "booting" : "busy";
const waitStartedAt = Date.now(); const waitStartedAt = Date.now();
@ -1885,6 +1980,7 @@ async function runJvmExtractCommand(
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`); logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
} }
// Fallback: spawn a new JVM process (daemon not available after waiting)
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`); logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
const mode = effectiveConflictMode(conflictMode); const mode = effectiveConflictMode(conflictMode);
@ -2053,6 +2149,10 @@ async function runJvmExtractCommand(
}); });
} }
// ════════════════════════════════════════════════════════════════════════════
// Sektion 13 — Legacy Extraction (buildExternalExtractArgs, runExternalExtract*)
// ════════════════════════════════════════════════════════════════════════════
export function buildExternalExtractArgs( export function buildExternalExtractArgs(
command: string, command: string,
archivePath: string, archivePath: string,
@ -2079,6 +2179,7 @@ export function buildExternalExtractArgs(
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`]; return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
} }
// Delay helper for extraction retries
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
async function runExternalExtractInner( async function runExternalExtractInner(
@ -2112,6 +2213,7 @@ async function runExternalExtractInner(
let createErrorText = ""; let createErrorText = "";
let createErrorPassword = ""; let createErrorPassword = "";
// Skip normal extraction loop if flat mode is already known to be needed for this package
if (forceFlatMode) { if (forceFlatMode) {
logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`); logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`); onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
@ -2228,6 +2330,8 @@ async function runExternalExtractInner(
lastError = result.errorText; lastError = result.errorText;
} }
// Some archives store internal paths with a leading \, causing invalid \\ paths.
// Retry in flat mode ("e" instead of "x") which strips all archive paths.
const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : ""); const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : "");
if (pathCreateError) { if (pathCreateError) {
const flatPasswords = createErrorPassword const flatPasswords = createErrorPassword
@ -2351,6 +2455,7 @@ async function runExternalExtract(
} }
} }
// Use a short drive mapping for legacy native extractors on Windows.
subst = createSubstMapping(targetDir); subst = createSubstMapping(targetDir);
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir; const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
if (subst) { if (subst) {
@ -2403,6 +2508,7 @@ async function runExternalExtract(
const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password"; const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password";
let finalLegacyError: Error; let finalLegacyError: Error;
// Retry once after a short delay to let Windows flush freshly completed archive parts.
if (isCrcOrWrongPw && !signal?.aborted) { if (isCrcOrWrongPw && !signal?.aborted) {
const retryDelayMs = 2500; const retryDelayMs = 2500;
logger.warn( logger.warn(
@ -2528,6 +2634,10 @@ async function runExternalExtract(
} }
} }
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 14 ZIP Extraction (AdmZip)
// ══════════════════════════════════════════════════════════════════════════════
function isZipSafetyGuardError(error: unknown): boolean { function isZipSafetyGuardError(error: unknown): boolean {
const text = String(error || "").toLowerCase(); const text = String(error || "").toLowerCase();
return text.includes("path traversal") return text.includes("path traversal")
@ -2616,6 +2726,9 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
let outputKey = pathSetKey(outputPath); let outputKey = pathSetKey(outputPath);
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true }); await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
// TOCTOU note: There is a small race between access and writeFile below.
// This is acceptable here because zip extraction is single-threaded and we need
// the exists check to implement skip/rename conflict resolution semantics.
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false); const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
if (outputExists) { if (outputExists) {
if (mode === "skip") { if (mode === "skip") {
@ -2665,6 +2778,10 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
} }
} }
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 15 Disk Space, Timeout & Memory Limits
// ══════════════════════════════════════════════════════════════════════════════
async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> { async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> {
let total = 0; let total = 0;
for (const archivePath of candidates) { for (const archivePath of candidates) {
@ -2672,7 +2789,7 @@ async function estimateArchivesTotalBytes(candidates: string[]): Promise<number>
for (const part of parts) { for (const part of parts) {
try { try {
total += (await fs.promises.stat(part)).size; total += (await fs.promises.stat(part)).size;
} catch { } } catch { /* missing part, ignore */ }
} }
} }
return total; return total;
@ -2735,6 +2852,7 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
try { try {
totalBytes += (await fs.promises.stat(filePath)).size; totalBytes += (await fs.promises.stat(filePath)).size;
} catch { } catch {
// ignore missing parts
} }
} }
if (totalBytes <= 0) { if (totalBytes <= 0) {
@ -2748,6 +2866,10 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
} }
} }
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 16 Resume State
// ══════════════════════════════════════════════════════════════════════════════
function extractProgressFilePath(packageDir: string, packageId?: string): string { function extractProgressFilePath(packageDir: string, packageId?: string): string {
if (packageId) { if (packageId) {
return path.join(packageDir, `.rd_extract_progress_${packageId}.json`); return path.join(packageDir, `.rd_extract_progress_${packageId}.json`);
@ -2783,6 +2905,7 @@ async function writeExtractResumeState(packageDir: string, completedArchives: Se
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp"; const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8"); await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
await fs.promises.rename(tmpPath, progressPath).catch(async () => { await fs.promises.rename(tmpPath, progressPath).catch(async () => {
// rename may fail if another writer renamed tmpPath first (parallel workers)
await fs.promises.rm(tmpPath, { force: true }).catch(() => {}); await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
}); });
} catch (error) { } catch (error) {
@ -2794,9 +2917,14 @@ export async function clearExtractResumeState(packageDir: string, packageId?: st
try { try {
await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true }); await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true });
} catch { } catch {
// ignore
} }
} }
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 17 Progress & Conflict Helpers
// ══════════════════════════════════════════════════════════════════════════════
function emitExtractLog( function emitExtractLog(
onLog: ExtractOptions["onLog"] | undefined, onLog: ExtractOptions["onLog"] | undefined,
level: "INFO" | "WARN" | "ERROR", level: "INFO" | "WARN" | "ERROR",
@ -2822,6 +2950,10 @@ function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip"
return "skip"; return "skip";
} }
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 18 extractPackageArchives (Orchestrierung)
// ══════════════════════════════════════════════════════════════════════════════
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> { export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
if (options.signal?.aborted) { if (options.signal?.aborted) {
throw new Error("aborted:extract"); throw new Error("aborted:extract");
@ -2837,11 +2969,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`); options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
// Disk space pre-check
if (candidates.length > 0) { if (candidates.length > 0) {
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" }); options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
try { try {
await fs.promises.mkdir(options.targetDir, { recursive: true }); await fs.promises.mkdir(options.targetDir, { recursive: true });
} catch { } } catch { /* ignore */ }
await checkDiskSpaceForExtraction(options.targetDir, candidates); await checkDiskSpaceForExtraction(options.targetDir, candidates);
} }
@ -2963,6 +3096,9 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
emitProgress(extracted, "", "extracting"); emitProgress(extracted, "", "extracting");
// Emit "done" progress for archives already completed via resume state
// so the caller's onProgress handler can mark their items as "Done" immediately
// rather than leaving them as "Entpacken - Ausstehend" until all extraction finishes.
for (const archivePath of candidates) { for (const archivePath of candidates) {
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) { if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true }); emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
@ -2995,6 +3131,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, 1100); }, 1100);
const hybrid = Boolean(options.hybridMode); const hybrid = Boolean(options.hybridMode);
// Before the first successful extraction, filename-derived candidates are useful.
// After a known password is learned, try that first to avoid per-archive delays.
const filenamePasswords = archiveFilenamePasswords(archiveName); const filenamePasswords = archiveFilenamePasswords(archiveName);
const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== ""); const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
const orderedNonEmpty = learnedPassword const orderedNonEmpty = learnedPassword
@ -3012,6 +3150,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt); emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}; };
// Validate generic .001 splits via file signature before attempting extraction
const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName); const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName);
if (isGenericSplit) { if (isGenericSplit) {
const sig = await detectArchiveSignature(archivePath); const sig = await detectArchiveSignature(archivePath);
@ -3046,6 +3185,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
: undefined; : undefined;
try { try {
// Set module-level priority before each extract call (race-safe: spawn is synchronous)
currentExtractCpuPriority = options.extractCpuPriority; currentExtractCpuPriority = options.extractCpuPriority;
const ext = path.extname(archivePath).toLowerCase(); const ext = path.extname(archivePath).toLowerCase();
if (ext === ".zip") { if (ext === ".zip") {
@ -3157,6 +3297,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (options.signal?.aborted || noExtractorEncountered) break; if (options.signal?.aborted || noExtractorEncountered) break;
await extractSingleArchive(archivePath); await extractSingleArchive(archivePath);
} }
// Count remaining archives as failed when no extractor was found
if (noExtractorEncountered) { if (noExtractorEncountered) {
const remaining = candidates.length - (extracted + failed); const remaining = candidates.length - (extracted + failed);
if (remaining > 0) { if (remaining > 0) {
@ -3165,6 +3306,8 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
} }
} else { } else {
// Password discovery: extract first archive serially to find the correct password,
// then run remaining archives in parallel with the promoted password order.
let parallelQueue = pendingCandidates; let parallelQueue = pendingCandidates;
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) { if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`); logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
@ -3175,6 +3318,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} catch (err) { } catch (err) {
const errText = String(err); const errText = String(err);
if (/aborted:extract/i.test(errText)) throw err; if (/aborted:extract/i.test(errText)) throw err;
// noextractor:skipped — handled by noExtractorEncountered flag below
} }
parallelQueue = pendingCandidates.slice(1); parallelQueue = pendingCandidates.slice(1);
if (parallelQueue.length > 0) { if (parallelQueue.length > 0) {
@ -3183,6 +3327,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) { if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) {
// Parallel extraction pool: N workers pull from a shared queue
const queue = [...parallelQueue]; const queue = [...parallelQueue];
let nextIdx = 0; let nextIdx = 0;
let abortError: Error | null = null; let abortError: Error | null = null;
@ -3197,20 +3342,24 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} catch (error) { } catch (error) {
const errText = String(error); const errText = String(error);
if (errText.includes("noextractor:skipped")) { if (errText.includes("noextractor:skipped")) {
break; break; // handled by noExtractorEncountered flag after the pool
} }
if (isExtractAbortError(errText)) { if (isExtractAbortError(errText)) {
abortError = error instanceof Error ? error : new Error(errText); abortError = error instanceof Error ? error : new Error(errText);
break; break;
} }
// Non-abort errors are already handled inside extractSingleArchive
} }
} }
}; };
const workerCount = Math.min(maxParallel, parallelQueue.length); const workerCount = Math.min(maxParallel, parallelQueue.length);
logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`); logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`);
// Snapshot passwordCandidates before parallel extraction to avoid concurrent mutation.
// Each worker reads the same promoted order from the serial password-discovery pass.
const frozenPasswords = [...passwordCandidates]; const frozenPasswords = [...passwordCandidates];
await Promise.all(Array.from({ length: workerCount }, () => worker())); await Promise.all(Array.from({ length: workerCount }, () => worker()));
// Restore passwordCandidates from frozen snapshot (parallel mutations are discarded).
passwordCandidates = frozenPasswords; passwordCandidates = frozenPasswords;
if (abortError) throw new Error("aborted:extract"); if (abortError) throw new Error("aborted:extract");
@ -3242,6 +3391,11 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
} }
// ── Retry failed wrong_password archives serially ──
// Parallel UnRAR processes writing to the same target directory can cause
// CRC mismatches that are misreported as "Incorrect password".
// If any archive succeeded (i.e. the password is known), retry the failed
// ones one-at-a-time to eliminate false positives from I/O contention.
if (failed > 0 && extracted > 0) { if (failed > 0 && extracted > 0) {
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap)))); const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
if (failedArchives.length > 0) { if (failedArchives.length > 0) {
@ -3250,12 +3404,14 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
for (const archivePath of failedArchives) { for (const archivePath of failedArchives) {
if (options.signal?.aborted || noExtractorEncountered) break; if (options.signal?.aborted || noExtractorEncountered) break;
try { try {
// Reset failed count for this archive before retry
failed -= 1; failed -= 1;
await extractSingleArchive(archivePath); await extractSingleArchive(archivePath);
retryRecovered += 1; retryRecovered += 1;
} catch (retryError) { } catch (retryError) {
const errText = String(retryError); const errText = String(retryError);
if (isExtractAbortError(errText)) throw retryError; if (isExtractAbortError(errText)) throw retryError;
// extractSingleArchive already incremented failed and logged the error
} }
} }
if (retryRecovered > 0) { if (retryRecovered > 0) {
@ -3274,6 +3430,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} }
} }
// ── Nested extraction: extract archives found inside the output (1 level) ──
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) { if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
try { try {
const nestedCandidates = (await findArchiveCandidates(options.targetDir)) const nestedCandidates = (await findArchiveCandidates(options.targetDir))
@ -3402,6 +3559,7 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
await fs.promises.rm(options.targetDir, { recursive: true, force: true }); await fs.promises.rm(options.targetDir, { recursive: true, force: true });
} }
} catch { } catch {
// ignore
} }
} }

3564
src/main/extractor.ts.bak Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,56 +0,0 @@
// Maps low-level filesystem/OS error codes to a human-readable cause so that a
// generic "write failed" or "timeout" can be reported as the specific root cause
// (disk full, permission denied, ...). Pure + side-effect-free for testing.
const DISK_ERROR_REASONS: Record<string, string> = {
ENOSPC: "Festplatte voll (ENOSPC)",
EDQUOT: "Speicher-Kontingent erschöpft (EDQUOT)",
EROFS: "Laufwerk schreibgeschützt (EROFS)",
EACCES: "Zugriff verweigert (EACCES)",
EPERM: "Operation nicht erlaubt (EPERM)",
EMFILE: "Zu viele offene Dateien (EMFILE)",
ENFILE: "System-Limit offener Dateien erreicht (ENFILE)",
EBUSY: "Datei/Laufwerk belegt (EBUSY)",
ENODEV: "Gerät nicht vorhanden (ENODEV)",
ENXIO: "Gerät getrennt (ENXIO)",
EIO: "Ein-/Ausgabefehler des Datenträgers (EIO)"
};
export function classifyDiskError(err: unknown): string | null {
const code = extractErrorCode(err);
if (code && DISK_ERROR_REASONS[code]) {
return DISK_ERROR_REASONS[code];
}
// Some errors arrive as plain strings/messages without a `.code`; fall back to
// scanning the text for a known code token.
const text = errorText(err);
for (const knownCode of Object.keys(DISK_ERROR_REASONS)) {
if (text.includes(knownCode)) {
return DISK_ERROR_REASONS[knownCode];
}
}
return null;
}
function extractErrorCode(err: unknown): string {
if (err && typeof err === "object") {
const code = (err as { code?: unknown }).code;
if (typeof code === "string") {
return code.toUpperCase();
}
}
return "";
}
function errorText(err: unknown): string {
if (typeof err === "string") {
return err;
}
if (err && typeof err === "object") {
const message = (err as { message?: unknown }).message;
if (typeof message === "string") {
return message;
}
}
return String(err ?? "");
}

View File

@ -94,6 +94,7 @@ function flushPending(): void {
try { try {
fs.appendFileSync(logPath, chunk, "utf8"); fs.appendFileSync(logPath, chunk, "utf8");
} catch { } catch {
// ignore write errors
} }
} }
} }
@ -123,9 +124,11 @@ async function cleanupOldItemLogs(dir: string): Promise<void> {
await fs.promises.unlink(filePath); await fs.promises.unlink(filePath);
} }
} catch { } catch {
// ignore locked/missing files
} }
} }
} catch { } catch {
// ignore missing dir
} }
} }
@ -223,6 +226,7 @@ export function shutdownItemLogs(): void {
try { try {
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
} }
pendingLinesByItem.clear(); pendingLinesByItem.clear();

View File

@ -1,5 +1,16 @@
/**
* Zeitstempel für Log-Dateien in LOKALER Zeit mit explizitem UTC-Offset
* (ISO 8601, z. B. "2026-05-31T19:29:43.605+02:00").
*
* Vorher nutzten alle Logger `new Date().toISOString()` UTC ("...Z"). Auf einem
* CEST-Server (UTC+2) las der User dadurch z. B. "17:29:43" statt der erwarteten
* lokalen "19:29:43". Lokale Zeit MIT Offset bleibt eindeutig + maschinell parsebar
* (Date.parse versteht den Offset), zeigt dem User aber die Uhrzeit seiner Zeitzone.
*/
export function logTimestamp(date: Date = new Date()): string { export function logTimestamp(date: Date = new Date()): string {
const pad = (value: number, length = 2): string => String(value).padStart(length, "0"); const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
// getTimezoneOffset() liefert Minuten, die man zur LOKALEN Zeit ADDIEREN muss, um
// UTC zu erhalten — also negiert = Offset der lokalen Zone gegenüber UTC.
const offsetMinutes = -date.getTimezoneOffset(); const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-"; const sign = offsetMinutes >= 0 ? "+" : "-";
const absOffset = Math.abs(offsetMinutes); const absOffset = Math.abs(offsetMinutes);

View File

@ -1,24 +1,7 @@
import fs from "node:fs"; import fs from "node:fs";
import { logTimestamp } from "./log-timestamp"; import { logTimestamp } from "./log-timestamp";
import { recordRecentError } from "./error-ring";
import path from "node:path"; import path from "node:path";
export function isDebugFlagEnabled(value: string | undefined): boolean {
if (!value) {
return false;
}
return /^(1|true|yes|on)$/i.test(value.trim());
}
// Read once at startup. Enabling verbose DEBUG logging on the (unattended) server
// is a deliberate support action that requires a restart — the runtime-toggleable
// channel is the trace log, not this.
const DEBUG_ENABLED = isDebugFlagEnabled(process.env.RD_DEBUG);
export function isDebugLoggingEnabled(): boolean {
return DEBUG_ENABLED;
}
let logFilePath = path.resolve(process.cwd(), "rd_downloader.log"); let logFilePath = path.resolve(process.cwd(), "rd_downloader.log");
let fallbackLogFilePath: string | null = null; let fallbackLogFilePath: string | null = null;
const LOG_FLUSH_INTERVAL_MS = 120; const LOG_FLUSH_INTERVAL_MS = 120;
@ -87,6 +70,7 @@ function writeStderr(text: string): void {
try { try {
process.stderr.write(text); process.stderr.write(text);
} catch { } catch {
// ignore stderr failures
} }
} }
@ -152,9 +136,11 @@ function rotateIfNeeded(filePath: string): void {
try { try {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} catch { } catch {
// ignore
} }
fs.renameSync(filePath, backup); fs.renameSync(filePath, backup);
} catch { } catch {
// ignore - file may not exist yet
} }
} }
@ -174,6 +160,7 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
await fs.promises.rm(backup, { force: true }).catch(() => {}); await fs.promises.rm(backup, { force: true }).catch(() => {});
await fs.promises.rename(filePath, backup); await fs.promises.rename(filePath, backup);
} catch { } catch {
// ignore - file may not exist yet
} }
} }
@ -221,21 +208,14 @@ function ensureExitHook(): void {
process.once("exit", flushSyncPending); process.once("exit", flushSyncPending);
} }
function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): void { function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
ensureExitHook(); ensureExitHook();
const ts = logTimestamp(); const line = `${logTimestamp()} [${level}] ${message}\n`;
const line = `${ts} [${level}] ${message}\n`;
pendingLines.push(line); pendingLines.push(line);
pendingChars += line.length; pendingChars += line.length;
// Single chokepoint: every WARN/ERROR also lands in the in-memory ring so
// "what failed recently" is answerable even after the file rotates.
if (level === "ERROR" || level === "WARN") {
recordRecentError(level, message, ts);
}
for (const listener of logListeners) { for (const listener of logListeners) {
try { listener(line); } catch { } try { listener(line); } catch { /* ignore */ }
} }
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) { while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
@ -254,9 +234,6 @@ function write(level: "DEBUG" | "INFO" | "WARN" | "ERROR", message: string): voi
} }
export const logger = { export const logger = {
// Gated to a no-op when RD_DEBUG is unset so verbose call sites cost nothing
// (no formatting, no allocation) in the normal/production path.
debug: DEBUG_ENABLED ? (msg: string): void => write("DEBUG", msg) : (_msg: string): void => {},
info: (msg: string): void => write("INFO", msg), info: (msg: string): void => write("INFO", msg),
warn: (msg: string): void => write("WARN", msg), warn: (msg: string): void => write("WARN", msg),
error: (msg: string): void => write("ERROR", msg) error: (msg: string): void => write("ERROR", msg)

View File

@ -9,6 +9,7 @@ import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils"; import { extractHttpLinksFromText } from "./utils";
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor"; import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
/* ── IPC validation helpers ────────────────────────────────────── */
function validateString(value: unknown, name: string): string { function validateString(value: unknown, name: string): string {
if (typeof value !== "string") { if (typeof value !== "string") {
throw new Error(`${name} muss ein String sein`); throw new Error(`${name} muss ein String sein`);
@ -43,23 +44,19 @@ function validateStringArray(value: unknown, name: string): string[] {
return value as string[]; return value as string[];
} }
/* ── Single Instance Lock ───────────────────────────────────────── */
const gotLock = app.requestSingleInstanceLock(); const gotLock = app.requestSingleInstanceLock();
if (!gotLock) { if (!gotLock) {
app.exit(0); app.exit(0);
process.exit(0); process.exit(0);
} }
/* ── Unhandled error protection ─────────────────────────────────── */
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`); logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
}); });
process.on("unhandledRejection", (reason) => { process.on("unhandledRejection", (reason) => {
const detail = reason instanceof Error ? (reason.stack || reason.message) : String(reason); logger.error(`Unhandled Rejection: ${String(reason)}`);
logger.error(`Unhandled Rejection: ${detail}`);
});
// Node-Warnungen (z.B. MaxListenersExceeded, DeprecationWarning) sind ein
// Frühindikator für Leaks/Fehlnutzung in einem langlaufenden Server-Prozess.
process.on("warning", (warning) => {
logger.warn(`Node-Warnung: ${warning.name}: ${warning.message}${warning.stack ? ` | ${warning.stack.replace(/\s*\n\s*/g, " ⏎ ")}` : ""}`);
}); });
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
@ -116,23 +113,6 @@ function createWindow(): BrowserWindow {
return window; return window;
} }
let rendererReloadTimes: number[] = [];
const RENDERER_RELOAD_WINDOW_MS = 5 * 60 * 1000;
const RENDERER_RELOAD_MAX = 3;
// Circuit breaker: recover from a one-off renderer crash by reloading, but stop
// after a few crashes in a short window so a reproducible crash can't spin into a
// reload loop that pegs an unattended server.
function allowRendererReload(): boolean {
const now = Date.now();
rendererReloadTimes = rendererReloadTimes.filter((t) => now - t < RENDERER_RELOAD_WINDOW_MS);
if (rendererReloadTimes.length >= RENDERER_RELOAD_MAX) {
return false;
}
rendererReloadTimes.push(now);
return true;
}
function bindMainWindowLifecycle(window: BrowserWindow): void { function bindMainWindowLifecycle(window: BrowserWindow): void {
window.on("close", (event) => { window.on("close", (event) => {
const settings = controller.getSettings(); const settings = controller.getSettings();
@ -147,33 +127,6 @@ function bindMainWindowLifecycle(window: BrowserWindow): void {
mainWindow = null; mainWindow = null;
} }
}); });
window.webContents.on("render-process-gone", (_event, details) => {
logger.error(`Renderer-Prozess beendet: reason=${details.reason} exitCode=${details.exitCode ?? "?"}`);
if (details.reason === "clean-exit" || window.isDestroyed()) {
return;
}
if (allowRendererReload()) {
logger.warn("Renderer wird automatisch neu geladen (Wiederherstellung nach Absturz)");
try {
window.webContents.reload();
} catch (error) {
logger.error(`Renderer-Reload fehlgeschlagen: ${String(error)}`);
}
} else {
logger.error(`Renderer-Absturz: Auto-Reload gestoppt (mehr als ${RENDERER_RELOAD_MAX} Abstürze in ${RENDERER_RELOAD_WINDOW_MS / 60000} Min) - manueller Neustart nötig`);
}
});
// Nur protokollieren, niemals killen/neu laden: "unresponsive" feuert auch
// während legitimer langer Sync-Arbeit (große JSON-Serialisierung) und erholt
// sich meist von selbst. Eingreifen würde einen Schluckauf zum Ausfall machen.
window.webContents.on("unresponsive", () => {
logger.warn("Renderer reagiert nicht (unresponsive) - evtl. langer Sync-Task, warte auf Erholung");
});
window.webContents.on("responsive", () => {
logger.info("Renderer wieder reaktionsfähig (responsive)");
});
} }
function createTray(): void { function createTray(): void {
@ -184,6 +137,9 @@ function createTray(): void {
try { try {
tray = new Tray(iconPath); tray = new Tray(iconPath);
} catch (error) { } catch (error) {
// Fails on headless servers / Windows Service / RDP-disconnected sessions.
// Log so a user running on a non-Administrator/headless server can see
// why minimize-to-tray doesn't work, instead of getting an inaccessible window.
logger.warn(`Tray-Icon konnte nicht erstellt werden (Headless/RDP/Service?): ${String(error)} - Minimize-to-Tray steht nicht zur Verfuegung, Fenster bleibt sichtbar.`); logger.warn(`Tray-Icon konnte nicht erstellt werden (Headless/RDP/Service?): ${String(error)} - Minimize-to-Tray steht nicht zur Verfuegung, Fenster bleibt sichtbar.`);
return; return;
} }
@ -326,6 +282,7 @@ function registerIpcHandlers(): void {
const result = controller.updateSettings(validated as Partial<AppSettings>); const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher(); updateClipboardWatcher();
updateTray(); updateTray();
// Manage scheduled-start timer
if (scheduledStartTimer !== null) { if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer); clearTimeout(scheduledStartTimer);
scheduledStartTimer = null; scheduledStartTimer = null;
@ -334,6 +291,7 @@ function registerIpcHandlers(): void {
if (schedMs > 0) { if (schedMs > 0) {
const delay = schedMs - Date.now(); const delay = schedMs - Date.now();
if (delay <= 0) { if (delay <= 0) {
// Time already passed — start immediately and clear setting
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`)); void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 }); controller.updateSettings({ scheduledStartEpochMs: 0 });
} else { } else {
@ -387,6 +345,7 @@ function registerIpcHandlers(): void {
}); });
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll()); ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
ipcMain.handle(IPC_CHANNELS.START, () => { ipcMain.handle(IPC_CHANNELS.START, () => {
// Cancel any pending scheduled start when the user starts manually
if (scheduledStartTimer !== null) { if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer); clearTimeout(scheduledStartTimer);
scheduledStartTimer = null; scheduledStartTimer = null;
@ -714,10 +673,13 @@ function registerIpcHandlers(): void {
} }
const data = await fs.promises.readFile(filePath); const data = await fs.promises.readFile(filePath);
const importResult = controller.importBackup(data); const importResult = controller.importBackup(data);
// Only a full restore (queue swapped) needs the auto-relaunch. A settings- if (importResult.restored) {
// only import applied live — relaunching would be pointless and would drop // M2: Nach erfolgreichem Import die App automatisch neu starten. Der frische
// the running queue. // Prozess lädt die wiederhergestellte Session sauber vom Disk (kein Stale-
if (importResult.restored && importResult.relaunch) { // In-Memory-State, kein dauerhaft blockierter Persist in einer lebenden Session).
// Vom Main getrieben (nicht Renderer), damit ein Renderer-Fehler den Restart
// nicht verhindern kann. Kurze Verzögerung, damit das Ergebnis den Renderer
// erreicht (Toast "App startet automatisch neu…").
setTimeout(() => { setTimeout(() => {
app.relaunch(); app.relaunch();
app.quit(); app.quit();
@ -726,14 +688,6 @@ function registerIpcHandlers(): void {
return importResult; return importResult;
}); });
ipcMain.on(IPC_CHANNELS.LOG_RENDERER_ERROR, (_event, rawReport: unknown) => {
try {
logger.error(formatRendererErrorReport(rawReport));
} catch (error) {
logger.error(`[Renderer] Fehlerbericht konnte nicht verarbeitet werden: ${String(error)}`);
}
});
controller.onState = (snapshot) => { controller.onState = (snapshot) => {
if (!mainWindow || mainWindow.isDestroyed()) { if (!mainWindow || mainWindow.isDestroyed()) {
return; return;
@ -742,41 +696,6 @@ function registerIpcHandlers(): void {
}; };
} }
function formatRendererErrorReport(rawReport: unknown): string {
const report = (rawReport && typeof rawReport === "object" ? rawReport : {}) as Record<string, unknown>;
const str = (value: unknown): string => (typeof value === "string" ? value : "");
const num = (value: unknown): string => (typeof value === "number" && Number.isFinite(value) ? String(value) : "");
const kind = str(report.kind) || "error";
const message = (str(report.message) || "(ohne Nachricht)").slice(0, 2000);
const source = str(report.source);
const line = num(report.line);
const column = num(report.column);
const stack = str(report.stack).slice(0, 4000);
const componentStack = str(report.componentStack).slice(0, 4000);
const parts: string[] = [`[Renderer:${kind}] ${message}`];
if (source) {
parts.push(`@ ${source}${line ? `:${line}${column ? `:${column}` : ""}` : ""}`);
}
if (stack) {
parts.push(`| stack: ${stack.replace(/\s*\n\s*/g, " ⏎ ")}`);
}
if (componentStack) {
parts.push(`| react: ${componentStack.replace(/\s*\n\s*/g, " ⏎ ")}`);
}
return parts.join(" ");
}
app.on("child-process-gone", (_event, details) => {
const killed = details.reason !== "clean-exit" && details.reason !== "killed";
const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`;
if (killed) {
logger.error(line);
} else {
logger.warn(line);
}
});
app.on("second-instance", () => { app.on("second-instance", () => {
if (mainWindow) { if (mainWindow) {
if (mainWindow.isMinimized()) { if (mainWindow.isMinimized()) {

View File

@ -1,3 +1,19 @@
// Mega.nz Public API: Filename + Size aus Public-Link ohne Mega-Debrid-Account.
//
// Erlaubt Pre-Resolve von Filenames sobald Links in die Queue kommen — ohne
// Mega-Debrid-Quota anzufassen. Funktioniert fuer jeden public mega.nz Link
// (mit Decryption-Key im URL-Fragment).
//
// Protokoll: https://g.api.mega.co.nz/cs
// Request: POST [{"a":"g","g":1,"p":"<file-id>"}]
// Response: [{"s": <size>, "at": <base64url encrypted attributes>, ...}]
// Attribute-Decryption: AES-128-CBC, key = file-key[0..16], IV = 16x \0
// Plaintext startet mit "MEGA" gefolgt von JSON: {"n": "filename.mkv", ...}
//
// Datei-Key im URL-Fragment ist 32 Bytes (base64url-encoded). Bytes 0-15
// sind der AES-Schluessel, 16-23 der CTR-Nonce, 24-31 die Meta-MAC. Fuer
// Attribut-Decryption brauchen wir nur den AES-Teil.
import crypto from "node:crypto"; import crypto from "node:crypto";
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs"; const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
@ -37,6 +53,7 @@ export function parseMegaUrl(url: string): ParsedMegaLink | null {
if (!m) return null; if (!m) return null;
const id = m[1]; const id = m[1];
const rawKey = base64UrlDecode(m[2]); const rawKey = base64UrlDecode(m[2]);
// Files: 32 Bytes (256 bit). Folders: 16 Bytes — wir behandeln nur Files.
if (!rawKey || rawKey.length !== 32) return null; if (!rawKey || rawKey.length !== 32) return null;
return { id, rawKey }; return { id, rawKey };
} }
@ -106,6 +123,8 @@ export async function resolveMegaFilename(
return null; return null;
} }
// Mega gibt entweder ein Array mit File-Infos oder eine numerische Error-ID
// zurueck (z.B. -9 ENOENT, -11 EACCESS, -14 EKEY, -16 EBLOCKED, -25 EOVERQUOTA).
if (typeof payload === "number") return null; if (typeof payload === "number") return null;
if (!Array.isArray(payload) || payload.length === 0) return null; if (!Array.isArray(payload) || payload.length === 0) return null;

View File

@ -16,6 +16,13 @@ const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid";
const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json"; const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json";
const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de"; const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de";
/**
* Mega-Debrid-Antwort "Kein Server für diesen Hoster verfügbar". Kommt zurück, wenn
* das Tageslimit DIESES Accounts für den Hoster erschöpft ist (oder der Hoster kurz
* nicht bedient wird). KEIN Session-/Leer-Fall der Account soll schnell scheitern,
* damit die Multi-Account-Rotation sofort zum nächsten (nicht limitierten) Account
* wechselt, statt re-Login + Retry-Sturm das geteilte Unrestrict-Budget zu fressen.
*/
export const MEGA_DEBRID_NO_SERVER_RE = /kein server f(?:ü|u)r diesen hoster|no server (?:is )?available for this host|aucun serveur disponible/i; export const MEGA_DEBRID_NO_SERVER_RE = /kein server f(?:ü|u)r diesen hoster|no server (?:is )?available for this host|aucun serveur disponible/i;
function normalizeLink(link: string): string { function normalizeLink(link: string): string {
@ -221,6 +228,11 @@ export class MegaWebFallback {
private getCredentials: () => MegaCredentials; private getCredentials: () => MegaCredentials;
// Per-Login Session-Cache: login(lowercase) → { cookie, setAt }. Multi-Account-
// Rotation: jeder Account nutzt SEINE eigene Session. Frueher gab es nur EINE
// geteilte Cookie-Session → der Web-Unrestrict lief fuer JEDEN rotierten Account mit
// den Creds des ersten/Legacy-Accounts (settings.megaLogin); der naechste Account
// wurde nie wirklich verwendet (Rotation war wirkungslos).
private sessions = new Map<string, { cookie: string; setAt: number }>(); private sessions = new Map<string, { cookie: string; setAt: number }>();
public constructor(getCredentials: () => MegaCredentials) { public constructor(getCredentials: () => MegaCredentials) {
@ -235,6 +247,7 @@ export class MegaWebFallback {
const overallSignal = withTimeoutSignal(signal, 180000); const overallSignal = withTimeoutSignal(signal, 180000);
return this.runExclusive(async () => { return this.runExclusive(async () => {
throwIfAborted(overallSignal); throwIfAborted(overallSignal);
// Per-Account-Creds aus der Rotation bevorzugen; sonst Legacy-Default.
const creds = (account && account.login.trim() && account.password.trim()) const creds = (account && account.login.trim() && account.password.trim())
? account ? account
: this.getCredentials(); : this.getCredentials();
@ -246,6 +259,7 @@ export class MegaWebFallback {
let generated = await this.generate(link, cookie, overallSignal); let generated = await this.generate(link, cookie, overallSignal);
if (!generated) { if (!generated) {
// Session evtl. abgelaufen → fuer DIESEN Login neu einloggen + einmal erneut.
this.sessions.delete(key); this.sessions.delete(key);
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal); cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
generated = await this.generate(link, cookie, overallSignal); generated = await this.generate(link, cookie, overallSignal);
@ -262,6 +276,8 @@ export class MegaWebFallback {
}, overallSignal); }, overallSignal);
} }
/** Liefert ein gueltiges Session-Cookie fuer den gegebenen Login (aus Cache oder
* via frischem Login). Cache-TTL 20 min. */
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> { private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
const existing = this.sessions.get(key); const existing = this.sessions.get(key);
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) { if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
@ -352,12 +368,17 @@ export class MegaWebFallback {
const html = await page.text(); const html = await page.text();
// Check for permanent hoster errors before looking for debrid codes
const pageErrors = parsePageErrors(html); const pageErrors = parsePageErrors(html);
const permanentError = isPermanentHosterError(pageErrors); const permanentError = isPermanentHosterError(pageErrors);
if (permanentError) { if (permanentError) {
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`); throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
} }
// Tageslimit dieses Accounts: die DEBRID-Seite enthaelt dann KEINEN processDebrid-
// Code (parseCodes leer → wir wuerden gleich null/"Antwort leer" liefern). Steht die
// "Kein Server für diesen Hoster"-Meldung als Page-Error im HTML, surface sie hier,
// damit die Rotation den Account als tageslimitiert erkennt statt als Leer-Blip.
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err)); const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
if (noServerError) { if (noServerError) {
throw new Error(`Mega-Web: ${noServerError}`); throw new Error(`Mega-Web: ${noServerError}`);
@ -405,6 +426,11 @@ export class MegaWebFallback {
continue; continue;
} }
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim(); const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
// "Kein Server für diesen Hoster verfügbar" = Account-Tageslimit erschöpft.
// Surface die Meldung statt null zurückzugeben — sonst re-loggt unrestrict()
// ein + pollt erneut (Retry-Sturm), was bei einem limitierten Account zwecklos
// ist und das geteilte Rotations-Budget verbrennt. So scheitert der Account
// schnell und die Rotation nutzt den nächsten Account.
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) { if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
throw new Error(`Mega-Web: ${serverMsg}`); throw new Error(`Mega-Web: ${serverMsg}`);
} }

View File

@ -93,6 +93,7 @@ function flushPending(): void {
try { try {
fs.appendFileSync(logPath, chunk, "utf8"); fs.appendFileSync(logPath, chunk, "utf8");
} catch { } catch {
// ignore write errors
} }
} }
} }
@ -122,9 +123,11 @@ async function cleanupOldPackageLogs(dir: string): Promise<void> {
await fs.promises.unlink(filePath); await fs.promises.unlink(filePath);
} }
} catch { } catch {
// ignore locked/missing files
} }
} }
} catch { } catch {
// ignore missing dir
} }
} }
@ -221,6 +224,7 @@ export function shutdownPackageLogs(): void {
try { try {
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
} }
pendingLinesByPackage.clear(); pendingLinesByPackage.clear();

View File

@ -158,10 +158,12 @@ export class RealDebridWebFallback {
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"] storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
}); });
} catch { } catch {
// ignore
} }
try { try {
await currentSession.clearCache(); await currentSession.clearCache();
} catch { } catch {
// ignore
} }
} }
} }
@ -318,6 +320,7 @@ export class RealDebridWebFallback {
return this.rememberToken(token); return this.rememberToken(token);
} }
} catch { } catch {
// ignore window scraping errors and fall back to session fetch
} }
return null; return null;
@ -327,12 +330,14 @@ export class RealDebridWebFallback {
try { try {
await this.extractApiTokenFromWindow(window); await this.extractApiTokenFromWindow(window);
} catch { } catch {
// ignore best-effort token warmup failures
} }
} }
private async extractApiToken(signal?: AbortSignal): Promise<string | null> { private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
throwIfAborted(signal); throwIfAborted(signal);
// Return cached token if fresh (max 30 min)
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) { if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
return this.cachedToken; return this.cachedToken;
} }
@ -394,6 +399,7 @@ export class RealDebridWebFallback {
const text = await response.text(); const text = await response.text();
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
// Token expired or revoked — invalidate cache
this.cachedToken = ""; this.cachedToken = "";
this.cachedTokenAt = 0; this.cachedTokenAt = 0;
return { kind: "login_required" }; return { kind: "login_required" };

View File

@ -82,6 +82,8 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
await sleep(ms); await sleep(ms);
return; return;
} }
// Check before entering the Promise constructor to avoid a race where the timer
// resolves before the aborted check runs (especially when ms=0).
if (signal.aborted) { if (signal.aborted) {
throw new Error("aborted"); throw new Error("aborted");
} }

View File

@ -46,9 +46,11 @@ function rotateIfNeeded(filePath: string): void {
try { try {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} catch { } catch {
// ignore
} }
fs.renameSync(filePath, backup); fs.renameSync(filePath, backup);
} catch { } catch {
// ignore
} }
} }
@ -61,6 +63,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} }
} catch { } catch {
// ignore
} }
} }
@ -97,6 +100,7 @@ export function logRenameEvent(level: RenameLogLevel, message: string, fields?:
"utf8" "utf8"
); );
} catch { } catch {
// ignore write errors
} }
} }
@ -114,6 +118,7 @@ export function shutdownRenameLog(): void {
try { try {
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
renameLogPath = null; renameLogPath = null;
} }

View File

@ -30,6 +30,7 @@ function flushPending(): void {
try { try {
fs.appendFileSync(sessionLogPath, chunk, "utf8"); fs.appendFileSync(sessionLogPath, chunk, "utf8");
} catch { } catch {
// ignore write errors
} }
} }
@ -66,9 +67,11 @@ async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<v
await fs.promises.unlink(filePath); await fs.promises.unlink(filePath);
} }
} catch { } catch {
// ignore - file may be locked
} }
} }
} catch { } catch {
// ignore - dir may not exist
} }
} }
@ -106,16 +109,19 @@ export function shutdownSessionLog(): void {
return; return;
} }
// Flush any pending lines
if (flushTimer) { if (flushTimer) {
clearTimeout(flushTimer); clearTimeout(flushTimer);
flushTimer = null; flushTimer = null;
} }
flushPending(); flushPending();
// Write closing line
const isoTimestamp = logTimestamp(); const isoTimestamp = logTimestamp();
try { try {
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8"); fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
setLogListener(null); setLogListener(null);

View File

@ -5,6 +5,17 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts"; import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
import { StoragePaths } from "./storage"; import { StoragePaths } from "./storage";
/** Startup Health-Check: runs once at app boot and surfaces potential problem
* states BEFORE the user hits them mid-download.
*
* Goals:
* - Warn on missing / unreachable download directory
* - Warn on low disk space (< 5 GB free)
* - Warn when no debrid provider is configured (app is effectively offline)
* - Warn when state file is suspiciously large (>50 MB pruning recommended)
*
* Non-goals: blocking startup. The check only logs the app continues. */
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR"; export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
export interface HealthCheckFinding { export interface HealthCheckFinding {
@ -21,8 +32,8 @@ export interface HealthCheckReport {
infoCount: number; infoCount: number;
} }
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; // 50 MB
function safeExists(p: string): boolean { function safeExists(p: string): boolean {
try { try {
@ -41,6 +52,9 @@ function getFileSizeBytes(p: string): number {
} }
} }
/** Attempt a tiny write-probe in the given directory. Returns true on
* success, false if the directory isn't writable. We write and immediately
* delete a uniquely-named temp file so we never leave garbage behind. */
function isWritable(dir: string): boolean { function isWritable(dir: string): boolean {
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
try { try {
@ -52,8 +66,12 @@ function isWritable(dir: string): boolean {
} }
} }
/** Query free disk space for a given path. Returns null if unsupported or
* the query fails callers treat null as "unknown" and skip the check. */
function getFreeDiskSpaceBytes(target: string): number | null { function getFreeDiskSpaceBytes(target: string): number | null {
try { try {
// fs.statfsSync is available on Node 18.15+; on Windows it still maps to
// the underlying volume so it works for download dirs on any drive.
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync; const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
if (typeof statfs !== "function") { if (typeof statfs !== "function") {
return null; return null;
@ -105,9 +123,12 @@ function countConfiguredProviders(settings: AppSettings): { count: number; provi
return { count: providers.length, providers }; return { count: providers.length, providers };
} }
/** Pure check function: takes inputs, returns findings. Kept side-effect-free
* so it's trivial to unit-test the caller handles logging / persistence. */
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport { export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
const findings: HealthCheckFinding[] = []; const findings: HealthCheckFinding[] = [];
// ── 1. Download directory ───────────────────────────────────────────────
const outputDir = String(settings.outputDir || "").trim(); const outputDir = String(settings.outputDir || "").trim();
if (!outputDir) { if (!outputDir) {
findings.push({ findings.push({
@ -131,6 +152,7 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern." hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
}); });
} else { } else {
// Check available disk space only when the directory is actually usable
const freeBytes = getFreeDiskSpaceBytes(outputDir); const freeBytes = getFreeDiskSpaceBytes(outputDir);
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) { if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
const freeMb = Math.round(freeBytes / (1024 * 1024)); const freeMb = Math.round(freeBytes / (1024 * 1024));
@ -143,6 +165,7 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
} }
} }
// ── 2. Provider-Credentials ─────────────────────────────────────────────
const { count, providers } = countConfiguredProviders(settings); const { count, providers } = countConfiguredProviders(settings);
if (count === 0) { if (count === 0) {
findings.push({ findings.push({
@ -159,6 +182,7 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
}); });
} }
// ── 3. State-File-Groesse ──────────────────────────────────────────────
if (safeExists(storagePaths.sessionFile)) { if (safeExists(storagePaths.sessionFile)) {
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile); const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
if (sizeBytes > LARGE_STATE_FILE_BYTES) { if (sizeBytes > LARGE_STATE_FILE_BYTES) {
@ -172,6 +196,7 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
} }
} }
// ── 4. Storage-Basis-Verzeichnis muss beschreibbar sein (fuer Logs) ────
if (!safeExists(storagePaths.baseDir)) { if (!safeExists(storagePaths.baseDir)) {
findings.push({ findings.push({
severity: "ERROR", severity: "ERROR",

View File

@ -120,6 +120,7 @@ function normalizeColumnOrder(raw: unknown): string[] {
result.push(col); result.push(col);
} }
} }
// "name" is mandatory — ensure it's always present
if (!seen.has("name")) { if (!seen.has("name")) {
result.unshift("name"); result.unshift("name");
} }
@ -308,6 +309,7 @@ function normalizeProviderOrder(
if (Array.isArray(raw) && raw.length > 0) { if (Array.isArray(raw) && raw.length > 0) {
list = raw; list = raw;
} else { } else {
// Migrate from old primary/secondary/tertiary
const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter( const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter(
(v) => v && String(v).trim() && String(v).trim() !== "none" (v) => v && String(v).trim() && String(v).trim() !== "none"
); );
@ -345,6 +347,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
const currentUsageDay = getProviderUsageDayKey(); const currentUsageDay = getProviderUsageDayKey();
const megaLogin = asText(settings.megaLogin); const megaLogin = asText(settings.megaLogin);
const megaPassword = asText(settings.megaPassword); const megaPassword = asText(settings.megaPassword);
// Migrate legacy single-account to multi-account format
let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim(); let megaCredentials = String(settings.megaCredentials ?? "").replace(/\r\n|\r/g, "\n").trim();
if (!megaCredentials && megaLogin && megaPassword) { if (!megaCredentials && megaLogin && megaPassword) {
megaCredentials = `${megaLogin}:${megaPassword}`; megaCredentials = `${megaLogin}:${megaPassword}`;
@ -421,8 +424,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
packageName: asText(settings.packageName), packageName: asText(settings.packageName),
autoExtract: Boolean(settings.autoExtract), autoExtract: Boolean(settings.autoExtract),
autoRename4sf4sj: Boolean(settings.autoRename4sf4sj), autoRename4sf4sj: Boolean(settings.autoRename4sf4sj),
keepGermanAudioOnly: Boolean(settings.keepGermanAudioOnly),
germanAudioMode: settings.germanAudioMode === "first" ? "first" : "tag",
extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir), extractDir: normalizeAbsoluteDir(settings.extractDir, defaults.extractDir),
collectMkvToLibrary: Boolean(settings.collectMkvToLibrary), collectMkvToLibrary: Boolean(settings.collectMkvToLibrary),
mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir), mkvLibraryDir: normalizeAbsoluteDir(settings.mkvLibraryDir, defaults.mkvLibraryDir),
@ -458,7 +459,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted, autoSkipExtracted: settings.autoSkipExtracted !== undefined ? Boolean(settings.autoSkipExtracted) : defaults.autoSkipExtracted,
hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems, hideExtractedItems: settings.hideExtractedItems !== undefined ? Boolean(settings.hideExtractedItems) : defaults.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection, confirmDeleteSelection: settings.confirmDeleteSelection !== undefined ? Boolean(settings.confirmDeleteSelection) : defaults.confirmDeleteSelection,
backupIncludeDownloads: settings.backupIncludeDownloads !== undefined ? Boolean(settings.backupIncludeDownloads) : defaults.backupIncludeDownloads,
totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime, totalDownloadedAllTime: typeof settings.totalDownloadedAllTime === "number" && settings.totalDownloadedAllTime >= 0 ? settings.totalDownloadedAllTime : defaults.totalDownloadedAllTime,
totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime, totalCompletedFilesAllTime: typeof settings.totalCompletedFilesAllTime === "number" && settings.totalCompletedFilesAllTime >= 0 ? settings.totalCompletedFilesAllTime : defaults.totalCompletedFilesAllTime,
totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs, totalRuntimeAllTimeMs: typeof settings.totalRuntimeAllTimeMs === "number" && settings.totalRuntimeAllTimeMs >= 0 ? settings.totalRuntimeAllTimeMs : defaults.totalRuntimeAllTimeMs,
@ -575,6 +575,7 @@ function ensureBaseDir(baseDir: string): void {
} }
} }
/** JSON replacer that sanitizes NaN/Infinity to null to prevent file corruption. */
function safeJsonReplacer(_key: string, value: unknown): unknown { function safeJsonReplacer(_key: string, value: unknown): unknown {
if (typeof value === "number" && !Number.isFinite(value)) { if (typeof value === "number" && !Number.isFinite(value)) {
return null; return null;
@ -598,8 +599,12 @@ function readSettingsFile(filePath: string): AppSettings | null {
}); });
return sanitizeCredentialPersistence(merged); return sanitizeCredentialPersistence(merged);
} catch (error) { } catch (error) {
// Distinguish permission/access errors from missing/corrupt JSON so a
// misconfigured server (e.g. unusual user, restricted AppData) shows a
// clear log entry instead of silently falling back to defaults.
const code = (error as NodeJS.ErrnoException)?.code || ""; const code = (error as NodeJS.ErrnoException)?.code || "";
if (code === "ENOENT") { if (code === "ENOENT") {
// file doesn't exist — normal on first run
} else if (code === "EACCES" || code === "EPERM") { } else if (code === "EACCES" || code === "EPERM") {
logger.error(`Settings-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`); logger.error(`Settings-Datei nicht zugreifbar (${code}): ${filePath} - pruefe Datei-/Ordner-Berechtigungen fuer Benutzer ${process.env.USERNAME || process.env.USER || "?"}`);
} else { } else {
@ -785,6 +790,7 @@ export function loadSettings(paths: StoragePaths): AppSettings {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile); syncRenameWithExdevFallback(tempPath, paths.configFile);
} catch { } catch {
// ignore restore write failure
} }
return backupLoaded; return backupLoaded;
} }
@ -815,15 +821,18 @@ function sessionBackupPath(sessionFile: string): string {
} }
export function normalizeLoadedSessionTransientFields(session: SessionState): SessionState { export function normalizeLoadedSessionTransientFields(session: SessionState): SessionState {
// Reset transient fields that may be stale from a previous crash
const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]); const ACTIVE_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
for (const item of Object.values(session.items)) { for (const item of Object.values(session.items)) {
if (ACTIVE_STATUSES.has(item.status)) { if (ACTIVE_STATUSES.has(item.status)) {
item.status = "queued"; item.status = "queued";
item.lastError = ""; item.lastError = "";
} }
// Always clear stale speed values
item.speedBps = 0; item.speedBps = 0;
} }
// Reset package-level active statuses to queued (mirrors item reset above)
const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]); const ACTIVE_PKG_STATUSES = new Set(["downloading", "validating", "extracting", "integrity_check", "paused", "reconnect_wait"]);
for (const pkg of Object.values(session.packages)) { for (const pkg of Object.values(session.packages)) {
if (ACTIVE_PKG_STATUSES.has(pkg.status)) { if (ACTIVE_PKG_STATUSES.has(pkg.status)) {
@ -832,6 +841,7 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
pkg.postProcessLabel = undefined; pkg.postProcessLabel = undefined;
} }
// Clear stale session-level running/paused flags
session.running = false; session.running = false;
session.paused = false; session.paused = false;
@ -840,6 +850,9 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
function readSessionFile(filePath: string): SessionState | null { function readSessionFile(filePath: string): SessionState | null {
try { try {
// Inline readFileSync into JSON.parse so the raw string is not bound to a
// variable and can be GC'd immediately — avoids holding the full JSON text
// and the parsed object graph in memory simultaneously.
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown; const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown;
const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed)); const session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
const pkgCount = Object.keys(session.packages).length; const pkgCount = Object.keys(session.packages).length;
@ -859,10 +872,12 @@ function readSessionFile(filePath: string): SessionState | null {
export function saveSettings(paths: StoragePaths, settings: AppSettings): void { export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
// Create a backup of the existing config before overwriting
if (fs.existsSync(paths.configFile)) { if (fs.existsSync(paths.configFile)) {
try { try {
fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`); fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`);
} catch { } catch {
// Best-effort backup; proceed even if it fails
} }
} }
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings)); const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
@ -872,7 +887,7 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile); syncRenameWithExdevFallback(tempPath, paths.configFile);
} catch (error) { } catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { } try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
throw error; throw error;
} }
} }
@ -941,6 +956,11 @@ export function loadSession(paths: StoragePaths): SessionState {
ensureBaseDir(paths.baseDir); ensureBaseDir(paths.baseDir);
const backupFile = sessionBackupPath(paths.sessionFile); const backupFile = sessionBackupPath(paths.sessionFile);
const primaryExists = fs.existsSync(paths.sessionFile); const primaryExists = fs.existsSync(paths.sessionFile);
// A missing primary file is only a genuine "fresh start" when there is also
// nothing to recover from. If a backup or an interrupted-write temp file
// exists, fall through to the recovery chain below instead of returning an
// empty session — otherwise a momentarily-absent primary during an update
// restart would discard a perfectly good backup and wipe the whole queue.
if (!primaryExists) { if (!primaryExists) {
const hasRecoverable = fs.existsSync(backupFile) const hasRecoverable = fs.existsSync(backupFile)
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync")) || fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
@ -954,6 +974,7 @@ export function loadSession(paths: StoragePaths): SessionState {
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null; const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
// If primary loaded but is empty, check if backup has packages (safety net)
if (primary) { if (primary) {
const primaryPkgCount = Object.keys(primary.packages).length; const primaryPkgCount = Object.keys(primary.packages).length;
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) { if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
@ -968,6 +989,7 @@ export function loadSession(paths: StoragePaths): SessionState {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch { } catch {
// ignore restore write failure
} }
return backup; return backup;
} }
@ -985,10 +1007,12 @@ export function loadSession(paths: StoragePaths): SessionState {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch { } catch {
// ignore restore write failure
} }
return backup; return backup;
} }
// Last resort: try to recover from temp files left by interrupted writes
for (const kind of ["sync", "async"] as const) { for (const kind of ["sync", "async"] as const) {
const tmpPath = sessionTempPath(paths.sessionFile, kind); const tmpPath = sessionTempPath(paths.sessionFile, kind);
if (fs.existsSync(tmpPath)) { if (fs.existsSync(tmpPath)) {
@ -999,6 +1023,7 @@ export function loadSession(paths: StoragePaths): SessionState {
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer); const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(paths.sessionFile, payload, "utf8"); fs.writeFileSync(paths.sessionFile, payload, "utf8");
} catch { } catch {
// ignore restore write failure
} }
return tmpSession; return tmpSession;
} }
@ -1016,6 +1041,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
try { try {
fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile)); fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile));
} catch { } catch {
// Best-effort backup; proceed even if it fails
} }
} }
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
@ -1024,7 +1050,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile); syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch (error) { } catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { } try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
throw error; throw error;
} }
} }
@ -1038,6 +1064,7 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {}); await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
const tempPath = sessionTempPath(paths.sessionFile, "async"); const tempPath = sessionTempPath(paths.sessionFile, "async");
await fsp.writeFile(tempPath, payload, "utf8"); await fsp.writeFile(tempPath, payload, "utf8");
// If a synchronous save occurred after this async save started, discard the stale write
if (generation < syncSaveGeneration) { if (generation < syncSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {}); await fsp.rm(tempPath, { force: true }).catch(() => {});
return; return;
@ -1061,6 +1088,11 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> { async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
if (asyncSaveRunning) { if (asyncSaveRunning) {
// Keep the freshest payload, but preserve the generation captured when THIS
// payload was snapshotted. Re-reading syncSaveGeneration at re-invoke time
// would let a stale queued write slip past the guard and clobber a newer
// synchronous save (persistNowSync/prepareForShutdown) — which could drop
// packages that the sync save had just persisted.
asyncSaveQueued = { paths, payload, generation }; asyncSaveQueued = { paths, payload, generation };
return; return;
} }
@ -1086,6 +1118,8 @@ export function cancelPendingAsyncSaves(): void {
} }
export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> { export async function saveSessionAsync(paths: StoragePaths, session: SessionState): Promise<void> {
// Capture the generation at snapshot time so the guard in writeSessionPayload
// can reliably discard this write if a synchronous save lands afterwards.
const generation = syncSaveGeneration; const generation = syncSaveGeneration;
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer); const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
await saveSessionPayloadAsync(paths, payload, generation); await saveSessionPayloadAsync(paths, payload, generation);
@ -1146,7 +1180,7 @@ export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void
fs.writeFileSync(tempPath, payload, "utf8"); fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.historyFile); syncRenameWithExdevFallback(tempPath, paths.historyFile);
} catch (error) { } catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { } try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
throw error; throw error;
} }
} }
@ -1189,6 +1223,7 @@ export function clearHistory(paths: StoragePaths): void {
try { try {
fs.unlinkSync(paths.historyFile); fs.unlinkSync(paths.historyFile);
} catch { } catch {
// ignore
} }
} }
} }

View File

@ -5,7 +5,6 @@ import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log"; import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup"; import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger"; import { getLogFilePath } from "./logger";
import { getRecentErrors } from "./error-ring";
import { getPackageLogPath } from "./package-log"; import { getPackageLogPath } from "./package-log";
import { getRenameLogPath } from "./rename-log"; import { getRenameLogPath } from "./rename-log";
import { getDesktopRenameLogPath } from "./desktop-rename-log"; import { getDesktopRenameLogPath } from "./desktop-rename-log";
@ -53,6 +52,7 @@ function addDirectoryIfExists(zip: AdmZip, dirPath: string, zipRoot: string): vo
} }
} }
/** Wie addDirectoryIfExists, aber nur Dateien die in den letzten maxAgeMs ms geaendert wurden. */
function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number { function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number {
if (!fs.existsSync(dirPath)) { if (!fs.existsSync(dirPath)) {
return 0; return 0;
@ -68,7 +68,7 @@ function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string,
zip.addLocalFile(fullPath, zipRoot, entry.name); zip.addLocalFile(fullPath, zipRoot, entry.name);
added += 1; added += 1;
} }
} catch { } } catch { /* ignorieren */ }
} }
return added; return added;
} }
@ -170,8 +170,6 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
}); });
addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode)); addJson(zip, "overview/host-diagnostics.json", resolveHostDiagnostics(hostDiagnosticsMode));
addJson(zip, "overview/trace-config.json", getTraceConfig()); addJson(zip, "overview/trace-config.json", getTraceConfig());
const recentErrors = getRecentErrors();
addJson(zip, "overview/recent-errors.json", { count: recentErrors.length, entries: recentErrors });
addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`); addFileIfExists(zip, path.join(baseDir, AI_MANIFEST_FILE), `runtime/${AI_MANIFEST_FILE}`);
addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt"); addFileIfExists(zip, path.join(baseDir, "debug_host.txt"), "runtime/debug_host.txt");
@ -189,11 +187,16 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old"); addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
// Granulare Per-Item/-Package/-Session-Logs nur der letzten 8h.
// Vorher wurden alle logs-Unterordner rekursiv gepackt → tausende Item-Logs
// → 200+ MB, unhandlich zum Verschicken. Mit 8h-Fenster bleibt das Bundle
// klein genug und enthaelt alles fuer aktuelle Fehler + Rename-Probleme.
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000; const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS); addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
// Live-Logs der aktiven Queue (aktuelle Session) immer vollstaendig mitsichern.
for (const packageId of packageIds) { for (const packageId of packageIds) {
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`); addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
} }

View File

@ -64,6 +64,7 @@ function flushPending(): void {
try { try {
fs.appendFileSync(traceLogPath, chunk, "utf8"); fs.appendFileSync(traceLogPath, chunk, "utf8");
} catch { } catch {
// ignore
} }
} }
@ -77,9 +78,11 @@ function rotateIfNeeded(filePath: string): void {
try { try {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} catch { } catch {
// ignore
} }
fs.renameSync(filePath, backup); fs.renameSync(filePath, backup);
} catch { } catch {
// ignore
} }
} }
@ -92,6 +95,7 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true }); fs.rmSync(backup, { force: true });
} }
} catch { } catch {
// ignore
} }
} }
@ -159,6 +163,7 @@ function persistTraceConfig(): void {
try { try {
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8"); fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
} catch { } catch {
// ignore
} }
} }
@ -305,6 +310,7 @@ export function shutdownTraceLog(): void {
try { try {
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8"); fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
} catch { } catch {
// ignore
} }
traceLogPath = null; traceLogPath = null;
traceConfigPath = null; traceConfigPath = null;

View File

@ -8,6 +8,8 @@ import { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult } from ".
import { compactErrorText, humanSize } from "./utils"; import { compactErrorText, humanSize } from "./utils";
import { logger } from "./logger"; import { logger } from "./logger";
// ─── Constants ─────────────────────────────────────────────────────────────────
const RELEASE_FETCH_TIMEOUT_MS = 12_000; const RELEASE_FETCH_TIMEOUT_MS = 12_000;
const CONNECT_TIMEOUT_MS = 30_000; const CONNECT_TIMEOUT_MS = 30_000;
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000; const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
@ -16,6 +18,8 @@ const RETRY_DELAY_MS = 1_500;
const MAX_DOWNLOAD_PASSES = 3; const MAX_DOWNLOAD_PASSES = 3;
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`; const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
// ─── Types ─────────────────────────────────────────────────────────────────────
type UpdateSource = { type UpdateSource = {
name: string; name: string;
webBase: string; webBase: string;
@ -36,6 +40,8 @@ type ExpectedDigest = {
encoding: "hex" | "base64"; encoding: "hex" | "base64";
}; };
// ─── Update Sources ────────────────────────────────────────────────────────────
const UPDATE_SOURCES: UpdateSource[] = [ const UPDATE_SOURCES: UpdateSource[] = [
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" }, { name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" },
{ name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" }, { name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" },
@ -46,16 +52,23 @@ const PRIMARY_SOURCE = UPDATE_SOURCES[0];
const WEB_BASE = PRIMARY_SOURCE.webBase; const WEB_BASE = PRIMARY_SOURCE.webBase;
const API_BASE = PRIMARY_SOURCE.apiBase; const API_BASE = PRIMARY_SOURCE.apiBase;
// ─── Module State ──────────────────────────────────────────────────────────────
let activeAbortController: AbortController | null = null; let activeAbortController: AbortController | null = null;
// ─── Progress Helper ───────────────────────────────────────────────────────────
function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void { function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
if (!cb) return; if (!cb) return;
try { try {
cb(progress); cb(progress);
} catch { } catch {
// ignore renderer callback errors
} }
} }
// ─── Version Utilities ─────────────────────────────────────────────────────────
export function parseVersionParts(version: string): number[] { export function parseVersionParts(version: string): number[] {
const cleaned = version.replace(/^v/i, "").trim(); const cleaned = version.replace(/^v/i, "").trim();
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0")); return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
@ -74,6 +87,8 @@ export function isRemoteNewer(currentVersion: string, latestVersion: string): bo
return false; return false;
} }
// ─── Repository Normalization ──────────────────────────────────────────────────
function isValidRepoPart(value: string): boolean { function isValidRepoPart(value: string): boolean {
const part = String(value || "").trim(); const part = String(value || "").trim();
if (!part || part === "." || part === ".." || part.includes("..")) return false; if (!part || part === "." || part === ".." || part.includes("..")) return false;
@ -112,12 +127,15 @@ export function normalizeUpdateRepo(repo: string): string {
if (result) return result; if (result) return result;
} }
} catch { } catch {
// not a URL, try as plain text
} }
const result = extractOwnerRepo(raw); const result = extractOwnerRepo(raw);
return result || DEFAULT_UPDATE_REPO; return result || DEFAULT_UPDATE_REPO;
} }
// ─── Network Utilities ─────────────────────────────────────────────────────────
function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } { function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } {
const ctrl = new AbortController(); const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms); const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms);
@ -172,18 +190,25 @@ function getBodyIdleTimeout(): number {
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS; return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
} }
// ─── Digest Parsing & Verification ─────────────────────────────────────────────
//
// SHA-256 = 32 bytes → hex: 64 chars, base64: 43-44 chars (+ up to 1 padding =)
// SHA-512 = 64 bytes → hex: 128 chars, base64: 86-88 chars (+ up to 2 padding =)
function normalizeBase64(raw: string): string { function normalizeBase64(raw: string): string {
return String(raw || "") return String(raw || "")
.trim() .trim()
.replace(/-/g, "+") .replace(/-/g, "+") // URL-safe → standard
.replace(/_/g, "/") .replace(/_/g, "/") // URL-safe → standard
.replace(/=+$/g, ""); .replace(/=+$/g, ""); // strip padding for consistent comparison
} }
export function parseExpectedDigest(raw: string): ExpectedDigest | null { export function parseExpectedDigest(raw: string): ExpectedDigest | null {
const text = String(raw || "").trim(); const text = String(raw || "").trim();
if (!text) return null; if (!text) return null;
// ── Prefixed: sha256:<value> ──
const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i); const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
if (pre256hex) { if (pre256hex) {
return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" }; return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" };
@ -194,6 +219,8 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" }; return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" };
} }
// ── Prefixed: sha512:<value> ──
const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i); const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
if (pre512hex) { if (pre512hex) {
return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" }; return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" };
@ -204,6 +231,8 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" }; return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" };
} }
// ── Plain hex ──
if (/^[a-fA-F0-9]{64}$/.test(text)) { if (/^[a-fA-F0-9]{64}$/.test(text)) {
return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" }; return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" };
} }
@ -211,6 +240,8 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" }; return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" };
} }
// ── Plain base64 (SHA-512 first since it's longer → won't accidentally match SHA-256) ──
const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/); const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
if (plain512b64) { if (plain512b64) {
return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" }; return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" };
@ -240,6 +271,8 @@ async function hashFile(filePath: string, algorithm: "sha256" | "sha512", encodi
}); });
} }
// ─── latest.yml Parsing ────────────────────────────────────────────────────────
function normalizeNameForMatch(value: string): string { function normalizeNameForMatch(value: string): string {
const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || ""; const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || "";
return name.toLowerCase().replace(/[^a-z0-9]/g, ""); return name.toLowerCase().replace(/[^a-z0-9]/g, "");
@ -251,8 +284,10 @@ function stripYamlQuotes(raw: string): string {
function extractSha512Value(raw: string): string { function extractSha512Value(raw: string): string {
const stripped = stripYamlQuotes(raw); const stripped = stripYamlQuotes(raw);
// Base64 SHA-512: 86-88 chars + optional padding
const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/); const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
if (b64) return b64[1]; if (b64) return b64[1];
// Hex SHA-512: exactly 128 hex chars
const hex = stripped.match(/^([a-fA-F0-9]{128})$/); const hex = stripped.match(/^([a-fA-F0-9]{128})$/);
if (hex) return hex[1]; if (hex) return hex[1];
return ""; return "";
@ -270,24 +305,28 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
for (const rawLine of lines) { for (const rawLine of lines) {
const line = String(rawLine); const line = String(rawLine);
// File entry URL (inside files: array)
const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i); const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
if (fileUrlItem?.[1]) { if (fileUrlItem?.[1]) {
currentFileUrl = stripYamlQuotes(fileUrlItem[1]); currentFileUrl = stripYamlQuotes(fileUrlItem[1]);
continue; continue;
} }
// Top-level or non-array URL
const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i); const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
if (urlMatch?.[1]) { if (urlMatch?.[1]) {
currentFileUrl = stripYamlQuotes(urlMatch[1]); currentFileUrl = stripYamlQuotes(urlMatch[1]);
continue; continue;
} }
// Top-level path
const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i); const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
if (pathMatch?.[1]) { if (pathMatch?.[1]) {
topLevelPath = stripYamlQuotes(pathMatch[1]); topLevelPath = stripYamlQuotes(pathMatch[1]);
continue; continue;
} }
// SHA-512 value (handles quoted and unquoted)
const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i); const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i);
if (!shaMatch?.[1]) continue; if (!shaMatch?.[1]) continue;
@ -306,6 +345,7 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
if (!topLevelSha) topLevelSha = sha; if (!topLevelSha) topLevelSha = sha;
} }
// Try matching via top-level path
if (target && topLevelPath && topLevelSha) { if (target && topLevelPath && topLevelSha) {
if (normalizeNameForMatch(topLevelPath) === target) { if (normalizeNameForMatch(topLevelPath) === target) {
return topLevelSha; return topLevelSha;
@ -315,6 +355,8 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
return topLevelSha || firstFileSha || ""; return topLevelSha || firstFileSha || "";
} }
// ─── Installer Verification ───────────────────────────────────────────────────
async function verifyBinaryShape(filePath: string): Promise<void> { async function verifyBinaryShape(filePath: string): Promise<void> {
const stats = await fs.promises.stat(filePath); const stats = await fs.promises.stat(filePath);
if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) { if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) {
@ -360,6 +402,8 @@ async function verifyDownloadedInstaller(filePath: string, digestRaw: string): P
logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`); logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`);
} }
// ─── Release API ───────────────────────────────────────────────────────────────
async function fetchRelease(repo: string, endpoint: string): Promise<{ async function fetchRelease(repo: string, endpoint: string): Promise<{
ok: boolean; ok: boolean;
status: number; status: number;
@ -431,6 +475,8 @@ function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: stri
}; };
} }
// ─── Download Candidates ───────────────────────────────────────────────────────
function uniqueStrings(values: string[]): string[] { function uniqueStrings(values: string[]): string[] {
const seen = new Set<string>(); const seen = new Set<string>();
const out: string[] = []; const out: string[] = [];
@ -509,6 +555,8 @@ function deriveFileName(check: UpdateCheckResult, url: string): string {
} }
} }
// ─── Error Classification ──────────────────────────────────────────────────────
function httpStatusFromError(error: unknown): number { function httpStatusFromError(error: unknown): number {
const match = String(error || "").match(/HTTP\s+(\d{3})/i); const match = String(error || "").match(/HTTP\s+(\d{3})/i);
return match ? Number(match[1]) : 0; return match ? Number(match[1]) : 0;
@ -537,6 +585,8 @@ function isIntegrityError(error: unknown): boolean {
return text.includes("integrit") || text.includes("mismatch"); return text.includes("integrit") || text.includes("mismatch");
} }
// ─── Sleep ─────────────────────────────────────────────────────────────────────
async function sleep(ms: number, signal?: AbortSignal): Promise<void> { async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms)); if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
if (signal.aborted) throw new Error("aborted:update_shutdown"); if (signal.aborted) throw new Error("aborted:update_shutdown");
@ -561,6 +611,8 @@ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
}); });
} }
// ─── Download Engine ───────────────────────────────────────────────────────────
async function downloadFile( async function downloadFile(
url: string, url: string,
targetPath: string, targetPath: string,
@ -571,6 +623,7 @@ async function downloadFile(
logger.info(`Update-Download versucht: ${url}`); logger.info(`Update-Download versucht: ${url}`);
// Connect with timeout
const tc = timeoutController(CONNECT_TIMEOUT_MS); const tc = timeoutController(CONNECT_TIMEOUT_MS);
let response: Response; let response: Response;
try { try {
@ -587,9 +640,11 @@ async function downloadFile(
throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`); throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
} }
// Parse content-length
const clRaw = Number(response.headers.get("content-length") || NaN); const clRaw = Number(response.headers.get("content-length") || NaN);
const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null; const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null;
// Progress tracking
let downloadedBytes = 0; let downloadedBytes = 0;
let lastProgressAt = 0; let lastProgressAt = 0;
@ -608,11 +663,13 @@ async function downloadFile(
reportProgress(true); reportProgress(true);
// Prepare filesystem
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true }); await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
const tempPath = `${targetPath}.tmp`; const tempPath = `${targetPath}.tmp`;
const writeStream = fs.createWriteStream(tempPath); const writeStream = fs.createWriteStream(tempPath);
const reader = response.body.getReader(); const reader = response.body.getReader();
// Idle timeout tracking
const idleMs = getBodyIdleTimeout(); const idleMs = getBodyIdleTimeout();
let idleTimer: NodeJS.Timeout | null = null; let idleTimer: NodeJS.Timeout | null = null;
let idleTimedOut = false; let idleTimedOut = false;
@ -634,6 +691,7 @@ async function downloadFile(
} }
}; };
// Stream body to disk
try { try {
resetIdle(); resetIdle();
for (;;) { for (;;) {
@ -663,21 +721,25 @@ async function downloadFile(
clearIdle(); clearIdle();
} }
// Flush and close write stream
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
writeStream.end(() => resolve()); writeStream.end(() => resolve());
writeStream.on("error", reject); writeStream.on("error", reject);
}); });
// Handle idle timeout on clean reader exit
if (idleTimedOut) { if (idleTimedOut) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {}); await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`); throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
} }
// Verify completeness
if (totalBytes && downloadedBytes !== totalBytes) { if (totalBytes && downloadedBytes !== totalBytes) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {}); await fs.promises.rm(tempPath, { force: true }).catch(() => {});
throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`); throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
} }
// Atomic rename temp → final
await fs.promises.rename(tempPath, targetPath); await fs.promises.rename(tempPath, targetPath);
reportProgress(true); reportProgress(true);
logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`); logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`);
@ -741,6 +803,8 @@ async function downloadFromCandidates(
throw lastError; throw lastError;
} }
// ─── Asset Resolution Helpers ──────────────────────────────────────────────────
async function resolveAssetFromApi(repo: string, tag: string): Promise<{ async function resolveAssetFromApi(repo: string, tag: string): Promise<{
setupAssetUrl: string; setupAssetUrl: string;
setupAssetName: string; setupAssetName: string;
@ -763,6 +827,7 @@ async function resolveAssetFromApi(repo: string, tag: string): Promise<{
setupAssetDigest: setup.digest, setupAssetDigest: setup.digest,
}; };
} catch { } catch {
// try next endpoint
} }
} }
return null; return null;
@ -798,11 +863,14 @@ async function resolveDigestFromYml(repo: string, tag: string, setupName: string
const sha = parseSha512FromLatestYml(yamlText, setupName); const sha = parseSha512FromLatestYml(yamlText, setupName);
if (sha) return `sha512:${sha}`; if (sha) return `sha512:${sha}`;
} catch { } catch {
// try next endpoint
} }
} }
return ""; return "";
} }
// ─── Public API ────────────────────────────────────────────────────────────────
export function buildInstallerLaunchArgs(): string[] { export function buildInstallerLaunchArgs(): string[] {
return ["/S", "--updated", "--force-run"]; return ["/S", "--updated", "--force-run"];
} }
@ -835,6 +903,7 @@ export async function installLatestUpdate(
prechecked?: UpdateCheckResult, prechecked?: UpdateCheckResult,
onProgress?: UpdateProgressCallback, onProgress?: UpdateProgressCallback,
): Promise<UpdateInstallResult> { ): Promise<UpdateInstallResult> {
// Prevent concurrent updates
if (activeAbortController && !activeAbortController.signal.aborted) { if (activeAbortController && !activeAbortController.signal.aborted) {
emitProgress(onProgress, { emitProgress(onProgress, {
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null, stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
@ -847,6 +916,7 @@ export async function installLatestUpdate(
activeAbortController = abortCtrl; activeAbortController = abortCtrl;
const safeRepo = normalizeUpdateRepo(repo); const safeRepo = normalizeUpdateRepo(repo);
// Resolve update check
const check = prechecked && !prechecked.error const check = prechecked && !prechecked.error
? prechecked ? prechecked
: await checkGitHubUpdate(safeRepo); : await checkGitHubUpdate(safeRepo);
@ -869,6 +939,7 @@ export async function installLatestUpdate(
return { started: false, message: "Kein neues Update verfügbar" }; return { started: false, message: "Kein neues Update verfügbar" };
} }
// Mutable effective state for enrichment
let effective: UpdateCheckResult = { let effective: UpdateCheckResult = {
...check, ...check,
setupAssetUrl: String(check.setupAssetUrl || ""), setupAssetUrl: String(check.setupAssetUrl || ""),
@ -876,6 +947,7 @@ export async function installLatestUpdate(
setupAssetDigest: String(check.setupAssetDigest || ""), setupAssetDigest: String(check.setupAssetDigest || ""),
}; };
// Enrich: resolve asset from API if needed
if (!effective.setupAssetUrl || !effective.setupAssetDigest) { if (!effective.setupAssetUrl || !effective.setupAssetDigest) {
const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag); const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
if (refreshed) { if (refreshed) {
@ -888,6 +960,7 @@ export async function installLatestUpdate(
} }
} }
// Enrich: resolve digest from latest.yml if still missing
if (!effective.setupAssetDigest && effective.setupAssetUrl) { if (!effective.setupAssetDigest && effective.setupAssetUrl) {
const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || ""); const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
if (digest) { if (digest) {
@ -896,6 +969,7 @@ export async function installLatestUpdate(
} }
} }
// Build download candidates
let candidates = buildCandidates(safeRepo, effective); let candidates = buildCandidates(safeRepo, effective);
if (candidates.length === 0) { if (candidates.length === 0) {
activeAbortController = null; activeAbortController = null;
@ -917,6 +991,7 @@ export async function installLatestUpdate(
if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown"); if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");
// ── Download + verify with retry passes ──
let verified = false; let verified = false;
let lastVerifyError: unknown = null; let lastVerifyError: unknown = null;
let integrityError: unknown = null; let integrityError: unknown = null;
@ -955,6 +1030,7 @@ export async function installLatestUpdate(
if (verified) break; if (verified) break;
// Refresh candidates on 404 or integrity mismatch
const status = httpStatusFromError(lastVerifyError); const status = httpStatusFromError(lastVerifyError);
const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null); const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null);
if (!shouldRefresh) break; if (!shouldRefresh) break;
@ -997,6 +1073,7 @@ export async function installLatestUpdate(
throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen"); throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen");
} }
// ── Launch installer ──
emitProgress(onProgress, { emitProgress(onProgress, {
stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null, stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null,
message: "Starte stille Update-Installation", message: "Starte stille Update-Installation",
@ -1022,6 +1099,7 @@ export async function installLatestUpdate(
try { try {
await fs.promises.rm(targetPath, { force: true }); await fs.promises.rm(targetPath, { force: true });
} catch { } catch {
// ignore
} }
const releaseUrl = String(effective.releaseUrl || "").trim(); const releaseUrl = String(effective.releaseUrl || "").trim();
const hint = releaseUrl ? ` Manuell: ${releaseUrl}` : ""; const hint = releaseUrl ? ` Manuell: ${releaseUrl}` : "";

View File

@ -1,468 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { spawn } from "node:child_process";
// Removes only-German audio handling for "Dual Language" (.DL.) scene releases.
// Mirrors the user's ffmpeg script but adds: language-tag detection (with safe
// fallbacks), disk-space pre-check, atomic temp->replace, mtime preservation,
// abort-into-child, and "never destroy the only usable audio" safety.
//
// The ffmpeg/ffprobe-specific logic lives here so it is mockable in isolation;
// the per-package iteration + filename/.DL. rename + logging stays in
// download-manager.ts (its existing domain).
export type GermanAudioMode = "tag" | "first";
export interface ProbedAudioStream {
language: string;
title: string;
}
export type AudioTrackDecision =
| { action: "remux"; audioRelIndex: number; reason: string }
| { action: "single"; audioRelIndex: 0; reason: string }
| { action: "skip"; reason: string };
export type VideoProcessAction =
| "remuxed"
| "kept-single"
| "skipped-no-german"
| "skipped-no-audio"
| "skipped-no-space"
| "skipped-no-tool"
| "error"
| "aborted";
export interface VideoProcessResult {
action: VideoProcessAction;
reason: string;
keptTrackIndex?: number;
totalAudioTracks?: number;
audioLanguages?: string[];
error?: string;
}
export interface ProcessVideoOptions {
mode: GermanAudioMode;
cpuPriority?: string;
signal?: AbortSignal;
}
// Injection seam so the irreversible file-mutating body (temp -> replace ->
// utimes -> rm-on-failure) can be exercised in tests with a fake ffmpeg/ffprobe
// runner, without spawning real processes. Production passes nothing.
export interface ProcessVideoDeps {
resolveTooling?: () => Promise<{ ffmpeg: string; ffprobe: string } | null>;
runProcess?: typeof runVideoProcess;
}
const VIDEO_REMUX_EXTENSIONS = new Set([".mkv", ".mp4"]);
const PROBE_TIMEOUT_MS = 60_000;
const STDOUT_CAP = 2 * 1024 * 1024;
const STDERR_CAP = 64 * 1024;
// ---------------------------------------------------------------------------
// Pure helpers (no fs / no process) — unit-tested in isolation.
// ---------------------------------------------------------------------------
// "X.German.DL.720p.mkv" -> "X.German.720p.mkv"; "X.DL.mkv" -> "X.mkv".
export function stripDualLangMarker(fileName: string): string {
const ext = path.extname(fileName);
const base = ext ? fileName.slice(0, -ext.length) : fileName;
const stripped = base.replace(/\.DL\./gi, ".").replace(/\.DL$/i, "");
return stripped + ext;
}
export function hasDualLangMarker(fileName: string): boolean {
return stripDualLangMarker(fileName) !== fileName;
}
export function isRemuxableVideoFile(fileName: string): boolean {
return VIDEO_REMUX_EXTENSIONS.has(path.extname(fileName).toLowerCase());
}
// True when the release name explicitly marks it as a German release. Used in
// tag mode to fall back to the first audio track (German-first scene convention)
// when the audio language tags are wrong (a German dub mislabeled "eng"), instead
// of skipping. Deliberately requires an explicit german/deutsch/dubbed token —
// the ".DL." marker alone (present on every processed file) is not enough.
export function looksLikeGermanRelease(fileName: string): boolean {
return /(^|[._\s-])(german|deutsch|dubbed)([._\s-]|$)/i.test(fileName);
}
function isGermanStream(stream: ProbedAudioStream): boolean {
const lang = (stream.language || "").toLowerCase().trim();
if (["ger", "deu", "de", "german", "deutsch"].includes(lang)) {
return true;
}
const title = (stream.title || "").toLowerCase();
return /\b(german|deutsch|ger|deu)\b/.test(title);
}
// Decide which audio track to keep. Safety invariant: only ever choose to remux
// (which destroys the original) when we are confident; otherwise skip untouched.
export function pickAudioTrack(streams: ProbedAudioStream[], mode: GermanAudioMode, germanRelease = false): AudioTrackDecision {
const total = streams.length;
if (total === 0) {
return { action: "skip", reason: "no-audio" };
}
if (mode === "first") {
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-audio" }
: { action: "remux", audioRelIndex: 0, reason: "first-audio" };
}
// tag mode
const germanPos = streams.findIndex(isGermanStream);
if (germanPos >= 0) {
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-german" }
: { action: "remux", audioRelIndex: germanPos, reason: "german-tag" };
}
const anyTagged = streams.some((s) => (s.language || "").trim().length > 0);
if (!anyTagged) {
// No language metadata at all -> fall back to the script's behavior.
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-untagged" }
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-untagged" };
}
if (germanRelease) {
// Tagged, no German track found, but the release name explicitly says German
// -> the dub is mislabeled (German audio tagged "eng"). Trust the German-first
// scene convention rather than skipping.
return total === 1
? { action: "single", audioRelIndex: 0, reason: "single-german-mislabeled" }
: { action: "remux", audioRelIndex: 0, reason: "fallback-first-german-release" };
}
// Tagged, no German track, and nothing says German -> never guess-delete.
return { action: "skip", reason: "no-german-track" };
}
export function parseFfprobeAudioStreams(jsonText: string): ProbedAudioStream[] {
let parsed: unknown;
try {
parsed = JSON.parse(jsonText);
} catch {
return [];
}
const streams = (parsed as { streams?: unknown }).streams;
if (!Array.isArray(streams)) {
return [];
}
return streams.map((raw) => {
const tags = (raw && typeof raw === "object" ? (raw as { tags?: unknown }).tags : undefined) as
| { language?: unknown; title?: unknown }
| undefined;
return {
language: typeof tags?.language === "string" ? tags.language : "",
title: typeof tags?.title === "string" ? tags.title : ""
};
});
}
export function buildFfprobeArgs(input: string): string[] {
return [
"-v", "error",
"-select_streams", "a",
"-show_entries", "stream=index:stream_tags=language,title",
"-of", "json",
input
];
}
export function buildFfmpegRemuxArgs(opts: { input: string; output: string; audioRelIndex: number; keepSubs?: boolean }): string[] {
const args = ["-i", opts.input, "-map", "0:v:0", "-map", `0:a:${opts.audioRelIndex}`];
if (opts.keepSubs) {
// Optional (not enabled by current settings): keep German subtitle tracks only.
args.push("-map", "0:s:m:language:ger?", "-map", "0:s:m:language:deu?");
}
// Stream-copy and keep metadata (so the kept track's language tag survives;
// unlike the original script's -map_metadata -1 which dropped it).
args.push("-c", "copy", "-disposition:a:0", "default", "-y", opts.output);
return args;
}
// Stream-copy remux is disk-bound; generous budget scaled by size, clamped.
export function computeRemuxTimeoutMs(bytes: number): number {
const perBytes = Math.ceil((Number(bytes) || 0) / (10 * 1024 * 1024)) * 1000;
return Math.max(120_000, Math.min(60 * 60 * 1000, 120_000 + perBytes));
}
// ---------------------------------------------------------------------------
// Tooling discovery (system PATH + RD_FFMPEG_BIN/RD_FFPROBE_BIN env override).
// Lazy probe + cache, mirroring the extractor's 7z/Java resolution convention.
// ---------------------------------------------------------------------------
interface VideoTooling {
ffmpeg: string;
ffprobe: string;
}
let cachedTooling: VideoTooling | null | undefined;
let cachedToolingNullSince = 0;
const TOOLING_NULL_TTL_MS = 5 * 60 * 1000;
function ffmpegCandidate(): string {
return String(process.env.RD_FFMPEG_BIN || "").trim() || "ffmpeg";
}
function ffprobeCandidate(): string {
return String(process.env.RD_FFPROBE_BIN || "").trim() || "ffprobe";
}
async function probeVersion(command: string): Promise<boolean> {
const result = await runVideoProcess(command, ["-version"], { timeoutMs: 10_000 });
return result.ok && !result.missing;
}
export async function resolveVideoTooling(): Promise<VideoTooling | null> {
if (cachedTooling) {
return cachedTooling;
}
if (cachedTooling === null && Date.now() - cachedToolingNullSince < TOOLING_NULL_TTL_MS) {
return null;
}
const ffmpeg = ffmpegCandidate();
const ffprobe = ffprobeCandidate();
const [ffmpegOk, ffprobeOk] = await Promise.all([probeVersion(ffmpeg), probeVersion(ffprobe)]);
if (ffmpegOk && ffprobeOk) {
cachedTooling = { ffmpeg, ffprobe };
return cachedTooling;
}
cachedTooling = null;
cachedToolingNullSince = Date.now();
return null;
}
export function resetVideoToolingCache(): void {
cachedTooling = undefined;
cachedToolingNullSince = 0;
}
// ---------------------------------------------------------------------------
// Process spawning (ffmpeg/ffprobe). ffmpeg/ffprobe exit conventions: 0 = ok,
// anything else = real failure (NOT 7-Zip's "exit 1 = warning" semantics).
// ---------------------------------------------------------------------------
export interface VideoSpawnResult {
ok: boolean;
aborted: boolean;
timedOut: boolean;
missing: boolean;
exitCode: number | null;
stdout: string;
stderr: string;
}
function appendCapped(buffer: string, text: string, cap: number): string {
const next = buffer + text;
return next.length > cap ? next.slice(next.length - cap) : next;
}
function applyChildPriority(pid: number | undefined, cpuPriority?: string): void {
if (process.platform !== "win32") {
return;
}
const numeric = Number(pid || 0);
if (!Number.isFinite(numeric) || numeric <= 0) {
return;
}
try {
const level = cpuPriority === "high" ? os.constants.priority.PRIORITY_NORMAL : os.constants.priority.PRIORITY_BELOW_NORMAL;
os.setPriority(numeric, level);
} catch {
}
}
function killChildTree(child: { pid?: number; kill: () => void }): void {
const pid = Number(child.pid || 0);
if (process.platform === "win32" && Number.isFinite(pid) && pid > 0) {
try {
const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, stdio: "ignore" });
killer.on("error", () => { try { child.kill(); } catch {} });
return;
} catch {
}
}
try {
child.kill();
} catch {
}
}
export function runVideoProcess(
command: string,
args: string[],
opts: { signal?: AbortSignal; timeoutMs?: number; cpuPriority?: string } = {}
): Promise<VideoSpawnResult> {
const { signal, timeoutMs, cpuPriority } = opts;
if (signal?.aborted) {
return Promise.resolve({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: null, stdout: "", stderr: "" });
}
return new Promise((resolve) => {
let settled = false;
let stdout = "";
let stderr = "";
let timedOut = false;
let aborted = false;
let timeoutId: NodeJS.Timeout | null = null;
const child = spawn(command, args, { windowsHide: true });
applyChildPriority(child.pid, cpuPriority);
const onAbort = (): void => {
aborted = true;
killChildTree(child);
};
const finish = (result: VideoSpawnResult): void => {
if (settled) {
return;
}
settled = true;
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (signal) {
signal.removeEventListener("abort", onAbort);
}
resolve(result);
};
if (timeoutMs && timeoutMs > 0) {
timeoutId = setTimeout(() => {
timedOut = true;
killChildTree(child);
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: null, stdout, stderr });
}, timeoutMs);
}
if (signal) {
signal.addEventListener("abort", onAbort, { once: true });
}
child.stdout?.on("data", (chunk) => { stdout = appendCapped(stdout, String(chunk || ""), STDOUT_CAP); });
child.stderr?.on("data", (chunk) => { stderr = appendCapped(stderr, String(chunk || ""), STDERR_CAP); });
child.on("error", (error) => {
const text = String(error || "");
finish({ ok: false, aborted: false, timedOut: false, missing: text.toLowerCase().includes("enoent"), exitCode: null, stdout, stderr: stderr || text });
});
child.on("close", (code) => {
if (aborted) {
finish({ ok: false, aborted: true, timedOut: false, missing: false, exitCode: code, stdout, stderr });
return;
}
if (timedOut) {
finish({ ok: false, aborted: false, timedOut: true, missing: false, exitCode: code, stdout, stderr });
return;
}
finish({ ok: code === 0, aborted: false, timedOut: false, missing: false, exitCode: code, stdout, stderr });
});
});
}
// ---------------------------------------------------------------------------
// Per-file orchestration: probe -> decide -> (disk check) -> remux -> atomic
// replace -> preserve mtime. Operates IN PLACE (same filename); the .DL. rename
// + companion handling + logging is done by the caller (download-manager).
// ---------------------------------------------------------------------------
async function getFreeSpaceBytes(dir: string): Promise<number | null> {
try {
const stat = await fs.promises.statfs(dir);
return Number(stat.bavail) * Number(stat.bsize);
} catch {
return null;
}
}
export async function processVideoFile(filePath: string, opts: ProcessVideoOptions, deps: ProcessVideoDeps = {}): Promise<VideoProcessResult> {
const resolveTool = deps.resolveTooling || resolveVideoTooling;
const run = deps.runProcess || runVideoProcess;
if (opts.signal?.aborted) {
return { action: "aborted", reason: "aborted" };
}
const tooling = await resolveTool();
if (!tooling) {
return { action: "skipped-no-tool", reason: "ffmpeg/ffprobe nicht gefunden (PATH oder RD_FFMPEG_BIN)" };
}
const probe = await run(tooling.ffprobe, buildFfprobeArgs(filePath), { signal: opts.signal, timeoutMs: PROBE_TIMEOUT_MS });
if (probe.aborted) {
return { action: "aborted", reason: "aborted" };
}
if (!probe.ok) {
return { action: "error", reason: "ffprobe fehlgeschlagen", error: probe.stderr || `exit ${String(probe.exitCode)}` };
}
const streams = parseFfprobeAudioStreams(probe.stdout);
const audioLanguages = streams.map((s) => (s.language || "").trim() || "und");
const decision = pickAudioTrack(streams, opts.mode, looksLikeGermanRelease(path.basename(filePath)));
if (decision.action === "skip") {
return {
action: decision.reason === "no-german-track" ? "skipped-no-german" : "skipped-no-audio",
reason: decision.reason,
totalAudioTracks: streams.length,
audioLanguages
};
}
if (decision.action === "single") {
return { action: "kept-single", reason: decision.reason, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: 0 };
}
// remux path
let originalStat: fs.Stats;
try {
originalStat = await fs.promises.stat(filePath);
} catch (error) {
return { action: "error", reason: "stat fehlgeschlagen", error: String(error), audioLanguages };
}
const free = await getFreeSpaceBytes(path.dirname(filePath));
if (free !== null && free < Math.ceil(originalStat.size * 1.05)) {
return { action: "skipped-no-space", reason: "zu wenig freier Speicher fuer Remux", totalAudioTracks: streams.length, audioLanguages };
}
const ext = path.extname(filePath);
// Short, same-directory temp name (never longer than the original file name) so
// a long scene filename + temp suffix cannot push the temp path past Windows
// MAX_PATH and make ffmpeg fail (which would leave the file unprocessed).
const tempPath = path.join(path.dirname(filePath), `~rdtmp${ext}`);
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
const remux = await run(
tooling.ffmpeg,
buildFfmpegRemuxArgs({ input: filePath, output: tempPath, audioRelIndex: decision.audioRelIndex, keepSubs: false }),
{ signal: opts.signal, timeoutMs: computeRemuxTimeoutMs(originalStat.size), cpuPriority: opts.cpuPriority }
);
if (remux.aborted) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "aborted", reason: "aborted" };
}
if (!remux.ok) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "ffmpeg remux fehlgeschlagen", error: remux.stderr || `exit ${String(remux.exitCode)}`, totalAudioTracks: streams.length, audioLanguages, keptTrackIndex: decision.audioRelIndex };
}
const tempStat = await fs.promises.stat(tempPath).catch(() => null);
if (!tempStat || tempStat.size <= 0) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "Remux ergab leere Datei", totalAudioTracks: streams.length, audioLanguages };
}
try {
// libuv rename replaces an existing destination on Windows; fall back if not.
await fs.promises.rename(tempPath, filePath).catch(async () => {
await fs.promises.rm(filePath, { force: true });
await fs.promises.rename(tempPath, filePath);
});
// Preserve original mtime so freshness gates (hybrid collect) don't skip it.
await fs.promises.utimes(filePath, originalStat.atime, originalStat.mtime).catch(() => {});
} catch (error) {
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
return { action: "error", reason: "Ersetzen der Datei fehlgeschlagen", error: String(error), totalAudioTracks: streams.length, audioLanguages };
}
return { action: "remuxed", reason: decision.reason, keptTrackIndex: decision.audioRelIndex, totalAudioTracks: streams.length, audioLanguages };
}

View File

@ -319,6 +319,7 @@ export function hasRecentWindowsMinidumps(): boolean {
return true; return true;
} }
} catch { } catch {
// ignore
} }
} }
return false; return false;

View File

@ -9,7 +9,6 @@ import {
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
@ -59,7 +58,7 @@ const api: ElectronApi = {
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART), restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT), quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP), exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
importBackup: (): Promise<{ restored: boolean; relaunch: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP), importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE), exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG), openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG), openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
@ -89,7 +88,6 @@ const api: ElectronApi = {
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds), skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds), resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds), startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
reportRendererError: (report: RendererErrorReport): void => ipcRenderer.send(IPC_CHANNELS.LOG_RENDERER_ERROR, report),
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => { onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot); const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener); ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);

View File

@ -32,7 +32,6 @@ import {
getProviderUsageDayKey getProviderUsageDayKey
} from "../shared/provider-daily-limits"; } from "../shared/provider-daily-limits";
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
import { pruneSelection } from "./selection";
type Tab = "collector" | "downloads" | "history" | "statistics" | "settings"; type Tab = "collector" | "downloads" | "history" | "statistics" | "settings";
type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates"; type SettingsSubTab = "allgemein" | "accounts" | "entpacken" | "geschwindigkeit" | "bereinigung" | "updates";
@ -116,6 +115,8 @@ interface AccountDialogState {
megaAccounts: MegaDialogAccount[]; megaAccounts: MegaDialogAccount[];
megaNewLogin: string; megaNewLogin: string;
megaNewPassword: string; megaNewPassword: string;
// IDs der im Bearbeiten-Dialog (temporär) deaktivierten Mega-Debrid-Accounts.
// Draft-State: wird erst beim Speichern in settings.megaDebridDisabledAccountIds übernommen.
megaDisabledIds: string[]; megaDisabledIds: string[];
} }
@ -466,12 +467,17 @@ function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[]
return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p)); return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p));
} }
// Leitet die aktive Provider-Reihenfolge aus providerOrder ab,
// gefiltert auf tatsächlich konfigurierte und nicht deaktivierte Provider.
// Direkt-Hoster (onefichier, ddownload) werden ausgeschlossen.
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]); const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] { function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p))); const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p)));
// Behalte bestehende Reihenfolge aus providerOrder, filtere nicht-konfigurierte heraus
const ordered = (settings.providerOrder || []).filter((p) => active.has(p)); const ordered = (settings.providerOrder || []).filter((p) => active.has(p));
const inOrder = new Set(ordered); const inOrder = new Set(ordered);
// Füge neue Provider hinten an, die noch nicht in der Reihenfolge sind
for (const p of active) { for (const p of active) {
if (!inOrder.has(p)) ordered.push(p); if (!inOrder.has(p)) ordered.push(p);
} }
@ -603,6 +609,7 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega }; return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
case "megadebrid-api": case "megadebrid-api":
case "megadebrid-web": { case "megadebrid-web": {
// Populate megaAccounts from megaCredentials, or build from legacy megaLogin/megaPassword
let megaToken = (settings.megaCredentials || "").trim(); let megaToken = (settings.megaCredentials || "").trim();
if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) { if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) {
megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`; megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`;
@ -844,14 +851,14 @@ const emptySnapshot = (): UiSnapshot => ({
archivePasswordList: "", archivePasswordList: "",
rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none", rememberToken: true, providerOrder: [], providerPrimary: "realdebrid", providerSecondary: "none",
providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "", providerTertiary: "none", autoProviderFallback: true, outputDir: "", packageName: "",
autoExtract: true, autoRename4sf4sj: false, keepGermanAudioOnly: false, germanAudioMode: "tag", extractDir: "", createExtractSubfolder: true, hybridExtract: true, autoExtract: true, autoRename4sf4sj: false, extractDir: "", createExtractSubfolder: true, hybridExtract: true,
collectMkvToLibrary: false, mkvLibraryDir: "", collectMkvToLibrary: false, mkvLibraryDir: "",
cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false, cleanupMode: "none", extractConflictMode: "overwrite", removeLinkFilesAfterExtract: false,
removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true,
autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never",
maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global",
updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false,
theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, theme: "dark", collapseNewPackages: true, historyRetentionMode: "permanent", autoSortPackagesByProgress: true, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true,
accountListShowDetailedDebridLinkKeys: false, accountListShowDetailedDebridLinkKeys: false,
bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0, bandwidthSchedules: [], totalDownloadedAllTime: 0, totalCompletedFilesAllTime: 0, totalRuntimeAllTimeMs: 0,
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"], columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
@ -1150,10 +1157,6 @@ function rotationEventText(ev: { event: string; cooldownSec?: number; next?: str
return `fehlgeschlagen${cd}${nx}`; return `fehlgeschlagen${cd}${nx}`;
} }
case "FATAL": return "abgebrochen (fataler Fehler)"; case "FATAL": return "abgebrochen (fataler Fehler)";
case "TIMEOUT_COOLDOWN": {
const cd = ev.cooldownSec ? `, Cooldown ${ev.cooldownSec}s` : "";
return `Timeout/Abbruch${cd} → nächster Account beim Retry`;
}
case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)"; case "SKIP_COOLDOWN": return untilRestart ? "übersprungen (bis Neustart gesperrt)" : "übersprungen (Cooldown aktiv)";
case "SKIP_DISABLED": return "übersprungen (deaktiviert)"; case "SKIP_DISABLED": return "übersprungen (deaktiviert)";
case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)"; case "SKIP_DAILY_LIMIT": return "übersprungen (Tageslimit erreicht)";
@ -1285,6 +1288,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
maxSpeed = Math.max(maxSpeed, 1024 * 1024); maxSpeed = Math.max(maxSpeed, 1024 * 1024);
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed))); const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
// Measure widest label to set dynamic left padding
ctx.font = "11px 'Manrope', sans-serif"; ctx.font = "11px 'Manrope', sans-serif";
let maxLabelWidth = 0; let maxLabelWidth = 0;
for (let i = 0; i <= 5; i += 1) { for (let i = 0; i <= 5; i += 1) {
@ -1367,7 +1371,12 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
}, [running, paused]); }, [running, paused]);
useEffect(() => { useEffect(() => {
// Always draw once on mount / when running/paused state changes so the
// chart shows the latest history.
drawChart(); drawChart();
// Only schedule periodic redraws while actively downloading — when
// stopped or paused the speed history doesn't change, so polling
// every 250ms would just burn CPU on the renderer process.
if (!running || paused) { if (!running || paused) {
return; return;
} }
@ -1378,6 +1387,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
}, [drawChart, running, paused]); }, [drawChart, running, paused]);
useEffect(() => { useEffect(() => {
// Only record samples while the session is running and not paused
if (!running || paused) return; if (!running || paused) return;
const now = Date.now(); const now = Date.now();
@ -1433,6 +1443,7 @@ function createScheduleId(): string {
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
} }
function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] { function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
const sorted = [...order]; const sorted = [...order];
sorted.sort((a, b) => { sorted.sort((a, b) => {
@ -1567,6 +1578,10 @@ export function App(): ReactElement {
const settingsDraftRevisionRef = useRef(0); const settingsDraftRevisionRef = useRef(0);
const panelDirtyRevisionRef = useRef(0); const panelDirtyRevisionRef = useRef(0);
const latestStateRef = useRef<UiSnapshot | null>(null); const latestStateRef = useRef<UiSnapshot | null>(null);
// Master state used to apply incoming delta payloads. The wire format from
// the main process sends only changed items/packages (with payloadKind="delta")
// most of the time and a full snapshot every 30s for safety. Without this
// master, we'd only see the changed slice each emit.
const masterSnapshotRef = useRef<UiSnapshot | null>(null); const masterSnapshotRef = useRef<UiSnapshot | null>(null);
const snapshotRef = useRef(snapshot); const snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot; snapshotRef.current = snapshot;
@ -1601,6 +1616,7 @@ export function App(): ReactElement {
const [showAllPackages, setShowAllPackages] = useState(false); const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = useState(false); const [actionBusy, setActionBusy] = useState(false);
const [accountCheckBusy, setAccountCheckBusy] = useState(false); const [accountCheckBusy, setAccountCheckBusy] = useState(false);
// Account-IDs, die gerade beim Hinzufügen einzeln geprüft werden (Mega-Debrid).
const [megaCheckingIds, setMegaCheckingIds] = useState<Set<string>>(() => new Set()); const [megaCheckingIds, setMegaCheckingIds] = useState<Set<string>>(() => new Set());
const actionBusyRef = useRef(false); const actionBusyRef = useRef(false);
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -1675,6 +1691,7 @@ export function App(): ReactElement {
window.addEventListener("mouseup", stopAccountColumnResize); window.addEventListener("mouseup", stopAccountColumnResize);
}, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]); }, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]);
// Load history when tab changes to history
useEffect(() => { useEffect(() => {
if (tab !== "history") return; if (tab !== "history") return;
const loadHistory = async (): Promise<void> => { const loadHistory = async (): Promise<void> => {
@ -1696,6 +1713,7 @@ export function App(): ReactElement {
try { try {
window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths)); window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths));
} catch { } catch {
// Ignore local persistence failures for optional UI state.
} }
}, [accountColumnWidths]); }, [accountColumnWidths]);
@ -1704,10 +1722,16 @@ export function App(): ReactElement {
try { try {
window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY); window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY);
} catch { } catch {
// Ignore local persistence failures for optional UI state.
} }
showToast("Accounts-Spalten zurückgesetzt", 1800); showToast("Accounts-Spalten zurückgesetzt", 1800);
}, []); }, []);
// Sync column order from settings. Avoid JSON.stringify on every render
// (which was a 7-element array stringify per snapshot tick). A simple
// join() is one O(n) string concat without Object/Array allocation overhead,
// and useMemo caches the resulting key so React only sees a new dep when the
// contents actually changed.
const columnOrderKey = useMemo( const columnOrderKey = useMemo(
() => (snapshot.settings.columnOrder || []).join("|"), () => (snapshot.settings.columnOrder || []).join("|"),
[snapshot.settings.columnOrder] [snapshot.settings.columnOrder]
@ -1717,6 +1741,7 @@ export function App(): ReactElement {
if (order && order.length > 0) { if (order && order.length > 0) {
setColumnOrder(order); setColumnOrder(order);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnOrderKey]); }, [columnOrderKey]);
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0]; const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
@ -1892,6 +1917,7 @@ export function App(): ReactElement {
if (!mountedRef.current) { if (!mountedRef.current) {
return; return;
} }
// Seed the master snapshot — incoming delta payloads will merge into this.
masterSnapshotRef.current = state; masterSnapshotRef.current = state;
setSnapshot(state); setSnapshot(state);
if (state.settings.columnOrder?.length > 0) { if (state.settings.columnOrder?.length > 0) {
@ -1914,6 +1940,14 @@ export function App(): ReactElement {
showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800); showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800);
}); });
unsubscribe = window.rd.onStateUpdate((wireState) => { unsubscribe = window.rd.onStateUpdate((wireState) => {
// Merge delta payloads into the master snapshot. Full payloads replace
// the master entirely (initial sync + periodic 30s resync).
// NOTE: `settings` and `rotationEvents` are NOT delta-filtered — every emit
// (full or delta) carries the complete `settings` object and recent
// rotationEvents. The account-validity badges read
// `snapshot.settings.debridAccountStatuses` and the rotation panel reads
// `snapshot.rotationEvents`; if `settings` is ever delta-optimized, both
// must keep flowing on every emit or those views go stale.
let merged: UiSnapshot; let merged: UiSnapshot;
const master = masterSnapshotRef.current; const master = masterSnapshotRef.current;
if (wireState.payloadKind === "delta" && master) { if (wireState.payloadKind === "delta" && master) {
@ -2114,16 +2148,14 @@ export function App(): ReactElement {
}); });
}, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]); }, [downloadsTabActive, packageOrderKey, snapshot.session.packageOrder, snapshot.session.packages, totalPackageCount]);
// Prune selection when its packages/items disappear (e.g. via delta-removal or
// a backup-driven session swap). selectedIds holds BOTH package and item ids;
// a stale id would otherwise inflate the selection count and the "(N)" labels.
useEffect(() => {
setSelectedIds((prev) => pruneSelection(prev, snapshot.session));
}, [snapshot.session.packages, snapshot.session.items]);
const hiddenPackageCount = shouldLimitPackageRendering const hiddenPackageCount = shouldLimitPackageRendering
? Math.max(0, totalPackageCount - packages.length) ? Math.max(0, totalPackageCount - packages.length)
: 0; : 0;
// The sort-by-progress logic only runs when the session is running AND auto-sort
// is enabled AND there's more than one package. When any of those isn't true,
// the items reference is irrelevant — passing null here makes useMemo skip the
// re-evaluation that previously fired on EVERY item update (progress, status,
// speed) even when the sort would have returned the original `packages` array.
const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1) const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1)
? snapshot.session.items ? snapshot.session.items
: null; : null;
@ -2161,6 +2193,7 @@ export function App(): ReactElement {
void loadAllDebridHostInfo(true); void loadAllDebridHostInfo(true);
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]); }, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
// Auto-expand packages that are currently extracting (only once per extraction cycle)
useEffect(() => { useEffect(() => {
const extractingPkgIds: string[] = []; const extractingPkgIds: string[] = [];
const currentlyExtracting = new Set<string>(); const currentlyExtracting = new Set<string>();
@ -2177,6 +2210,7 @@ export function App(): ReactElement {
} }
} }
} }
// Reset tracking for packages no longer extracting
for (const id of autoExpandedPkgsRef.current) { for (const id of autoExpandedPkgsRef.current) {
if (!currentlyExtracting.has(id)) { if (!currentlyExtracting.has(id)) {
autoExpandedPkgsRef.current.delete(id); autoExpandedPkgsRef.current.delete(id);
@ -2197,6 +2231,9 @@ export function App(): ReactElement {
const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]); const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]);
// DDownload is a direct file hoster (not a debrid service) and is used automatically
// for ddownload.com/ddl.to URLs. It counts as a configured account but does not
// appear in the primary/secondary/tertiary provider dropdowns.
const hasDdownloadAccount = useMemo(() => const hasDdownloadAccount = useMemo(() =>
Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()), Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()),
[settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]); [settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]);
@ -2207,8 +2244,10 @@ export function App(): ReactElement {
const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0); const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0);
// Dynamische Provider-Reihenfolge (ersetzt altes primary/secondary/tertiary)
const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]); const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]);
// Setzt providerOrder + backwards-kompatible Felder synchron
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => { const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
settingsDraftRevisionRef.current += 1; settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1; panelDirtyRevisionRef.current += 1;
@ -3293,7 +3332,7 @@ export function App(): ReactElement {
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => { const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
let shouldRename = false; let shouldRename = false;
setEditingPackageId((prev) => { setEditingPackageId((prev) => {
if (prev !== packageId) return prev; if (prev !== packageId) return prev; // already finished (e.g. blur after Enter key)
shouldRename = true; shouldRename = true;
return null; return null;
}); });
@ -3379,6 +3418,8 @@ export function App(): ReactElement {
pendingPackageOrderRef.current = [...order]; pendingPackageOrderRef.current = [...order];
pendingPackageOrderAtRef.current = Date.now(); pendingPackageOrderAtRef.current = Date.now();
packageOrderRef.current = [...order]; packageOrderRef.current = [...order];
// Optimistic UI update ? apply the new order immediately so the user
// sees the change without waiting for the backend round-trip.
setSnapshot((prev) => { setSnapshot((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: [...order] } }; return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
@ -3387,6 +3428,7 @@ export function App(): ReactElement {
pendingPackageOrderRef.current = null; pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0; pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current; packageOrderRef.current = serverPackageOrderRef.current;
// Rollback: restore original order from server
setSnapshot((prev) => { setSnapshot((prev) => {
if (!prev) return prev; if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
@ -3536,6 +3578,7 @@ export function App(): ReactElement {
const dragDidMoveRef = useRef(false); const dragDidMoveRef = useRef(false);
const lastClickedIdRef = useRef<string | null>(null); const lastClickedIdRef = useRef<string | null>(null);
// Flat list of all visible IDs (package headers + their visible items) in display order
const visibleOrderIds = useMemo(() => { const visibleOrderIds = useMemo(() => {
const ids: string[] = []; const ids: string[] = [];
for (const pkg of visiblePackages) { for (const pkg of visiblePackages) {
@ -3551,13 +3594,8 @@ export function App(): ReactElement {
return ids; return ids;
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]); }, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
// Keep a ref of the currently VISIBLE ids so the (deps-[]) Ctrl+A keyboard
// handler can select exactly what the user sees — not the whole unfiltered map.
const visibleOrderIdsRef = useRef<string[]>(visibleOrderIds);
visibleOrderIdsRef.current = visibleOrderIds;
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => { const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
if (dragDidMoveRef.current) return; if (dragDidMoveRef.current) return; // drag handled it, skip click
if (shiftKey && lastClickedIdRef.current) { if (shiftKey && lastClickedIdRef.current) {
const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current); const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current);
const targetIdx = visibleOrderIds.indexOf(id); const targetIdx = visibleOrderIds.indexOf(id);
@ -3604,6 +3642,7 @@ export function App(): ReactElement {
if (!dragSelectRef.current) return; if (!dragSelectRef.current) return;
if (!dragDidMoveRef.current) { if (!dragDidMoveRef.current) {
dragDidMoveRef.current = true; dragDidMoveRef.current = true;
// Add anchor item now that we know it's a drag
const anchor = dragAnchorRef.current; const anchor = dragAnchorRef.current;
if (anchor) { if (anchor) {
setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; }); setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; });
@ -3616,6 +3655,7 @@ export function App(): ReactElement {
const sel = selectedIds; const sel = selectedIds;
const currentPackages = snapshotRef.current.session.packages; const currentPackages = snapshotRef.current.session.packages;
const currentItems = snapshotRef.current.session.items; const currentItems = snapshotRef.current.session.items;
// Multi-select: collect links from all selected packages/items
if (sel.size > 1) { if (sel.size > 1) {
const allLinks: { name: string; url: string }[] = []; const allLinks: { name: string; url: string }[] = [];
for (const id of sel) { for (const id of sel) {
@ -3745,6 +3785,7 @@ export function App(): ReactElement {
useEffect(() => { useEffect(() => {
if (!colHeaderCtx) return; if (!colHeaderCtx) return;
const close = (e: MouseEvent): void => { const close = (e: MouseEvent): void => {
// Don't close if click is inside the menu or on the header bar (re-position instead)
if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return; if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return;
if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return; if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return;
setColHeaderCtx(null); setColHeaderCtx(null);
@ -3815,6 +3856,7 @@ export function App(): ReactElement {
if (e.key === "Escape") { if (e.key === "Escape") {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") { if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
// Don't clear selection if an overlay is open ? let the overlay close first
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return; if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
if (tabRef.current === "downloads") setSelectedIds(new Set()); if (tabRef.current === "downloads") setSelectedIds(new Set());
else if (tabRef.current === "history") setSelectedHistoryIds(new Set()); else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
@ -3855,13 +3897,6 @@ export function App(): ReactElement {
const result = await window.rd.importBackup(); const result = await window.rd.importBackup();
if (result.restored) { if (result.restored) {
showToast(result.message, 4000); showToast(result.message, 4000);
// A settings-only import applies live without a relaunch, so the editable
// settings form would otherwise keep showing the old values. Pull the
// fresh settings and re-seed the draft so the UI reflects the import.
if (!result.relaunch) {
const fresh = await window.rd.getSnapshot();
applyPersistedSettings(fresh.settings);
}
} else if (result.message !== "Abgebrochen") { } else if (result.message !== "Abgebrochen") {
showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000); showToast(`Sicherung laden fehlgeschlagen: ${result.message}`, 3000);
} }
@ -3985,10 +4020,7 @@ export function App(): ReactElement {
if (inInput) return; if (inInput) return;
if (tabRef.current === "downloads") { if (tabRef.current === "downloads") {
e.preventDefault(); e.preventDefault();
// Select exactly the VISIBLE rows (packages + their items), honouring setSelectedIds(new Set(Object.keys(snapshotRef.current.session.packages)));
// the active search / collapse / hide-extracted filters — selecting
// the unfiltered package map would let a later delete hit hidden ones.
setSelectedIds(new Set(visibleOrderIdsRef.current));
} else if (tabRef.current === "history") { } else if (tabRef.current === "history") {
e.preventDefault(); e.preventDefault();
setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id))); setSelectedHistoryIds(new Set(historyEntriesRef.current.map(e => e.id)));
@ -4492,7 +4524,7 @@ export function App(): ReactElement {
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>} {snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
</div> </div>
)} )}
{} {/* Action buttons moved to footer */}
<div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}> <div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}>
{columnOrder.map((col) => { {columnOrder.map((col) => {
const def = COLUMN_DEFS[col]; const def = COLUMN_DEFS[col];
@ -4908,7 +4940,6 @@ export function App(): ReactElement {
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.clipboardWatch} onChange={(e) => setBool("clipboardWatch", e.target.checked)} /> Zwischenablage überwachen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.minimizeToTray} onChange={(e) => setBool("minimizeToTray", e.target.checked)} /> In System Tray minimieren</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.confirmDeleteSelection} onChange={(e) => setBool("confirmDeleteSelection", e.target.checked)} /> Vor dem Löschen bestätigen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.backupIncludeDownloads} onChange={(e) => setBool("backupIncludeDownloads", e.target.checked)} /> Download-Liste in Sicherung mitsichern (Standard: nur Einstellungen)</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => { <label className="toggle-line"><input type="checkbox" checked={settingsDraft.theme === "light"} onChange={(e) => {
const next = e.target.checked ? "light" : "dark"; const next = e.target.checked ? "light" : "dark";
settingsDraftRevisionRef.current += 1; settingsDraftRevisionRef.current += 1;
@ -5376,11 +5407,6 @@ export function App(): ReactElement {
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoSkipExtracted} onChange={(e) => setBool("autoSkipExtracted", e.target.checked)} /> Bereits Entpacktes beim Start überspringen</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hideExtractedItems} onChange={(e) => setBool("hideExtractedItems", e.target.checked)} /> Entpackte Items in Paketliste ausblenden</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoRename4sf4sj} onChange={(e) => setBool("autoRename4sf4sj", e.target.checked)} /> Auto-Rename (Beta)</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.keepGermanAudioOnly} onChange={(e) => setBool("keepGermanAudioOnly", e.target.checked)} /> Nur deutsche Tonspur behalten (.DL.-Dateien, braucht ffmpeg)</label>
<div><label>Tonspur-Auswahl</label><select value={settingsDraft.germanAudioMode} disabled={!settingsDraft.keepGermanAudioOnly} onChange={(e) => setText("germanAudioMode", e.target.value)}>
<option value="tag">Deutsche Spur per Sprach-Tag (empfohlen)</option>
<option value="first">Immer erste Tonspur (wie Script)</option>
</select></div>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.createExtractSubfolder} onChange={(e) => setBool("createExtractSubfolder", e.target.checked)} /> Entpackte Dateien in Paket-Unterordner speichern</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.hybridExtract} onChange={(e) => setBool("hybridExtract", e.target.checked)} /> Hybrid-Extract</label>
<label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label> <label className="toggle-line"><input type="checkbox" checked={settingsDraft.autoExtractWhenStopped} onChange={(e) => setBool("autoExtractWhenStopped", e.target.checked)} /> Entpacken auch ohne laufende Session (bei Stopp / Programmstart)</label>
@ -5687,6 +5713,8 @@ export function App(): ReactElement {
const nextAccounts = [...prev.megaAccounts, { login, password }]; const nextAccounts = [...prev.megaAccounts, { login, password }];
return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) }; return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) };
}); });
// Sofort beim Anlegen pruefen (Gueltigkeit + Premium-Restlaufzeit) —
// Badge aktualisiert sich via Snapshot, ohne Tab schliessen / "Alle pruefen".
if (!exists) { if (!exists) {
void runMegaAccountCheck(login, password); void runMegaAccountCheck(login, password);
} }
@ -6111,6 +6139,7 @@ export function App(): ReactElement {
if (isVisible) { if (isVisible) {
newOrder = columnOrder.filter((c) => c !== col); newOrder = columnOrder.filter((c) => c !== col);
} else { } else {
// Insert at original default position relative to existing columns
newOrder = [...columnOrder]; newOrder = [...columnOrder];
const defaultIdx = ALL_COLUMN_KEYS.indexOf(col); const defaultIdx = ALL_COLUMN_KEYS.indexOf(col);
let insertAt = newOrder.length; let insertAt = newOrder.length;
@ -6309,6 +6338,8 @@ export function App(): ReactElement {
); );
} }
/** Computes the user-facing status text for an item, applying business rules
* about which states are visible while the session is stopped. */
function computeDisplayedItemStatus(item: DownloadItem, sessionRunning: boolean): string { function computeDisplayedItemStatus(item: DownloadItem, sessionRunning: boolean): string {
const statusText = String(item.fullStatus || "").trim(); const statusText = String(item.fullStatus || "").trim();
if (statusText === "Wartet") return ""; if (statusText === "Wartet") return "";
@ -6334,6 +6365,9 @@ interface ItemRowProps {
onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void; onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void;
} }
/** Per-item row, memoized so a status update on one item doesn't re-render
* every other item in the same package (the bottleneck on packages with
* many episodes). Custom equality only checks the fields actually rendered. */
const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement { const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement {
const handleClick = useCallback((e: React.MouseEvent) => { const handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -6351,7 +6385,10 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
e.stopPropagation(); e.stopPropagation();
onContextMenu(packageId, item.id, e.clientX, e.clientY); onContextMenu(packageId, item.id, e.clientX, e.clientY);
}, [packageId, item.id, onContextMenu]); }, [packageId, item.id, onContextMenu]);
// Memoize the date string so it doesn't get re-formatted on every re-render
// when only progress/speed changed but createdAt is stable.
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]); const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
// Memoize the displayed status so we don't compute it twice (title + body)
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]); const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : ""; const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
@ -6368,10 +6405,7 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
switch (col) { switch (col) {
case "name": return ( case "name": return (
<span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}> <span key={col} className="pkg-col pkg-col-name item-indent" title={item.fileName}>
<span {item.onlineStatus && <span className={`link-status-dot ${item.onlineStatus}`} title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : "Wird geprüft..."} />}
className={item.onlineStatus ? `link-status-dot ${item.onlineStatus}` : "link-status-dot link-status-dot-empty"}
title={item.onlineStatus === "online" ? "Online" : item.onlineStatus === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Wird geprüft..." : undefined}
/>
{item.fileName} {item.fileName}
</span> </span>
); );
@ -6419,6 +6453,7 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
</div> </div>
); );
}, (prev, next) => { }, (prev, next) => {
// Skip re-render unless something visible actually changed for THIS item.
if (prev.item !== next.item) { if (prev.item !== next.item) {
const a = prev.item; const a = prev.item;
const b = next.item; const b = next.item;
@ -6486,6 +6521,8 @@ interface PackageCardProps {
} }
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, sessionRunning, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, sessionRunning, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
// Single-pass aggregation: replaces 5 separate filter()/some() + 2 reduce() calls.
// For a package with N items this is O(N) instead of O(7N) per render.
const stats = useMemo(() => { const stats = useMemo(() => {
let done = 0; let done = 0;
let failed = 0; let failed = 0;
@ -6651,6 +6688,10 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
|| prev.gridTemplate !== next.gridTemplate) { || prev.gridTemplate !== next.gridTemplate) {
return false; return false;
} }
// selectedIds is a Set that gets a new reference on every selection change
// anywhere in the app. Only re-render this card if the selection state
// changed for an item that ACTUALLY belongs to this package — that way
// selecting an item in a different package doesn't re-render all 200+ cards.
if (prev.selectedIds !== next.selectedIds) { if (prev.selectedIds !== next.selectedIds) {
for (const itemId of next.pkg.itemIds) { for (const itemId of next.pkg.itemIds) {
if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) { if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) {

View File

@ -1,94 +0,0 @@
import React from "react";
interface ErrorBoundaryProps {
children: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
message: string;
}
// Catches render-time errors in the component tree so a crash shows a minimal
// recovery surface instead of a silent white screen, and forwards the error to
// the main process log. Kept deliberately dead-simple and state-independent: an
// error inside the error path is how you get a second white screen or a loop.
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, message: "" };
}
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
return { hasError: true, message: error instanceof Error ? error.message : String(error) };
}
componentDidCatch(error: unknown, info: React.ErrorInfo): void {
try {
window.rd?.reportRendererError({
kind: "react",
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
componentStack: info?.componentStack || undefined
});
} catch {
}
}
private handleReload = (): void => {
window.location.reload();
};
render(): React.ReactNode {
if (!this.state.hasError) {
return this.props.children;
}
const overlay: React.CSSProperties = {
position: "fixed",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 16,
padding: 32,
background: "#070b14",
color: "#e6edf6",
fontFamily: "Segoe UI, system-ui, sans-serif",
textAlign: "center"
};
const pre: React.CSSProperties = {
maxWidth: 640,
maxHeight: 200,
overflow: "auto",
padding: 12,
background: "#0d1422",
border: "1px solid #243049",
borderRadius: 6,
color: "#ff9a8c",
fontSize: 12,
whiteSpace: "pre-wrap",
textAlign: "left"
};
const button: React.CSSProperties = {
padding: "8px 20px",
background: "#2d5cff",
color: "#fff",
border: "none",
borderRadius: 6,
cursor: "pointer",
fontSize: 14
};
return (
<div style={overlay}>
<h1 style={{ margin: 0, fontSize: 20 }}>Die Oberfläche hat einen Fehler ausgelöst</h1>
<p style={{ margin: 0, maxWidth: 560, color: "#9aa7bd" }}>
Die Anzeige wurde gestoppt, um Datenverlust zu vermeiden. Die laufenden Downloads im
Hintergrund sind nicht betroffen. Der Fehler wurde ins Log geschrieben.
</p>
<pre style={pre}>{this.state.message}</pre>
<button type="button" style={button} onClick={this.handleReload}>Oberfläche neu laden</button>
</div>
);
}
}

View File

@ -1,39 +1,8 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import { App } from "./App"; import { App } from "./App";
import { ErrorBoundary } from "./error-boundary";
import "./styles.css"; import "./styles.css";
// Forward otherwise-silent renderer failures (uncaught errors, unhandled promise
// rejections) to the main process log. Without this, a renderer crash leaves no
// trace anywhere on an unattended server.
function reportRendererError(report: Parameters<typeof window.rd.reportRendererError>[0]): void {
try {
window.rd?.reportRendererError(report);
} catch {
}
}
window.addEventListener("error", (event) => {
reportRendererError({
kind: "error",
message: event.message || String(event.error || "Unbekannter Fehler"),
stack: event.error instanceof Error ? event.error.stack : undefined,
source: event.filename || undefined,
line: typeof event.lineno === "number" ? event.lineno : undefined,
column: typeof event.colno === "number" ? event.colno : undefined
});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
reportRendererError({
kind: "unhandledrejection",
message: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : undefined
});
});
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");
if (!rootElement) { if (!rootElement) {
throw new Error("Root element fehlt"); throw new Error("Root element fehlt");
@ -41,8 +10,6 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<ErrorBoundary>
<App /> <App />
</ErrorBoundary>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -36,26 +36,38 @@ export function sortPackagesForDisplay(
return packages; return packages;
} }
const active: PackageEntry[] = []; const active: Array<{ pkg: PackageEntry; index: number; completedRatio: number; downloadedBytes: number }> = [];
const rest: PackageEntry[] = []; const rest: PackageEntry[] = [];
// Float packages that have an active item to the top, but keep BOTH groups in packages.forEach((pkg, index) => {
// their original (queue) order. Earlier this sorted the active group by live const items = pkg.itemIds
// completedRatio/downloadedBytes — which change on every progress tick (every .map((id) => itemsById[id])
// 150-700ms), so active packages visibly reshuffled the whole time. A package .filter((item): item is DownloadItem => Boolean(item));
// entering/leaving the active bucket is a real, discrete event (start/finish); const hasActive = items.some((item) => ACTIVE_PACKAGE_STATUSES.has(item.status));
// ranking *within* the bucket by live bytes was pure jitter nobody needs. if (!hasActive) {
for (const pkg of packages) { rest.push(pkg);
const hasActive = pkg.itemIds.some((id) => { return;
const item = itemsById[id];
return item != null && ACTIVE_PACKAGE_STATUSES.has(item.status);
});
(hasActive ? active : rest).push(pkg);
} }
const completedRatio = items.length > 0
? items.filter((item) => item.status === "completed").length / items.length
: 0;
const downloadedBytes = items.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0);
active.push({ pkg, index, completedRatio, downloadedBytes });
});
if (active.length === 0 || active.length === packages.length) { if (active.length === 0 || active.length === packages.length) {
return packages; return packages;
} }
return [...active, ...rest]; active.sort((a, b) => {
if (a.completedRatio !== b.completedRatio) {
return b.completedRatio - a.completedRatio;
}
if (a.downloadedBytes !== b.downloadedBytes) {
return b.downloadedBytes - a.downloadedBytes;
}
return a.index - b.index;
});
return [...active.map((entry) => entry.pkg), ...rest];
} }

View File

@ -1,27 +0,0 @@
import type { SessionState } from "../shared/types";
/**
* Drop selected ids whose package OR item no longer exists in the session.
* The selection set mixes package and item ids; when entries vanish (delta
* removal, backup-driven session swap, completed-cleanup) a stale id would
* otherwise inflate the selection count and the "(N)" action labels and keep
* "multi" styling alive for ghosts.
*
* Returns the SAME set instance when nothing changed, so callers can use it
* directly as a React state updater without forcing a re-render.
*/
export function pruneSelection(
selected: ReadonlySet<string>,
session: Pick<SessionState, "packages" | "items">
): Set<string> {
if (selected.size === 0) {
return selected as Set<string>;
}
const next = new Set<string>();
for (const id of selected) {
if (session.packages[id] || session.items[id]) {
next.add(id);
}
}
return next.size === selected.size ? (selected as Set<string>) : next;
}

View File

@ -70,6 +70,8 @@ body,
gap: 8px; gap: 8px;
} }
/* ── Menu Bar ───────────────────────────────────────────────── */
.menu-bar { .menu-bar {
display: flex; display: flex;
align-items: center; align-items: center;
@ -276,6 +278,8 @@ body,
white-space: nowrap; white-space: nowrap;
} }
.control-strip { .control-strip {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@ -656,7 +660,7 @@ body,
.pkg-column-header { .pkg-column-header {
display: grid; display: grid;
/* grid-template-columns set via inline style from columnOrder */
gap: 8px; gap: 8px;
padding: 4px 10px; padding: 4px 10px;
background: color-mix(in srgb, var(--card) 58%, transparent); background: color-mix(in srgb, var(--card) 58%, transparent);
@ -695,7 +699,7 @@ body,
.pkg-columns { .pkg-columns {
display: grid; display: grid;
/* grid-template-columns set via inline style from columnOrder */
gap: 8px; gap: 8px;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
@ -1487,6 +1491,7 @@ body,
margin: -2px 0 10px; margin: -2px 0 10px;
} }
.key-stats-popup { .key-stats-popup {
width: min(1360px, calc(100vw - 20px)); width: min(1360px, calc(100vw - 20px));
max-width: min(1360px, calc(100vw - 20px)); max-width: min(1360px, calc(100vw - 20px));
@ -2180,6 +2185,7 @@ body,
color: #0a0f1a; color: #0a0f1a;
} }
.pkg-toggle { .pkg-toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -2272,6 +2278,7 @@ body,
background: linear-gradient(90deg, #22c55e, #4ade80); background: linear-gradient(90deg, #22c55e, #4ade80);
} }
/* History Tab */
.history-view { .history-view {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -2372,7 +2379,7 @@ td {
.item-row { .item-row {
display: grid; display: grid;
/* grid-template-columns set via inline style from columnOrder */
gap: 8px; gap: 8px;
align-items: center; align-items: center;
margin: 0 -10px; margin: 0 -10px;
@ -2434,12 +2441,6 @@ td {
background: #f59e0b; background: #f59e0b;
box-shadow: 0 0 4px #f59e0b80; box-shadow: 0 0 4px #f59e0b80;
} }
/* Reserve the dot's footprint even before a status exists, so the filename does
not shift ~14px right when the online/offline/checking dot first appears. */
.link-status-dot-empty {
background: transparent;
box-shadow: none;
}
.prio-high { .prio-high {
color: #f59e0b !important; color: #f59e0b !important;
@ -3138,6 +3139,8 @@ td {
} }
} }
/* ── Account validity + premium badges (account check) ───────────────── */
.account-board-header-actions { display: flex; gap: 8px; align-items: center; } .account-board-header-actions { display: flex; gap: 8px; align-items: center; }
.account-validity-badge { .account-validity-badge {
display: inline-block; display: inline-block;
@ -3155,6 +3158,7 @@ td {
.account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; } .account-validity-badge.invalid { color: #fff; background: #d9534f; border-color: #c0392b; }
.account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); } .account-validity-badge.unknown { color: var(--muted, #a59c8e); background: transparent; border-color: var(--line, #4a4032); }
/* ── Live account-rotation panel ─────────────────────────────────────── */
.rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; } .rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; }
.rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; } .rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; }
.rotation-event { .rotation-event {

View File

@ -1,3 +1,5 @@
/// <reference types="vite/client" />
import type { ElectronApi } from "../shared/preload-api"; import type { ElectronApi } from "../shared/preload-api";
declare global { declare global {

View File

@ -66,6 +66,5 @@ export const IPC_CHANNELS = {
SET_PACKAGE_PRIORITY: "queue:set-package-priority", SET_PACKAGE_PRIORITY: "queue:set-package-priority",
SKIP_ITEMS: "queue:skip-items", SKIP_ITEMS: "queue:skip-items",
RESET_ITEMS: "queue:reset-items", RESET_ITEMS: "queue:reset-items",
START_ITEMS: "queue:start-items", START_ITEMS: "queue:start-items"
LOG_RENDERER_ERROR: "log:renderer-error"
} as const; } as const;

View File

@ -39,6 +39,11 @@ export function getMegaDebridAccountLabel(index: number): string {
return `Account ${index + 1}`; return `Account ${index + 1}`;
} }
/**
* Parse newline-separated "login:password" pairs.
* Falls back to treating the entire string as a single login if no colon
* is found (backward compat with old megaLogin field).
*/
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] { export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
const seen = new Set<string>(); const seen = new Set<string>();
const lines = String(raw || "") const lines = String(raw || "")
@ -55,6 +60,7 @@ export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaD
login = line.slice(0, colonIdx).trim(); login = line.slice(0, colonIdx).trim();
password = line.slice(colonIdx + 1).trim(); password = line.slice(colonIdx + 1).trim();
} else { } else {
// Legacy format: just a login, use the provided fallback password
login = line; login = line;
password = legacyPassword; password = legacyPassword;
} }

View File

@ -9,7 +9,6 @@ import type {
DuplicatePolicy, DuplicatePolicy,
HistoryEntry, HistoryEntry,
PackagePriority, PackagePriority,
RendererErrorReport,
SessionStats, SessionStats,
StartConflictEntry, StartConflictEntry,
StartConflictResolutionResult, StartConflictResolutionResult,
@ -56,7 +55,7 @@ export interface ElectronApi {
restart: () => Promise<void>; restart: () => Promise<void>;
quit: () => Promise<void>; quit: () => Promise<void>;
exportBackup: () => Promise<{ saved: boolean }>; exportBackup: () => Promise<{ saved: boolean }>;
importBackup: () => Promise<{ restored: boolean; relaunch: boolean; message: string }>; importBackup: () => Promise<{ restored: boolean; message: string }>;
exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>; exportSupportBundle: () => Promise<{ saved: boolean; filePath?: string }>;
openLog: () => Promise<void>; openLog: () => Promise<void>;
openAuditLog: () => Promise<void>; openAuditLog: () => Promise<void>;
@ -86,7 +85,6 @@ export interface ElectronApi {
skipItems: (itemIds: string[]) => Promise<void>; skipItems: (itemIds: string[]) => Promise<void>;
resetItems: (itemIds: string[]) => Promise<void>; resetItems: (itemIds: string[]) => Promise<void>;
startItems: (itemIds: string[]) => Promise<void>; startItems: (itemIds: string[]) => Promise<void>;
reportRendererError: (report: RendererErrorReport) => void;
onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void; onStateUpdate: (callback: (snapshot: UiSnapshot) => void) => () => void;
onClipboardDetected: (callback: (links: string[]) => void) => () => void; onClipboardDetected: (callback: (links: string[]) => void) => () => void;
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void; onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void) => () => void;

View File

@ -250,6 +250,8 @@ export function addDebridLinkApiKeyTotalUsageBytes(
}; };
} }
// ── Mega-Debrid per-account limits ──
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean { export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId); return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
} }

View File

@ -53,16 +53,25 @@ export interface DownloadStats {
runtimeMeasuredAt: number; runtimeMeasuredAt: number;
} }
/** Result of a login/premium validity check for a single multi-account
* credential (Mega-Debrid account or Debrid-Link API key). Persisted in
* settings so the badges survive an app restart, refreshed by the "Check all"
* button or whenever an account is used. */
export interface DebridAccountStatus { export interface DebridAccountStatus {
accountId: string; accountId: string;
provider: "megadebrid" | "debridlink"; provider: "megadebrid" | "debridlink";
label: string; label: string;
maskedLogin: string; maskedLogin: string;
/** Login worked (credentials accepted by the provider). */
valid: boolean; valid: boolean;
/** Currently a paying/premium account. */
isPremium: boolean; isPremium: boolean;
/** Epoch ms when premium expires; null = unknown, 0 = no premium. */
premiumUntilMs: number | null; premiumUntilMs: number | null;
email?: string; email?: string;
/** Human-readable one-line summary for the badge tooltip. */
message: string; message: string;
/** Epoch ms of the last check. */
checkedAt: number; checkedAt: number;
} }
@ -97,8 +106,6 @@ export interface AppSettings {
packageName: string; packageName: string;
autoExtract: boolean; autoExtract: boolean;
autoRename4sf4sj: boolean; autoRename4sf4sj: boolean;
keepGermanAudioOnly: boolean;
germanAudioMode: "tag" | "first";
extractDir: string; extractDir: string;
collectMkvToLibrary: boolean; collectMkvToLibrary: boolean;
mkvLibraryDir: string; mkvLibraryDir: string;
@ -131,7 +138,6 @@ export interface AppSettings {
autoSkipExtracted: boolean; autoSkipExtracted: boolean;
hideExtractedItems: boolean; hideExtractedItems: boolean;
confirmDeleteSelection: boolean; confirmDeleteSelection: boolean;
backupIncludeDownloads: boolean;
totalDownloadedAllTime: number; totalDownloadedAllTime: number;
totalCompletedFilesAllTime: number; totalCompletedFilesAllTime: number;
totalRuntimeAllTimeMs: number; totalRuntimeAllTimeMs: number;
@ -151,6 +157,8 @@ export interface AppSettings {
megaDebridAccountDailyLimitBytes: Record<string, number>; megaDebridAccountDailyLimitBytes: Record<string, number>;
megaDebridAccountDailyUsageBytes: Record<string, number>; megaDebridAccountDailyUsageBytes: Record<string, number>;
megaDebridAccountTotalUsageBytes: Record<string, number>; megaDebridAccountTotalUsageBytes: Record<string, number>;
/** Last known login/premium status per multi-account credential (id to status).
* Keyed by Mega-Debrid / Debrid-Link account ids; refreshed by the account check. */
debridAccountStatuses: Record<string, DebridAccountStatus>; debridAccountStatuses: Record<string, DebridAccountStatus>;
providerDailyUsageDay: string; providerDailyUsageDay: string;
scheduledStartEpochMs: number; scheduledStartEpochMs: number;
@ -234,12 +242,16 @@ export interface ContainerImportResult {
source: "dlc"; source: "dlc";
} }
/** A single account/key rotation event surfaced to the UI so the user sees
* exactly which account was tried and why it failed (not just a generic
* "Link-Umwandlung erneut"). Mirrors what is written to account-rotation.log. */
export interface RotationEvent { export interface RotationEvent {
id: string; id: string;
at: number; at: number;
level: "INFO" | "WARN" | "ERROR"; level: "INFO" | "WARN" | "ERROR";
provider: string; provider: string;
accountLabel: string; accountLabel: string;
/** OK | FAILED | FATAL | SKIP_COOLDOWN | SKIP_DISABLED | SKIP_DAILY_LIMIT | TEST | ... */
event: string; event: string;
reason?: string; reason?: string;
category?: string; category?: string;
@ -260,9 +272,17 @@ export interface UiSnapshot {
clipboardActive: boolean; clipboardActive: boolean;
reconnectSeconds: number; reconnectSeconds: number;
packageSpeedBps: Record<string, number>; packageSpeedBps: Record<string, number>;
/** When set to "delta", session.items contains ONLY items that changed since
* the last emit, and removedItemIds lists items that were removed. The
* renderer must merge these into its master state. When undefined or "full",
* session.items is the complete set (initial sync or periodic resync). */
payloadKind?: "full" | "delta"; payloadKind?: "full" | "delta";
/** Item IDs to remove from the renderer's master state when payloadKind="delta". */
removedItemIds?: string[]; removedItemIds?: string[];
/** Package IDs to remove from the renderer's master state when payloadKind="delta". */
removedPackageIds?: string[]; removedPackageIds?: string[];
/** Most-recent account/key rotation events (newest first), for the live
* rotation panel. Always sent on full snapshots. */
rotationEvents?: RotationEvent[]; rotationEvents?: RotationEvent[];
} }
@ -501,13 +521,3 @@ export interface HistoryState {
entries: HistoryEntry[]; entries: HistoryEntry[];
maxEntries: number; maxEntries: number;
} }
export interface RendererErrorReport {
kind: "error" | "unhandledrejection" | "react";
message: string;
stack?: string;
source?: string;
line?: number;
column?: number;
componentStack?: string;
}

View File

@ -1,104 +0,0 @@
# Plan: „Nur deutsche Tonspur behalten" (.DL.) als Tool-Funktion
Quelle der Idee: User-Script `Remove Non German Audio.py` (ffmpeg `-map 0:v:0 -map 0:a:0
-c copy -map_metadata -1`, + `.DL.`→`.` Rename). Soll als **toggle­barer Post-Extract-Schritt**
nach jedem Entpacken laufen, nur für **MKV/MP4 mit `.DL.` im Namen** (Dual-Language),
und nur die **deutsche** Spur behalten. Fundiert per 6-Agent-Analyse + Advisor.
## 1. Verhalten (Soll)
- Läuft automatisch nach dem Entpacken eines Pakets (wenn Toggle an), bevor MKV-Collect.
- Pro extrahierter Video-Datei mit `.DL.` im Namen (case-insensitive, nur .mkv/.mp4):
1. Audiospuren prüfen → deutsche/erste Spur bestimmen (Modus = User-Entscheidung, s.u.).
2. Wenn >1 Audiospur: remux (stream-copy, kein Re-Encode) → behält Video + 1 Audio
(+ optional dt. Untertitel) → Temp-Datei → atomar ersetzen.
3. `.DL.` aus dem Dateinamen strippen (`.DL.`→`.`, `.DL`→``), Companion-Dateien (Untertitel/.nfo) mitziehen.
4. Wenn nur 1 Audiospur: **kein** Remux (spart Neuschreiben großer Dateien), ABER `.DL.`-Strip trotzdem.
- Status pro Item sichtbar (z.B. „Tonspur wird bereinigt" / „Deutsche Spur behalten").
## 2. Architektur
- **NEUES Modul `src/main/video-processor.ts`** (spiegelt `extractor.ts`: exportierte async-Funktion
+ Options-Bag, KEINE DI-Klasse — es gibt keinen Constructor-Seam). Enthält:
- ffmpeg/ffprobe-Spawn nach dem `runExtractCommand`-Muster (extractor.ts:1296): `spawn(cmd,args,{windowsHide:true})`,
Promise-Wrapper, Timeout-Watchdog → `killProcessTree` (taskkill /T /F), **AbortSignal IN den Child** geben.
- **Pure exportierte Helfer** für Unit-Tests: `pickGermanAudioTrack(probeJson, mode)`, `stripDualLangMarker(name)`,
`buildFfmpegRemuxArgs(...)`, `computeRemuxTimeoutMs(bytes)`.
- ffmpeg-Exit-Codes ≠ 7-Zip (NICHT die „exit 1 = ok"-Logik kopieren — nur das Spawn/Await/Kill-Gerüst).
- ffprobe-JSON auf stdout NICHT durch den 48KB-Tail-Cap (`appendLimited`) — stdout separat voll puffern.
- **ffmpeg-Discovery (Option a, empfohlen):** System-PATH + `RD_FFMPEG_BIN` env + lazy `ffmpeg -version`-Probe
gecacht (spiegelt `RD_7Z_BIN`, extractor.ts:1030-1083). **Nicht bündeln** (~80-150MB → triggert den
eigenen 150MB-Large-Bundle-Selfcheck debug-setup.ts:22 + GPL-Lizenzpflicht). Wenn ffmpeg fehlt → Schritt
überspringen + WARN loggen + (optional) in Health-Check/Errors surfacen. NIE Downloads blockieren.
- **CPU-Priorität:** `lowerExtractProcessPriority(pid, priority)` + `extractOsPriority` wiederverwenden,
Priorität als **expliziten Param** (nicht das Modul-Global `currentExtractCpuPriority` — Cross-Talk-Gefahr).
Honoriert `settings.extractCpuPriority`.
## 3. Einhängepunkte (BEIDE Pfade — kritisch!)
Post-Processing ist **pro Paket**, zwei Pfade; Hybrid-Pakete durchlaufen NIE den Deferred-Pass:
- **Deferred** (download-manager.ts ~11614): nach `autoRenameExtractedVideoFiles`, VOR archive-cleanup/collect.
- **Hybrid** (download-manager.ts ~10944): zwischen Rename und Collect im detached Block.
- Beide: **innerhalb `chainPackageFileOp(pkg.id, ...)`** (serialisiert Datei-Ops pro Paket), nur auf
`pkg.extractDir` operieren — NIE im geteilten `mkvLibraryDir` (= der v1.7.107-revertierte Cross-Package-Crash;
autoRename bricht bei Overlap ab, 3905-3919).
- **Gate:** neuen Flag in den Post-Process-Aggregator OR-en (~7078-7084), sonst läuft der Schritt nie
standalone. Hängt inhärent an `autoExtract` (braucht entpackte Dateien).
- Datei-Enumeration: `collectVideoFiles(rootDir)` (rekursiv, SAMPLE_VIDEO_EXTENSIONS, constants.ts:28) — nur
.mkv/.mp4 verarbeiten; Sample/Bonus-Dateien per vorhandenem Skip-Prädikat auslassen.
## 4. Der .DL.-Knoten (LÖST den „Feature no-op"-Fehler)
- Selektion = „Datei hat `.DL.`"; der Schritt strippt `.DL.`. → KEIN früherer Schritt darf den Marker entfernen.
- **autoRename NICHT ändern** (behält `.DL.` verbatim) → Marker überlebt bis zum Video-Schritt.
- Video-Schritt läuft **nach** autoRename → sieht `.DL.` → remuxt + strippt `.DL.` atomar pro Datei.
- **NUR `collectMkvFilesToLibrary.deriveCleanCollectFileName`** bekommt den `.DL.`-Strip als Post-Transform
(läuft NACH dem Video-Schritt → kann den Selektor nicht brechen, verhindert nur Re-Einführung aus dem
Ordner-Token). Companion-Files via `renameCompanionFiles`/`moveCompanionFiles` mitziehen.
## 5. Sicherheitsmodell (Original NIE verlieren)
- Remux → Temp-Datei → Größe > 0 (idealerweise ~plausibel) prüfen → erst dann atomar ersetzen/umbenennen
(`renamePathWithExdevFallback` + `verifyRenameAsync`). ffmpeg-Fehler/Abbruch → Temp löschen, Original bleibt.
- **Disk-Space-Pre-Check**: vor Remux freien Platz ≥ Dateigröße (+Marge) prüfen, sonst skip+log
(Temp verdoppelt transient den Platz auf einer Platte, die grad entpackt hat / parallel lädt).
- **AbortSignal in den ffmpeg-Child** (Deferred-/Hybrid-Controller) → Stop/Cancel/Reset killt laufenden Remux.
- **mtime erhalten** (`fs.utimes` nach Remux) → sonst überspringt Hybrid-Collect (deferFreshFiles=true) die
frisch angefasste Datei.
- **Sicherheits-Invariante (BEIDE Modi):** Original nur ersetzen, wenn die behaltene Spur sicher die richtige
ist. Bei Unsicherheit (keine Tags / kein Deutsch gefunden) → Datei UNANGETASTET lassen + loggen, statt
versehentlich die einzige brauchbare Spur zu löschen.
- Dispositions-Flag der behaltenen Spur auf „default" setzen.
- Best-effort pro Datei: ein Fehler markiert NICHT das Paket als failed und blockiert nicht den Collect anderer Dateien.
## 6. ffmpeg/ffprobe-Aufrufe (Stream-Copy, schnell)
- Probe (nur im Tag-Modus): `ffprobe -v error -select_streams a -show_entries stream=index:stream_tags=language,title -of json INPUT`
- Remux erste Spur (Script-Parität): `ffmpeg -i INPUT -map 0:v:0 -map 0:a:0 [-map 0:s? je nach Untertitel-Option] -c copy -map_metadata -1 -disposition:a:0 default -y TEMP`
- Remux deutsche Spur (Tag-Modus): `-map 0:v:0 -map 0:a:<dt-Index> ...` (Index aus ffprobe).
## 7. Settings/UI-Wiring (5 Pflicht-Stellen, +1 optional)
1. `src/shared/types.ts` AppSettings: `keepGermanAudioOnly: boolean` (+ ggf. `germanAudioMode`, `keepGermanSubs`, `ffmpegPath`).
2. `src/main/constants.ts` defaultSettings: `keepGermanAudioOnly: false` etc.
3. `src/main/storage.ts` normalizeSettings: `Boolean(...)` (Pfad: `asText`, NICHT normalizeAbsoluteDir → leer = System-ffmpeg).
4. `src/renderer/App.tsx` Settings-Tab „entpacken" neben collectMkvToLibrary: Toggle + eingerückte Sub-Optionen (disabled wenn aus).
5. `src/renderer/App.tsx` **emptySnapshot()-Literal** (~840-859) — sonst tsc-Fehler (Feld non-optional).
6. (optional) `src/main/support-data.ts` ~95: Flag in Diagnose-Export spiegeln.
## 8. Tests + Verifikations-Gate
- ffmpeg in Tests **gemockt** (kein echter ffmpeg-Lauf): neues Modul via `vi.mock` in download-manager.test.ts
(assert: korrekt aufgerufen + Sequenz nach autoRename / vor collect, Deferred + Hybrid). KEIN blankes
`vi.mock("node:child_process")` in download-manager.test.ts (bricht echte Extractor-ZIP-Tests).
- Separate `video-processor.test.ts`: `node:child_process` mocken → ffmpeg/ffprobe-ARGS asserten (Track-Wahl, Untertitel-Option).
- Pure Helfer fs-frei testen (wie tests/auto-rename.test.ts): `pickGermanAudioTrack`, `stripDualLangMarker`.
- Negativ-Test: Toggle aus → keine Verarbeitung. Edge: 1-Audio-`.DL.` → nur Rename, kein Remux. Kein-Deutsch → unangetastet.
- **Gate:** tsc-Baseline = 6 vorbestehende Fehler (NICHT clean) → „keine NEUEN tsc-Fehler" + vitest 728→728+N grün + `npm run self-check` grün.
## 9. OFFENE ENTSCHEIDUNGEN (vor Bau — per AskUserQuestion)
- **A. Spurauswahl:** Script-Parität (immer erste Audiospur, kein ffprobe, validiertes Verhalten) vs.
Smart (deutsche Spur per Sprach-Tag, Fallback erste Spur, skip wenn kein Deutsch).
- **B. Untertitel:** weglassen (wie Script) vs. deutsche Untertitel behalten.
- **C. ffmpeg-Quelle:** nur System-PATH + `RD_FFMPEG_BIN` env vs. zusätzlich Settings-Pfad-Feld im UI.
## 10. Umsetzungsreihenfolge (nach Entscheidungen)
1. `video-processor.ts` + pure Helfer + deren Unit-Tests (TDD).
2. ffmpeg/ffprobe-Discovery (probe+cache).
3. Settings-Wiring (5 Stellen) + UI-Toggle.
4. Einhängen in Deferred + Hybrid (in chainPackageFileOp), Gate OR-en.
5. collect deriveCleanCollectFileName: `.DL.`-Strip-Safety-Net.
6. Logging (logRenameProcess, neuer Stage 'audio-strip').
7. Tests (download-manager mock + video-processor args + negativ/edge). Gate prüfen.

View File

@ -1,100 +1,109 @@
# Real-Debrid-Downloader — Tasks (Stand 2026-06-07) # Real-Debrid-Downloader — Analyse & Verbesserungen (2026-05-23)
**Status:** Alle zugesagten Features erledigt+released (Archiv unten). EIN Bug analysiert Tiefe Analyse via 3 parallele Subagents (Bugs / Features / UI) + 4 Design-Mockups.
+ geparkt (Mega-Web Account-3-Rotation, siehe direkt unten — wartet auf 1 Log-Zahl vom User).
Rest ist freiwilliger Backlog.
--- ---
## 🟢 OFFEN — Backlog (optional, nie begonnen) ## A. BUGS / ROBUSTHEIT (verifiziert gegen Quellcode)
### ✅ Mega-Web Account-Rotation überspringt Account 3 — GEFIXT 2026-06-08 (v1.7.187) Roter Faden: Die **Deferred-Post-Processing-Pipeline** (eingeführt um den Extract-Slot schnell freizugeben) ist nur halb ins Abbruch-/Lifecycle-Management integriert. Genau der Bereich des v1.7.156-Fixes.
**Fix:** Ein Mega-Web-Account-Abbruch (geteiltes Timeout feuert während der Account lief)
setzt jetzt einen 2-min-Cooldown auf den Account (nur wenn er ≥8s lief, sonst = User-Cancel,
RD_MEGA_ABORT_MIN_RUN_MS env). Dadurch überspringt der download-manager-Retry diesen Account
und rotiert zum nächsten (debrid.ts, abort-Handling im Rotations-catch, vor classifyAccountFailure).
Log-Event `TIMEOUT_COOLDOWN` (gelb, "Timeout/Abbruch → nächster Account beim Retry") statt
rotem "fataler Fehler" (App.tsx:1141 Label). 2 Regressionstests (Cooldown gesetzt → Call 2
rotiert; Quick-Abbruch → kein Cooldown). EHRLICH: fixt Korrektheit, NICHT Latenz — Account 1
brennt weiter ~60s ins Timeout bevor der Retry auf Account 2 wechselt (instant-Failover bräuchte
per-Account-Timeout = größerer Eingriff, bewusst verschoben). Advisor-gegengeprüft.
**(Ursprüngliche Analyse — Symptom & Mechanismus, zur Doku belassen)** ### HOCH
**Symptom (User):** 3 Mega-Debrid-Web-Accounts aktiv, Rotation pendelt aber nur zwischen - [ ] **H1 — Globaler Stop/Shutdown bricht Deferred-Post-Processing nicht ab.** `abortPostProcessing` (download-manager.ts:7053) iteriert nur über `packagePostProcessAbortControllers`, nie über `packageDeferredPostProcessAbortControllers`. Bei Stop/Shutdown/clearAll laufen MKV-Move/Archiv-Cleanup/Rename weiter, während synchron persistiert wird → FS-Zustand ≠ Session-State (halb verschobene Datei, halb gelöschtes Archiv).
Account 1 ↔ 2 (bzw. nur Account 1), Account 3 (Su****xe) wird NIE probiert. - [ ] **H2 — Hybrid-Post-Extract feuert MKV-Collection/Rename als losgelöstes Promise** (download-manager.ts:11334). In keiner Tracking-Map, kein `shouldAbort`. Cancel/Reset während Hybrid-Collect → Dateien werden trotzdem verschoben/gelöscht.
- [ ] **H3 — 0-Byte-Datei wird als vollständig akzeptiert** wenn keine Größeninfo (download-completion.ts:129, source "stream-end"). Hoster antwortet HTTP 200 ohne Content-Length + schließt sofort → Item "Fertig" mit leerer Datei, kein Auto-Redownload.
**Verifizierter Mechanismus (Code):** ### MITTEL
- Rotationsschleife `debrid.ts:1898`. Account 1 → "Mega-Web Antwort leer" → Cooldown 20s → - [ ] **M1 — Deferred-Post-Extraction nicht in `packagePostProcessTasks`** (download-manager.ts:11974). Scheduler-Abschluss (8154) + finishRun (12310) sehen Deferred-Tasks nicht → Run-Ende/Summary feuert während noch Dateien verschoben werden, State-Reset mitten in FS-Arbeit.
weiter zu Account 2. Account 2 → `aborted:debrid`. - [ ] **M2 — `blockAllPersistence` wird nach Backup-Import nie zurückgesetzt** (app-controller.ts:678). Weiterarbeiten ohne Neustart → `persistSoon` ist dauerhaft No-Op → bei hartem Crash alle Änderungen weg.
- `classifyAccountFailure` (`debrid.ts:2036`) stuft JEDEN Abbruch als **fatal** ein → - [ ] **M3 — `cancelPendingAsyncSaves` wartet nicht auf laufenden Async-Save** (storage.ts:1064). I/O-Overlap beim Import (Datenintegrität durch Generation-Guard geschützt, nur Robustheit).
`throw` (`debrid.ts:1991`) → Schleife bricht ab → **Account 3 nie erreicht.**
- Account 2 bekommt beim Fatal-Abbruch **keinen Cooldown** (cooldownMs:0). Beim
download-manager-Retry wird Account 1 (Cooldown) übersprungen, aber Account 2 (kein
Cooldown) ERNEUT vor Account 3 probiert → bricht wieder ab → ewiges 1↔2.
- Geteiltes 60s-Unrestrict-Timeout `download-manager.ts:8590` (`AbortSignal.any([taskAbort,
timeout(60s)])`) gilt für die GANZE Rotation, nicht pro Account. Mega-Web pollt intern bis
180s (`mega-web-fallback.ts:235` + Poll-Loop `:371`). Sobald das geteilte 60s feuert, bleibt
das kombinierte Signal aborted → KEIN späterer Account kriegt im selben Pass eine echte Chance.
**BESTÄTIGT 2026-06-08 (zweite Screenshots):** Account 1 läuft 10x rasch "erfolgreich" ### NIEDRIG
(11:51:4511:52:26), dann zwei "abgebrochen (aborted:debrid)" um 11:53:30 UND 11:54:30 — - [ ] **N1 — Toter Code in `findReadyArchiveSets`** (download-manager.ts:10847). Unbedingtes `ready.add+continue` macht strengeren Disk-Fallback-Block (untracked-pending-Schutz) unerreichbar.
**exakt 60s auseinander** = das geteilte 60s-Unrestrict-Timeout feuert (kein User-Stop, der
wiederholt sich nicht periodisch). Hier rotiert GAR NICHTS: Account 1 bricht ab → fatal →
Rotation stoppt sofort bei idx=0 → Account 2 und 3 werden NIE probiert. Bug eindeutig
bestätigt, elapsedMs nicht mehr nötig. Account 1 selbst ist gesund (10x ok) — Mega-Web hängt
nur sporadisch (no-server-Poll) bis ins 60s-Timeout.
**Fix-Design (wenn bestätigt):** Pro-Account-Timeout-Budget, abgekoppelt vom geteilten Cap. **Empfehlung:** H1 + H2 + M1 zusammen fixen (eine kohärente Härtung der Deferred-Pipeline). H3 ist klein & unabhängig. M2 trivial.
debrid.ts braucht das **cancel-only** Signal getrennt vom Timeout (kombiniertes Signal kann
beides nicht unterscheiden). Minimal-invasiv: optionaler `opts`-Param an `unrestrictLink`
({cancelSignal, perAttemptTimeoutMs}) — nur die Mega-Rotation liest ihn, andere Provider
unberührt (kombiniertes Signal bleibt). Pro Account: `AbortSignal.any([cancelSignal,
AbortSignal.timeout(perAttemptMs)])`. Abbruch-Logik: cancelSignal aborted → echter Stop;
eigenes Account-Timer gefeuert → non-fatal, Cooldown, weiter zum nächsten Account (inkl. 3).
**Regressionstest ZUERST** (3 Accounts, 1+2 failen/aborten → assert Account 3 kriegt TEST).
**Advisor-Gate** vor Eingriff (kritischer Unrestrict-Pfad, betrifft jeden Download).
Hinweis: Grundursache der leeren Antworten = Mega-Debrid Server/IP-Thema — Fix macht Rotation
nur FAIRER (alle Accounts drankommen), bringt aber keinen busy Server zum Antworten.
### Features / UX (nach ROI)
App läuft headless auf Windows-Server → Nutzer sitzt nicht davor.
1. [ ] **Push-Benachrichtigungen** (Discord/Telegram/ntfy) — SM. Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
2. [ ] **Fernsteuerung über Debug-Server** (POST-Endpunkte) — SM. Server hat HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop`.
3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird nie geprüft → versehentliche Re-Downloads. Warnen: "3 Links bereits geladen".
4. [ ] **Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen".
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung für Downloads → Abbruch mitten drin bei voller Platte.
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen". (Daten dafür liegen jetzt teils in der Error-Ring aus v1.7.185.)
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nicht dargestellt. Welches Abo lohnt sich?
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — SM. Quota/Cooldown-Fails am nächsten Tag automatisch neu.
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Gleicher Hook wie #1.
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M.
### Design-Richtung (Entscheidung steht aus)
4 Mockups in `design-mockups/` (index.html = Vergleich): **Aurora** (verfeinert dark, geringstes Risiko) · **Command** (Terminal/Ops, dicht) · **Vellum** (light editorial) · **Nebula** (neon).
→ Richtung wählen. Siehe Memory: design-taste (Anti-KI-Look) + design-direction (Ember-Wärme, flach/ehrlich).
### Alte Audit-Items (2026-04-04, Status ggf. veraltet — VOR Fix gegen aktuellen Code verifizieren)
- [ ] Debrid-Link `maxDataHost` kühlt ganzen Key ab statt nur den Host
- [ ] Debrid-Link `fileNotAvailable` setzt Key auf "error" statt temporär
- [ ] AllDebrid: kein per-host-Cooldown für erschöpfte Quotas
- [ ] LinkSnappy: keine Auth-Dedup (parallele Requests rufen beide authenticate())
- [ ] Extractor password-cache race (parallele Worker mutieren `packageLearnedPasswords`)
- [ ] Hybrid race: 1 Datei/Staffel evtl. beim MKV-Move nicht umbenannt (NUR per-package fixen — Post-MKV-Move-Scan ist tabu, v1.7.107 revertiert)
--- ---
## ✅ ERLEDIGT — Archiv (Details in git-History + Memory) ## B. FEATURES / UX-GAPS (nach Mehrwert/Aufwand)
- **Erweitertes Logging** → released **v1.7.185** (Crash-Handler, Renderer-Fehler-IPC, RD_DEBUG-Level, Error-Ring + `/errors`, ENOSPC-Klassifizierung, Memory-Heartbeat). → Memory: extended-logging App läuft headless auf Windows-Server → Nutzer sitzt nicht davor. Größte Lücke: keine Benachrichtigungen.
- **Link-Prefetch** → untersucht (6-Agent) + **bewusst verworfen** (marginal bei maxParallel 8, Mega-Web single-flight). → Memory: link-prefetch-declined
- **Backup nur Settings** → v1.7.184 (`backupIncludeDownloads`-Toggle + 4 Selektions/Flicker-Fixes). → Memory: backup-settings-only
- **Account-Rotation-Overhaul** → v1.7.164168 (Validity/Premium-Badges, Live-Panel, "Alle prüfen"). → Memory: account-rotation
- **Mega-Debrid-Account deaktivieren (UI)** → erledigt (Toggle im Edit-Dialog, im Code verifiziert 2026-06-07)
- **Bugs/Robustheit (Deferred-Pipeline H1/H2/H3/M1/M2/N1)** → v1.7.158/159; M3 bewusst übersprungen (Generation-Guard schützt Integrität bereits)
- **Deferred-Pfad Rename-Gap** → gefixt v1.7.162+ (finaler Deferred-Pass benennt frische Dateien vor Collect um; Repro-Test grün)
- **Repo-Privacy-Audit** → GitHub gelöscht+neu (saubere History), Gitea unberührt. → Memory: repo-privacy-audit
### Bewusst NICHT angefasst (Crash-Debris / alte Experimente) 1. [ ] **Webhook/Push-Benachrichtigungen** (Discord/Telegram/ntfy) — SM. Bei Paket fertig/Fehler/Quota/Provider-down aufs Handy. Neuer `notifier.ts`, Hooks an Completion-Punkten. **Höchster ROI.**
- Gestashtes Crash-Debris `stash@{0}` (Revert von 08372f9/18eada9/98dc366 + log.old) — bei Bedarf recoverbar, sonst verwerfbar 2. [ ] **Fernsteuerung über bestehenden Debug-Server** (POST-Endpunkte) — SM. Server hat schon HTTP + Token-Auth, aber nur GET. POST `/control/add-links`, `/start`, `/stop` → vom Handy steuern.
- Untracked `*-postprocess/` + `fix-library-renames.mjs` — alte Experimente (Apr/Mai) 3. [ ] **URL-Duplikat-Erkennung beim Hinzufügen** — S. History-`urls` existiert, wird aber nie geprüft → versehentliche Re-Downloads verschwenden Quota. Warnen: "3 Links bereits geladen".
4. [ ] **Vereinheitlichter Pre-Flight-Check + Bulk-Skip toter Links** — M. Vor Start Größe/Name/Online für ganze Queue, "alle offline überspringen"-Button.
5. [ ] **Speicherplatz-Vorabprüfung vor Start** — S. Aktuell keine Free-Space-Prüfung → Abbruch mitten im Download bei voller Platte.
6. [ ] **Konsolidierte Fehler-Ansicht** — M. Alle fehlgeschlagenen Items flach + Fehlertext + "alle erneut versuchen".
7. [ ] **Per-Provider-Statistik** — M. Rohdaten (`providerTotalUsageBytes`) existieren, werden nur nicht dargestellt. Welches Abo lohnt sich?
8. [ ] **Auto-Retry fehlgeschlagener Pakete nach Wartezeit** — SM. Quota/Cooldown-Fails am nächsten Tag automatisch neu versuchen.
9. [ ] **Plex/Jellyfin Library-Refresh nach MKV-Move** — S. Neue Folgen sofort sichtbar. Gleicher Hook wie #1.
10. [ ] **Watch-Folder für DLC/Link-Auto-Import** — M. Ordner überwachen → automatisch importieren+starten.
---
## C. DESIGN-MOCKUPS
4 Varianten in `design-mockups/` (index.html = Vergleich):
1. **Aurora** — verfeinerte Dark-Evolution (premium, vertraut, geringstes Risiko)
2. **Command** — Terminal/Ops-Dashboard (max. Dichte, Monospace, Status-LEDs)
3. **Vellum** — Light Editorial (warmes Papier, Serif, mutige helle Alternative)
4. **Nebula** — Neon/Synthwave (Magenta-Cyan-Glow, auffällig)
→ Nutzer wählt Richtung (oder Mischung).
---
## REVIEW / ERGEBNISSE (2026-05-23)
**Umgesetzt (v1.7.158):**
- ✅ **H1**`abortPostProcessing` aborted jetzt auch alle Deferred- + Hybrid-Controller (globaler Stop/Shutdown/clearAll/external). Keine FS-Race gegen Shutdown-Save mehr.
- ✅ **H2** — Hybrid-Post-Extract läuft über neue `packageHybridPostProcessControllers`-Map (Set pro Package), Controller SYNCHRON vor dem detached Promise registriert, `shouldAbort` an Rename + MKV-Collect durchgereicht. `abortPackagePostProcessing` + `clearAll` räumen die Map. Cancel/Reset stoppt jetzt laufende Hybrid-Arbeit.
- ✅ **M1** — neuer `hasAnyDeferredPostProcessPending()`; Scheduler-Abschluss + `finishRun`-Clear gaten darauf. `hasDeferredPostProcessPending` (per-Package, für package_done-Cleanup) prüft jetzt auch Hybrid. Run endet erst wenn Background-FS-Arbeit fertig.
- ✅ **H3**`validateDownloadedFileCompletion`: 0-Byte bei `stream-end``ok:false` (download_underflow), routet in den bestehenden Retry-Pfad. Regressionstest in `tests/download-completion.test.ts` (8 Tests).
- ✅ **N1** — toter Disk-Fallback-Block in `findReadyArchiveSets` + verwaiste `pendingItemStatus`-Map entfernt (verhaltensneutral).
**Bewusst NICHT umgesetzt (mit Begründung):**
- ✅ **M2** (`blockAllPersistence` nie zurückgesetzt) — GELÖST in v1.7.159 via **Auto-Relaunch**. In-Memory-Reload wäre unsicher (Task-finally-Blöcke settlen async gegen `this.session.items[id]` → Race beim Session-Swap, bräuchte async-Refactor). Stattdessen: nach erfolgreichem Import startet die App automatisch neu (main-getrieben in main.ts, nicht Renderer — robust gegen Renderer-Fehler). Der frische Prozess lädt die restored Session sauber via Standard-Startup-Pfad. `skipShutdownPersist`/`blockAllPersistence` schützen das ~1.5s-Fenster + den Quit (verifiziert: prepareForShutdown:5680 überspringt Persistenz sauber). Footgun eliminiert — User kann nicht mehr im blockierten Zustand weiterarbeiten.
- ⏭️ **M3** (`cancelPendingAsyncSaves` wartet nicht auf laufenden Save) — Report stuft selbst als reines I/O-Overlap ein; die Generation-Guard (storage.ts:1022) schützt die Datenintegrität bereits (stale Write wird verworfen). Kein Korrektheitsgewinn, daher kein Eingriff.
**Verifikation:** 30 Test-Dateien, 621 Tests grün. Build sauber. Advisor-Review vor Implementierung (fing H2-Falle: Hybrid-Controller nicht in die Deferred-Map legen, sonst killt `runDeferredPostExtraction` sie selbst).
---
## D. DEFERRED-PFAD RENAME-GAP (2026-05-28, Opus-Verifikation von 18eada9)
**Kontext:** Eine abgestürzte Session (API 400 thinking-blocks) hinterließ ein uncommittetes Working-Tree, das **drei** releaste Commits revertierte (08372f9 Passwort + 18eada9 Hybrid-Rename + 98dc366 Support-Bundle, zurück auf v1.7.159). Kein dokumentierter Intent → als Crash-Debris bewertet, non-destruktiv **gestasht** (`git stash` — recoverable), HEAD/v1.7.162 wiederhergestellt.
**Verifizierter Fund (Folge-Bug zu 18eada9):**
- 18eada9 schloss den "frische Datei landet unbenannt"-Bug nur für den **Hybrid-Pfad** (`deferFreshFiles=true` + Mehrfach-Pässe).
- Der **finale Deferred-Pass** (`runDeferredPostExtraction`) macht Rename (12125) → Collect (12156, `deferFreshFiles=false`). Ist eine Datei beim Deferred-Rename noch frisch (< `fileStabilizeMinAgeMs`, prod=2000ms) — v.a. eine eben per **Nested-Extraction** (12045, unmittelbar davor) geschriebene Datei — überspringt der Frische-Gate sie, und der Collect moved sie mit **Original-Scene-Namen** in die Library. `collectMkvFilesToLibrary` benennt selbst nicht um (Move-Body: `buildUniqueFlattenTargetPath`, nur Flatten).
- Pre-existierender Gap (Frische-Skip-Block älter als 18eada9); auch HEAD/v1.7.162 betroffen.
**Gate (TDD, vor Fix):** neuer Regressionstest "deferred final pass renames fresh files before collecting them" → reproduzierte den Bug zuverlässig gegen HEAD (Datei landete unbenannt).
**Fix (minimal, Root-Cause):** `treatFilesAsStable`-Param durch `autoRenameExtractedVideoFiles(Impl)`. Im Deferred-**Final**-Pass (kein concurrent Extractor-Write mehr, Extraktion awaited) wird der Frische-Gate umgangen → alle Dateien werden umbenannt, bevor der Collect sie sammelt. Hybrid-Pfad unangetastet (nutzt `...Impl` mit Default `false` → Frische-Skip bleibt aktiv, schützt weiter vor Rename mitten in concurrent Write).
**Verifikation:** neuer Test grün, Hybrid-Test grün (kein Regress), **623 Tests grün** (31 Dateien), tsc unverändert (9 pre-existing). Advisor-Gate vor Fix (verlangte Repro-Test statt Timing-Argument).
**Offen / bewusst nicht angefasst:**
- Gestashtes Crash-Debris (`stash@{0}`): enthält Revert von 08372f9/18eada9/98dc366 + log.old. Bei Bedarf inspizierbar/recoverbar; sonst irgendwann verwerfbar.
- 08372f9 (Passwort-Daemon-Reset) bewusst nicht neu aufgerollt (außerhalb dieses Goals, kein Hinweis auf Defekt).
- Untracked `*-postprocess/` + `fix-library-renames.mjs`: alte Experimente (Apr/Mai), unverändert gelassen.
---
## E. Mega-Debrid Account temporär deaktivieren (UI) — 2026-05-31
**Goal:** Einzelne Mega-Debrid-Accounts deaktivieren (statt löschen) → Rotation überspringt sie, nutzt die anderen.
**Befund:** Backend KOMPLETT vorhanden — `megaDebridDisabledAccountIds` (Typ/Defaults/Storage-Normalisierung) + Rotation-Skip (debrid.ts:1944) + Verfügbarkeits-Checks. Es fehlt NUR das UI-Toggle (Debrid-Link hat es bereits via keyStatsPopup). ID-Seam verifiziert: `getMegaDebridAccountId(login)` (trim+lowercase) ist auf beiden Seiten identisch → Test grün.
**Ansatz (B-kohärent):** Toggle in die bestehende Mega-Account-Liste im Bearbeiten-Dialog falten (draft-then-Save, KEIN Live-Persist → kohärent, minimal). Schritte:
1. [x] TDD: Test „skips a manually disabled Mega-Debrid account" (acc1 disabled → acc2) — grün gegen Backend.
2. [ ] `AccountDialogState`: Feld `megaDisabledIds: string[]`.
3. [ ] Dialog-Init (createAccountDialogState): aus `settings.megaDebridDisabledAccountIds`.
4. [ ] JSX Mega-Account-Liste: Aktivieren/Deaktivieren-Button + disabled-Styling pro Account.
5. [ ] Entfernen-Handler: ID auch aus megaDisabledIds entfernen.
6. [ ] `buildSettingsFromDialog` (megadebrid-api/web): `megaDebridDisabledAccountIds` aus Draft (gefiltert auf vorhandene Accounts) übernehmen.
7. [ ] Verifizieren: tsc unverändert, volle Suite grün, Toggle-Test grün.

View File

@ -21,7 +21,7 @@ function mockFetchOnce(status: number, body: unknown): void {
})) as unknown as typeof fetch); })) as unknown as typeof fetch);
} }
const NOW = 1_700_000_000_000; const NOW = 1_700_000_000_000; // fixed epoch ms
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
@ -29,7 +29,7 @@ afterEach(() => {
describe("checkMegaDebridAccount", () => { describe("checkMegaDebridAccount", () => {
it("reports valid + premium from vip_end (future Unix ts)", async () => { it("reports valid + premium from vip_end (future Unix ts)", async () => {
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60; const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60; // +30 days
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" }); mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW); const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
expect(st.valid).toBe(true); expect(st.valid).toBe(true);
@ -80,7 +80,7 @@ describe("checkMegaDebridAccount", () => {
describe("checkDebridLinkKey", () => { describe("checkDebridLinkKey", () => {
it("reports valid + premium from premiumLeft seconds", async () => { it("reports valid + premium from premiumLeft seconds", async () => {
const premiumLeft = 60 * 24 * 60 * 60; const premiumLeft = 60 * 24 * 60 * 60; // 60 days in seconds
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } }); mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW); const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
expect(st.valid).toBe(true); expect(st.valid).toBe(true);
@ -118,6 +118,7 @@ describe("checkAllDebridAccounts", () => {
}); });
it("checks every configured mega account + debrid-link key", async () => { it("checks every configured mega account + debrid-link key", async () => {
// All requests succeed as valid premium
const futureSec = Math.floor(Date.now() / 1000) + 1000; const futureSec = Math.floor(Date.now() / 1000) + 1000;
vi.stubGlobal("fetch", vi.fn(async (url: string) => { vi.stubGlobal("fetch", vi.fn(async (url: string) => {
if (String(url).includes("mega-debrid")) { if (String(url).includes("mega-debrid")) {
@ -133,7 +134,7 @@ describe("checkAllDebridAccounts", () => {
} as unknown as AppSettings; } as unknown as AppSettings;
const result = await checkAllDebridAccounts(settings); const result = await checkAllDebridAccounts(settings);
expect(result).toHaveLength(5); expect(result).toHaveLength(5); // 2 mega + 3 debrid-link
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2); expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3); expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
expect(result.every((r) => r.valid)).toBe(true); expect(result.every((r) => r.valid)).toBe(true);

View File

@ -10,6 +10,7 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" }); logAccountRotation("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" }); logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" }); logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
// simulate an await boundary — ALS must survive it
await Promise.resolve(); await Promise.resolve();
}); });
@ -22,6 +23,7 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
it("does not leak events to the sink outside the run() scope", () => { it("does not leak events to the sink outside the run() scope", () => {
const captured: RotationEvent[] = []; const captured: RotationEvent[] = [];
// No active sink here
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK"); logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
expect(captured).toHaveLength(0); expect(captured).toHaveLength(0);
}); });
@ -41,6 +43,7 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" }); logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
}) })
]); ]);
// Each sink only saw its own provider's events
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true); expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true); expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]); expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
@ -51,6 +54,7 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST"); logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" }); logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
const ring = getRecentRotationEvents(10); const ring = getRecentRotationEvents(10);
// OK is in the ring; the TEST marker is filtered out of the panel
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true); expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false); expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
}); });

View File

@ -14,6 +14,10 @@ import {
} from "../src/main/download-manager"; } from "../src/main/download-manager";
describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => { describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => {
// Characterization corpus: pins the EXACT decision for the real failures from
// rename-session_2026-06-02 (17 raw files that mkv-move moved un-renamed) plus
// the guard cases. mkv-collect now routes through this same function so a file
// auto-rename missed still lands clean in the library.
it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => { it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => {
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv"; const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
@ -74,6 +78,7 @@ describe("decideAutoRenameBaseName (shared naming decision — used by auto-rena
}); });
it("GUARD: lets the parent folder token override an OBFUSCATED source filename (anti-piracy scramble)", () => { it("GUARD: lets the parent folder token override an OBFUSCATED source filename (anti-piracy scramble)", () => {
// Obfuscated file (E16) inside an explicitly-named E01 folder → trust the folder.
const decision = decideAutoRenameBaseName( const decision = decideAutoRenameBaseName(
["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"], ["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"],
"awa-diethundermans02e16hd.mkv", "awa-diethundermans02e16hd.mkv",
@ -86,6 +91,7 @@ describe("decideAutoRenameBaseName (shared naming decision — used by auto-rena
}); });
it("GUARD: a CLEAN scene source is NEVER overridden by a mismatching folder token (folder is wrong, not the file)", () => { it("GUARD: a CLEAN scene source is NEVER overridden by a mismatching folder token (folder is wrong, not the file)", () => {
// Clean source S01E09 in a folder that says E08 → must NOT rename to E08.
const decision = decideAutoRenameBaseName( const decision = decideAutoRenameBaseName(
["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"], ["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"],
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv", "the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv",
@ -109,6 +115,9 @@ describe("decideAutoRenameBaseName (shared naming decision — used by auto-rena
}); });
it("uses the CLEAN per-episode folder (scene group WITH underscore, e.g. -idTV_iNT) — not the obfuscated package folder", () => { it("uses the CLEAN per-episode folder (scene group WITH underscore, e.g. -idTV_iNT) — not the obfuscated package folder", () => {
// User-Report v1.7.178: castle.s08e02....mkv im sauberen Ordner "Castle.S08E02...H264-idTV_iNT"
// (Paket: "scn2-cstl7") wurde zu "scn2-cstl7.S08E02" verschlimmbessert, weil hasSceneGroupSuffix
// die Unterstrich-Gruppe "-idTV_iNT" nicht erkannte und auf den Paketordner zurueckfiel.
const epFolder = "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT"; const epFolder = "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT";
const decision = decideAutoRenameBaseName( const decision = decideAutoRenameBaseName(
[epFolder, "scn2-cstl7"], [epFolder, "scn2-cstl7"],
@ -122,6 +131,9 @@ describe("decideAutoRenameBaseName (shared naming decision — used by auto-rena
}); });
it("uses the complete per-episode folder when the SOURCE has no SxxExx token (bare 'Folge 01' format)", () => { it("uses the complete per-episode folder when the SOURCE has no SxxExx token (bare 'Folge 01' format)", () => {
// User-Report: "Kreuzfahrt ins Glück" — Datei "bet_kig_01_hdt.mkv" (kein SxxExx-Token),
// Episoden-Ordner nummeriert mit "01" statt S01E01. Frueher "kein Zielname" -> roh in die
// Library. Jetzt: der vollstaendige Release-Ordnername wird direkt verwendet.
const folders = [ const folders = [
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET", "Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET",
"kig.hdtv.7p-001", "kig.hdtv.7p-001",
@ -177,12 +189,14 @@ describe("hasMeaningfulSeriesPrefix", () => {
describe("looksLikeObfuscatedSceneFileName", () => { describe("looksLikeObfuscatedSceneFileName", () => {
it("flags hoster-obfuscated names with no scene markers as obfuscated", () => { it("flags hoster-obfuscated names with no scene markers as obfuscated", () => {
// No 720p / german / x264 / bluray, no dot-separated structure
expect(looksLikeObfuscatedSceneFileName("awa-diethundermans02e16hd.mkv")).toBe(true); expect(looksLikeObfuscatedSceneFileName("awa-diethundermans02e16hd.mkv")).toBe(true);
expect(looksLikeObfuscatedSceneFileName("scn-dthund7-S02E06.mkv")).toBe(true); expect(looksLikeObfuscatedSceneFileName("scn-dthund7-S02E06.mkv")).toBe(true);
expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true); expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true);
}); });
it("treats clean scene releases with multiple markers as NOT obfuscated", () => { it("treats clean scene releases with multiple markers as NOT obfuscated", () => {
// Has 720p + german + bluray + x264 — clearly a clean scene file
expect(looksLikeObfuscatedSceneFileName("the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv")).toBe(false); expect(looksLikeObfuscatedSceneFileName("the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv")).toBe(false);
expect(looksLikeObfuscatedSceneFileName("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake.mkv")).toBe(false); expect(looksLikeObfuscatedSceneFileName("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake.mkv")).toBe(false);
expect(looksLikeObfuscatedSceneFileName("Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv")).toBe(false); expect(looksLikeObfuscatedSceneFileName("Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv")).toBe(false);
@ -194,6 +208,7 @@ describe("looksLikeObfuscatedSceneFileName", () => {
}); });
it("treats long dotted names as scene-style even with few markers", () => { it("treats long dotted names as scene-style even with few markers", () => {
// 6+ dots → looks like scene structure even without quality/codec markers
expect(looksLikeObfuscatedSceneFileName("Some.Show.With.Many.Tokens.S01E01.mkv")).toBe(false); expect(looksLikeObfuscatedSceneFileName("Some.Show.With.Many.Tokens.S01E01.mkv")).toBe(false);
}); });
}); });
@ -203,22 +218,31 @@ describe("extractEpisodeToken (extended formats)", () => {
expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01"); expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01");
expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05"); expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05");
expect(extractEpisodeToken("Show.Name.10x99.mkv")).toBe("S10E99"); expect(extractEpisodeToken("Show.Name.10x99.mkv")).toBe("S10E99");
// 3-digit episode in xX format is intentionally NOT supported — would
// collide with codec tokens (x264/x265/x266). 3-digit episodes still
// work in the modern SxxEnnn format which has explicit S/E delimiters.
expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBeNull(); expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBeNull();
expect(extractEpisodeToken("Show.Name.S10E100.mkv")).toBe("S10E100"); expect(extractEpisodeToken("Show.Name.S10E100.mkv")).toBe("S10E100");
}); });
it("does not falsely match resolution tokens like 1080x720", () => { it("does not falsely match resolution tokens like 1080x720", () => {
// The xX regex is bounded; 1080p shouldn't match as "1080x???" because
// there's no second number group in 1080p / 720p / etc.
expect(extractEpisodeToken("show.1080p.mkv")).toBeNull(); expect(extractEpisodeToken("show.1080p.mkv")).toBeNull();
expect(extractEpisodeToken("show.S01E01.1080p.mkv")).toBe("S01E01"); expect(extractEpisodeToken("show.S01E01.1080p.mkv")).toBe("S01E01");
}); });
it("does not falsely match codec tokens like x264 / x265 (caps episode digits)", () => { it("does not falsely match codec tokens like x264 / x265 (caps episode digits)", () => {
// First number 5, second number capped to 2 digits → "5x265" CANNOT
// match because 265 has 3 digits. Same for x264, x266, h264, h265.
expect(extractEpisodeToken("Movie.x264-GROUP.mkv")).toBeNull(); expect(extractEpisodeToken("Movie.x264-GROUP.mkv")).toBeNull();
expect(extractEpisodeToken("Movie.5x265.x265.mkv")).toBeNull(); expect(extractEpisodeToken("Movie.5x265.x265.mkv")).toBeNull();
// SxxExx still wins ahead of phantom xX matches.
expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01"); expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01");
}); });
it("does not falsely match common aspect ratios like 1920x1080", () => { it("does not falsely match common aspect ratios like 1920x1080", () => {
// 1920 has 4 digits, first group capped at 2 → no match.
expect(extractEpisodeToken("Movie.1920x1080.mkv")).toBeNull(); expect(extractEpisodeToken("Movie.1920x1080.mkv")).toBeNull();
}); });
}); });
@ -467,6 +491,7 @@ describe("buildAutoRenameBaseName", () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
// Edge cases
it("handles 2160p quality token", () => { it("handles 2160p quality token", () => {
const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv"); const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv");
expect(result).toBe("Show.S01E01.REPACK.2160p-4sf"); expect(result).toBe("Show.S01E01.REPACK.2160p-4sf");
@ -484,10 +509,12 @@ describe("buildAutoRenameBaseName", () => {
it("handles high season and episode numbers", () => { it("handles high season and episode numbers", () => {
const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv"); const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv");
// SCENE_EPISODE_RE allows up to 3-digit episodes and 2-digit seasons
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!).toContain("S99E999"); expect(result!).toContain("S99E999");
}); });
// Real-world scene release patterns
it("real-world: German series with dots", () => { it("real-world: German series with dots", () => {
const result = buildAutoRenameBaseName( const result = buildAutoRenameBaseName(
"Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ", "Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ",
@ -552,13 +579,18 @@ describe("buildAutoRenameBaseName", () => {
expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF"); expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF");
}); });
// Bug-hunting edge cases
it("source filename extension is not included in episode detection", () => { it("source filename extension is not included in episode detection", () => {
// The sourceFileName passed to buildAutoRenameBaseName is the basename without extension
// so .mkv should not interfere, but let's verify with an actual extension
const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv"); const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv");
// "mkv" should not be treated as part of the filename match
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!).toContain("S01E01"); expect(result!).toContain("S01E01");
}); });
it("does not match episode-like patterns in codec strings", () => { it("does not match episode-like patterns in codec strings", () => {
// h.265 has digits but should not be confused with episode tokens
const token = extractEpisodeToken("show.s01e01.h.265"); const token = extractEpisodeToken("show.s01e01.h.265");
expect(token).toBe("S01E01"); expect(token).toBe("S01E01");
}); });
@ -576,19 +608,23 @@ describe("buildAutoRenameBaseName", () => {
"Show.S01E05.720p-4sf", "Show.S01E05.720p-4sf",
"show.s01e05.720p" "show.s01e05.720p"
); );
// Must NOT produce "Show.S01E05.720p.S01E05-4sf" (double episode bug)
expect(result).toBe("Show.S01E05.720p-4sf"); expect(result).toBe("Show.S01E05.720p-4sf");
}); });
it("handles folder with only -4sf suffix (edge case)", () => { it("handles folder with only -4sf suffix (edge case)", () => {
const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv"); const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv");
// Extreme edge case - sanitizeFilename trims leading dots
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!).toContain("S01E01"); expect(result!).toContain("S01E01");
expect(result!).toContain("-4sf"); expect(result!).toContain("-4sf");
expect(result!).not.toContain(".S01E01.S01E01"); expect(result!).not.toContain(".S01E01.S01E01"); // no duplication
}); });
it("sanitizes special characters from result", () => { it("sanitizes special characters from result", () => {
// sanitizeFilename should strip dangerous chars
const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv"); const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv");
// The colon should be sanitized away
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result!).not.toContain(":"); expect(result!).not.toContain(":");
}); });
@ -852,6 +888,7 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD"); expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD");
}); });
// Last-resort fallback: folder has season but no scene group suffix (user-renamed packages)
it("renames when folder has season but no scene group suffix (Mystery Road case)", () => { it("renames when folder has season but no scene group suffix (Mystery Road case)", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions( const result = buildAutoRenameBaseNameFromFoldersWithOptions(
["Mystery Road S02"], ["Mystery Road S02"],
@ -879,6 +916,7 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
"myst.road.de.dl.hdtv.7p-s02e05", "myst.road.de.dl.hdtv.7p-s02e05",
{ forceEpisodeForSeasonFolder: true } { forceEpisodeForSeasonFolder: true }
); );
// Should use the scene-group folder (hrs), not the custom one
expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs"); expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs");
}); });
@ -964,6 +1002,11 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
}); });
it("documents malformed package name (S01GERMAN) limitation", () => { it("documents malformed package name (S01GERMAN) limitation", () => {
// Real-world: "Drei.Meter.ueber.dem.Himmel.S01GERMAN.DL.720P.WEB.X264-WAYNE"
// is malformed (no separator between S01 and GERMAN). SCENE_SEASON_ONLY_RE
// doesn't match this, so the helper falls back to the package name as-is.
// The download-manager autoRenameExtractedVideoFiles safety net repairs
// this at runtime by inserting the source's episode token.
const result = buildAutoRenameBaseNameFromFoldersWithOptions( const result = buildAutoRenameBaseNameFromFoldersWithOptions(
[ [
"3MH.web.7p-101", "3MH.web.7p-101",
@ -972,6 +1015,8 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE", "Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
{ forceEpisodeForSeasonFolder: true } { forceEpisodeForSeasonFolder: true }
); );
// Helper limitation: returns the malformed folder name unchanged.
// The download-manager safety net catches this at runtime.
if (result !== null) { if (result !== null) {
expect(typeof result).toBe("string"); expect(typeof result).toBe("string");
} }
@ -982,6 +1027,7 @@ describe("isBonusContent (numbered episodes are never bonus)", () => {
const pkgDir = "/pkg/Show.S04.GERMAN.DL.720p.WEB.x264-GRP"; const pkgDir = "/pkg/Show.S04.GERMAN.DL.720p.WEB.x264-GRP";
it("does NOT treat a numbered episode as bonus even when its TITLE is a bonus word", () => { it("does NOT treat a numbered episode as bonus even when its TITLE is a bonus word", () => {
// Der gemeldete Bug: Revenge.2011.S04E19.Interview wurde als Bonus verworfen.
const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC"; const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
const fp = `${pkgDir}/${name}/${name}.mkv`; const fp = `${pkgDir}/${name}/${name}.mkv`;
expect(isBonusContent(fp, pkgDir, name)).toBe(false); expect(isBonusContent(fp, pkgDir, name)).toBe(false);
@ -1020,6 +1066,9 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
const hash = "c284d9d9072eaf3ac314d05f951dd115"; const hash = "c284d9d9072eaf3ac314d05f951dd115";
it("uses the clean folder name when it has an episode token + codec but no -GROUP (safari S04E08a)", () => { it("uses the clean folder name when it has an episode token + codec but no -GROUP (safari S04E08a)", () => {
// Echter Bug (rename-session 2026-06-04): alte deutsche Doku ohne Gruppen-Suffix.
// Ordner endet auf ".XviD" (kein "-GROUP") -> buildAutoRenameBaseName lieferte null ->
// "kein Zielname" -> Datei landete roh als "safari-fm-s04e08a.avi" in der Library.
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD"; const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash); const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
expect(decision).toEqual({ kind: "rename", baseName: folder }); expect(decision).toEqual({ kind: "rename", baseName: folder });
@ -1032,6 +1081,7 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
const db = decideAutoRenameBaseName([fb, hash], "safari-fm-s04e08b.avi", "safari-fm-s04e08b", hash, hash); const db = decideAutoRenameBaseName([fb, hash], "safari-fm-s04e08b.avi", "safari-fm-s04e08b", hash, hash);
expect(da).toEqual({ kind: "rename", baseName: fa }); expect(da).toEqual({ kind: "rename", baseName: fa });
expect(db).toEqual({ kind: "rename", baseName: fb }); expect(db).toEqual({ kind: "rename", baseName: fb });
// Verschiedene Zielnamen -> keine "(2)"-Kollision beim Sammeln.
expect((da as any).baseName).not.toBe((db as any).baseName); expect((da as any).baseName).not.toBe((db as any).baseName);
}); });
@ -1042,6 +1092,8 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
}); });
it("does NOT use a bare episode folder WITHOUT any codec/resolution marker (stays conservative)", () => { it("does NOT use a bare episode folder WITHOUT any codec/resolution marker (stays conservative)", () => {
// "Show.S01E01" allein (keine Qualitaets-/Codec-Info, kein -GROUP) ist mehrdeutig ->
// weiterhin kein Ableiten (kein Over-Firing auf generische Ordner).
const decision = decideAutoRenameBaseName(["Show.S01E01", hash], "abc-s01e01.avi", "abc-s01e01", hash, hash); const decision = decideAutoRenameBaseName(["Show.S01E01", hash], "abc-s01e01.avi", "abc-s01e01", hash, hash);
expect(decision.kind).toBe("skip"); expect(decision.kind).toBe("skip");
}); });
@ -1054,6 +1106,11 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
describe("collect must not mangle an already-clean SxxExx name via an episode-title folder", () => { describe("collect must not mangle an already-clean SxxExx name via an episode-title folder", () => {
const hash = "c284d9d9072eaf3ac314d05f951dd115"; const hash = "c284d9d9072eaf3ac314d05f951dd115";
// Echter Bug (rename-session 2026-06-05): Miniserie "Steven Spielbergs Taken". Auto-Rename
// benannte korrekt zu "...S01E01...-GTVG". Der per-Episode-Ordner traegt aber nur einen
// Episode-only-Token + Titel ("...E01.Hinter.dem.Himmel...-GTVG", KEIN S01). Der Collect leitete
// daraus neu ab und HAENGTE den Quell-Token verkrueppelt an: "...-GTVG.S01E01" → in der Library
// stand dann "E01.Titel...S01E01" statt sauber "S01E01".
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG"; const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
const pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG"; const pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG"; const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG";
@ -1061,10 +1118,14 @@ describe("collect must not mangle an already-clean SxxExx name via an episode-ti
it("keeps the clean source (skip) instead of appending the token to the episode-title folder", () => { it("keeps the clean source (skip) instead of appending the token to the episode-title folder", () => {
const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder); const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder);
expect(decision.kind).toBe("skip"); expect(decision.kind).toBe("skip");
// NICHT der verkrueppelte "...-GTVG.S01E01"-Name.
expect(JSON.stringify(decision)).not.toContain("GTVG.S01E01"); expect(JSON.stringify(decision)).not.toContain("GTVG.S01E01");
}); });
it("still cleans a JUNK/obfuscated source via an episode-title folder (append path intact, no skip)", () => { it("still cleans a JUNK/obfuscated source via an episode-title folder (append path intact, no skip)", () => {
// Obfuskierter Hoster-Name (obf=true) → meine Klausel greift NICHT (sie behaelt nur saubere
// Quellen). Mit Season-Ordner als Kontext (Root-Guard ok) wird der Token weiter angewandt:
// die Quelle wird NICHT roh behalten. Pruegt, dass der Fix nicht zu breit ist.
const epFolder = "Show.E05.Die.Sache.German.720p.HDTV.x264-GRP"; const epFolder = "Show.E05.Die.Sache.German.720p.HDTV.x264-GRP";
const seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP"; const seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP";
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder); const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder);
@ -1079,6 +1140,9 @@ describe("collect must not mangle an already-clean SxxExx name via an episode-ti
}); });
it("keeps a clean SHORT-prefix series source (ER) instead of the crippled token append", () => { it("keeps a clean SHORT-prefix series source (ER) instead of the crippled token append", () => {
// Adversarial-Befund: die Praefix-Laenge darf KEIN Kriterium sein. Kurze Serien (ER, V, 24, Yu)
// sind genauso autoritativ; mit dem alten `hasMeaningfulSeriesPrefix`-Konjunkt (>=3 Alpha vor S0x)
// waere ER durchgefallen -> selber verkrueppelter Name wie der gemeldete Bug.
const epFolder = "ER.E01.Tag.und.Nacht.German.720p.HDTV.x264-GROUP"; const epFolder = "ER.E01.Tag.und.Nacht.German.720p.HDTV.x264-GROUP";
const seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP"; const seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP";
const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP"; const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP";

View File

@ -20,6 +20,7 @@ describe("backup-crypto", () => {
const plaintext = JSON.stringify({ settings: { token: secret } }); const plaintext = JSON.stringify({ settings: { token: secret } });
const encrypted = encryptBackup(plaintext); const encrypted = encryptBackup(plaintext);
// The encrypted buffer should NOT contain the secret in plaintext
expect(encrypted.toString("utf8")).not.toContain(secret); expect(encrypted.toString("utf8")).not.toContain(secret);
expect(encrypted.toString("latin1")).not.toContain(secret); expect(encrypted.toString("latin1")).not.toContain(secret);
}); });
@ -33,7 +34,9 @@ describe("backup-crypto", () => {
const plaintext = "same input data"; const plaintext = "same input data";
const a = encryptBackup(plaintext); const a = encryptBackup(plaintext);
const b = encryptBackup(plaintext); const b = encryptBackup(plaintext);
// IVs are different, so full buffers must differ
expect(a.equals(b)).toBe(false); expect(a.equals(b)).toBe(false);
// But both decrypt to the same plaintext
expect(decryptBackup(a)).toBe(plaintext); expect(decryptBackup(a)).toBe(plaintext);
expect(decryptBackup(b)).toBe(plaintext); expect(decryptBackup(b)).toBe(plaintext);
}); });
@ -46,6 +49,7 @@ describe("backup-crypto", () => {
it("throws on corrupted ciphertext", () => { it("throws on corrupted ciphertext", () => {
const encrypted = encryptBackup("test data"); const encrypted = encryptBackup("test data");
// Flip a byte in the ciphertext area
const corrupted = Buffer.from(encrypted); const corrupted = Buffer.from(encrypted);
corrupted[corrupted.length - 1] ^= 0xff; corrupted[corrupted.length - 1] ^= 0xff;
expect(() => decryptBackup(corrupted)).toThrow(); expect(() => decryptBackup(corrupted)).toThrow();

View File

@ -1,81 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildBackupPayload, planBackupImport } from "../src/main/backup-payload";
import type { AppSettings, SessionState, HistoryEntry } from "../src/shared/types";
function settings(overrides: Partial<AppSettings> = {}): AppSettings {
return { backupIncludeDownloads: false, token: "secret", outputDir: "C:\\dl" } as unknown as AppSettings;
}
const session: SessionState = {
version: 2, packageOrder: ["p1"], packages: { p1: {} as never }, items: { i1: {} as never },
runStartedAt: 0, totalDownloadedBytes: 0, summaryText: "", reconnectUntil: 0,
reconnectReason: "", paused: false, running: true, updatedAt: 0
};
const history: HistoryEntry[] = [{ id: "h1" } as unknown as HistoryEntry];
const baseInput = { appVersion: "1.7.183", exportedAt: "2026-06-07T00:00:00Z", session, history };
describe("buildBackupPayload — default is settings-only", () => {
it("omits session AND history when backupIncludeDownloads is false (default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
expect(p.history).toBeUndefined();
expect(p.settings).toBeDefined();
});
it("includes session + history when backupIncludeDownloads is true", () => {
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
expect(p.kind).toBe("full");
expect(p.session).toBe(session);
expect(p.history).toBe(history);
});
it("treats a missing flag as settings-only (safe default)", () => {
const p = buildBackupPayload({ ...baseInput, settings: {} as AppSettings });
expect(p.kind).toBe("settings-only");
expect(p.session).toBeUndefined();
});
it("ROUND-TRIP: toggle off -> exported payload carries the flag still false", () => {
// "Haken aus bleibt aus": the exported settings object preserves the flag,
// so importing it keeps the toggle off.
const p = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
expect((p.settings as AppSettings).backupIncludeDownloads).toBe(false);
});
});
describe("planBackupImport — decision follows the file, not the local toggle", () => {
it("settings-only backup (no session) -> restore settings only, no relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "settings-only", settings: { theme: "dark" } });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(false);
expect(plan.message).toMatch(/Einstellungen/);
});
it("full backup (with session) -> restore downloads + relaunch", () => {
const plan = planBackupImport({ version: 2, kind: "full", settings: { theme: "dark" }, session });
expect(plan.valid).toBe(true);
expect(plan.restoreDownloads).toBe(true);
});
it("rejects payloads without settings", () => {
expect(planBackupImport({ session }).valid).toBe(false);
expect(planBackupImport(null).valid).toBe(false);
expect(planBackupImport("nope").valid).toBe(false);
expect(planBackupImport({}).valid).toBe(false);
});
it("a settings-only export then import does NOT pull in the download list", () => {
// Build with toggle off, then plan the import of exactly that payload.
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: false } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(false); // queue stays untouched
});
it("a full export then import DOES restore the download list", () => {
const exported = buildBackupPayload({ ...baseInput, settings: { backupIncludeDownloads: true } as AppSettings });
const plan = planBackupImport(JSON.parse(JSON.stringify(exported)));
expect(plan.restoreDownloads).toBe(true);
});
});

View File

@ -73,6 +73,7 @@ describe("bestdebrid-web", () => {
try { try {
fs.rmSync(filePath, { force: true }); fs.rmSync(filePath, { force: true });
} catch { } catch {
// ignore temp cleanup failures
} }
} }
}); });

View File

@ -42,6 +42,7 @@ describe("cleanup", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir); tempDirs.push(dir);
// Create nested directory structure with archive files
const sub1 = path.join(dir, "season1"); const sub1 = path.join(dir, "season1");
const sub2 = path.join(dir, "season1", "extras"); const sub2 = path.join(dir, "season1", "extras");
fs.mkdirSync(sub2, { recursive: true }); fs.mkdirSync(sub2, { recursive: true });
@ -50,15 +51,17 @@ describe("cleanup", () => {
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x"); fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x"); fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x"); fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
// Non-archive files should be kept
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content"); fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content"); fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
const removed = cleanupCancelledPackageArtifacts(dir); const removed = cleanupCancelledPackageArtifacts(dir);
expect(removed).toBe(4); expect(removed).toBe(4); // 2 rar parts + zip + 7z
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false); expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false); expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false); expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false); expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
// Non-archives kept
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true); expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true); expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
}); });
@ -67,17 +70,23 @@ describe("cleanup", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir); tempDirs.push(dir);
// File with link-like name containing URLs should be removed
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n"); fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
// File with link-like name but no URLs should be kept
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs"); fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
// Regular text file that doesn't match the link pattern should be kept
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com"); fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
// .url files should always be removed
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com"); fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
// .dlc files should always be removed
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data"); fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
const removed = await removeDownloadLinkArtifacts(dir); const removed = await removeDownloadLinkArtifacts(dir);
expect(removed).toBeGreaterThanOrEqual(3); expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false); expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false); expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false); expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
// Non-matching files should be kept
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true); expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
}); });

View File

@ -22,7 +22,9 @@ describe("container", () => {
const oversizedFilePath = path.join(dir, "oversized.dlc"); const oversizedFilePath = path.join(dir, "oversized.dlc");
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1)); fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
// Create a valid mockup DLC that would be skipped if an error was thrown
const validFilePath = path.join(dir, "valid.dlc"); const validFilePath = path.join(dir, "valid.dlc");
// Just needs to be short enough to pass file limits but fail parsing, triggering dcrypt fallback
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content...")); fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
const fetchSpy = vi.fn(async (url: string | URL | Request) => { const fetchSpy = vi.fn(async (url: string | URL | Request) => {
@ -36,6 +38,7 @@ describe("container", () => {
const result = await importDlcContainers([oversizedFilePath, validFilePath]); const result = await importDlcContainers([oversizedFilePath, validFilePath]);
// Expect the oversized to be silently skipped, and valid to be parsed into 1 package with DLC filename
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].name).toBe("valid"); expect(result[0].name).toBe("valid");
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]); expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
@ -57,14 +60,17 @@ describe("container", () => {
tempDirs.push(dir); tempDirs.push(dir);
const filePath = path.join(dir, "fallback.dlc"); const filePath = path.join(dir, "fallback.dlc");
// A file large enough to trigger local decryption attempt (needs > 89 bytes to pass the slice check)
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64")); fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => { const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url); const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) { if (urlStr.includes("service.jdownloader.org")) {
// Mock local RC service failure (returning 404)
return new Response("", { status: 404 }); return new Response("", { status: 404 });
} }
if (urlStr.includes("dcrypt.it/decrypt/upload")) { if (urlStr.includes("dcrypt.it/decrypt/upload")) {
// Mock dcrypt fallback success
return new Response("http://fallback.com/1", { status: 200 }); return new Response("http://fallback.com/1", { status: 200 });
} }
return new Response("", { status: 404 }); return new Response("", { status: 404 });
@ -75,6 +81,7 @@ describe("container", () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].name).toBe("fallback"); expect(result[0].name).toBe("fallback");
expect(result[0].links).toEqual(["http://fallback.com/1"]); expect(result[0].links).toEqual(["http://fallback.com/1"]);
// Should have tried both!
expect(fetchSpy).toHaveBeenCalledTimes(2); expect(fetchSpy).toHaveBeenCalledTimes(2);
}); });
@ -128,6 +135,7 @@ describe("container", () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(result[0].name).toBe("big-dlc"); expect(result[0].name).toBe("big-dlc");
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]); expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
// local RC + upload + paste = 3 calls
expect(fetchSpy).toHaveBeenCalledTimes(3); expect(fetchSpy).toHaveBeenCalledTimes(3);
}); });

View File

@ -11,7 +11,6 @@ afterEach(() => {
globalThis.fetch = originalFetch; globalThis.fetch = originalFetch;
resetDebridLinkRuntimeStateForTests(); resetDebridLinkRuntimeStateForTests();
resetMegaDebridRuntimeStateForTests(); resetMegaDebridRuntimeStateForTests();
delete process.env.RD_MEGA_ABORT_MIN_RUN_MS;
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@ -425,10 +424,14 @@ describe("debrid service", () => {
}); });
} }
// Only count calls to /downloader/add (the unrestrict endpoint)
if (url.includes("/downloader/add")) { if (url.includes("/downloader/add")) {
unrestrictAuthHeaders.push(authHeader); unrestrictAuthHeaders.push(authHeader);
// Read the body to know which link is being unrestricted
const bodyText = init?.body ? String(init.body) : ""; const bodyText = init?.body ? String(init.body) : "";
const isRapidgator = /rapidgator/i.test(bodyText); const isRapidgator = /rapidgator/i.test(bodyText);
// Only key-one + rapidgator returns maxDataHost. All other (key, host)
// combinations succeed.
if (authHeader === "Bearer dl-key-one" && isRapidgator) { if (authHeader === "Bearer dl-key-one" && isRapidgator) {
return new Response(JSON.stringify({ return new Response(JSON.stringify({
success: false, success: false,
@ -451,14 +454,19 @@ describe("debrid service", () => {
const service = new DebridService(settings); const service = new DebridService(settings);
// 1) First rapidgator: key-one hits maxDataHost → key-two succeeds.
const r1 = await service.unrestrictLink("https://rapidgator.net/file/first"); const r1 = await service.unrestrictLink("https://rapidgator.net/file/first");
expect(r1.providerLabel).toContain("Key 2"); expect(r1.providerLabel).toContain("Key 2");
// 2) Second rapidgator request: key-one MUST be skipped (host cooldown
// on (key1, rapidgator)), only key-two should be tried.
unrestrictAuthHeaders.length = 0; unrestrictAuthHeaders.length = 0;
const r2 = await service.unrestrictLink("https://rapidgator.net/file/second"); const r2 = await service.unrestrictLink("https://rapidgator.net/file/second");
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]); expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]);
expect(r2.providerLabel).toContain("Key 2"); expect(r2.providerLabel).toContain("Key 2");
// 3) Different host: key-one must NOT be skipped — its host-cooldown is
// only for rapidgator, not for uploaded.net.
unrestrictAuthHeaders.length = 0; unrestrictAuthHeaders.length = 0;
const r3 = await service.unrestrictLink("https://uploaded.net/file/third"); const r3 = await service.unrestrictLink("https://uploaded.net/file/third");
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]); expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]);
@ -515,7 +523,10 @@ describe("debrid service", () => {
const result = await service.unrestrictLink("https://rapidgator.net/file/example"); const result = await service.unrestrictLink("https://rapidgator.net/file/example");
expect(result.providerLabel).toContain("Key 2"); expect(result.providerLabel).toContain("Key 2");
// Key-one responded normally — just that the link was unavailable on the
// hoster side. Key-one is NOT broken and must not be flagged as "error".
expect(getDebridLinkKeyRuntimeStateForTests(key1Id)).not.toBe("error"); expect(getDebridLinkKeyRuntimeStateForTests(key1Id)).not.toBe("error");
// Key-two served the link successfully, so it's "ready".
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready"); expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
}); });
@ -683,6 +694,7 @@ describe("debrid service", () => {
const service = new DebridService(settings); const service = new DebridService(settings);
await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/); await expect(service.unrestrictLink("https://hoster.example/not-debrid.bin")).rejects.toThrow(/debrid_link_cooldown.*notDebrid/);
// 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"]); expect(authHeaders).toEqual(["Bearer dl-key-one"]);
}); });
@ -1255,6 +1267,7 @@ describe("debrid service", () => {
autoProviderFallback: true autoProviderFallback: true
}; };
// API returns 404 for connectUser → API fails, falls back to web
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 })); const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
globalThis.fetch = fetchSpy as unknown as typeof fetch; globalThis.fetch = fetchSpy as unknown as typeof fetch;
@ -1353,6 +1366,7 @@ describe("debrid service", () => {
autoProviderFallback: false autoProviderFallback: false
}; };
// API connect fails fast → falls through to web fallback
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<never> => new Promise((_, reject) => { const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<never> => new Promise((_, reject) => {
@ -1380,6 +1394,9 @@ describe("debrid service", () => {
}); });
it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => { it("rotates to the next Mega-Debrid account when one hits its daily limit (error-based)", async () => {
// User-Anforderung: bei mehreren Mega-Debrid-Accounts (Tageslimit pro Premium-
// Account) MUSS die Rotation feuern, sobald ein Account den Limit-FEHLER liefert
// — der naechste Account wird probiert. (Fehler-basiert, NICHT timeout-basiert.)
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@ -1396,14 +1413,17 @@ describe("debrid service", () => {
autoProviderFallback: false autoProviderFallback: false
}; };
// API-Connect schlaegt schnell fehl -> Web-Pfad (megaWeb) pro Account.
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
let webCalls = 0; let webCalls = 0;
const megaWeb = vi.fn(async (_link: string, _signal?: AbortSignal) => { const megaWeb = vi.fn(async (_link: string, _signal?: AbortSignal) => {
webCalls += 1; webCalls += 1;
// Account 1: liefert bei jedem seiner REQUEST_RETRIES-Versuche den Tageslimit-Fehler.
if (webCalls <= 3) { if (webCalls <= 3) {
throw new Error("Mega-Web: daily limit reached (Tageslimit erreicht)"); throw new Error("Mega-Web: daily limit reached (Tageslimit erreicht)");
} }
// Account 2: hat noch Kontingent -> loest den Link auf.
return { return {
fileName: "rotated-to-acc2.rar", fileName: "rotated-to-acc2.rar",
directUrl: "https://mega-web.example/rotated-to-acc2.rar", directUrl: "https://mega-web.example/rotated-to-acc2.rar",
@ -1415,11 +1435,17 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/limit-rotation-test"); const result = await service.unrestrictLink("https://rapidgator.net/file/limit-rotation-test");
// Beweis der Rotation: das Ergebnis stammt vom ZWEITEN Account, nicht vom ersten.
expect(result.directUrl).toBe("https://mega-web.example/rotated-to-acc2.rar"); expect(result.directUrl).toBe("https://mega-web.example/rotated-to-acc2.rar");
// acc1 wurde versucht (und fiel mit Limit-Fehler), dann acc2 erfolgreich.
expect(webCalls).toBeGreaterThanOrEqual(4); expect(webCalls).toBeGreaterThanOrEqual(4);
}, 30000); }, 30000);
it("skips a manually disabled Mega-Debrid account and uses the next one", async () => { it("skips a manually disabled Mega-Debrid account and uses the next one", async () => {
// User-Feature: einen Account temporaer deaktivieren (statt loeschen) -> die
// Rotation ueberspringt ihn und nutzt die anderen. Beweist den ID-Seam: die ID in
// megaDebridDisabledAccountIds MUSS exakt der ID entsprechen, die die Rotation via
// getMegaDebridAccountId(login) liest (sonst greift das Deaktivieren nicht).
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@ -1428,7 +1454,7 @@ describe("debrid service", () => {
megaLogin: "user1", megaLogin: "user1",
megaPassword: "pass1", megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2", megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridDisabledAccountIds: [getMegaDebridAccountId("user1")], megaDebridDisabledAccountIds: [getMegaDebridAccountId("user1")], // acc1 deaktiviert
megaDebridPreferApi: false, megaDebridPreferApi: false,
providerOrder: [] as const, providerOrder: [] as const,
providerPrimary: "megadebrid" as const, providerPrimary: "megadebrid" as const,
@ -1449,12 +1475,18 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/disabled-acc-test"); const result = await service.unrestrictLink("https://rapidgator.net/file/disabled-acc-test");
// Der deaktivierte acc1 wird uebersprungen -> acc2 loest den Link auf.
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
expect(result.directUrl).toBe("https://mega-web.example/from-acc2.rar"); expect(result.directUrl).toBe("https://mega-web.example/from-acc2.rar");
// acc1 wurde gar nicht erst versucht -> megaWeb nur 1x (fuer acc2) aufgerufen.
expect(megaWeb).toHaveBeenCalledTimes(1); expect(megaWeb).toHaveBeenCalledTimes(1);
}, 20000); }, 20000);
it("fails fast on Mega-Debrid hoster quota ('Kein Server') and rotates to the next account", async () => { it("fails fast on Mega-Debrid hoster quota ('Kein Server') and rotates to the next account", async () => {
// User-Report: Account 1 am Tageslimit liefert "Kein Server für diesen Hoster
// verfügbar". Frueher lief das durch die volle Retry-Maschine (re-Login + 3x) und
// fraß das geteilte Rotations-Budget -> der funktionierende Account 2 lief in den
// Timeout (aborted:debrid -> fatal). Jetzt: schnell scheitern (1 Versuch) + rotieren.
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@ -1486,10 +1518,15 @@ describe("debrid service", () => {
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
expect(result.directUrl).toBe("https://mega-web.example/acc2.rar"); expect(result.directUrl).toBe("https://mega-web.example/acc2.rar");
// Fail-fast: acc1 darf NICHT 3x (REQUEST_RETRIES) probiert werden -> genau 1x acc1, dann acc2.
expect(calls).toBe(2); expect(calls).toBe(2);
}, 20000); }, 20000);
it("passes each account's OWN credentials to the Mega web unrestrict during rotation", async () => { it("passes each account's OWN credentials to the Mega web unrestrict during rotation", async () => {
// Echter Root-Cause (Support-Bundle): der Web-Pfad nutzte fuer JEDEN rotierten
// Account die Creds des ersten/Legacy-Accounts → "Account 2" lief in Wahrheit mit
// Account-1-Login → der zweite (funktionierende) Account wurde nie verwendet.
// Jetzt muss jeder Account-Versuch SEINE eigenen Creds an den Web-Unrestrict reichen.
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@ -1511,40 +1548,55 @@ describe("debrid service", () => {
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => { const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
accountsSeen.push(account?.login); accountsSeen.push(account?.login);
if (account?.login === "user1") { if (account?.login === "user1") {
// Account 1 am Tageslimit.
throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal."); throw new Error("Mega-Web: Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.");
} }
// Account 2 (eigene Creds) loest auf.
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 }; return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
}); });
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/per-account-creds"); const result = await service.unrestrictLink("https://rapidgator.net/file/per-account-creds");
// Jeder Account wurde mit SEINEM eigenen Login angesprochen (nicht 2x user1).
expect(accountsSeen).toContain("user1"); expect(accountsSeen).toContain("user1");
expect(accountsSeen).toContain("user2"); expect(accountsSeen).toContain("user2");
// Und der funktionierende Account 2 loest auf.
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
expect(result.directUrl).toBe("https://mega-web.example/ok.rar"); expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
}, 20000); }, 20000);
it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => { it("escalates a Mega-Debrid account to 'until restart' after the empty-response streak threshold", () => {
// User-Entscheidung: ein tageslimitierter Account soll NICHT alle 20s neu getestet
// werden, sondern bis Programm-Neustart geparkt. Da Tageslimit und transienter
// Leer-Blip auf Message-Ebene identisch sind ("Antwort leer", nie "Kein Server" in
// echten Logs), zaehlt eine Streak: erst ab der Schwelle wird geparkt.
const key = `${getMegaDebridAccountId("user1")}:web`; const key = `${getMegaDebridAccountId("user1")}:web`;
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3); expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1); // 1. Blip -> NICHT parken
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2); // 2. -> NICHT parken
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3); // 3. -> Schwelle erreicht -> parken
// Ein Erfolg/anderer Fehlertyp setzt die Streak zurueck (Account wieder frisch).
clearMegaDebridEmptyResponseStreak(key); clearMegaDebridEmptyResponseStreak(key);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1); expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
}); });
it("keeps an 'until restart' park active forever (never expires until process restart)", () => { it("keeps an 'until restart' park active forever (never expires until process restart)", () => {
// Anders als ein zeitbasierter Cooldown darf die Bis-Neustart-Sperre NIE ablaufen
// (nur ein Neustart loescht die In-Memory-Map). Sonst wuerde der limitierte Account
// doch wieder getestet werden.
const key = `${getMegaDebridAccountId("user1")}:api`; const key = `${getMegaDebridAccountId("user1")}:api`;
primeMegaDebridUntilRestartForTests(key); primeMegaDebridUntilRestartForTests(key);
const now = getMegaDebridAccountCooldownState(key); const now = getMegaDebridAccountCooldownState(key);
expect(now?.untilRestart).toBe(true); expect(now?.untilRestart).toBe(true);
// Selbst 100 Tage in der Zukunft ist die Sperre noch aktiv.
const farFuture = Date.now() + 100 * 24 * 60 * 60 * 1000; const farFuture = Date.now() + 100 * 24 * 60 * 60 * 1000;
expect(getMegaDebridAccountCooldownState(key, farFuture)?.untilRestart).toBe(true); expect(getMegaDebridAccountCooldownState(key, farFuture)?.untilRestart).toBe(true);
}); });
it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => { it("skips a Mega-Debrid account parked until restart and rotates to the next, without re-testing it", async () => {
// Beweis der Skip-Logik: ein bis Neustart geparkter Account wird NICHT mehr per
// Netzwerk getestet (kein megaWeb-Call fuer ihn), die Rotation nutzt den naechsten.
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@ -1562,6 +1614,8 @@ describe("debrid service", () => {
}; };
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
// user1 ist bereits bis Neustart geparkt (Tageslimit). Beide Mode-Keys parken, damit
// der Test unabhaengig vom intern gewaehlten mode ("api"/"web") ist.
const user1 = getMegaDebridAccountId("user1"); const user1 = getMegaDebridAccountId("user1");
primeMegaDebridUntilRestartForTests(`${user1}:api`); primeMegaDebridUntilRestartForTests(`${user1}:api`);
primeMegaDebridUntilRestartForTests(`${user1}:web`); primeMegaDebridUntilRestartForTests(`${user1}:web`);
@ -1575,12 +1629,17 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const result = await service.unrestrictLink("https://rapidgator.net/file/parked-skip-test"); const result = await service.unrestrictLink("https://rapidgator.net/file/parked-skip-test");
// user1 wurde NICHT angefasst (geparkt), nur user2 wurde getestet und loest auf.
expect(loginsSeen).not.toContain("user1"); expect(loginsSeen).not.toContain("user1");
expect(loginsSeen).toContain("user2"); expect(loginsSeen).toContain("user2");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2")); expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000); }, 20000);
it("fails terminally (no retry timer) when ALL Mega-Debrid accounts are parked until restart", async () => { it("fails terminally (no retry timer) when ALL Mega-Debrid accounts are parked until restart", async () => {
// Sind alle Accounts am Tageslimit (bis Neustart gesperrt), gibt es keinen
// sinnvollen endlichen Retry-Zeitpunkt: die Rotation muss klar und endgueltig
// scheitern statt einen absurden (MAX_SAFE_INTEGER) Retry-Timer zu werfen — und
// KEINEN Account erneut per Netzwerk pollen.
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@ -1608,10 +1667,16 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await expect(service.unrestrictLink("https://rapidgator.net/file/all-parked-test")).rejects.toThrow(/bis Neustart gesperrt/i); await expect(service.unrestrictLink("https://rapidgator.net/file/all-parked-test")).rejects.toThrow(/bis Neustart gesperrt/i);
// Kein Account wurde erneut getestet.
expect(megaWeb).not.toHaveBeenCalled(); expect(megaWeb).not.toHaveBeenCalled();
}, 20000); }, 20000);
it("drives a real empty response through the full rotation into an until-restart park (wiring test)", async () => { it("drives a real empty response through the full rotation into an until-restart park (wiring test)", async () => {
// Lesson "Wiring-Lock vs. Mechanism-Test": die Helfer-Unit-Tests beweisen nur, dass der
// Streak-Zaehler funktioniert — NICHT, dass der Produktionspfad ihn fuettert. Dieser Test
// faehrt eine ECHTE leere Antwort durch unrestrictWithAccounts -> classifyAccountFailure
// (limitSignal) -> catch -> recordStreak -> Park. Kaeme limitSignal nicht an, wuerde der
// catch-else die Streak loeschen und KEIN until-restart setzen -> Assertion faellt.
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
token: "", token: "",
@ -1619,7 +1684,7 @@ describe("debrid service", () => {
allDebridToken: "", allDebridToken: "",
megaLogin: "user1", megaLogin: "user1",
megaPassword: "pass1", megaPassword: "pass1",
megaCredentials: "user1:pass1", megaCredentials: "user1:pass1", // genau EIN Account
megaDebridPreferApi: false, megaDebridPreferApi: false,
providerOrder: [] as const, providerOrder: [] as const,
providerPrimary: "megadebrid" as const, providerPrimary: "megadebrid" as const,
@ -1629,90 +1694,24 @@ describe("debrid service", () => {
}; };
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch; globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
// Provider "megadebrid" + preferApi:false -> resolveMegaDebridProvider -> "megadebrid-web"
// -> mode "web" -> Key-Suffix ":web" (das ist genau der Web-Pfad aus dem User-Screenshot).
const key = `${getMegaDebridAccountId("user1")}:web`; const key = `${getMegaDebridAccountId("user1")}:web`;
// Streak schon EINS unter der Schwelle (2 vorherige leere Antworten) — noch NICHT geparkt.
recordMegaDebridEmptyResponseStreak(key); recordMegaDebridEmptyResponseStreak(key);
recordMegaDebridEmptyResponseStreak(key); recordMegaDebridEmptyResponseStreak(key);
expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false); expect(getMegaDebridAccountCooldownState(key)?.untilRestart ?? false).toBe(false);
// megaWeb liefert null -> der echte Web-Pfad macht daraus "Mega-Web Antwort leer".
const megaWeb = vi.fn(async () => null); const megaWeb = vi.fn(async () => null);
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb }); const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await service.unrestrictLink("https://rapidgator.net/file/wiring").catch(() => undefined); await service.unrestrictLink("https://rapidgator.net/file/wiring").catch(() => undefined);
expect(megaWeb).toHaveBeenCalled(); // Der echte Fehlversuch tippte die Streak auf die Schwelle -> Park bis Neustart.
expect(megaWeb).toHaveBeenCalled(); // Account wurde wirklich getestet (nicht vorab geparkt)
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true); expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
}, 20000); }, 20000);
it("cools down a Mega-Web account that aborts (timeout) so the NEXT unrestrict rotates to the next account", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "0"; // treat the instant mock abort as a real timeout
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const loginsSeen: Array<string | undefined> = [];
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
loginsSeen.push(account?.login);
if (account?.login === "user1") {
throw new Error("aborted:debrid");
}
return { fileName: "acc2.rar", directUrl: "https://mega-web.example/acc2.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
// Call 1: account 1 aborts -> rotation stops this pass, account 2 NOT tried, but account 1 is cooled down.
await expect(service.unrestrictLink("https://rapidgator.net/file/abort-call-1")).rejects.toThrow();
expect(loginsSeen).toContain("user1");
expect(loginsSeen).not.toContain("user2");
expect(getMegaDebridAccountCooldownState(user1Key)).not.toBeNull();
// Call 2 (the retry, same state): account 1 is on cooldown -> skipped -> account 2 served.
loginsSeen.length = 0;
const result = await service.unrestrictLink("https://rapidgator.net/file/abort-call-2");
expect(loginsSeen).not.toContain("user1");
expect(loginsSeen).toContain("user2");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000);
it("does NOT cool down a Mega-Web account on a quick abort (below the min-run threshold = user cancel)", async () => {
process.env.RD_MEGA_ABORT_MIN_RUN_MS = "99999"; // any realistic elapsed stays below -> no cooldown
const settings = {
...defaultSettings(),
token: "",
bestToken: "",
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
providerSecondary: "none" as const,
providerTertiary: "none" as const,
autoProviderFallback: false
};
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn(async () => { throw new Error("aborted:debrid"); });
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
const user1Key = `${getMegaDebridAccountId("user1")}:web`;
await expect(service.unrestrictLink("https://rapidgator.net/file/quick-cancel")).rejects.toThrow();
expect(getMegaDebridAccountCooldownState(user1Key)).toBeNull();
}, 20000);
it("respects provider selection and does not append hidden providers", async () => { it("respects provider selection and does not append hidden providers", async () => {
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),
@ -2083,9 +2082,11 @@ describe("normalizeResolvedFilename", () => {
}); });
it("strips HTML tags and collapses whitespace", () => { it("strips HTML tags and collapses whitespace", () => {
// Tags are replaced by spaces, then multiple spaces collapsed
const result = normalizeResolvedFilename("<b>Show.S01E01</b>.part01.rar"); const result = normalizeResolvedFilename("<b>Show.S01E01</b>.part01.rar");
expect(result).toBe("Show.S01E01 .part01.rar"); expect(result).toBe("Show.S01E01 .part01.rar");
// Entity decoding happens before tag removal, so &lt;...&gt; becomes <...> then gets stripped
const entityTagResult = normalizeResolvedFilename("File&lt;Tag&gt;.part1.rar"); const entityTagResult = normalizeResolvedFilename("File&lt;Tag&gt;.part1.rar");
expect(entityTagResult).toBe("File .part1.rar"); expect(entityTagResult).toBe("File .part1.rar");
}); });
@ -2108,6 +2109,7 @@ describe("normalizeResolvedFilename", () => {
}); });
it("handles combined transforms", () => { it("handles combined transforms", () => {
// "Download file" prefix stripped, &amp; decoded to &, "- Rapidgator" suffix stripped
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator")) expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator"))
.toBe("Show.S01E01.part01.rar"); .toBe("Show.S01E01.part01.rar");
}); });

View File

@ -78,6 +78,7 @@ async function waitForReady(url: string): Promise<void> {
return; return;
} }
} catch { } catch {
// retry
} }
await new Promise((resolve) => setTimeout(resolve, 50)); await new Promise((resolve) => setTimeout(resolve, 50));
} }
@ -313,6 +314,7 @@ afterEach(() => {
try { try {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} catch { } catch {
// ignore cleanup failures
} }
} }
}); });

View File

@ -24,6 +24,7 @@ afterEach(() => {
try { try {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} catch { } catch {
// ignore
} }
} }
createdTmpDirs.length = 0; createdTmpDirs.length = 0;
@ -49,6 +50,8 @@ describe("desktop-rename-log", () => {
}); });
it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => { it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => {
// Genau die User-Anforderung: Ordner zur Laufzeit geloescht -> beim naechsten
// Rename automatisch wieder da, selbst wenn das Programm offen ist.
const desktop = tmpDesktop(); const desktop = tmpDesktop();
initDesktopRenameLog(desktop); initDesktopRenameLog(desktop);
const logPath = getDesktopRenameLogPath() as string; const logPath = getDesktopRenameLogPath() as string;
@ -57,6 +60,7 @@ describe("desktop-rename-log", () => {
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true }); fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
expect(fs.existsSync(logPath)).toBe(false); expect(fs.existsSync(logPath)).toBe(false);
// Naechster Vorgang muss Ordner UND Datei (mit Header) selbstheilend neu anlegen.
logDesktopRename("INFO", "ZeileB"); logDesktopRename("INFO", "ZeileB");
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true); expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
expect(fs.existsSync(logPath)).toBe(true); expect(fs.existsSync(logPath)).toBe(true);
@ -76,7 +80,7 @@ describe("desktop-rename-log", () => {
const dir = tmpDesktop(); const dir = tmpDesktop();
const source = path.join(dir, "scn-xyz.part1.rar"); const source = path.join(dir, "scn-xyz.part1.rar");
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar"); const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
fs.writeFileSync(target, "data"); fs.writeFileSync(target, "data"); // Post-Rename-Zustand: Ziel da, Quelle weg.
const v = verifyRename(source, target); const v = verifyRename(source, target);
expect(v.ok).toBe(true); expect(v.ok).toBe(true);
@ -98,6 +102,7 @@ describe("desktop-rename-log", () => {
}); });
it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => { it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => {
// EXDEV-Copy gelang, aber rm(source) schlug fehl -> Ziel da, Quelle auch noch.
const dir = tmpDesktop(); const dir = tmpDesktop();
const source = path.join(dir, "src.rar"); const source = path.join(dir, "src.rar");
const target = path.join(dir, "dst.rar"); const target = path.join(dir, "dst.rar");

View File

@ -26,6 +26,8 @@ describe("download-completion", () => {
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true }); const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false }); const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
// H3 regression: a stream-end download (no Content-Length, no provider size)
// that yielded 0 bytes is a FAILED download, not a valid completion.
it("rejects a 0-byte stream-end download (H3)", () => { it("rejects a 0-byte stream-end download (H3)", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd }); const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
@ -55,6 +57,7 @@ describe("download-completion", () => {
it("accepts provider-metadata download and flags size mismatch", () => { it("accepts provider-metadata download and flags size mismatch", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 }); const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
// provider-metadata: shorter than expected -> underflow rejected
expect(result.ok).toBe(false); expect(result.ok).toBe(false);
}); });
}); });

View File

@ -2453,6 +2453,8 @@ describe("download manager", () => {
await waitFor(() => !manager.getSnapshot().session.running, 25000); await waitFor(() => !manager.getSnapshot().session.running, 25000);
const item = Object.values(manager.getSnapshot().session.items)[0]; const item = Object.values(manager.getSnapshot().session.items)[0];
// Content-Length matches actual bytes sent, so completion validation passes
// (HTTP-level truth takes priority over provider-metadata filesize).
expect(item?.status).toBe("completed"); expect(item?.status).toBe("completed");
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(actual.length); expect(item?.downloadedBytes).toBeGreaterThanOrEqual(actual.length);
} finally { } finally {
@ -3290,6 +3292,7 @@ describe("download manager", () => {
expect(item?.status).toBe("completed"); expect(item?.status).toBe("completed");
expect(item?.targetPath).toBe(existingTargetPath); expect(item?.targetPath).toBe(existingTargetPath);
expect(sawResumeRange).toBe(true); expect(sawResumeRange).toBe(true);
// Allow ALLOCATION_UNIT_SIZE (4096) tolerance for write-flush timing on Windows
const fileSize = fs.statSync(existingTargetPath).size; const fileSize = fs.statSync(existingTargetPath).size;
expect(fileSize).toBeGreaterThanOrEqual(binary.length - 4096); expect(fileSize).toBeGreaterThanOrEqual(binary.length - 4096);
expect(fileSize).toBeLessThanOrEqual(binary.length); expect(fileSize).toBeLessThanOrEqual(binary.length);
@ -3575,6 +3578,9 @@ describe("download manager", () => {
const item = manager.getSnapshot().session.items[itemId]; const item = manager.getSnapshot().session.items[itemId];
expect(item?.status).toBe("completed"); expect(item?.status).toBe("completed");
expect(item?.provider).toBe("debridlink"); expect(item?.provider).toBe("debridlink");
// downloadedBytes may reflect stat.size which can be within ALLOCATION_UNIT_SIZE
// tolerance of the expected total, or the original partial size if the settle
// recovery finalized the item from disk before retry completed.
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(partialSize); expect(item?.downloadedBytes).toBeGreaterThanOrEqual(partialSize);
expect(item?.downloadedBytes).toBeLessThanOrEqual(binary.length); expect(item?.downloadedBytes).toBeLessThanOrEqual(binary.length);
expect(unrestrictCalls).toBeGreaterThanOrEqual(1); expect(unrestrictCalls).toBeGreaterThanOrEqual(1);
@ -4395,6 +4401,7 @@ describe("download manager", () => {
for (const [index, archiveName] of archiveNames.entries()) { for (const [index, archiveName] of archiveNames.entries()) {
const targetPath = path.join(outputDir, archiveName); const targetPath = path.join(outputDir, archiveName);
// Write garbage content (no valid archive signature) — simulates corrupt download
fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA)); fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA));
session.items[itemIds[index]!] = { session.items[itemIds[index]!] = {
id: itemIds[index]!, id: itemIds[index]!,
@ -4443,6 +4450,7 @@ describe("download manager", () => {
"hybrid" "hybrid"
); );
// Invalid archive signature = genuine corruption → force re-download
expect(changed).toBe(2); expect(changed).toBe(2);
for (const itemId of itemIds) { for (const itemId of itemIds) {
const item = session.items[itemId]!; const item = session.items[itemId]!;
@ -4486,6 +4494,7 @@ describe("download manager", () => {
for (const [index, archiveName] of archiveNames.entries()) { for (const [index, archiveName] of archiveNames.entries()) {
const targetPath = path.join(outputDir, archiveName); const targetPath = path.join(outputDir, archiveName);
// Write file with valid RAR5 signature — simulates wrong password, not corruption
const content = Buffer.alloc(archiveSize, 0); const content = Buffer.alloc(archiveSize, 0);
Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content); Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content);
fs.writeFileSync(targetPath, content); fs.writeFileSync(targetPath, content);
@ -4536,6 +4545,7 @@ describe("download manager", () => {
"hybrid" "hybrid"
); );
// Valid RAR signature = file is structurally intact → wrong password, don't re-download
expect(changed).toBe(0); expect(changed).toBe(0);
for (const itemId of itemIds) { for (const itemId of itemIds) {
const item = session.items[itemId]!; const item = session.items[itemId]!;
@ -6119,6 +6129,9 @@ describe("download manager", () => {
const item = Object.values(manager.getSnapshot().session.items)[0]; const item = Object.values(manager.getSnapshot().session.items)[0];
expect(item?.status).toBe("completed"); expect(item?.status).toBe("completed");
// On Windows with pre-allocation, the tiny error-page detection is masked
// (stat reports pre-allocated size, not actual written bytes), so the first
// download may be accepted without a retry.
if (process.platform !== "win32") { if (process.platform !== "win32") {
expect(directCalls).toBeGreaterThan(1); expect(directCalls).toBeGreaterThan(1);
} }
@ -6132,6 +6145,7 @@ describe("download manager", () => {
it("accepts small .sfv metadata files without rejecting them as suspicious", async () => { it("accepts small .sfv metadata files without rejecting them as suspicious", 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);
// SFV content is just CRC32 checksums — legitimately tiny
const sfvContent = Buffer.from("archive.part1.rar 1A2B3C4D\narchive.part2.rar 5E6F7A8B\n", "utf8"); const sfvContent = Buffer.from("archive.part1.rar 1A2B3C4D\narchive.part2.rar 5E6F7A8B\n", "utf8");
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
@ -9345,6 +9359,12 @@ describe("download manager", () => {
}, 20000); }, 20000);
it("hybrid collect defers fresh files instead of moving them unrenamed; final pass collects them", async () => { it("hybrid collect defers fresh files instead of moving them unrenamed; final pass collects them", async () => {
// Regression: User-Report — bei Hybrid-Extraktion blieben 1-2 Dateien pro
// Staffel unbenannt (mit Original-Scene-Namen in der Library). Ursache: eine
// frisch extrahierte Datei wird vom Auto-Rename absichtlich deferred (noch nicht
// stabil), aber der Collect moved sie vorher mit Original-Namen. Fix: der
// Hybrid-Collect (deferFreshFiles=true) ueberspringt frische Dateien; der finale
// Deferred-Pass (deferFreshFiles=false) sammelt sie nach Stabilisierung ein.
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);
@ -9355,7 +9375,7 @@ describe("download manager", () => {
const mkvName = "grp-freshshow.s01e07-720p.mkv"; const mkvName = "grp-freshshow.s01e07-720p.mkv";
const mkvPath = path.join(extractDir, mkvName); const mkvPath = path.join(extractDir, mkvName);
fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7)); fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7)); // mtime = jetzt → "frisch"
const session = emptySession(); const session = emptySession();
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
@ -9390,14 +9410,18 @@ describe("download manager", () => {
session, session,
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
// In Tests ist fileStabilizeMinAgeMs=0 (Frische-Erkennung aus) — fuer diesen
// Test aktivieren, damit die gerade erstellte Datei als "frisch" gilt.
(manager as any).fileStabilizeMinAgeMs = 30_000; (manager as any).fileStabilizeMinAgeMs = 30_000;
const libPath = path.join(mkvLibraryDir, mkvName); const libPath = path.join(mkvLibraryDir, mkvName);
// Hybrid-Collect (deferFreshFiles=true): frische Datei darf NICHT gemoved werden.
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, true); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, true);
expect(fs.existsSync(libPath)).toBe(false); expect(fs.existsSync(libPath)).toBe(false);
expect(fs.existsSync(mkvPath)).toBe(true); expect(fs.existsSync(mkvPath)).toBe(true);
// Finaler Deferred-Pass (deferFreshFiles=false): sammelt die Datei trotzdem ein.
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
expect(fs.existsSync(libPath)).toBe(true); expect(fs.existsSync(libPath)).toBe(true);
expect(fs.existsSync(mkvPath)).toBe(false); expect(fs.existsSync(mkvPath)).toBe(false);
@ -9406,12 +9430,19 @@ describe("download manager", () => {
}, 20000); }, 20000);
it("collect CLEANS a raw scene file that auto-rename never processed (the 17-file library bug)", async () => { it("collect CLEANS a raw scene file that auto-rename never processed (the 17-file library bug)", async () => {
// Echter Bug aus rename-session_2026-06-02: Auto-Rename verpasste einzelne Dateien
// (verpasster Scan / lag ausserhalb der extractDir), der Collect schob sie dann ROH in
// die Library ("tvarchiv...s07e12-720.mkv"). Fix: Collect leitet den sauberen Namen
// selbst ab (gleiche Logik wie Auto-Rename) — die Library-Datei heisst garantiert sauber,
// auch wenn KEIN Auto-Rename-Pass die Datei je angefasst hat (hier: Collect direkt
// aufgerufen, ohne vorherigen Auto-Rename-Lauf).
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);
const packageName = "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV"; const packageName = "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV";
const outputDir = path.join(root, "downloads", packageName); const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
// Per-Episoden-Ordner (vom Release-Group sauber benannt) mit ROHER MKV darin.
const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV"; const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV";
const epDir = path.join(extractDir, episodeFolder); const epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true }); fs.mkdirSync(epDir, { recursive: true });
@ -9443,7 +9474,7 @@ describe("download manager", () => {
outputDir: path.join(root, "downloads"), outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"), extractDir: path.join(root, "extract"),
autoExtract: true, autoExtract: true,
autoRename4sf4sj: true, autoRename4sf4sj: true, // Umbenennen AN — wie in der echten User-Config
collectMkvToLibrary: true, collectMkvToLibrary: true,
mkvLibraryDir, mkvLibraryDir,
enableIntegrityCheck: false, enableIntegrityCheck: false,
@ -9453,10 +9484,13 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
// Collect DIREKT aufrufen, OHNE vorherigen Auto-Rename-Lauf — simuliert genau die
// verpasste Datei. deferFreshFiles=false (finaler Pass).
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
const cleanLibPath = path.join(mkvLibraryDir, `${episodeFolder}.mkv`); const cleanLibPath = path.join(mkvLibraryDir, `${episodeFolder}.mkv`);
const rawLibPath = path.join(mkvLibraryDir, rawName); const rawLibPath = path.join(mkvLibraryDir, rawName);
// Library-Datei heisst SAUBER, nicht roh; Quelle ist weg.
expect(fs.existsSync(cleanLibPath)).toBe(true); expect(fs.existsSync(cleanLibPath)).toBe(true);
expect(fs.existsSync(rawLibPath)).toBe(false); expect(fs.existsSync(rawLibPath)).toBe(false);
expect(fs.existsSync(rawPath)).toBe(false); expect(fs.existsSync(rawPath)).toBe(false);
@ -9465,12 +9499,16 @@ describe("download manager", () => {
}, 20000); }, 20000);
it("collect cleans a raw file sitting OUTSIDE extractDir (Downloader-Unfertig case) AND its .srt follows the rename", async () => { it("collect cleans a raw file sitting OUTSIDE extractDir (Downloader-Unfertig case) AND its .srt follows the rename", async () => {
// Die 5 Fritzie-S04-Dateien lagen in "Downloader Unfertig" (= outputDir-Seite, NICHT
// extractDir) — Auto-Rename scannt nur extractDir, sah sie also nie. Collect muss sie
// trotzdem aus dem Staffel-Ordner heraus sauber benennen, und der Untertitel muss
// mit dem Video mitwandern (auf den GEAENDERTEN Namen).
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);
const packageName = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF"; const packageName = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
const outputDir = path.join(root, "downloads", packageName); const outputDir = path.join(root, "downloads", packageName); // = "Unfertig"-Aequivalent
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName); // bleibt leer/fehlt
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
const rawName = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv"; const rawName = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
@ -9517,18 +9555,27 @@ describe("download manager", () => {
const cleanBase = "Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF"; const cleanBase = "Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF";
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.mkv`))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.mkv`))).toBe(true);
expect(fs.existsSync(path.join(mkvLibraryDir, rawName))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, rawName))).toBe(false);
// Untertitel folgt dem Video auf den sauberen Namen (Sprach-Suffix .de erhalten).
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.de.srt`))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.de.srt`))).toBe(true);
void manager; void manager;
}, 20000); }, 20000);
it("collect MOVES a numbered episode whose TITLE is a bonus keyword (Revenge S04E19 'Interview')", async () => { it("collect MOVES a numbered episode whose TITLE is a bonus keyword (Revenge S04E19 'Interview')", async () => {
// Echter Bug aus rd-support-bundle 2026-06-04: Revenge.2011.S04E19.Interview blieb roh
// in "Downloader Fertig" haengen — nie in die Library verschoben, KEIN Fehler. Ursache:
// der Episodentitel "Interview" (UND der Episoden-Ordnername) matcht BONUS_FILENAME_RE /
// isInsideBonusDir -> der Collect stufte die Folge als Bonus/Extras ein und skippte sie
// (nur logger.info, im Paket-Log unsichtbar). Eine Folge MIT gueltigem SxxExx-Token ist
// aber eine echte Episode, niemals Bonus. Betrifft Interview/Outtakes/Special/Featurette-
// Titel -> "selten, aber 4-5 Folgen pro grossem Download".
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);
const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC"; const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC";
const outputDir = path.join(root, "downloads", packageName); const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
// Per-Episoden-Ordner UND Datei tragen beide das Bonus-Wort "Interview" — exakt der Fall.
const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC"; const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
const epDir = path.join(extractDir, episodeFolder); const epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true }); fs.mkdirSync(epDir, { recursive: true });
@ -9571,6 +9618,7 @@ describe("download manager", () => {
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
// Die Folge MUSS in der Library liegen (nicht als Bonus verworfen) und die Quelle weg sein.
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
expect(fs.existsSync(path.join(epDir, epName))).toBe(false); expect(fs.existsSync(path.join(epDir, epName))).toBe(false);
@ -9578,6 +9626,9 @@ describe("download manager", () => {
}, 20000); }, 20000);
it("collect STILL skips genuine bonus/extras with NO episode token (Making.Of) — proves the filter isn't disabled", async () => { it("collect STILL skips genuine bonus/extras with NO episode token (Making.Of) — proves the filter isn't disabled", async () => {
// Guard zum Fix oben: eine echte Bonus-Datei OHNE SxxExx-Token (Making.Of) bleibt Bonus
// und darf NICHT in die Library wandern. Sonst haetten wir den Bonus-Filter nur kaputt
// gemacht statt praezisiert.
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);
@ -9585,6 +9636,7 @@ describe("download manager", () => {
const outputDir = path.join(root, "downloads", packageName); const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(extractDir, { recursive: true }); fs.mkdirSync(extractDir, { recursive: true });
// Echte Episode (mit Token) + echtes Extra (ohne Token) im selben Paket.
const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv"; const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv";
const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv"; const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv";
fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1)); fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1));
@ -9626,6 +9678,7 @@ describe("download manager", () => {
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
// Echte Episode wandert in die Library; das Making-Of bleibt liegen (Bonus).
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
expect(fs.existsSync(path.join(mkvLibraryDir, bonusName))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, bonusName))).toBe(false);
expect(fs.existsSync(path.join(extractDir, bonusName))).toBe(true); expect(fs.existsSync(path.join(extractDir, bonusName))).toBe(true);
@ -9634,6 +9687,10 @@ describe("download manager", () => {
}, 20000); }, 20000);
it("collect CLEANS a raw .avi whose folder is a complete episode name WITHOUT a -GROUP suffix (safari S04E08a)", async () => { it("collect CLEANS a raw .avi whose folder is a complete episode name WITHOUT a -GROUP suffix (safari S04E08a)", async () => {
// Echter Bug (rename-session 2026-06-04): alte deutsche Doku ohne Gruppen-Suffix
// (Ordner endet ".XviD", kein "-GROUP"). buildAutoRenameBaseName lieferte null -> die
// Folge landete ROH als "safari-fm-s04e08a.avi" in der Library. Der Part-Buchstabe a/b
// muss erhalten bleiben (Teil 1 vs Teil 2 duerfen nicht kollidieren).
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);
@ -9686,6 +9743,7 @@ describe("download manager", () => {
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
// Beide Folgen sauber benannt in der Library, Part a/b distinkt, KEINE rohen Namen.
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderA}.avi`))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, `${folderA}.avi`))).toBe(true);
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderB}.avi`))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, `${folderB}.avi`))).toBe(true);
expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08a.avi"))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08a.avi"))).toBe(false);
@ -9695,6 +9753,10 @@ describe("download manager", () => {
}, 20000); }, 20000);
it("collect KEEPS the clean SxxExx name and does NOT mangle it via an episode-title folder (Taken S01E01)", async () => { it("collect KEEPS the clean SxxExx name and does NOT mangle it via an episode-title folder (Taken S01E01)", async () => {
// Echter Bug (rename-session 2026-06-05): Auto-Rename hatte die Datei bereits korrekt zu
// "...S01E01...-GTVG.mkv" benannt. Der per-Episode-Ordner traegt nur "E01" + Titel (kein S01).
// Der Collect leitete daraus neu ab und haengte den Token verkrueppelt an
// ("...-GTVG.S01E01"). Erwartung: der saubere S01E01-Name bleibt unangetastet.
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);
@ -9705,6 +9767,7 @@ describe("download manager", () => {
const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv"; const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv";
const epDir = path.join(extractDir, epFolder); const epDir = path.join(extractDir, epFolder);
fs.mkdirSync(epDir, { recursive: true }); fs.mkdirSync(epDir, { recursive: true });
// Datei liegt bereits SAUBER benannt vor (so wie Auto-Rename sie hinterlassen hat).
fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5)); fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5));
const session = emptySession(); const session = emptySession();
@ -9743,9 +9806,11 @@ describe("download manager", () => {
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
// Sauberer S01E01-Name bleibt; KEIN verkrueppelter "...E01.Titel...S01E01"-Name.
expect(fs.existsSync(path.join(mkvLibraryDir, cleanName))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, cleanName))).toBe(true);
const mangled = `${epFolder}.S01E01.mkv`; const mangled = `${epFolder}.S01E01.mkv`;
expect(fs.existsSync(path.join(mkvLibraryDir, mangled))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, mangled))).toBe(false);
// Nichts mit dem Episoden-Titel im Library-Ordner.
const inLib = fs.readdirSync(mkvLibraryDir); const inLib = fs.readdirSync(mkvLibraryDir);
expect(inLib.some((n) => /Hinter\.dem\.Himmel/i.test(n))).toBe(false); expect(inLib.some((n) => /Hinter\.dem\.Himmel/i.test(n))).toBe(false);
@ -9753,18 +9818,30 @@ describe("download manager", () => {
}, 20000); }, 20000);
it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => { it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => {
// Folge-Fund zu 18eada9 (verifiziert via Advisor-Gate): 18eada9 schloss den
// "frische Datei landet unbenannt"-Bug nur fuer den HYBRID-Pfad (deferFreshFiles=true
// + Mehrfach-Pässe). Der finale Deferred-Pass (runDeferredPostExtraction) macht
// Rename (treatFilesAsStable? nein) -> Collect (deferFreshFiles=false). Ist eine
// Datei beim Deferred-Rename noch "frisch" (< fileStabilizeMinAgeMs) — z.B. eine
// gerade per Nested-Extraction (12045) geschriebene Datei — ueberspringt der
// Frische-Gate sie, und der Collect moved sie mit Original-Scene-Namen in die
// Library. Im Deferred-FINAL-Pass laeuft aber KEIN concurrent Extractor mehr
// (Extraktion abgeschlossen/awaited), der Frische-Gate ist dort ein False
// Positive. Fix: der Final-Pass-Rename behandelt alle Dateien als stabil
// (treatFilesAsStable=true) → benennt um, bevor der Collect sie sammelt.
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);
const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake"; const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake";
const outputDir = path.join(root, "downloads", packageName); const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
// Episoden-Ordner liefert den kanonischen Zielnamen (enthaelt SxxExx).
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"); const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
fs.mkdirSync(epFolder, { recursive: true }); fs.mkdirSync(epFolder, { recursive: true });
const sceneName = "awa-testshow02e05hd.mkv"; const sceneName = "awa-testshow02e05hd.mkv";
const scenePath = path.join(epFolder, sceneName); const scenePath = path.join(epFolder, sceneName);
fs.writeFileSync(scenePath, Buffer.alloc(4096, 5)); fs.writeFileSync(scenePath, Buffer.alloc(4096, 5)); // mtime = jetzt → "frisch"
const session = emptySession(); const session = emptySession();
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
@ -9799,15 +9876,22 @@ describe("download manager", () => {
session, session,
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
// Produktion: fileStabilizeMinAgeMs=2000. Hier 30s, damit die gerade erstellte
// Datei garantiert als "frisch" gilt — wie eine eben extrahierte Datei, die der
// Deferred-Pass sofort danach verarbeitet.
(manager as any).fileStabilizeMinAgeMs = 30_000; (manager as any).fileStabilizeMinAgeMs = 30_000;
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`); const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`);
const sceneLibPath = path.join(mkvLibraryDir, sceneName); const sceneLibPath = path.join(mkvLibraryDir, sceneName);
// Deferred-FINAL-Pass-Sequenz, exakt wie runDeferredPostExtraction:
// 1) Rename — treatFilesAsStable=true (Extraktion abgeschlossen, kein Frische-Skip)
// 2) Collect — deferFreshFiles=false
await (manager as any).autoRenameExtractedVideoFiles(extractDir, session.packages[packageId], undefined, true); await (manager as any).autoRenameExtractedVideoFiles(extractDir, session.packages[packageId], undefined, true);
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
// Die Datei landet UMBENANNT in der Library — nicht mit dem Scene-Namen.
expect(fs.existsSync(renamedLibPath)).toBe(true); expect(fs.existsSync(renamedLibPath)).toBe(true);
expect(fs.existsSync(sceneLibPath)).toBe(false); expect(fs.existsSync(sceneLibPath)).toBe(false);
@ -9815,6 +9899,11 @@ describe("download manager", () => {
}, 20000); }, 20000);
it("deferred post-extraction wiring renames fresh files end-to-end (treatFilesAsStable reaches the rename)", async () => { it("deferred post-extraction wiring renames fresh files end-to-end (treatFilesAsStable reaches the rename)", async () => {
// Wiring-Lock zum vorherigen Test: stellt sicher, dass runDeferredPostExtraction
// den Rename TATSAECHLICH mit treatFilesAsStable=true aufruft. Wuerde jemand das
// `true` an der Call-Site (autoRenameExtractedVideoFiles(..., true)) entfernen,
// faellt dieser Test (frische Datei landet wieder unbenannt) — der reine
// Mechanism-Test wuerde das NICHT bemerken (er ruft den Rename selbst mit true).
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);
@ -9826,7 +9915,7 @@ describe("download manager", () => {
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
const sceneName = "awa-testshow02e05hd.mkv"; const sceneName = "awa-testshow02e05hd.mkv";
fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5)); fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5)); // mtime = jetzt → "frisch"
const session = emptySession(); const session = emptySession();
const packageId = `${packageName}-pkg`; const packageId = `${packageName}-pkg`;
@ -9864,6 +9953,9 @@ describe("download manager", () => {
(manager as any).fileStabilizeMinAgeMs = 30_000; (manager as any).fileStabilizeMinAgeMs = 30_000;
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
// Treibt den ECHTEN Produktionspfad: runDeferredPostExtraction → Rename
// (Call-Site mit treatFilesAsStable=true) → Collect (deferFreshFiles=false).
// success=1 (Collect-Gate), alreadyMarkedExtracted=true (Rename-Gate), failed=0.
await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1); await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true);
@ -9881,6 +9973,7 @@ describe("download manager", () => {
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
// Direct .mkv download (no archive) — wie es Mega-Debrid bei mega.nz liefert.
const directMkvName = "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv"; const directMkvName = "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv";
const directMkvPath = path.join(outputDir, directMkvName); const directMkvPath = path.join(outputDir, directMkvName);
fs.writeFileSync(directMkvPath, Buffer.alloc(2048, 1)); fs.writeFileSync(directMkvPath, Buffer.alloc(2048, 1));
@ -9946,13 +10039,20 @@ describe("download manager", () => {
await waitFor(() => fs.existsSync(libraryPath), 12000); await waitFor(() => fs.existsSync(libraryPath), 12000);
expect(fs.existsSync(libraryPath)).toBe(true); expect(fs.existsSync(libraryPath)).toBe(true);
// Filename darf NICHT umbenannt werden (Mega-Files sind oft schon korrekt benannt).
expect(fs.readFileSync(libraryPath).length).toBe(directMkvSize); expect(fs.readFileSync(libraryPath).length).toBe(directMkvSize);
// Quelle ist weg (verschoben).
expect(fs.existsSync(directMkvPath)).toBe(false); expect(fs.existsSync(directMkvPath)).toBe(false);
void manager; void manager;
}, 20000); }, 20000);
it("does NOT delete pending RAR archive sets in outputDir when collecting MKVs from extractDir", async () => { it("does NOT delete pending RAR archive sets in outputDir when collecting MKVs from extractDir", async () => {
// Regression v1.7.156: bei einem Multi-Archive-Set-Paket (z.B. S01 + S02 RARs
// im selben outputDir) wurde nach dem Extrahieren von S01 die MKV-Collection
// getriggert. Diese loeschte als "Restdateien" ALLE Nicht-Video-Files im
// outputDir — also auch die noch nicht entpackten S02-RAR-Parts. Folge:
// S02 ging verloren ("missing_file" beim spaeteren Extract).
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);
@ -9962,6 +10062,7 @@ describe("download manager", () => {
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true }); fs.mkdirSync(extractDir, { recursive: true });
// outputDir: S02-RAR-Set noch NICHT entpackt (pending). Muss erhalten bleiben.
const s02Parts = [ const s02Parts = [
"Ugly.Americans.S02.COMPLETE.German.part1.rar", "Ugly.Americans.S02.COMPLETE.German.part1.rar",
"Ugly.Americans.S02.COMPLETE.German.part2.rar", "Ugly.Americans.S02.COMPLETE.German.part2.rar",
@ -9970,8 +10071,10 @@ describe("download manager", () => {
for (const part of s02Parts) { for (const part of s02Parts) {
fs.writeFileSync(path.join(outputDir, part), Buffer.alloc(1024, 7)); fs.writeFileSync(path.join(outputDir, part), Buffer.alloc(1024, 7));
} }
// Auch eine harmlose Nicht-Video-Restdatei im outputDir (z.B. .nfo).
fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo")); fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo"));
// extractDir: S01 wurde bereits entpackt → MKVs liegen hier.
const s01Mkvs = [ const s01Mkvs = [
"Ugly.Americans.S01E01.German.mkv", "Ugly.Americans.S01E01.German.mkv",
"Ugly.Americans.S01E02.German.mkv" "Ugly.Americans.S01E02.German.mkv"
@ -10015,11 +10118,14 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
// Direkt aufrufen (umgeht die volle Download/Extract-Pipeline).
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]); await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]);
// S01-MKVs sind in der Library angekommen.
for (const mkv of s01Mkvs) { for (const mkv of s01Mkvs) {
expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true); expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true);
} }
// KRITISCH: S02-RAR-Parts im outputDir wurden NICHT geloescht.
for (const part of s02Parts) { for (const part of s02Parts) {
expect(fs.existsSync(path.join(outputDir, part))).toBe(true); expect(fs.existsSync(path.join(outputDir, part))).toBe(true);
} }
@ -10036,6 +10142,7 @@ describe("download manager", () => {
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
// Build archive containing one real episode + several bonus files in an Extras subdirectory
const zip = new AdmZip(); const zip = new AdmZip();
zip.addFile("Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv", Buffer.from("episode-data")); zip.addFile("Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv", Buffer.from("episode-data"));
zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/Schrotflinte.mkv", Buffer.from("bonus-1")); zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/Schrotflinte.mkv", Buffer.from("bonus-1"));
@ -10102,18 +10209,22 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state")) createStoragePaths(path.join(root, "state"))
); );
// Wait until the real episode landed in the library
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv"); const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv");
await waitFor(() => fs.existsSync(flattenedEpisode), 12000); await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
// Bonus files MUST NOT be in the flat library
expect(fs.existsSync(path.join(mkvLibraryDir, "Schrotflinte.mkv"))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, "Schrotflinte.mkv"))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, "Die.Autoexplosion.mkv"))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, "Die.Autoexplosion.mkv"))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, "White.House.mkv"))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, "White.House.mkv"))).toBe(false);
// Bonus files MUST still exist in the extract dir Extras subfolder
const extrasDir = path.join(extractDir, "Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC"); const extrasDir = path.join(extractDir, "Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC");
expect(fs.existsSync(path.join(extrasDir, "Schrotflinte.mkv"))).toBe(true); expect(fs.existsSync(path.join(extrasDir, "Schrotflinte.mkv"))).toBe(true);
expect(fs.existsSync(path.join(extrasDir, "Die.Autoexplosion.mkv"))).toBe(true); expect(fs.existsSync(path.join(extrasDir, "Die.Autoexplosion.mkv"))).toBe(true);
expect(fs.existsSync(path.join(extrasDir, "White.House.mkv"))).toBe(true); expect(fs.existsSync(path.join(extrasDir, "White.House.mkv"))).toBe(true);
// The real episode must be in the library and removed from extract
expect(fs.existsSync(flattenedEpisode)).toBe(true); expect(fs.existsSync(flattenedEpisode)).toBe(true);
}, 20000); }, 20000);
@ -10126,6 +10237,7 @@ describe("download manager", () => {
const extractDir = path.join(root, "extract", packageName); const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(outputDir, { recursive: true }); fs.mkdirSync(outputDir, { recursive: true });
// Mix of dot-separated bonus subdirs - must all be detected as bonus
const zip = new AdmZip(); const zip = new AdmZip();
zip.addFile("Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv", Buffer.from("real-episode")); zip.addFile("Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv", Buffer.from("real-episode"));
zip.addFile("Breaking.Bad.S05.Making.Of/SomeBonusClip.mkv", Buffer.from("bonus-1")); zip.addFile("Breaking.Bad.S05.Making.Of/SomeBonusClip.mkv", Buffer.from("bonus-1"));
@ -10196,11 +10308,13 @@ describe("download manager", () => {
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv"); const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv");
await waitFor(() => fs.existsSync(flattenedEpisode), 12000); await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
// None of the bonus files should have landed in the flat library
expect(fs.existsSync(path.join(mkvLibraryDir, "SomeBonusClip.mkv"))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, "SomeBonusClip.mkv"))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, "AnotherClip.mkv"))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, "AnotherClip.mkv"))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, "DeletedClip.mkv"))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, "DeletedClip.mkv"))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, "GagClip.mkv"))).toBe(false); expect(fs.existsSync(path.join(mkvLibraryDir, "GagClip.mkv"))).toBe(false);
// All bonus files must still exist in their respective subfolders
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Making.Of", "SomeBonusClip.mkv"))).toBe(true); expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Making.Of", "SomeBonusClip.mkv"))).toBe(true);
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Behind.The.Scenes", "AnotherClip.mkv"))).toBe(true); expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Behind.The.Scenes", "AnotherClip.mkv"))).toBe(true);
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Deleted.Scenes", "DeletedClip.mkv"))).toBe(true); expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Deleted.Scenes", "DeletedClip.mkv"))).toBe(true);
@ -10884,6 +10998,7 @@ describe("download manager", () => {
tempDirs.push(root); tempDirs.push(root);
const binary = Buffer.alloc(256 * 1024, 7); const binary = Buffer.alloc(256 * 1024, 7);
// Slow server: delivers data in chunks with delay
const server = http.createServer((req, res) => { const server = http.createServer((req, res) => {
if ((req.url || "") !== "/slow-dl") { if ((req.url || "") !== "/slow-dl") {
res.statusCode = 404; res.statusCode = 404;
@ -10893,6 +11008,7 @@ describe("download manager", () => {
res.statusCode = 200; res.statusCode = 200;
res.setHeader("Accept-Ranges", "bytes"); res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", String(binary.length)); res.setHeader("Content-Length", String(binary.length));
// Send first half, then delay
res.write(binary.subarray(0, Math.floor(binary.length / 4))); res.write(binary.subarray(0, Math.floor(binary.length / 4)));
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (!res.writableEnded && !res.destroyed) { if (!res.writableEnded && !res.destroyed) {
@ -10949,19 +11065,23 @@ describe("download manager", () => {
manager.addPackages([{ name: "hang-test", links: ["https://dummy/hang-test"] }]); manager.addPackages([{ name: "hang-test", links: ["https://dummy/hang-test"] }]);
// Step 1: Start and wait for download to begin
await manager.start(); await manager.start();
await waitFor(() => { await waitFor(() => {
const items = Object.values(manager.getSnapshot().session.items); const items = Object.values(manager.getSnapshot().session.items);
return items.some((item) => item.status === "downloading"); return items.some((item) => item.status === "downloading");
}, 12000); }, 12000);
// Step 2: Stop — do NOT wait for running=false
manager.stop(); manager.stop();
// Step 3: Immediately disable the active provider
manager.setSettings({ manager.setSettings({
...settings, ...settings,
disabledProviders: ["realdebrid"] disabledProviders: ["realdebrid"]
}); });
// Step 4: Start again immediately — must resolve (not hang)
const startPromise = manager.start(); const startPromise = manager.start();
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000)); const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]); const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]);
@ -10992,6 +11112,9 @@ describe("download manager", () => {
createStoragePaths(stateDir) createStoragePaths(stateDir)
); );
// 60 packages with 25 links each = 1500 items. This was freezing the UI
// for 1-2 min on slower filesystems because every item triggered
// ensurePackageLog + ensureItemLog + multiple sync appendFileSync calls.
const packages = Array.from({ length: 60 }, (_, pkgIdx) => ({ const packages = Array.from({ length: 60 }, (_, pkgIdx) => ({
name: `bulk-pkg-${pkgIdx}`, name: `bulk-pkg-${pkgIdx}`,
links: Array.from({ length: 25 }, (_, linkIdx) => `https://dummy/bulk-${pkgIdx}-${linkIdx}.rar`) links: Array.from({ length: 25 }, (_, linkIdx) => `https://dummy/bulk-${pkgIdx}-${linkIdx}.rar`)
@ -11004,14 +11127,25 @@ describe("download manager", () => {
expect(result.addedPackages).toBe(60); expect(result.addedPackages).toBe(60);
expect(result.addedLinks).toBe(1500); expect(result.addedLinks).toBe(1500);
// Hard cap: on any reasonable CI box this should complete well under 5 s.
// Before the fix, the same workload produced thousands of sync-FS writes
// and took 60-120 s even on fast local disks.
expect(elapsedMs).toBeLessThan(5000); expect(elapsedMs).toBeLessThan(5000);
// No per-item log files should have been created — they're only
// initialized lazily when an item gets a real lifecycle event later.
// Item log files are named item_<id>.txt.
const itemLogsDir = path.join(stateDir, "item-logs"); const itemLogsDir = path.join(stateDir, "item-logs");
const itemLogFiles = fs.existsSync(itemLogsDir) const itemLogFiles = fs.existsSync(itemLogsDir)
? fs.readdirSync(itemLogsDir).filter((f) => f.startsWith("item_") && f.endsWith(".txt")) ? fs.readdirSync(itemLogsDir).filter((f) => f.startsWith("item_") && f.endsWith(".txt"))
: []; : [];
expect(itemLogFiles.length).toBe(0); expect(itemLogFiles.length).toBe(0);
// One package log per package. Package log file names are package_<id>.txt.
// The "Links registriert" entry is appended async (batched flush every
// ~250ms), so we don't assert content here — just that each package has
// been initialized with the startup block (ensurePackageLog wrote the
// "Paket-Log Start" header synchronously).
const packageLogsDir = path.join(stateDir, "package-logs"); const packageLogsDir = path.join(stateDir, "package-logs");
const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt")); const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt"));
expect(pkgLogFiles.length).toBe(60); expect(pkgLogFiles.length).toBe(60);
@ -11026,6 +11160,8 @@ describe("download manager", () => {
initItemLogs(stateDir); initItemLogs(stateDir);
initRenameLog(stateDir); initRenameLog(stateDir);
// Build extract tree with 3 episode-folders, each containing 1 obfuscated MKV
// mirroring the scene release pattern from the production log.
const extractDir = path.join(root, "extracted"); const extractDir = path.join(root, "extracted");
const episodes = [ const episodes = [
{ folder: "Test.Show.S02E01.Pilot.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e01hd.mkv" }, { folder: "Test.Show.S02E01.Pilot.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e01hd.mkv" },
@ -11068,15 +11204,24 @@ describe("download manager", () => {
downloadCompletedAt: 0 downloadCompletedAt: 0
}; };
// Fire two scans simultaneously for the SAME package — without
// serialization, both would race on the same fileset.
const [n1, n2] = await Promise.all([ const [n1, n2] = await Promise.all([
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg), (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg),
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg) (manager as any).autoRenameExtractedVideoFiles(extractDir, pkg)
]); ]);
// First scan should rename all 3 files. Second scan, having waited for
// the first via the in-flight promise, should find them already
// renamed (== 0 fresh renames). What matters is that BOTH calls
// resolved cleanly (no thrown ENOENT) and the disk state is correct.
expect(typeof n1).toBe("number"); expect(typeof n1).toBe("number");
expect(typeof n2).toBe("number"); expect(typeof n2).toBe("number");
expect(n1 + n2).toBe(3); expect(n1 + n2).toBe(3);
// All three episodes should now have the folder-derived name (the
// obfuscated source name was overridden via the v1.7.148 logic AND
// the rename actually succeeded for ALL of them, not just some).
for (const ep of episodes) { for (const ep of episodes) {
const dir = path.join(extractDir, ep.folder); const dir = path.join(extractDir, ep.folder);
const files = fs.readdirSync(dir); const files = fs.readdirSync(dir);
@ -11098,6 +11243,9 @@ describe("download manager", () => {
createStoragePaths(stateDir) createStoragePaths(stateDir)
); );
// Both rename and mkvMove route through the SAME chain, so any pair of
// invocations for the same package must run strictly sequentially —
// even when they come from different call sites (hybrid + deferred).
const pkgId = "crosspipe-pkg-1"; const pkgId = "crosspipe-pkg-1";
let concurrent = 0; let concurrent = 0;
let maxConcurrent = 0; let maxConcurrent = 0;
@ -11120,7 +11268,9 @@ describe("download manager", () => {
expect(r2).toBe("done-20"); expect(r2).toBe("done-20");
expect(r3).toBe("done-30"); expect(r3).toBe("done-30");
expect(r4).toBe("done-10"); expect(r4).toBe("done-10");
// Crucial: never more than 1 operation in flight at a time.
expect(maxConcurrent).toBe(1); expect(maxConcurrent).toBe(1);
// Chain slot cleared after the last op completed.
expect((manager as any).packageFileOpChain.has(pkgId)).toBe(false); expect((manager as any).packageFileOpChain.has(pkgId)).toBe(false);
}); });
@ -11161,6 +11311,7 @@ describe("download manager", () => {
const renamed = await (manager as any).autoRenameExtractedVideoFiles(sharedDir, pkg); const renamed = await (manager as any).autoRenameExtractedVideoFiles(sharedDir, pkg);
expect(renamed).toBe(0); expect(renamed).toBe(0);
// File must remain untouched — no rename performed.
expect(fs.existsSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"))).toBe(true); expect(fs.existsSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"))).toBe(true);
}); });
@ -11202,8 +11353,13 @@ describe("download manager", () => {
await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg); await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg);
const libFiles = fs.readdirSync(libDir); const libFiles = fs.readdirSync(libDir);
// Video AND subtitle moved to library.
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv"); expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv");
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.srt"); expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.srt");
// .nfo MUST NOT end up in the library — that's the user-visible bug
// we are fixing. (It gets cleaned up with other residual files in
// cleanupNonMkvResidualFiles after the move; we don't care whether it
// survives in the extract dir, only that the library stays clean.)
expect(libFiles).not.toContain("Show.S01E01.GERMAN.x264-GROUP.nfo"); expect(libFiles).not.toContain("Show.S01E01.GERMAN.x264-GROUP.nfo");
}); });
@ -11236,8 +11392,10 @@ describe("download manager", () => {
expect(renamed).toBe(1); expect(renamed).toBe(1);
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const files = fs.readdirSync(epFolder); const files = fs.readdirSync(epFolder);
// Video renamed.
expect(files).toContain(`${expectedBase}.mkv`); expect(files).toContain(`${expectedBase}.mkv`);
expect(files).not.toContain("awa-testshow02e05hd.mkv"); expect(files).not.toContain("awa-testshow02e05hd.mkv");
// Companions renamed alongside.
expect(files).toContain(`${expectedBase}.srt`); expect(files).toContain(`${expectedBase}.srt`);
expect(files).toContain(`${expectedBase}.de.srt`); expect(files).toContain(`${expectedBase}.de.srt`);
expect(files).toContain(`${expectedBase}.nfo`); expect(files).toContain(`${expectedBase}.nfo`);
@ -11273,6 +11431,7 @@ describe("download manager", () => {
expect(renamed).toBe(2); expect(renamed).toBe(2);
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake"; const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const files = fs.readdirSync(epFolder).sort(); const files = fs.readdirSync(epFolder).sort();
// First file got the canonical name; second got a numeric suffix.
expect(files).toContain(`${expectedBase}.mkv`); expect(files).toContain(`${expectedBase}.mkv`);
expect(files).toContain(`${expectedBase}.2.mkv`); expect(files).toContain(`${expectedBase}.2.mkv`);
expect(files).not.toContain("awa-testshow02e05hd.mkv"); expect(files).not.toContain("awa-testshow02e05hd.mkv");

View File

@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { createErrorRing } from "../src/main/error-ring";
describe("createErrorRing", () => {
it("keeps entries in insertion order", () => {
const ring = createErrorRing(10);
ring.push({ ts: "t1", level: "ERROR", message: "a" });
ring.push({ ts: "t2", level: "WARN", message: "b" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["a", "b"]);
expect(ring.size()).toBe(2);
});
it("caps at capacity by dropping the oldest", () => {
const ring = createErrorRing(3);
for (const m of ["a", "b", "c", "d", "e"]) {
ring.push({ ts: m, level: "ERROR", message: m });
}
expect(ring.snapshot().map((e) => e.message)).toEqual(["c", "d", "e"]);
expect(ring.size()).toBe(3);
});
it("snapshot returns a copy, not the live buffer", () => {
const ring = createErrorRing(5);
ring.push({ ts: "t", level: "WARN", message: "x" });
const snap = ring.snapshot();
snap.push({ ts: "t2", level: "ERROR", message: "injected" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["x"]);
});
it("clear empties the ring", () => {
const ring = createErrorRing(5);
ring.push({ ts: "t", level: "ERROR", message: "x" });
ring.clear();
expect(ring.snapshot()).toEqual([]);
expect(ring.size()).toBe(0);
});
it("coerces a non-positive capacity to at least 1", () => {
const ring = createErrorRing(0);
ring.push({ ts: "t1", level: "ERROR", message: "a" });
ring.push({ ts: "t2", level: "ERROR", message: "b" });
expect(ring.snapshot().map((e) => e.message)).toEqual(["b"]);
});
});

View File

@ -74,6 +74,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
const targetDir = path.join(root, "out"); const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(packageDir, { recursive: true });
// Create a ZIP with some content to trigger progress
const zipPath = path.join(packageDir, "progress-test.zip"); const zipPath = path.join(packageDir, "progress-test.zip");
const zip = new AdmZip(); const zip = new AdmZip();
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100))); zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
@ -107,16 +108,20 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.extracted).toBe(1); expect(result.extracted).toBe(1);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
// Should have at least preparing, extracting, and done phases
const phases = new Set(progressUpdates.map((u) => u.phase)); const phases = new Set(progressUpdates.map((u) => u.phase));
expect(phases.has("preparing")).toBe(true); expect(phases.has("preparing")).toBe(true);
expect(phases.has("extracting")).toBe(true); expect(phases.has("extracting")).toBe(true);
// Extracting phase should include the archive name
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip"); const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
expect(extracting.length).toBeGreaterThan(0); expect(extracting.length).toBeGreaterThan(0);
// Should end at 100%
const lastExtracting = extracting[extracting.length - 1]; const lastExtracting = extracting[extracting.length - 1];
expect(lastExtracting.archivePercent).toBe(100); expect(lastExtracting.archivePercent).toBe(100);
// Files should exist
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
}); });
@ -130,6 +135,7 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
const targetDir = path.join(root, "out"); const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(packageDir, { recursive: true });
// Create two separate ZIP archives
const zip1 = new AdmZip(); const zip1 = new AdmZip();
zip1.addFile("episode01.txt", Buffer.from("ep1 content")); zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
zip1.writeZip(path.join(packageDir, "archive1.zip")); zip1.writeZip(path.join(packageDir, "archive1.zip"));
@ -156,8 +162,10 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
expect(result.extracted).toBe(2); expect(result.extracted).toBe(2);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
// Both archive names should have appeared in progress
expect(archiveNames.has("archive1.zip")).toBe(true); expect(archiveNames.has("archive1.zip")).toBe(true);
expect(archiveNames.has("archive2.zip")).toBe(true); expect(archiveNames.has("archive2.zip")).toBe(true);
// Both files extracted
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true); expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
}); });

View File

@ -865,6 +865,7 @@ describe("extractor", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-")); const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-"));
tempDirs.push(root); tempDirs.push(root);
const filePath = path.join(root, "test.rar"); const filePath = path.join(root, "test.rar");
// RAR5 signature: 52 61 72 21 1A 07
fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex")); fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex"));
const sig = await detectArchiveSignature(filePath); const sig = await detectArchiveSignature(filePath);
expect(sig).toBe("rar"); expect(sig).toBe("rar");
@ -941,6 +942,7 @@ describe("extractor", () => {
const candidates = await findArchiveCandidates(packageDir); const candidates = await findArchiveCandidates(packageDir);
const names = candidates.map((c) => path.basename(c)); const names = candidates.map((c) => path.basename(c));
expect(names).toContain("movie.001"); expect(names).toContain("movie.001");
// .002 should NOT be in candidates (only .001 is the entry point)
expect(names).not.toContain("movie.002"); expect(names).not.toContain("movie.002");
}); });
@ -955,6 +957,7 @@ describe("extractor", () => {
const candidates = await findArchiveCandidates(packageDir); const candidates = await findArchiveCandidates(packageDir);
const names = candidates.map((c) => path.basename(c)); const names = candidates.map((c) => path.basename(c));
// .zip.001 should appear once from zipSplit detection, not duplicated by genericSplit
expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1); expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1);
}); });
@ -1097,6 +1100,7 @@ describe("extractor", () => {
const targetDir = path.join(root, "out"); const targetDir = path.join(root, "out");
fs.mkdirSync(packageDir, { recursive: true }); fs.mkdirSync(packageDir, { recursive: true });
// Create 3 zip archives
for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) { for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) {
const zip = new AdmZip(); const zip = new AdmZip();
zip.addFile(`${name}.txt`, Buffer.from(name)); zip.addFile(`${name}.txt`, Buffer.from(name));
@ -1123,6 +1127,7 @@ describe("extractor", () => {
expect(result.extracted).toBe(3); expect(result.extracted).toBe(3);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
// First archive should be ep01 (natural order, extracted serially for discovery)
expect(seenOrder[0]).toBe("ep01.zip"); expect(seenOrder[0]).toBe("ep01.zip");
}); });
@ -1139,6 +1144,7 @@ describe("extractor", () => {
zip.writeZip(path.join(packageDir, name)); zip.writeZip(path.join(packageDir, name));
} }
// No passwordList → only empty string → length=1 → no discovery phase
const result = await extractPackageArchives({ const result = await extractPackageArchives({
packageDir, packageDir,
targetDir, targetDir,

View File

@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { classifyDiskError } from "../src/main/fs-error";
import { isDebugFlagEnabled } from "../src/main/logger";
describe("classifyDiskError", () => {
it("maps ENOSPC from an error code to a disk-full reason", () => {
const err = Object.assign(new Error("write ENOSPC"), { code: "ENOSPC" });
expect(classifyDiskError(err)).toMatch(/Festplatte voll/);
});
it("maps EACCES from a code to a permission reason", () => {
const err = Object.assign(new Error("nope"), { code: "EACCES" });
expect(classifyDiskError(err)).toMatch(/Zugriff verweigert/);
});
it("lower-case codes are normalized", () => {
const err = Object.assign(new Error("x"), { code: "enospc" });
expect(classifyDiskError(err)).toMatch(/ENOSPC/);
});
it("falls back to scanning the message text when no code is present", () => {
expect(classifyDiskError(new Error("operation failed: ENOSPC on volume"))).toMatch(/Festplatte voll/);
});
it("handles a plain string error", () => {
expect(classifyDiskError("EROFS: read-only file system")).toMatch(/schreibgeschützt/);
});
it("returns null for an unrelated error", () => {
expect(classifyDiskError(new Error("write_drain_timeout"))).toBeNull();
expect(classifyDiskError(new Error("premature close"))).toBeNull();
expect(classifyDiskError(null)).toBeNull();
expect(classifyDiskError(undefined)).toBeNull();
});
});
describe("isDebugFlagEnabled", () => {
it("is true for affirmative values", () => {
for (const v of ["1", "true", "TRUE", "yes", "on", " on "]) {
expect(isDebugFlagEnabled(v)).toBe(true);
}
});
it("is false for empty/negative/garbage values", () => {
for (const v of [undefined, "", "0", "false", "off", "no", "maybe"]) {
expect(isDebugFlagEnabled(v)).toBe(false);
}
});
});

View File

@ -1,150 +0,0 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
// Mock only processVideoFile (the ffmpeg boundary); keep the real pure helpers
// (stripDualLangMarker / hasDualLangMarker / isRemuxableVideoFile) so the
// download-manager's selection + .DL.-rename wiring is exercised for real.
vi.mock("../src/main/video-processor", async (importActual) => {
const actual = await importActual<typeof import("../src/main/video-processor")>();
return { ...actual, processVideoFile: vi.fn(), resolveVideoTooling: vi.fn() };
});
import { DownloadManager } from "../src/main/download-manager";
import { defaultSettings } from "../src/main/constants";
import { createStoragePaths, emptySession } from "../src/main/storage";
import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log";
import { shutdownRenameLog } from "../src/main/rename-log";
import { processVideoFile, resolveVideoTooling, type VideoProcessResult } from "../src/main/video-processor";
const mockedProcess = processVideoFile as unknown as ReturnType<typeof vi.fn>;
const mockedTooling = resolveVideoTooling as unknown as ReturnType<typeof vi.fn>;
const tempDirs: string[] = [];
afterEach(() => {
mockedProcess.mockReset();
mockedTooling.mockReset();
shutdownItemLogs();
shutdownPackageLogs();
shutdownRenameLog();
for (const dir of tempDirs.splice(0)) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
}
});
function setup(keepGermanAudioOnly: boolean): { extractDir: string; manager: DownloadManager; pkg: any } {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-ga-"));
tempDirs.push(root);
const extractDir = path.join(root, "extract");
const stateDir = path.join(root, "state");
fs.mkdirSync(extractDir, { recursive: true });
fs.mkdirSync(stateDir, { recursive: true });
const manager = new DownloadManager(
{
...defaultSettings(),
token: "rd-token",
keepGermanAudioOnly,
germanAudioMode: "tag",
autoRename4sf4sj: false,
outputDir: path.join(root, "out"),
extractDir,
mkvLibraryDir: path.join(stateDir, "_mkv")
},
emptySession(),
createStoragePaths(stateDir)
);
const pkg: any = {
id: "ga-pkg-1",
name: "Test.Show.S01.GERMAN.DL.720p",
outputDir: path.join(root, "out", "Test.Show"),
extractDir,
status: "completed",
itemIds: [],
cancelled: false,
enabled: true,
priority: "normal",
createdAt: 0,
updatedAt: 0
};
// Default: ffmpeg/ffprobe "available" so the step proceeds to the (mocked)
// processVideoFile. Tests that need the no-tool path override this.
mockedTooling.mockResolvedValue({ ffmpeg: "ffmpeg", ffprobe: "ffprobe" });
return { extractDir, manager, pkg };
}
const DL_MKV = "Show.S01E01.German.DL.720p.x264.mkv";
const PLAIN_MKV = "Show.S01E02.German.1080p.x264.mkv";
const SAMPLE_DL = "Show.sample.DL.mkv";
const DL_AVI = "Show.S01E03.German.DL.avi";
function stage(extractDir: string): void {
for (const f of [DL_MKV, PLAIN_MKV, SAMPLE_DL, DL_AVI]) {
fs.writeFileSync(path.join(extractDir, f), "x");
}
}
describe("keepGermanAudioOnly integration", () => {
it("processes only .DL. mkv/mp4 and strips .DL. after a successful remux", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "remuxed", reason: "german-tag", totalAudioTracks: 2, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(mockedProcess.mock.calls[0][0]).toBe(path.join(extractDir, DL_MKV));
expect(n).toBe(1);
const files = fs.readdirSync(extractDir);
expect(files).toContain("Show.S01E01.German.720p.x264.mkv"); // .DL. stripped
expect(files).not.toContain(DL_MKV);
expect(files).toContain(PLAIN_MKV); // non-.DL. untouched
expect(files).toContain(SAMPLE_DL); // sample skipped
expect(files).toContain(DL_AVI); // avi not remuxable, skipped
});
it("does nothing when the setting is off", async () => {
const { extractDir, manager, pkg } = setup(false);
stage(extractDir);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled();
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
});
it("leaves the file fully untouched (name included) when no German track is found", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "skipped-no-german", reason: "no-german-track", totalAudioTracks: 2 } as VideoProcessResult);
await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(mockedProcess).toHaveBeenCalledTimes(1);
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // NOT renamed -> stays visible as unprocessed
});
it("still strips .DL. for a single-audio file (no remux needed)", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedProcess.mockResolvedValue({ action: "kept-single", reason: "single-german", totalAudioTracks: 1, keptTrackIndex: 0 } as VideoProcessResult);
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0); // not counted as a remux
expect(fs.readdirSync(extractDir)).toContain("Show.S01E01.German.720p.x264.mkv");
});
it("skips up front (no processVideoFile calls) and leaves files untouched when ffmpeg is missing", async () => {
const { extractDir, manager, pkg } = setup(true);
stage(extractDir);
mockedTooling.mockResolvedValue(null); // ffmpeg/ffprobe not found
const n = await (manager as any).keepGermanAudioOnlyImpl(extractDir, pkg);
expect(n).toBe(0);
expect(mockedProcess).not.toHaveBeenCalled(); // bailed before touching any file
expect(fs.readdirSync(extractDir)).toContain(DL_MKV); // untouched
});
});

View File

@ -34,20 +34,25 @@ describe("integrity", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-")); const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
tempDirs.push(dir); tempDirs.push(dir);
// Create a .md5 manifest that exceeds the 5MB limit
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000); const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
const manifestPath = path.join(dir, "hashes.md5"); const manifestPath = path.join(dir, "hashes.md5");
fs.writeFileSync(manifestPath, largeContent, "utf8"); fs.writeFileSync(manifestPath, largeContent, "utf8");
// Verify the file is actually > 5MB
const stat = fs.statSync(manifestPath); const stat = fs.statSync(manifestPath);
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024); expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
// readHashManifest should skip the oversized file
const manifest = readHashManifest(dir); const manifest = readHashManifest(dir);
expect(manifest.size).toBe(0); expect(manifest.size).toBe(0);
}); });
it("does not parse SHA256 (64-char hex) as valid hash", () => { it("does not parse SHA256 (64-char hex) as valid hash", () => {
// SHA256 is 64 chars - parseHashLine only supports 32 (MD5) and 40 (SHA1)
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin"; const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
const result = parseHashLine(sha256Line); const result = parseHashLine(sha256Line);
// 64-char hex should not match the MD5 (32) or SHA1 (40) pattern
expect(result).toBeNull(); expect(result).toBeNull();
}); });

View File

@ -8,15 +8,15 @@ describe("link-parser", () => {
{ name: "Package A", links: ["http://link1", "http://link2"] }, { name: "Package A", links: ["http://link1", "http://link2"] },
{ name: "Package B", links: ["http://link3"] }, { name: "Package B", links: ["http://link3"] },
{ name: "Package A", links: ["http://link4", "http://link1"] }, { name: "Package A", links: ["http://link4", "http://link1"] },
{ name: "", links: ["http://link5"] } { name: "", links: ["http://link5"] } // empty name will be inferred
]; ];
const result = mergePackageInputs(input); const result = mergePackageInputs(input);
expect(result).toHaveLength(3); expect(result).toHaveLength(3); // Package A, Package B, and inferred 'Paket'
const pkgA = result.find(p => p.name === "Package A"); const pkgA = result.find(p => p.name === "Package A");
expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]); expect(pkgA?.links).toEqual(["http://link1", "http://link2", "http://link4"]); // link1 deduplicated
const pkgB = result.find(p => p.name === "Package B"); const pkgB = result.find(p => p.name === "Package B");
expect(pkgB?.links).toEqual(["http://link3"]); expect(pkgB?.links).toEqual(["http://link3"]);
@ -30,6 +30,7 @@ describe("link-parser", () => {
const result = mergePackageInputs(input); const result = mergePackageInputs(input);
// "Valid?Name*" becomes "Valid Name " -> trimmed to "Valid Name"
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]); expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
}); });
@ -66,6 +67,7 @@ describe("link-parser", () => {
const result = parseCollectorInput(rawText, "DefaultFallback"); const result = parseCollectorInput(rawText, "DefaultFallback");
// Should have 2 packages: "DefaultFallback" and "Custom_Name"
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
const defaultPkg = result.find(p => p.name === "DefaultFallback"); const defaultPkg = result.find(p => p.name === "DefaultFallback");
@ -74,7 +76,7 @@ describe("link-parser", () => {
"http://example.com/part2.rar" "http://example.com/part2.rar"
]); ]);
const customPkg = result.find(p => p.name === "Custom_Name"); const customPkg = result.find(p => p.name === "Custom_Name"); // sanitized!
expect(customPkg?.links).toEqual([ expect(customPkg?.links).toEqual([
"http://other.com/file1", "http://other.com/file1",
"http://other.com/file2" "http://other.com/file2"

View File

@ -6,18 +6,23 @@ describe("logTimestamp", () => {
const instant = new Date("2026-05-31T17:29:43.605Z"); const instant = new Date("2026-05-31T17:29:43.605Z");
const formatted = logTimestamp(instant); const formatted = logTimestamp(instant);
// Shape: YYYY-MM-DDTHH:MM:SS.mmm±HH:MM
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/); expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
// The whole point: NOT the old UTC "...Z" format that showed 17:29 instead of 19:29.
expect(formatted.endsWith("Z")).toBe(false); expect(formatted.endsWith("Z")).toBe(false);
}); });
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => { it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
const instant = new Date("2026-05-31T17:29:43.605Z"); const instant = new Date("2026-05-31T17:29:43.605Z");
// Date.parse must still recover the identical instant (trace-log autoDisableAt etc.).
expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime()); expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime());
}); });
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => { it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
const instant = new Date("2026-05-31T17:29:43.605Z"); const instant = new Date("2026-05-31T17:29:43.605Z");
const formatted = logTimestamp(instant); const formatted = logTimestamp(instant);
// Hour segment must equal the local getHours() of the same instant — i.e. the
// user's wall clock, whatever the server timezone is.
expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0")); expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0"));
}); });
}); });

View File

@ -17,6 +17,7 @@ function makeRandomFileKey(): Buffer {
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string { function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
const plain = "MEGA" + JSON.stringify(jsonAttrs); const plain = "MEGA" + JSON.stringify(jsonAttrs);
// Pad to 16-byte boundary with \0 (Mega convention).
const padded = Buffer.from(plain, "utf8"); const padded = Buffer.from(plain, "utf8");
const padLen = (16 - (padded.length % 16)) % 16; const padLen = (16 - (padded.length % 16)) % 16;
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]); const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
@ -58,6 +59,7 @@ describe("mega-public-api", () => {
expect(parsed?.rawKey.length).toBe(32); expect(parsed?.rawKey.length).toBe(32);
}); });
it("parses legacy-format URL", () => { it("parses legacy-format URL", () => {
// Make a valid legacy URL with a 32-byte key.
const id = "abcDEF12"; const id = "abcDEF12";
const key = makeRandomFileKey(); const key = makeRandomFileKey();
const url = `https://mega.nz/#!${id}!${base64Url(key)}`; const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
@ -141,7 +143,7 @@ describe("mega-public-api", () => {
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
async json() { async json() {
return -9; return -9; // ENOENT — file not found
} }
} as unknown as Response); } as unknown as Response);
@ -154,7 +156,7 @@ describe("mega-public-api", () => {
global.fetch = vi.fn().mockResolvedValue({ global.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
async json() { async json() {
return [-16]; return [-16]; // EBLOCKED
} }
} as unknown as Response); } as unknown as Response);

View File

@ -33,6 +33,7 @@ describe("mega-web-fallback", () => {
} }
if (urlStr.includes("form=debrid")) { if (urlStr.includes("form=debrid")) {
// The POST to generate the code
return new Response(` return new Response(`
<div class="acp-box"> <div class="acp-box">
<h3>Link: https://mega.debrid/link1</h3> <h3>Link: https://mega.debrid/link1</h3>
@ -42,6 +43,7 @@ describe("mega-web-fallback", () => {
} }
if (urlStr.includes("ajax=debrid")) { if (urlStr.includes("ajax=debrid")) {
// Polling endpoint
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 }); return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
} }
@ -54,6 +56,7 @@ describe("mega-web-fallback", () => {
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(result?.directUrl).toBe("https://mega.direct/123"); expect(result?.directUrl).toBe("https://mega.direct/123");
expect(result?.fileName).toBe("link1"); expect(result?.fileName).toBe("link1");
// Calls: 1. Login POST, 2. Verify GET, 3. Generate POST, 4. Polling POST
expect(fetchCallCount).toBe(4); expect(fetchCallCount).toBe(4);
}); });
@ -80,11 +83,17 @@ describe("mega-web-fallback", () => {
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
// Muss schnell mit der ECHTEN Meldung scheitern — NICHT null zurückgeben (was
// re-Login + erneutes Pollen auslösen würde und das Rotations-Budget frisst).
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i); await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
expect(ajaxCalls).toBe(1); expect(ajaxCalls).toBe(1);
}); });
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => { it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
// Tageslimit dieses Accounts: die DEBRID-Seite enthält KEINEN processDebrid-Code,
// sondern die Limit-Meldung als Page-Error. Früher -> kein Code -> null -> "Antwort
// leer" (auf Message-Ebene nicht als Tageslimit erkennbar). Jetzt muss die Meldung
// als Fehler hochkommen, damit die Rotation den Account als limitiert behandelt.
let ajaxCalls = 0; let ajaxCalls = 0;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => { globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url); const urlStr = String(url);
@ -97,6 +106,7 @@ describe("mega-web-fallback", () => {
return new Response('<form id="debridForm"></form>', { status: 200 }); return new Response('<form id="debridForm"></form>', { status: 200 });
} }
if (urlStr.includes("form=debrid")) { if (urlStr.includes("form=debrid")) {
// Keine processDebrid(...)-Codes — nur die Tageslimit-Meldung als Page-Error.
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 }); return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
} }
if (urlStr.includes("ajax=debrid")) { if (urlStr.includes("ajax=debrid")) {
@ -108,6 +118,7 @@ describe("mega-web-fallback", () => {
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" })); const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i); await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
// Ohne Code wird gar nicht erst gepollt — die Meldung kommt direkt von der Seite.
expect(ajaxCalls).toBe(0); expect(ajaxCalls).toBe(0);
}); });
@ -134,7 +145,9 @@ describe("mega-web-fallback", () => {
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch; }) as unknown as typeof fetch;
// getCredentials liefert den DEFAULT/Legacy-Account ...
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" })); const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
// ... aber die Rotation übergibt explizit Account 2 — DESSEN Login MUSS verwendet werden.
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" }); const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
expect(result?.directUrl).toBe("https://mega.direct/ok"); expect(result?.directUrl).toBe("https://mega.direct/ok");
expect(loginsUsed).toContain("account2"); expect(loginsUsed).toContain("account2");
@ -145,7 +158,7 @@ describe("mega-web-fallback", () => {
globalThis.fetch = vi.fn(async (url: string | URL | Request) => { globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url); const urlStr = String(url);
if (urlStr.includes("form=login")) { if (urlStr.includes("form=login")) {
const headers = new Headers(); const headers = new Headers(); // No cookie
return new Response("", { headers, status: 200 }); return new Response("", { headers, status: 200 });
} }
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
@ -166,6 +179,7 @@ describe("mega-web-fallback", () => {
return new Response("", { headers, status: 200 }); return new Response("", { headers, status: 200 });
} }
if (urlStr.includes("page=debrideur")) { if (urlStr.includes("page=debrideur")) {
// Missing form!
return new Response('<html><body>Nothing here</body></html>', { status: 200 }); return new Response('<html><body>Nothing here</body></html>', { status: 200 });
} }
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
@ -191,6 +205,7 @@ describe("mega-web-fallback", () => {
return new Response('<form id="debridForm"></form>', { status: 200 }); return new Response('<form id="debridForm"></form>', { status: 200 });
} }
if (urlStr.includes("form=debrid")) { if (urlStr.includes("form=debrid")) {
// The generate POST returns HTML without any codes
return new Response(`<div>No links here</div>`, { status: 200 }); return new Response(`<div>No links here</div>`, { status: 200 });
} }
return new Response("Not found", { status: 404 }); return new Response("Not found", { status: 404 });
@ -199,6 +214,7 @@ describe("mega-web-fallback", () => {
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" })); const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
const result = await fallback.unrestrict("http://mega.debrid/file"); const result = await fallback.unrestrict("http://mega.debrid/file");
// Generation fails -> resets cookie -> tries again -> fails again -> returns null
expect(result).toBeNull(); expect(result).toBeNull();
}); });

View File

@ -44,50 +44,23 @@ function createItem(id: string, packageId: string, status: DownloadItem["status"
} }
describe("sortPackagesForDisplay", () => { describe("sortPackagesForDisplay", () => {
it("floats active packages to the top, keeping queue order within each group", () => { it("moves active packages with more progress to the top when auto sort is enabled", () => {
// pkg-a and pkg-b both have an active (downloading) item -> both float up in
// their original queue order; pkg-c (queued only) sinks below.
const packages = [ const packages = [
createPackage("pkg-a", ["a1", "a2"]), createPackage("pkg-a", ["a1", "a2"]),
createPackage("pkg-c", ["c1"]), createPackage("pkg-b", ["b1", "b2"]),
createPackage("pkg-b", ["b1", "b2"]) createPackage("pkg-c", ["c1"])
]; ];
const items: Record<string, DownloadItem> = { const items: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 250), a1: createItem("a1", "pkg-a", "downloading", 250),
a2: createItem("a2", "pkg-a", "completed", 500), a2: createItem("a2", "pkg-a", "completed", 500),
c1: createItem("c1", "pkg-c", "queued", 0),
b1: createItem("b1", "pkg-b", "downloading", 800), b1: createItem("b1", "pkg-b", "downloading", 800),
b2: createItem("b2", "pkg-b", "completed", 900) b2: createItem("b2", "pkg-b", "completed", 900),
c1: createItem("c1", "pkg-c", "queued", 0)
}; };
const sorted = sortPackagesForDisplay(packages, items, true, true); const sorted = sortPackagesForDisplay(packages, items, true, true);
// active group [pkg-a, pkg-b] in queue order, then rest [pkg-c] expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-b", "pkg-a", "pkg-c"]);
expect(sorted.map((pkg) => pkg.id)).toEqual(["pkg-a", "pkg-b", "pkg-c"]);
});
it("does NOT reshuffle active packages when only their progress changes (anti-flicker)", () => {
const packages = [
createPackage("pkg-a", ["a1"]),
createPackage("pkg-b", ["b1"])
];
// Both active. pkg-b initially has more bytes than pkg-a.
const before: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 100),
b1: createItem("b1", "pkg-b", "downloading", 900)
};
const orderBefore = sortPackagesForDisplay(packages, before, true, true).map((p) => p.id);
// A progress tick: pkg-a overtakes pkg-b in bytes. Order must NOT change —
// both are still active, so they keep queue order. (Old code swapped them.)
const after: Record<string, DownloadItem> = {
a1: createItem("a1", "pkg-a", "downloading", 5000),
b1: createItem("b1", "pkg-b", "downloading", 950)
};
const orderAfter = sortPackagesForDisplay(packages, after, true, true).map((p) => p.id);
expect(orderBefore).toEqual(["pkg-a", "pkg-b"]);
expect(orderAfter).toEqual(orderBefore);
}); });
it("keeps package order untouched when auto sort is disabled", () => { it("keeps package order untouched when auto sort is disabled", () => {

View File

@ -17,6 +17,7 @@ function makeItems(names: string[]): MinimalItem[] {
} }
describe("resolveArchiveItemsFromList", () => { describe("resolveArchiveItemsFromList", () => {
// ── Multipart RAR (.partN.rar) ──
it("matches multipart .part1.rar archives", () => { it("matches multipart .part1.rar archives", () => {
const items = makeItems([ const items = makeItems([
@ -45,6 +46,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
}); });
// ── Old-style RAR (.rar + .r00, .r01, etc.) ──
it("matches old-style .rar + .rNN volumes", () => { it("matches old-style .rar + .rNN volumes", () => {
const items = makeItems([ const items = makeItems([
"Archive.rar", "Archive.rar",
@ -57,6 +60,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(4); expect(result).toHaveLength(4);
}); });
// ── Single RAR ──
it("matches a single .rar file", () => { it("matches a single .rar file", () => {
const items = makeItems(["SingleFile.rar", "Other.mkv"]); const items = makeItems(["SingleFile.rar", "Other.mkv"]);
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any); const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
@ -64,6 +69,8 @@ describe("resolveArchiveItemsFromList", () => {
expect((result[0] as any).fileName).toBe("SingleFile.rar"); expect((result[0] as any).fileName).toBe("SingleFile.rar");
}); });
// ── Split ZIP ──
it("matches split .zip.NNN files", () => { it("matches split .zip.NNN files", () => {
const items = makeItems([ const items = makeItems([
"Data.zip", "Data.zip",
@ -75,6 +82,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(4); expect(result).toHaveLength(4);
}); });
// ── Split 7z ──
it("matches split .7z.NNN files", () => { it("matches split .7z.NNN files", () => {
const items = makeItems([ const items = makeItems([
"Backup.7z.001", "Backup.7z.001",
@ -84,6 +93,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
}); });
// ── Generic .NNN splits ──
it("matches generic .NNN split files", () => { it("matches generic .NNN split files", () => {
const items = makeItems([ const items = makeItems([
"video.001", "video.001",
@ -94,6 +105,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(3); expect(result).toHaveLength(3);
}); });
// ── Exact filename match ──
it("matches a single .zip by exact name", () => { it("matches a single .zip by exact name", () => {
const items = makeItems(["myarchive.zip", "other.rar"]); const items = makeItems(["myarchive.zip", "other.rar"]);
const result = resolveArchiveItemsFromList("myarchive.zip", items as any); const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
@ -101,6 +114,8 @@ describe("resolveArchiveItemsFromList", () => {
expect((result[0] as any).fileName).toBe("myarchive.zip"); expect((result[0] as any).fileName).toBe("myarchive.zip");
}); });
// ── Case insensitivity ──
it("matches case-insensitively", () => { it("matches case-insensitively", () => {
const items = makeItems([ const items = makeItems([
"MOVIE.PART1.RAR", "MOVIE.PART1.RAR",
@ -110,26 +125,40 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
}); });
// ── Stem-based fallback ──
it("uses stem-based fallback when exact patterns fail", () => { it("uses stem-based fallback when exact patterns fail", () => {
// Simulate a debrid service that renames "Movie.part1.rar" to "Movie.part1_dl.rar"
// but the disk file is "Movie.part1.rar"
const items = makeItems([ const items = makeItems([
"Movie.rar", "Movie.rar",
]); ]);
// The archive on disk is "Movie.part1.rar" but there's no item matching the
// .partN pattern. The stem "movie" should match "Movie.rar" via fallback.
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any); const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
// stem fallback: "movie" starts with "movie" and ends with .rar
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
}); });
// ── Single item fallback ──
it("returns single archive item when no pattern matches", () => { it("returns single archive item when no pattern matches", () => {
const items = makeItems(["totally-different-name.rar"]); const items = makeItems(["totally-different-name.rar"]);
const result = resolveArchiveItemsFromList("Original.rar", items as any); const result = resolveArchiveItemsFromList("Original.rar", items as any);
// Single item in list with archive extension → return it
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
}); });
// ── Empty when no match ──
it("returns empty when items have no archive extensions", () => { it("returns empty when items have no archive extensions", () => {
const items = makeItems(["video.mkv", "subtitle.srt"]); const items = makeItems(["video.mkv", "subtitle.srt"]);
const result = resolveArchiveItemsFromList("Archive.rar", items as any); const result = resolveArchiveItemsFromList("Archive.rar", items as any);
expect(result).toHaveLength(0); expect(result).toHaveLength(0);
}); });
// ── Items without targetPath ──
it("falls back to fileName when targetPath is missing", () => { it("falls back to fileName when targetPath is missing", () => {
const items = [ const items = [
{ fileName: "Movie.part1.rar", id: "1", status: "completed" }, { fileName: "Movie.part1.rar", id: "1", status: "completed" },
@ -139,6 +168,8 @@ describe("resolveArchiveItemsFromList", () => {
expect(result).toHaveLength(2); expect(result).toHaveLength(2);
}); });
// ── Multiple archives, should not cross-match ──
it("does not cross-match different archive groups", () => { it("does not cross-match different archive groups", () => {
const items = makeItems([ const items = makeItems([
"Episode.S01E01.part1.rar", "Episode.S01E01.part1.rar",

View File

@ -1,44 +0,0 @@
import { describe, expect, it } from "vitest";
import { pruneSelection } from "../src/renderer/selection";
import type { SessionState } from "../src/shared/types";
function session(packageIds: string[], itemIds: string[]): Pick<SessionState, "packages" | "items"> {
const packages: Record<string, never> = {};
const items: Record<string, never> = {};
for (const id of packageIds) packages[id] = {} as never;
for (const id of itemIds) items[id] = {} as never;
return { packages, items };
}
describe("pruneSelection", () => {
it("drops ids whose package/item no longer exists", () => {
const sel = new Set(["p1", "i1", "ghost-p", "ghost-i"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect([...next].sort()).toEqual(["i1", "p1"]);
});
it("returns the SAME set instance when nothing changed (no needless re-render)", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session(["p1"], ["i1"]));
expect(next).toBe(sel);
});
it("returns the same instance for an empty selection", () => {
const sel = new Set<string>();
expect(pruneSelection(sel, session(["p1"], ["i1"]))).toBe(sel);
});
it("prunes everything when the whole session was swapped out", () => {
const sel = new Set(["p1", "i1"]);
const next = pruneSelection(sel, session([], []));
expect(next.size).toBe(0);
expect(next).not.toBe(sel);
});
it("keeps a mixed package+item selection when both survive", () => {
const sel = new Set(["p1", "p2", "i1"]);
const next = pruneSelection(sel, session(["p1", "p2"], ["i1", "i2"]));
expect([...next].sort()).toEqual(["i1", "p1", "p2"]);
expect(next).toBe(sel); // unchanged → same instance
});
});

View File

@ -8,7 +8,9 @@ import { setLogListener } from "../src/main/logger";
const tempDirs: string[] = []; const tempDirs: string[] = [];
afterEach(() => { afterEach(() => {
// Ensure session log is shut down between tests
shutdownSessionLog(); shutdownSessionLog();
// Ensure listener is cleared between tests
setLogListener(null); setLogListener(null);
for (const dir of tempDirs.splice(0)) { for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
@ -40,9 +42,11 @@ describe("session-log", () => {
initSessionLog(baseDir); initSessionLog(baseDir);
const logPath = getSessionLogPath()!; const logPath = getSessionLogPath()!;
// Simulate a log line via the listener
const { logger } = await import("../src/main/logger"); const { logger } = await import("../src/main/logger");
logger.info("Test-Nachricht für Session-Log"); logger.info("Test-Nachricht für Session-Log");
// Wait for flush (200ms interval + margin)
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 500));
const content = fs.readFileSync(logPath, "utf8"); const content = fs.readFileSync(logPath, "utf8");
@ -73,6 +77,7 @@ describe("session-log", () => {
shutdownSessionLog(); shutdownSessionLog();
// Log after shutdown - should NOT appear in session log
const { logger } = await import("../src/main/logger"); const { logger } = await import("../src/main/logger");
logger.info("Nach-Shutdown-Nachricht"); logger.info("Nach-Shutdown-Nachricht");
@ -89,16 +94,21 @@ describe("session-log", () => {
const logsDir = path.join(baseDir, "session-logs"); const logsDir = path.join(baseDir, "session-logs");
fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true });
// Create a fake old session log
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt"); const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
fs.writeFileSync(oldFile, "old session"); fs.writeFileSync(oldFile, "old session");
// Set mtime to 30 days ago
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
fs.utimesSync(oldFile, oldTime, oldTime); fs.utimesSync(oldFile, oldTime, oldTime);
// Create a recent file
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt"); const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
fs.writeFileSync(newFile, "new session"); fs.writeFileSync(newFile, "new session");
// initSessionLog triggers cleanup
initSessionLog(baseDir); initSessionLog(baseDir);
// Wait for async cleanup
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
expect(fs.existsSync(oldFile)).toBe(false); expect(fs.existsSync(oldFile)).toBe(false);
@ -114,6 +124,7 @@ describe("session-log", () => {
const logsDir = path.join(baseDir, "session-logs"); const logsDir = path.join(baseDir, "session-logs");
fs.mkdirSync(logsDir, { recursive: true }); fs.mkdirSync(logsDir, { recursive: true });
// Create a file from 2 days ago (should be kept)
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt"); const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
fs.writeFileSync(recentFile, "recent session"); fs.writeFileSync(recentFile, "recent session");
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
@ -136,6 +147,7 @@ describe("session-log", () => {
const path1 = getSessionLogPath(); const path1 = getSessionLogPath();
shutdownSessionLog(); shutdownSessionLog();
// Small delay to ensure different timestamp
await new Promise((resolve) => setTimeout(resolve, 1100)); await new Promise((resolve) => setTimeout(resolve, 1100));
initSessionLog(baseDir); initSessionLog(baseDir);

View File

@ -12,6 +12,12 @@ import {
saveSessionAsync saveSessionAsync
} from "../src/main/storage"; } from "../src/main/storage";
// Regression tests for queue loss across an app-update restart.
// Both scenarios were observed empirically to drop packages before the fix:
// - a queued stale async save clobbering a newer synchronous save
// (persistNowSync / prepareForShutdown), and
// - loadSession ignoring a good .bak when the primary file is momentarily absent.
const tempDirs: string[] = []; const tempDirs: string[] = [];
afterEach(() => { afterEach(() => {
@ -60,6 +66,7 @@ function makeItem(id: string, packageId: string): DownloadItem {
}; };
} }
/** Build a session whose package set is exactly `ids`. */
function sessionWith(ids: string[]): SessionState { function sessionWith(ids: string[]): SessionState {
const s = emptySession(); const s = emptySession();
for (const id of ids) { for (const id of ids) {
@ -84,6 +91,8 @@ describe("session restart loss", () => {
saveSession(paths, sessionWith(["A", "B"])); saveSession(paths, sessionWith(["A", "B"]));
// An async save goes in-flight, a second async save (stale snapshot) gets
// queued, then a synchronous save persists the live state with package C.
const inflight = saveSessionAsync(paths, sessionWith(["A", "B"])); const inflight = saveSessionAsync(paths, sessionWith(["A", "B"]));
const queued = saveSessionAsync(paths, sessionWith(["A", "B"])); const queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
saveSession(paths, sessionWith(["A", "B", "C"])); saveSession(paths, sessionWith(["A", "B", "C"]));

View File

@ -13,6 +13,7 @@ afterEach(() => {
try { try {
fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(dir, { recursive: true, force: true });
} catch { } catch {
// ignore cleanup errors
} }
} }
}); });
@ -87,6 +88,7 @@ describe("runStartupHealthCheck", () => {
it("flags large state files", () => { it("flags large state files", () => {
const { outputDir, paths } = makeTempBase(); const { outputDir, paths } = makeTempBase();
fs.mkdirSync(paths.baseDir, { recursive: true }); fs.mkdirSync(paths.baseDir, { recursive: true });
// 60 MB dummy state file, threshold is 50 MB
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0)); fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
const settings = { const settings = {
@ -101,6 +103,7 @@ describe("runStartupHealthCheck", () => {
it("flags missing base dir as ERROR", () => { it("flags missing base dir as ERROR", () => {
const { outputDir, paths } = makeTempBase(); const { outputDir, paths } = makeTempBase();
// Intentionally DON'T create baseDir.
const settings = { const settings = {
...defaultSettings(), ...defaultSettings(),

View File

@ -453,13 +453,19 @@ describe("settings storage", () => {
saveSession(paths, session); saveSession(paths, session);
const loaded = loadSession(paths); const loaded = loadSession(paths);
// Active statuses (downloading, paused) should be reset to "queued"
expect(loaded.items["item1"].status).toBe("queued"); expect(loaded.items["item1"].status).toBe("queued");
expect(loaded.items["item2"].status).toBe("queued"); expect(loaded.items["item2"].status).toBe("queued");
// Speed should be cleared
expect(loaded.items["item1"].speedBps).toBe(0); expect(loaded.items["item1"].speedBps).toBe(0);
// lastError should be cleared for reset items
expect(loaded.items["item1"].lastError).toBe(""); expect(loaded.items["item1"].lastError).toBe("");
// Completed and queued statuses should be preserved
expect(loaded.items["item3"].status).toBe("completed"); expect(loaded.items["item3"].status).toBe("completed");
expect(loaded.items["item4"].status).toBe("queued"); expect(loaded.items["item4"].status).toBe("queued");
// Downloaded bytes should be preserved
expect(loaded.items["item1"].downloadedBytes).toBe(5000); expect(loaded.items["item1"].downloadedBytes).toBe(5000);
// Package data should be preserved
expect(loaded.packages["pkg1"].name).toBe("Test Package"); expect(loaded.packages["pkg1"].name).toBe("Test Package");
}); });
@ -536,6 +542,7 @@ describe("settings storage", () => {
tempDirs.push(dir); tempDirs.push(dir);
const paths = createStoragePaths(dir); const paths = createStoragePaths(dir);
// Write invalid JSON to the config file
fs.writeFileSync(paths.configFile, "{{{{not valid json!!!}", "utf8"); fs.writeFileSync(paths.configFile, "{{{{not valid json!!!}", "utf8");
const loaded = loadSettings(paths); const loaded = loadSettings(paths);
@ -721,6 +728,7 @@ describe("settings storage", () => {
tempDirs.push(dir); tempDirs.push(dir);
const paths = createStoragePaths(dir); const paths = createStoragePaths(dir);
// Write a minimal config that simulates an old version missing newer fields
fs.writeFileSync( fs.writeFileSync(
paths.configFile, paths.configFile,
JSON.stringify({ JSON.stringify({
@ -734,9 +742,11 @@ describe("settings storage", () => {
const loaded = loadSettings(paths); const loaded = loadSettings(paths);
const defaults = defaultSettings(); const defaults = defaultSettings();
// Old fields should be preserved
expect(loaded.token).toBe("my-token"); expect(loaded.token).toBe("my-token");
expect(loaded.outputDir).toBe(path.resolve("/custom/output")); expect(loaded.outputDir).toBe(path.resolve("/custom/output"));
// Missing new fields should get default values
expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback); expect(loaded.autoProviderFallback).toBe(defaults.autoProviderFallback);
expect(loaded.hybridExtract).toBe(defaults.hybridExtract); expect(loaded.hybridExtract).toBe(defaults.hybridExtract);
expect(loaded.completedCleanupPolicy).toBe(defaults.completedCleanupPolicy); expect(loaded.completedCleanupPolicy).toBe(defaults.completedCleanupPolicy);

View File

@ -10,6 +10,14 @@ import { createStoragePaths, emptySession, loadSession } from "../src/main/stora
import { shutdownItemLogs } from "../src/main/item-log"; import { shutdownItemLogs } from "../src/main/item-log";
import { shutdownPackageLogs } from "../src/main/package-log"; import { shutdownPackageLogs } from "../src/main/package-log";
// Regression for the reported symptom: after an app update while downloading,
// packages that were in flight do not continue after the restart.
//
// Root cause: installUpdate() called manager.stop(), whose abort continuation
// marks the in-flight item "cancelled"/"Gestoppt". autoResumeOnStart only
// resumes "queued"/"reconnect_wait" items, so after the silent-install relaunch
// the download silently stays parked instead of continuing.
const tempDirs: string[] = []; const tempDirs: string[] = [];
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;
@ -39,6 +47,9 @@ async function waitFor(predicate: () => boolean, timeoutMs = 20000): Promise<voi
} }
} }
/** Starts an HTTP server that trickles bytes forever so a download stays
* actively "downloading" until it is aborted. Returns the direct URL plus a
* stop() that tears down all open responses and the server. */
async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise<void> }> { async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise<void> }> {
const openTimers = new Set<NodeJS.Timeout>(); const openTimers = new Set<NodeJS.Timeout>();
const openResponses = new Set<http.ServerResponse>(); const openResponses = new Set<http.ServerResponse>();
@ -57,6 +68,7 @@ async function startTricklingServer(): Promise<{ directUrl: string; stop: () =>
try { try {
res.write(Buffer.alloc(16 * 1024, 9)); res.write(Buffer.alloc(16 * 1024, 9));
} catch { } catch {
// socket gone
} }
}, 100); }, 100);
openTimers.add(timer); openTimers.add(timer);
@ -82,6 +94,7 @@ async function startTricklingServer(): Promise<{ directUrl: string; stop: () =>
try { try {
res.destroy(); res.destroy();
} catch { } catch {
// ignore
} }
} }
openResponses.clear(); openResponses.clear();
@ -144,6 +157,7 @@ describe("update restart resume", () => {
const reloaded = loadSession(paths); const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0]; const item = Object.values(reloaded.items)[0];
expect(item).toBeTruthy(); expect(item).toBeTruthy();
// Documents the loss of resumability: cancelled items are not auto-resumed.
expect(item.status).toBe("cancelled"); expect(item.status).toBe("cancelled");
} finally { } finally {
await serverStop(); await serverStop();
@ -155,6 +169,7 @@ describe("update restart resume", () => {
tempDirs.push(root); tempDirs.push(root);
const { manager, paths, serverStop } = await driveActiveDownload(root); const { manager, paths, serverStop } = await driveActiveDownload(root);
try { try {
// Mirrors AppController.installUpdate(): park downloads, then sync-persist.
manager.stop({ parkForRestart: true }); manager.stop({ parkForRestart: true });
manager.persistNowSync(); manager.persistNowSync();
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0); await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
@ -163,6 +178,7 @@ describe("update restart resume", () => {
const reloaded = loadSession(paths); const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0]; const item = Object.values(reloaded.items)[0];
expect(item).toBeTruthy(); expect(item).toBeTruthy();
// The package/item must survive AND be resumable so auto-resume continues it.
expect(Object.keys(reloaded.packages).length).toBe(1); expect(Object.keys(reloaded.packages).length).toBe(1);
expect(item.status).toBe("queued"); expect(item.status).toBe("queued");
} finally { } finally {

Some files were not shown because too many files have changed in this diff Show More