chore: remove all source code comments and internal artifacts

Strip every comment from the source (parsed with the TypeScript compiler so
strings, template literals, regex literals and JSX are never touched), and drop
internal/working artifacts that do not belong in the public repository
(design mockups, internal analysis docs, a stray backup file and an old log).
No functional change: build is green, the full test suite passes.
This commit is contained in:
Sucukdeluxe 2026-06-06 04:38:21 +02:00
parent f3159b9c6e
commit 3ed3877ac9
85 changed files with 23678 additions and 111725 deletions

13
.gitignore vendored
View File

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

View File

@ -1,361 +0,0 @@
<!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>

View File

@ -1,345 +0,0 @@
<!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>

View File

@ -1,297 +0,0 @@
<!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>

View File

@ -1,289 +0,0 @@
<!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>

View File

@ -1,95 +0,0 @@
<!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>

View File

@ -1,333 +0,0 @@
<!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>

View File

@ -1,298 +0,0 @@
<!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

@ -1,183 +0,0 @@
# 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

@ -1,91 +0,0 @@
# 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`

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -66,8 +66,6 @@ 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) {
if (!megaCookie) {
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {

View File

@ -116,7 +116,6 @@ function getGiteaRepo() {
}
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
} catch {
// try next remote
}
}
@ -256,9 +255,6 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
target_commitish: "main",
name: 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,
prerelease: false
};
@ -266,8 +262,6 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
if (created.ok) {
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) {
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
if (retry.ok) {
@ -285,11 +279,6 @@ async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, f
const fileSize = fs.statSync(filePath).size;
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) {
const fileStream = fs.createReadStream(filePath);
let response;
@ -331,7 +320,6 @@ async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, f
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
break;
}
// 5xx = transient -> neu versuchen; 4xx (ausser 409/422) = echter Fehler -> abbrechen.
if (response.status >= 500 && attempt < MAX_ATTEMPTS) {
process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
@ -390,9 +378,6 @@ async function main() {
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
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 }));
if (!published.ok) {
throw new Error(`Failed to publish release (${published.status}): ${JSON.stringify(published.body)}`);

View File

@ -4,18 +4,6 @@ import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/de
import { logger } from "./logger";
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 DEBRID_LINK_API = "https://debrid-link.com/api/v2";
const CHECK_USER_AGENT =
@ -55,7 +43,6 @@ function formatRemaining(premiumUntilMs: number | null, now: number): string {
return `Premium noch ${hours} Std`;
}
/** Check a single Mega-Debrid account via connectUser. */
export async function checkMegaDebridAccount(
account: MegaDebridAccountEntry,
signal?: AbortSignal,
@ -87,7 +74,6 @@ export async function checkMegaDebridAccount(
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
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 premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
const isPremium = premiumUntilMs > now;
@ -110,7 +96,6 @@ export async function checkMegaDebridAccount(
}
}
/** Check a single Debrid-Link API key via /account/infos. */
export async function checkDebridLinkKey(
key: DebridLinkApiKeyEntry,
signal?: AbortSignal,
@ -138,7 +123,6 @@ export async function checkDebridLinkKey(
const text = await response.text();
const payload = parseJsonSafe(text);
if (!response.ok || !payload) {
// 401 = bad/expired token
if (response.status === 401 || response.status === 403) {
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
}
@ -149,7 +133,6 @@ export async function checkDebridLinkKey(
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
}
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 accountType = Number(value.accountType || 0);
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
@ -175,8 +158,6 @@ 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(
settings: AppSettings,
signal?: AbortSignal
@ -185,9 +166,6 @@ export async function checkAllDebridAccounts(
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
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>> = [
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
@ -203,7 +181,6 @@ export async function checkAllDebridAccounts(
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[]> {
const results: T[] = new Array(taskFns.length);
let nextIndex = 0;

View File

@ -4,51 +4,30 @@ import path from "node:path";
import { AsyncLocalStorage } from "node:async_hooks";
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;
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> {
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";
/** 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 rotationEventRing: RotationEvent[] = [];
let rotationEventSeq = 0;
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 {
rotationEventListener = listener;
}
/** Returns the recent rotation events, newest first. */
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
const slice = rotationEventRing.slice(-limit);
slice.reverse();
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 {
return event !== "TEST";
}
@ -75,20 +54,14 @@ function pushRotationEvent(
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();
if (itemSink) {
try {
itemSink(entry);
} 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)) {
return;
}
@ -100,7 +73,6 @@ function pushRotationEvent(
try {
rotationEventListener(entry);
} catch {
// never let a UI push break the rotation flow
}
}
}
@ -147,11 +119,9 @@ function rotateIfNeeded(filePath: string): void {
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore
}
}
@ -164,7 +134,6 @@ function cleanupOldBackup(filePath: string): void {
fs.rmSync(backup, { force: true });
}
} catch {
// ignore
}
}
@ -190,13 +159,6 @@ 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(
level: RotationLevel,
provider: string,
@ -204,7 +166,6 @@ export function logAccountRotation(
event: string,
fields?: Record<string, unknown>
): void {
// Surface to the UI ring buffer regardless of whether the file log is ready.
pushRotationEvent(level, provider, accountLabel, event, fields);
if (!rotationLogPath) {
return;
@ -217,7 +178,6 @@ export function logAccountRotation(
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
} catch {
// ignore write errors
}
}
@ -239,7 +199,6 @@ export function shutdownAccountRotationLog(): void {
"utf8"
);
} catch {
// ignore
}
rotationLogPath = null;
}

View File

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

View File

@ -92,12 +92,6 @@ export class AppController {
initAuditLog(this.storagePaths.baseDir);
initAccountRotationLog(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;
try {
desktopDir = app.getPath("desktop");
@ -135,10 +129,6 @@ export class AppController {
appVersion: APP_VERSION,
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 {
const report = runStartupHealthCheck(this.settings, this.storagePaths);
if (report.errorCount > 0 || report.warnCount > 0) {
@ -181,8 +171,6 @@ export class AppController {
void this.manager.getStartConflicts().then((conflicts) => {
const hasConflicts = conflicts.length > 0;
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) {
logger.info("Auto-Resume beim Start aktiviert (nach Konflikt-Check)");
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
@ -225,7 +213,6 @@ export class AppController {
void this.manager.start().catch((err) => logger.warn(`Auto-Resume Start Fehler: ${String(err)}`));
logger.info("Auto-Resume beim Start aktiviert");
} else {
// Trigger pending extractions without starting the session
this.manager.triggerIdleExtractions();
}
}
@ -296,7 +283,6 @@ export class AppController {
return previousSettings;
}
// Preserve the live all-time counters from the download manager
const liveSettings = this.manager.getSettings();
nextSettings.totalDownloadedAllTime = Math.max(nextSettings.totalDownloadedAllTime || 0, liveSettings.totalDownloadedAllTime || 0);
nextSettings.totalCompletedFilesAllTime = Math.max(nextSettings.totalCompletedFilesAllTime || 0, liveSettings.totalCompletedFilesAllTime || 0);
@ -310,11 +296,6 @@ export class AppController {
nextSettings.debridLinkApiKeyTotalUsageBytes = Object.fromEntries(
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 || {}) };
const retentionChanged = previousSettings.historyRetentionMode !== nextSettings.historyRetentionMode;
this.settings = nextSettings;
@ -401,9 +382,6 @@ export class AppController {
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[]> {
const statuses = await checkAllDebridAccounts(this.settings);
this.manager.applyDebridAccountStatuses(statuses);
@ -415,9 +393,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
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> {
const entry = parseMegaDebridAccounts(`${login.trim()}:${password.trim()}`)[0];
if (!entry) {
@ -438,20 +413,9 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
}
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()) {
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();
const cacheAgeMs = Date.now() - this.lastUpdateCheckAt;
@ -672,11 +636,9 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
public importBackup(data: Buffer): { restored: boolean; message: string } {
let parsed: Record<string, unknown>;
try {
// Try encrypted MDD format first
const json = decryptBackup(data);
parsed = JSON.parse(json) as Record<string, unknown>;
} catch {
// Fallback: try legacy plaintext JSON (old backups)
try {
const json = data.toString("utf8");
parsed = JSON.parse(json) as Record<string, unknown>;
@ -688,11 +650,9 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
return { restored: false, message: "Kein gültiges Backup (settings/session fehlen)" };
}
// Restore settings — ALL credentials are included (no more masking)
const importedSettings = parsed.settings as AppSettings;
const importedSettingsRecord = importedSettings 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)[] = [
"token", "megaLogin", "megaPassword", "bestToken", "allDebridToken",
"ddownloadLogin", "ddownloadPassword", "oneFichierApiKey",
@ -709,19 +669,16 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
// Full stop including extraction abort
this.manager.stop();
this.manager.abortAllPostProcessing();
this.manager.clearPersistTimer();
cancelPendingAsyncSaves();
// Restore session
const restoredSession = normalizeLoadedSessionTransientFields(
normalizeLoadedSession(parsed.session)
);
saveSession(this.storagePaths, restoredSession);
// Restore history (if present in backup)
if (Array.isArray(parsed.history) && parsed.history.length > 0) {
const normalizedHistory = (parsed.history as unknown[])
.map((raw, idx) => normalizeHistoryEntry(raw, idx))
@ -734,15 +691,6 @@ public async checkDebridAccounts(): Promise<DebridAccountStatus[]> {
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.blockAllPersistence = true;
logger.info("Backup wiederhergestellt — App startet automatisch neu");

View File

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

View File

@ -1,22 +1,15 @@
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 ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12; // 96-bit IV for GCM
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const MAGIC = Buffer.from("MDD1"); // file signature
const MAGIC = Buffer.from("MDD1");
function deriveKey(): Buffer {
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 {
const key = deriveKey();
const iv = crypto.randomBytes(IV_LENGTH);
@ -26,10 +19,6 @@ export function encryptBackup(plaintext: string): Buffer {
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 {
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error("Backup-Datei zu kurz oder ungültig");

View File

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

View File

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

View File

@ -17,12 +17,12 @@ export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
export const REQUEST_RETRIES = 3;
export const CHUNK_SIZE = 512 * 1024;
export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDownloader: 500 KB)
export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout
export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment
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; // Internal detection threshold for disk backpressure
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes
export const WRITE_BUFFER_SIZE = 512 * 1024;
export const WRITE_FLUSH_TIMEOUT_MS = 2000;
export const ALLOCATION_UNIT_SIZE = 4096;
export const STREAM_HIGH_WATER_MARK = 512 * 1024;
export const DISK_BUSY_THRESHOLD_MS = 300;
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500;
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"]);

View File

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

View File

@ -24,20 +24,13 @@ 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 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"]);
/** 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"]);
/** 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_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]);
const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]);
const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]);
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([
"disabledServerHost",
"notFreeHost",
@ -48,7 +41,6 @@ const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
"fileNotAvailable"
]);
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>();
type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory };
@ -60,7 +52,7 @@ type DebridLinkRuntimeStatus = {
};
const debridLinkKeyCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
const debridLinkKeyRuntimeStatuses = new Map<string, DebridLinkRuntimeStatus>();
const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000; // 2 min cooldown per failed key
const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000;
const DEBRID_LINK_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000;
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;
@ -72,9 +64,6 @@ export function resetDebridLinkRuntimeStateForTests(): void {
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 {
for (const keyId of debridLinkKeyCooldowns.keys()) {
if (!activeKeyIds.has(keyId)) {
@ -87,9 +76,6 @@ export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): v
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()) {
const sepIdx = stateKey.indexOf("|");
const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey;
@ -100,12 +86,9 @@ 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 {
let removed = 0;
const grace = 60 * 60 * 1000; // keep 1h grace for debugging
const grace = 60 * 60 * 1000;
for (const [keyId, until] of debridLinkKeyCooldowns) {
if (until + grace < now) {
debridLinkKeyCooldowns.delete(keyId);
@ -178,13 +161,6 @@ function setDebridLinkKeyCooldownState(
clearDebridLinkKeyCooldownState(keyId);
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 existingUntil = Number(debridLinkKeyCooldowns.get(keyId) || 0);
const existingDetail = debridLinkKeyCooldownDetails.get(keyId);
@ -192,7 +168,6 @@ function setDebridLinkKeyCooldownState(
const existingIsStrongCategory = existingDetail
? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
: false;
// Keep existing if it's still active and either lasts longer or has a stronger category
if (existingUntil > Date.now()) {
if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
return;
@ -227,9 +202,6 @@ 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 debridLinkKeyHostCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
@ -251,8 +223,6 @@ function setDebridLinkKeyHostCooldownState(
category: DebridLinkCooldownCategory
): void {
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);
return;
}
@ -261,10 +231,6 @@ function setDebridLinkKeyHostCooldownState(
return;
}
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 existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey);
@ -282,8 +248,6 @@ function setDebridLinkKeyHostCooldownState(
}
debridLinkKeyHostCooldowns.set(stateKey, newUntil);
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(
@ -313,37 +277,21 @@ 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 MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; // 2 min cooldown per failed account
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
/** Zaehlt aufeinanderfolgende "Antwort leer"-Fehlversuche je Account-Key. Ein
* tageslimitierter Mega-Debrid-Account liefert im Web-Pfad KEINE unterscheidbare
* Meldung ("Kein Server" taucht in echten Logs nie auf immer nur "Antwort leer"),
* ist aber daran erkennbar, dass er PERSISTENT leer antwortet. Nach
* MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART aufeinanderfolgenden Leer-Antworten wird der
* Account bis Neustart geparkt; ein einzelner transienter Blip (Streak < Schwelle)
* behaelt den kurzen 20s-Cooldown. Ein Erfolg oder ein anderer Fehlertyp setzt den
* Zaehler zurueck. */
const megaDebridEmptyResponseStreaks = new Map<string, number>();
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 {
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
megaDebridEmptyResponseStreaks.set(accountId, streak);
return streak;
}
/** Setzt die "Antwort leer"-Streak zurueck (bei Erfolg oder einem anderen Fehlertyp). */
export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
megaDebridEmptyResponseStreaks.delete(accountId);
}
@ -353,7 +301,6 @@ export function resetMegaDebridRuntimeStateForTests(): void {
megaDebridEmptyResponseStreaks.clear();
}
/** Periodic cleanup of expired Mega-Debrid cooldown entries. */
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
let removed = 0;
const grace = 60 * 60 * 1000;
@ -370,7 +317,6 @@ export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldo
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 {
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
}
@ -387,9 +333,6 @@ function setMegaDebridAccountCooldownState(
untilRestart = false
): void {
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, {
until: Number.MAX_SAFE_INTEGER,
message,
@ -500,21 +443,17 @@ 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[] {
return getMegaDebridAccountList(settings).filter(
(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[] {
// Multi-account format: newline-separated "login:password" pairs in megaCredentials
const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
if (multiAccounts.length > 0) {
return multiAccounts;
}
// Backward compat: single legacy megaLogin/megaPassword
if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
}
@ -575,7 +514,6 @@ function parseRetryAfterMs(value: string | null): number {
return 0;
}
// Cap at 1 hour — floodDetected can mandate "retry after 1 hour"
const maxRetryMs = 60 * 60 * 1000;
const asSeconds = Number(text);
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
@ -1416,8 +1354,6 @@ export function extractRapidgatorFilenameFromHtml(html: string): string {
for (const pattern of patterns) {
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 normalized = normalizeResolvedFilename(raw);
if (normalized) {
@ -1499,12 +1435,10 @@ async function readResponseTextLimited(response: Response, maxBytes: number, sig
try {
await reader.cancel();
} catch {
// ignore
}
try {
reader.releaseLock();
} catch {
// ignore
}
}
@ -1536,7 +1470,7 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
if (!response.ok) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
try { await response.body?.cancel(); } catch { }
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
@ -1552,11 +1486,11 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
&& !contentType.includes("text/plain")
&& !contentType.includes("text/xml")
&& !contentType.includes("application/xml")) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
try { await response.body?.cancel(); } catch { }
return "";
}
if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
try { await response.body?.cancel(); } catch { }
return "";
}
@ -1613,7 +1547,6 @@ export async function checkRapidgatorOnline(
"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) {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
@ -1634,30 +1567,26 @@ export async function checkRapidgatorOnline(
if (!finalUrl.includes(fileId)) {
return { online: false, fileName: "", fileSize: null };
}
// HEAD 200 + URL still contains file ID → online
const fileName = filenameFromRapidgatorUrlPath(link);
return { online: true, fileName, fileSize: null };
}
// Non-OK, non-404: retry or give up
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
}
// HEAD inconclusive — fall through to GET
break;
} catch (error) {
const errorText = compactErrorText(error);
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
break; // fall through to GET
break;
}
await sleepWithSignal(retryDelay(attempt), signal);
}
}
// Slow path: GET request (downloads HTML, more thorough)
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
@ -1670,12 +1599,12 @@ export async function checkRapidgatorOnline(
});
if (response.status === 404) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
try { await response.body?.cancel(); } catch { }
return { online: false, fileName: "", fileSize: null };
}
if (!response.ok) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
try { await response.body?.cancel(); } catch { }
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
continue;
@ -1685,7 +1614,7 @@ export async function checkRapidgatorOnline(
const finalUrl = response.url || link;
if (!finalUrl.includes(fileId)) {
try { await response.body?.cancel(); } catch { /* drain socket */ }
try { await response.body?.cancel(); } catch { }
return { online: false, fileName: "", fileSize: null };
}
@ -1739,14 +1668,10 @@ class MegaDebridClient {
private allowApiFallback: boolean;
/** Per-account API token cache: login (lowercase) → { token, timestamp } */
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>>();
/** 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 {
const keep = new Set<string>();
for (const login of activeLogins) {
@ -1764,8 +1689,6 @@ 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 {
const key = String(login || "").toLowerCase();
MegaDebridClient.cachedApiTokens.delete(key);
@ -1786,13 +1709,11 @@ class MegaDebridClient {
private async connectApi(signal?: AbortSignal): Promise<string | null> {
const key = this.cacheKey;
// Return cached token if fresh (max 20 min)
const cached = MegaDebridClient.cachedApiTokens.get(key);
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
return cached.token;
}
// Deduplicate parallel connectUser calls — only one in-flight request per account
const pending = MegaDebridClient.pendingConnects.get(key);
if (pending) {
return pending;
@ -1855,7 +1776,6 @@ class MegaDebridClient {
});
const text = await response.text();
if (!response.ok) {
// Token might be invalid, clear cache
if (response.status === 401 || response.status === 403) {
this.clearTokenCache();
}
@ -1863,7 +1783,6 @@ class MegaDebridClient {
}
const payload = parseJsonSafe(text);
if (!payload || payload.response_code !== "ok") {
// Token expired — clear cache for next attempt
if (payload && String(payload.response_code || "").includes("token")) {
this.clearTokenCache();
}
@ -1915,10 +1834,6 @@ class MegaDebridClient {
if (!lastError) {
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)) {
break;
}
@ -1954,12 +1869,6 @@ class MegaDebridClient {
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(
settings: AppSettings,
mode: "api" | "web",
@ -1986,11 +1895,8 @@ class MegaDebridClient {
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
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) {
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 rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
@ -2004,7 +1910,6 @@ class MegaDebridClient {
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
continue;
}
// Cooldown key includes mode so API failures don't block Web attempts
const cooldownKey = `${account.id}:${mode}`;
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
if (accountCooldownState) {
@ -2021,9 +1926,6 @@ class MegaDebridClient {
until: untilStr
});
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) {
parkedUntilRestartSeen = true;
} else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
@ -2032,9 +1934,6 @@ class MegaDebridClient {
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...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
const testStartedAt = Date.now();
@ -2063,11 +1962,6 @@ class MegaDebridClient {
const elapsedMs = Date.now() - testStartedAt;
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 parkMessage = failure.message;
if (failure.limitSignal) {
@ -2101,7 +1995,6 @@ class MegaDebridClient {
: failure.cooldownMs > 0
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
: "";
// Find the next account that will be tried (for clearer log)
let nextLabel = "ENDE";
for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) {
const nextAcc = accounts[nextIdx];
@ -2128,9 +2021,6 @@ class MegaDebridClient {
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
}
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: Kein aktiver Account verfuegbar");
@ -2138,21 +2028,15 @@ class MegaDebridClient {
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(
error: unknown
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
// Abort — don't retry other accounts
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
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)) {
return {
fatal: false,
@ -2162,12 +2046,10 @@ 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)) {
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
}
// Quota/limit errors — cooldown, try next account
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
return {
fatal: false,
@ -2177,11 +2059,6 @@ 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)) {
return {
fatal: false,
@ -2192,7 +2069,6 @@ class MegaDebridClient {
};
}
// Rate limit
if (/rate.?limit|too.?many|429/i.test(errorText)) {
return {
fatal: false,
@ -2202,12 +2078,6 @@ 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)) {
return {
fatal: false,
@ -2218,8 +2088,6 @@ 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)) {
return {
fatal: false,
@ -2229,7 +2097,6 @@ class MegaDebridClient {
};
}
// Unknown errors — short cooldown, try next account (non-fatal)
return {
fatal: false,
cooldownMs: 30_000,
@ -2660,8 +2527,6 @@ export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapi
return results;
}
// ── Debrid-Link Client ──
class DebridLinkClient {
private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>;
@ -2689,12 +2554,8 @@ class DebridLinkClient {
const linkShort = String(link || "").slice(0, 80);
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) {
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 rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
@ -2722,9 +2583,6 @@ class DebridLinkClient {
}
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;
if (hostCooldownState) {
const untilStr = new Date(hostCooldownState.until).toLocaleTimeString();
@ -2742,9 +2600,6 @@ class DebridLinkClient {
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...`);
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
const testStartedAt = Date.now();
@ -2778,10 +2633,6 @@ class DebridLinkClient {
failures.push(`Debrid-Link${keyLabel}: ${failure.message}`);
if (failure.cooldownMs > 0) {
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(
apiKey.id,
failure.hoster || "",
@ -2797,10 +2648,6 @@ class DebridLinkClient {
if (failure.category === "invalid") {
setDebridLinkKeyRuntimeStatus(apiKey.id, "invalid", failure.message);
} 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);
}
}
@ -2814,8 +2661,6 @@ class DebridLinkClient {
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
}
if (failure.providerWide) {
// Host-level issue (e.g. notDebrid) — rotating to other keys is pointless.
// Break immediately and apply a longer cooldown (5 min) to avoid burning all keys.
const providerWideCooldownMs = 5 * 60 * 1000;
logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", {
@ -2827,11 +2672,9 @@ class DebridLinkClient {
});
throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`);
}
// Track consecutive transport failures (timeout/network) to detect cascades.
const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
if (consecutiveTransportFailures >= 2) {
// 2+ keys timed out in a row — likely a server/network issue, not key-specific.
const cascadeCooldownMs = 3 * 60 * 1000;
logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", {
@ -2842,7 +2685,6 @@ class DebridLinkClient {
});
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";
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
const nextKey = this.apiKeys[nextIdx];
@ -2968,8 +2810,6 @@ class DebridLinkClient {
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;
for (let poll = 0; poll < maxPolls; poll++) {
if (signal?.aborted) {
@ -2987,7 +2827,6 @@ class DebridLinkClient {
}
}
}
// Return last fetched entry (caller will detect missing URL and throw)
return (await this.fetchDownloaderEntry(apiKey, id, signal)) || chosen;
}
@ -3045,9 +2884,6 @@ class DebridLinkClient {
};
}
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 hosterRaw = extractHosterFromUrl(link);
const hosterLabel = hosterRaw || "host";
@ -3061,7 +2897,6 @@ class DebridLinkClient {
};
}
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);
return {
fatal: false,
@ -3071,7 +2906,6 @@ class DebridLinkClient {
};
}
if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
// notDebrid = host-level issue — affects ALL keys equally, do NOT rotate.
return {
fatal: false,
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
@ -3110,8 +2944,6 @@ 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)) {
return {
fatal: false,
@ -3122,11 +2954,6 @@ class DebridLinkClient {
}
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)
&& !(error instanceof DebridLinkApiError);
return {
@ -3136,9 +2963,6 @@ 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)) {
return {
fatal: false,
@ -3156,8 +2980,6 @@ class DebridLinkClient {
}
}
// ── LinkSnappy Client ──
class LinkSnappyClient {
private username: string;
private password: string;
@ -3249,7 +3071,6 @@ class LinkSnappyClient {
if (!directUrl) {
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://")) {
directUrl = directUrl.replace("http://", "https://");
}
@ -3300,8 +3121,6 @@ function parseFileSizeString(s: string): number {
return Math.floor(num * (multipliers[unit] || 1));
}
// ── 1Fichier Client ──
class OneFichierClient {
private apiKey: string;
@ -3375,7 +3194,6 @@ class DdownloadClient {
}
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`, {
headers: { "User-Agent": DDOWNLOAD_WEB_UA },
redirect: "manual",
@ -3385,7 +3203,6 @@ class DdownloadClient {
const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/);
const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; ");
// Step 2: POST login
const body = new URLSearchParams({
op: "login",
token: tokenMatch?.[1] || "",
@ -3406,8 +3223,7 @@ class DdownloadClient {
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
// Drain body
try { await loginRes.text(); } catch { /* ignore */ }
try { await loginRes.text(); } catch { }
const setCookies = loginRes.headers.getSetCookie?.() || [];
const xfss = setCookies.find((c: string) => c.startsWith("xfss="));
@ -3430,12 +3246,10 @@ class DdownloadClient {
try {
if (signal?.aborted) throw new Error("aborted:debrid");
// Login if no session yet
if (!this.cookies) {
await this.webLogin(signal);
}
// Step 1: GET file page to extract form fields
const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, {
headers: {
"User-Agent": DDOWNLOAD_WEB_UA,
@ -3445,10 +3259,9 @@ class DdownloadClient {
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
});
// Premium with direct downloads enabled → redirect immediately
if (filePageRes.status >= 300 && filePageRes.status < 400) {
const directUrl = filePageRes.headers.get("location") || "";
try { await filePageRes.text(); } catch { /* drain */ }
try { await filePageRes.text(); } catch { }
if (directUrl) {
return {
fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
@ -3462,18 +3275,15 @@ class DdownloadClient {
const html = await filePageRes.text();
// Check for file not found
if (/File Not Found|file was removed|file was banned/i.test(html)) {
throw new Error("DDownload: Datei nicht gefunden");
}
// Extract form fields
const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode;
const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || "";
const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+)</);
const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link);
// Step 2: POST download2 for premium download
const dlBody = new URLSearchParams({
op: "download2",
id: idVal,
@ -3498,7 +3308,7 @@ class DdownloadClient {
if (dlRes.status >= 300 && dlRes.status < 400) {
const directUrl = dlRes.headers.get("location") || "";
try { await dlRes.text(); } catch { /* drain */ }
try { await dlRes.text(); } catch { }
if (directUrl) {
return {
fileName: fileName || filenameFromUrl(directUrl),
@ -3511,7 +3321,6 @@ class DdownloadClient {
}
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);
if (directMatch) {
return {
@ -3523,7 +3332,6 @@ class DdownloadClient {
};
}
// Check for error messages
const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)</i);
if (errMatch) {
throw new Error(`DDownload: ${errMatch[1].trim()}`);
@ -3535,7 +3343,6 @@ class DdownloadClient {
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
break;
}
// Re-login on auth errors
if (/login|session|cookie/i.test(lastError)) {
this.cookies = "";
}
@ -3571,10 +3378,6 @@ export class DebridService {
const prev = this.settings;
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) {
this.cachedDebridLinkClient = null;
this.cachedDebridLinkKey = "";
@ -3588,12 +3391,6 @@ export class DebridService {
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 nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || "");
const nextLogins = new Set<string>();
@ -3602,17 +3399,13 @@ export class DebridService {
nextLogins.add(acc.login.toLowerCase());
nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password);
}
// Drop tokens for logins no longer present
MegaDebridClient.pruneCachedTokensNotIn(nextLogins);
// For logins still present but with a changed password, force-clear the token
for (const prevAcc of prevAccounts) {
const loginKey = prevAcc.login.toLowerCase();
if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) {
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));
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
}
@ -3683,14 +3476,9 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
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));
if (megaLinks.length > 0) {
await runWithConcurrency(megaLinks, 4, async (link) => {
@ -3704,8 +3492,6 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
throw error;
}
// Schluck — Public API kann fehlen oder rate-limiten; faellt auf
// den normalen Mega-Debrid Unrestrict-Pfad zurueck.
}
});
}
@ -3766,7 +3552,6 @@ export class DebridService {
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
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 hosterKey = extractHosterFromUrl(link);
if (hosterKey && routing[hosterKey]) {
@ -3795,7 +3580,6 @@ export class DebridService {
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}`);
// Fall through to normal provider chain
}
} else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) {
logger.info(`Hoster-Zuordnung ${hosterKey}${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`);
@ -3804,8 +3588,6 @@ 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")) {
try {
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
@ -3819,13 +3601,9 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
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")) {
try {
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
@ -3839,11 +3617,9 @@ export class DebridService {
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
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)
? uniqueProviderOrder(settings.providerOrder)
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);

View File

@ -116,7 +116,6 @@ function getPort(baseDir: string): number {
return n;
}
} catch {
// ignore
}
return DEFAULT_PORT;
}
@ -135,7 +134,6 @@ function getHost(baseDir: string): string {
return raw;
}
} catch {
// ignore
}
return DEFAULT_HOST;
}

View File

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

View File

@ -2,26 +2,6 @@ import fs from "node:fs";
import path from "node:path";
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";
const FOLDER_NAME = "Downloader-Log";
@ -30,8 +10,6 @@ let logDir: string | null = null;
let logFilePath: string | null = null;
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 {
const pad = (value: number): string => String(value).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
@ -65,9 +43,6 @@ function formatFields(fields?: Record<string, unknown>): string {
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 {
if (!logDir || !logFilePath) {
return false;
@ -83,9 +58,6 @@ 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 {
try {
const base = String(desktopDir || "").trim();
@ -108,9 +80,6 @@ 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 {
if (!ensureWritable() || !logFilePath) {
return;
@ -118,7 +87,6 @@ export function logDesktopRename(level: DesktopRenameLevel, message: string, fie
try {
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
} catch {
// Logging darf einen Download niemals abbrechen.
}
}
@ -138,7 +106,6 @@ export function shutdownDesktopRenameLog(): void {
try {
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
} catch {
// ignore
}
}
logDir = null;
@ -146,34 +113,16 @@ export function shutdownDesktopRenameLog(): void {
}
export interface RenameVerification {
/** Gesamtergebnis: Datei liegt unter dem EXAKT erwarteten Namen vor und (sofern kein
* In-Place-Rename) die Quelle ist verschwunden. */
ok: boolean;
/** Empfohlenes Log-Level: ERROR (Rename nicht vollzogen / falscher Name),
* WARN (vollzogen, aber Schreibweise nicht pruefbar), INFO (alles ok). */
level: "INFO" | "WARN" | "ERROR";
/** Zieldatei (egal welche Schreibweise) auf der Platte vorhanden? */
targetExists: boolean;
/** Tatsaechlicher Name auf der Platte (Gross-/Kleinschreibung wie wirklich
* gespeichert), oder null wenn nicht gefunden / Verzeichnis nicht lesbar. */
onDiskName: string | null;
/** onDiskName === erwarteter Zielname (exakt, case-sensitive)? */
nameMatches: boolean;
/** Quelldatei verschwunden (Rename wirklich vollzogen, kein halb-fertiger Copy)? */
sourceGone: boolean;
/** Groesse der Zieldatei in Bytes, oder null. */
targetSize: number | null;
/** Menschenlesbarer Grund, wenn nicht sauber INFO. */
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 {
const absolute = path.resolve(String(filePath || ""));
if (process.platform !== "win32") {
@ -191,9 +140,6 @@ function toLongPath(filePath: string): string {
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 {
if (entries === null) {
return null;
@ -204,8 +150,6 @@ function resolveOnDiskName(requested: string, entries: string[] | null): string
|| requested;
}
/** Baut das Verifikations-Ergebnis aus den (sync ODER async) erhobenen Roh-Fakten.
* `dirEntries`=null bedeutet "Zielverzeichnis war nicht lesbar". */
function buildVerification(
sourcePath: string,
targetPath: string,
@ -215,8 +159,6 @@ function buildVerification(
const dirReadFailed = facts.targetExists && 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 sourceGone = samePath ? true : !facts.sourceExists;
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
@ -235,7 +177,6 @@ function buildVerification(
level = "ERROR";
}
if (level === "INFO" && dirReadFailed) {
// Datei da + Quelle weg, aber Schreibweise ungeprueft — KEIN stilles OK.
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
level = "WARN";
}
@ -252,10 +193,6 @@ 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 {
const longTarget = toLongPath(targetPath);
let targetExists = false;
@ -285,9 +222,6 @@ export function verifyRename(sourcePath: string, targetPath: string): RenameVeri
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> {
const longTarget = toLongPath(targetPath);
let targetExists = false;

View File

@ -127,11 +127,6 @@ export function validateDownloadedFileCompletion(args: {
}
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) {
return {
ok: false,

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,3 @@
// ════════════════════════════════════════════════════════════════════════════
// Sektion 1 — Imports & Konstanten
// ════════════════════════════════════════════════════════════════════════════
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
@ -47,10 +43,6 @@ const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
let currentExtractCpuPriority: string | undefined;
// ════════════════════════════════════════════════════════════════════════════
// Sektion 2 — Types & Interfaces
// ════════════════════════════════════════════════════════════════════════════
export interface ExtractOptions {
packageDir: string;
targetDir: string;
@ -169,20 +161,15 @@ interface DaemonRequest {
passwordCount: number;
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 3 — Subst Drive Mapping (Windows long-path workaround)
// ════════════════════════════════════════════════════════════════════════════
const activeSubstDrives = new Set<string>();
function findFreeSubstDrive(): string | null {
if (process.platform !== "win32") return null;
for (let code = 90; code >= 71; code--) { // Z to G
for (let code = 90; code >= 71; code--) {
const letter = String.fromCharCode(code);
if (activeSubstDrives.has(letter)) continue;
try {
fs.accessSync(`${letter}:\\`);
// Drive exists, skip
} catch {
return letter;
}
@ -226,14 +213,9 @@ export function cleanupStaleSubstDrives(): void {
}
}
} catch {
// ignore — subst cleanup is best-effort
}
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 4 — Archiv-Erkennung & Kandidaten
// ════════════════════════════════════════════════════════════════════════════
export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> {
let fd: fs.promises.FileHandle | null = null;
try {
@ -368,7 +350,6 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
return !fileNamesLower.has(`${fileName}.001`.toLowerCase());
});
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 fileName = archiveDetectionName(filePath).toLowerCase();
if (!/\.001$/.test(fileName)) return false;
@ -406,10 +387,6 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
return unique;
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 5 — Cleanup & Dateisystem
// ════════════════════════════════════════════════════════════════════════════
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@ -438,8 +415,6 @@ 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 addCompanions = (stemRe: string): void => {
for (const candidate of filesInDir) {
@ -504,12 +479,10 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
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)) {
return Array.from(targets);
}
// Generic .NNN split files (HJSplit etc.)
const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i);
if (genericSplit) {
const stem = escapeRegex(genericSplit[1]);
@ -572,7 +545,6 @@ export async function cleanupArchives(
index += 1;
}
} catch {
// ignore
}
return false;
};
@ -595,7 +567,6 @@ export async function cleanupArchives(
await fs.promises.rm(filePath, { force: true });
removed += 1;
} catch {
// ignore
}
}
return removed;
@ -684,16 +655,11 @@ export async function removeEmptyDirectoryTree(rootDir: string): Promise<number>
removed += 1;
}
} catch {
// ignore
}
}
return removed;
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 6 — Passwort-Management (LRU-Cache & Kandidaten)
// ════════════════════════════════════════════════════════════════════════════
function packagePasswordCacheKey(packageDir: string, packageId?: string): string {
const normalizedPackageId = String(packageId || "").trim();
if (normalizedPackageId) {
@ -715,7 +681,6 @@ function readCachedPackagePassword(cacheKey: string): string {
if (!cached) {
return "";
}
// Refresh insertion order to keep recently used package caches alive.
packageLearnedPasswords.delete(cacheKey);
packageLearnedPasswords.set(cacheKey, cached);
return cached;
@ -742,24 +707,6 @@ function clearCachedPackagePassword(cacheKey: string): void {
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 } {
const learnedCleared = packageLearnedPasswords.size;
packageLearnedPasswords.clear();
@ -820,10 +767,6 @@ function prioritizePassword(passwords: string[], successful: string): string[] {
return next;
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 7 — Fehler-Klassifizierung
// ════════════════════════════════════════════════════════════════════════════
export function cleanErrorText(text: string): string {
const normalized = String(text || "").replace(/\s+/g, " ").trim();
if (normalized.length <= 500) {
@ -964,10 +907,6 @@ function isJvmRuntimeMissingError(errorText: string): boolean {
|| text.includes("enoent");
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 8 — Backend-Modus (auto / jvm / legacy)
// ════════════════════════════════════════════════════════════════════════════
export function resolveExtractorBackendMode(
rawValue?: string | null,
isVitestEnv = Boolean(process.env.VITEST)
@ -993,9 +932,6 @@ export function resolveExtractorBackendModeForArchive(
if (requestedMode !== "auto") {
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)) {
return "legacy";
}
@ -1014,10 +950,6 @@ function isRarArchivePath(filePath: string): boolean {
return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || ""));
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 9 — Native Extractor Resolution (7-Zip / WinRAR)
// ════════════════════════════════════════════════════════════════════════════
function is7zCommand(command: string): boolean {
const lower = command.toLowerCase();
return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar");
@ -1229,12 +1161,6 @@ async function findAlternativeExtractor(currentCommand: string, archivePath = ""
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 {
const totalGb = os.totalmem() / (1024 ** 3);
const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16));
@ -1301,21 +1227,15 @@ function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?:
try {
os.setPriority(pid, extractOsPriority(cpuPriority));
} catch {
// ignore: priority lowering is best-effort
}
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 11 — Prozess-Ausführung (spawn, kill, progress parsing)
// ════════════════════════════════════════════════════════════════════════════
function killProcessTree(child: { pid?: number; kill: () => void }): void {
const pid = Number(child.pid || 0);
if (!Number.isFinite(pid) || pid <= 0) {
try {
child.kill();
} catch {
// ignore
}
return;
}
@ -1330,14 +1250,12 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
try {
child.kill();
} catch {
// ignore
}
});
} catch {
try {
child.kill();
} catch {
// ignore
}
}
return;
@ -1346,7 +1264,6 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
try {
child.kill();
} catch {
// ignore
}
}
@ -1503,10 +1420,6 @@ function runExtractCommand(
});
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 12 — JVM Backend & Daemon
// ════════════════════════════════════════════════════════════════════════════
let cachedJvmLayout: JvmExtractorLayout | null | undefined;
let cachedJvmLayoutNullSince = 0;
const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000;
@ -1628,10 +1541,6 @@ 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 daemonReady = false;
let daemonBusy = false;
@ -1645,8 +1554,8 @@ let daemonLayout: JvmExtractorLayout | null = null;
export function shutdownDaemon(): void {
if (daemonProcess) {
try { daemonProcess.stdin?.end(); } catch { /* ignore */ }
try { killProcessTree(daemonProcess); } catch { /* ignore */ }
try { daemonProcess.stdin?.end(); } catch { }
try { killProcessTree(daemonProcess); } catch { }
daemonProcess = null;
}
daemonReady = false;
@ -1822,7 +1731,6 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
});
}
// Clean up tmp dir
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
daemonProcess = null;
daemonReady = false;
@ -1845,7 +1753,6 @@ function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
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> {
return new Promise((resolve) => {
const start = Date.now();
@ -1958,14 +1865,12 @@ async function runJvmExtractCommand(
});
}
// Try persistent daemon first — saves ~5s JVM boot per archive
if (isDaemonAvailable(layout)) {
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
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);
}
// Daemon exists but is still booting or busy — wait up to 15s for it
if (daemonProcess) {
const reason = !daemonReady ? "booting" : "busy";
const waitStartedAt = Date.now();
@ -1980,7 +1885,6 @@ async function runJvmExtractCommand(
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)}`);
const mode = effectiveConflictMode(conflictMode);
@ -2149,10 +2053,6 @@ async function runJvmExtractCommand(
});
}
// ════════════════════════════════════════════════════════════════════════════
// Sektion 13 — Legacy Extraction (buildExternalExtractArgs, runExternalExtract*)
// ════════════════════════════════════════════════════════════════════════════
export function buildExternalExtractArgs(
command: string,
archivePath: string,
@ -2179,7 +2079,6 @@ export function buildExternalExtractArgs(
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));
async function runExternalExtractInner(
@ -2213,7 +2112,6 @@ async function runExternalExtractInner(
let createErrorText = "";
let createErrorPassword = "";
// Skip normal extraction loop if flat mode is already known to be needed for this package
if (forceFlatMode) {
logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
@ -2330,8 +2228,6 @@ async function runExternalExtractInner(
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 : "");
if (pathCreateError) {
const flatPasswords = createErrorPassword
@ -2455,7 +2351,6 @@ async function runExternalExtract(
}
}
// Use a short drive mapping for legacy native extractors on Windows.
subst = createSubstMapping(targetDir);
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
if (subst) {
@ -2508,7 +2403,6 @@ async function runExternalExtract(
const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password";
let finalLegacyError: Error;
// Retry once after a short delay to let Windows flush freshly completed archive parts.
if (isCrcOrWrongPw && !signal?.aborted) {
const retryDelayMs = 2500;
logger.warn(
@ -2634,10 +2528,6 @@ async function runExternalExtract(
}
}
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 14 ZIP Extraction (AdmZip)
// ══════════════════════════════════════════════════════════════════════════════
function isZipSafetyGuardError(error: unknown): boolean {
const text = String(error || "").toLowerCase();
return text.includes("path traversal")
@ -2726,9 +2616,6 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
let outputKey = pathSetKey(outputPath);
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);
if (outputExists) {
if (mode === "skip") {
@ -2778,10 +2665,6 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
}
}
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 15 Disk Space, Timeout & Memory Limits
// ══════════════════════════════════════════════════════════════════════════════
async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> {
let total = 0;
for (const archivePath of candidates) {
@ -2789,7 +2672,7 @@ async function estimateArchivesTotalBytes(candidates: string[]): Promise<number>
for (const part of parts) {
try {
total += (await fs.promises.stat(part)).size;
} catch { /* missing part, ignore */ }
} catch { }
}
}
return total;
@ -2852,7 +2735,6 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
try {
totalBytes += (await fs.promises.stat(filePath)).size;
} catch {
// ignore missing parts
}
}
if (totalBytes <= 0) {
@ -2866,10 +2748,6 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
}
}
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 16 Resume State
// ══════════════════════════════════════════════════════════════════════════════
function extractProgressFilePath(packageDir: string, packageId?: string): string {
if (packageId) {
return path.join(packageDir, `.rd_extract_progress_${packageId}.json`);
@ -2905,7 +2783,6 @@ async function writeExtractResumeState(packageDir: string, completedArchives: Se
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.rename(tmpPath, progressPath).catch(async () => {
// rename may fail if another writer renamed tmpPath first (parallel workers)
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
});
} catch (error) {
@ -2917,14 +2794,9 @@ export async function clearExtractResumeState(packageDir: string, packageId?: st
try {
await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true });
} catch {
// ignore
}
}
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 17 Progress & Conflict Helpers
// ══════════════════════════════════════════════════════════════════════════════
function emitExtractLog(
onLog: ExtractOptions["onLog"] | undefined,
level: "INFO" | "WARN" | "ERROR",
@ -2950,10 +2822,6 @@ function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip"
return "skip";
}
// ══════════════════════════════════════════════════════════════════════════════
// Sektion 18 extractPackageArchives (Orchestrierung)
// ══════════════════════════════════════════════════════════════════════════════
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
if (options.signal?.aborted) {
throw new Error("aborted:extract");
@ -2969,12 +2837,11 @@ 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}`);
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) {
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
try {
await fs.promises.mkdir(options.targetDir, { recursive: true });
} catch { /* ignore */ }
} catch { }
await checkDiskSpaceForExtraction(options.targetDir, candidates);
}
@ -3096,9 +2963,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
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) {
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
@ -3131,8 +2995,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
}, 1100);
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 nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
const orderedNonEmpty = learnedPassword
@ -3150,7 +3012,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
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);
if (isGenericSplit) {
const sig = await detectArchiveSignature(archivePath);
@ -3185,7 +3046,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
}
: undefined;
try {
// Set module-level priority before each extract call (race-safe: spawn is synchronous)
currentExtractCpuPriority = options.extractCpuPriority;
const ext = path.extname(archivePath).toLowerCase();
if (ext === ".zip") {
@ -3297,7 +3157,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
if (options.signal?.aborted || noExtractorEncountered) break;
await extractSingleArchive(archivePath);
}
// Count remaining archives as failed when no extractor was found
if (noExtractorEncountered) {
const remaining = candidates.length - (extracted + failed);
if (remaining > 0) {
@ -3306,8 +3165,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
}
}
} 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;
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
@ -3318,7 +3175,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} catch (err) {
const errText = String(err);
if (/aborted:extract/i.test(errText)) throw err;
// noextractor:skipped — handled by noExtractorEncountered flag below
}
parallelQueue = pendingCandidates.slice(1);
if (parallelQueue.length > 0) {
@ -3327,7 +3183,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
}
if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) {
// Parallel extraction pool: N workers pull from a shared queue
const queue = [...parallelQueue];
let nextIdx = 0;
let abortError: Error | null = null;
@ -3342,24 +3197,20 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
} catch (error) {
const errText = String(error);
if (errText.includes("noextractor:skipped")) {
break; // handled by noExtractorEncountered flag after the pool
break;
}
if (isExtractAbortError(errText)) {
abortError = error instanceof Error ? error : new Error(errText);
break;
}
// Non-abort errors are already handled inside extractSingleArchive
}
}
};
const workerCount = Math.min(maxParallel, parallelQueue.length);
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];
await Promise.all(Array.from({ length: workerCount }, () => worker()));
// Restore passwordCandidates from frozen snapshot (parallel mutations are discarded).
passwordCandidates = frozenPasswords;
if (abortError) throw new Error("aborted:extract");
@ -3391,11 +3242,6 @@ 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) {
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
if (failedArchives.length > 0) {
@ -3404,14 +3250,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
for (const archivePath of failedArchives) {
if (options.signal?.aborted || noExtractorEncountered) break;
try {
// Reset failed count for this archive before retry
failed -= 1;
await extractSingleArchive(archivePath);
retryRecovered += 1;
} catch (retryError) {
const errText = String(retryError);
if (isExtractAbortError(errText)) throw retryError;
// extractSingleArchive already incremented failed and logged the error
}
}
if (retryRecovered > 0) {
@ -3430,7 +3274,6 @@ 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) {
try {
const nestedCandidates = (await findArchiveCandidates(options.targetDir))
@ -3559,7 +3402,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
await fs.promises.rm(options.targetDir, { recursive: true, force: true });
}
} catch {
// ignore
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,16 +1,5 @@
/**
* 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 {
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 sign = offsetMinutes >= 0 ? "+" : "-";
const absOffset = Math.abs(offsetMinutes);

View File

@ -70,7 +70,6 @@ function writeStderr(text: string): void {
try {
process.stderr.write(text);
} catch {
// ignore stderr failures
}
}
@ -136,11 +135,9 @@ function rotateIfNeeded(filePath: string): void {
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore - file may not exist yet
}
}
@ -160,7 +157,6 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
await fs.promises.rm(backup, { force: true }).catch(() => {});
await fs.promises.rename(filePath, backup);
} catch {
// ignore - file may not exist yet
}
}
@ -215,7 +211,7 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
pendingChars += line.length;
for (const listener of logListeners) {
try { listener(line); } catch { /* ignore */ }
try { listener(line); } catch { }
}
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {

View File

@ -9,7 +9,6 @@ import { APP_NAME } from "./constants";
import { extractHttpLinksFromText } from "./utils";
import { cleanupStaleSubstDrives, shutdownDaemon } from "./extractor";
/* ── IPC validation helpers ────────────────────────────────────── */
function validateString(value: unknown, name: string): string {
if (typeof value !== "string") {
throw new Error(`${name} muss ein String sein`);
@ -44,14 +43,12 @@ function validateStringArray(value: unknown, name: string): string[] {
return value as string[];
}
/* ── Single Instance Lock ───────────────────────────────────────── */
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.exit(0);
process.exit(0);
}
/* ── Unhandled error protection ─────────────────────────────────── */
process.on("uncaughtException", (error) => {
logger.error(`Uncaught Exception: ${String(error?.stack || error)}`);
});
@ -137,9 +134,6 @@ function createTray(): void {
try {
tray = new Tray(iconPath);
} 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.`);
return;
}
@ -282,7 +276,6 @@ function registerIpcHandlers(): void {
const result = controller.updateSettings(validated as Partial<AppSettings>);
updateClipboardWatcher();
updateTray();
// Manage scheduled-start timer
if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
@ -291,7 +284,6 @@ function registerIpcHandlers(): void {
if (schedMs > 0) {
const delay = schedMs - Date.now();
if (delay <= 0) {
// Time already passed — start immediately and clear setting
void controller.start().catch((err) => logger.warn(`Scheduled-Start Fehler: ${String(err)}`));
controller.updateSettings({ scheduledStartEpochMs: 0 });
} else {
@ -345,7 +337,6 @@ function registerIpcHandlers(): void {
});
ipcMain.handle(IPC_CHANNELS.CLEAR_ALL, () => controller.clearAll());
ipcMain.handle(IPC_CHANNELS.START, () => {
// Cancel any pending scheduled start when the user starts manually
if (scheduledStartTimer !== null) {
clearTimeout(scheduledStartTimer);
scheduledStartTimer = null;
@ -674,12 +665,6 @@ function registerIpcHandlers(): void {
const data = await fs.promises.readFile(filePath);
const importResult = controller.importBackup(data);
if (importResult.restored) {
// M2: Nach erfolgreichem Import die App automatisch neu starten. Der frische
// Prozess lädt die wiederhergestellte Session sauber vom Disk (kein Stale-
// 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(() => {
app.relaunch();
app.quit();

View File

@ -1,19 +1,3 @@
// 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";
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
@ -53,7 +37,6 @@ export function parseMegaUrl(url: string): ParsedMegaLink | null {
if (!m) return null;
const id = m[1];
const rawKey = base64UrlDecode(m[2]);
// Files: 32 Bytes (256 bit). Folders: 16 Bytes — wir behandeln nur Files.
if (!rawKey || rawKey.length !== 32) return null;
return { id, rawKey };
}
@ -123,8 +106,6 @@ export async function resolveMegaFilename(
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 (!Array.isArray(payload) || payload.length === 0) return null;

View File

@ -16,13 +16,6 @@ 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_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;
function normalizeLink(link: string): string {
@ -228,11 +221,6 @@ export class MegaWebFallback {
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 }>();
public constructor(getCredentials: () => MegaCredentials) {
@ -247,7 +235,6 @@ export class MegaWebFallback {
const overallSignal = withTimeoutSignal(signal, 180000);
return this.runExclusive(async () => {
throwIfAborted(overallSignal);
// Per-Account-Creds aus der Rotation bevorzugen; sonst Legacy-Default.
const creds = (account && account.login.trim() && account.password.trim())
? account
: this.getCredentials();
@ -259,7 +246,6 @@ export class MegaWebFallback {
let generated = await this.generate(link, cookie, overallSignal);
if (!generated) {
// Session evtl. abgelaufen → fuer DIESEN Login neu einloggen + einmal erneut.
this.sessions.delete(key);
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
generated = await this.generate(link, cookie, overallSignal);
@ -276,8 +262,6 @@ export class MegaWebFallback {
}, 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> {
const existing = this.sessions.get(key);
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
@ -368,17 +352,12 @@ export class MegaWebFallback {
const html = await page.text();
// Check for permanent hoster errors before looking for debrid codes
const pageErrors = parsePageErrors(html);
const permanentError = isPermanentHosterError(pageErrors);
if (permanentError) {
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
}
// 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));
if (noServerError) {
throw new Error(`Mega-Web: ${noServerError}`);
@ -426,11 +405,6 @@ export class MegaWebFallback {
continue;
}
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)) {
throw new Error(`Mega-Web: ${serverMsg}`);
}

View File

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

View File

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

View File

@ -82,8 +82,6 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
await sleep(ms);
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) {
throw new Error("aborted");
}

View File

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

View File

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

View File

@ -5,17 +5,6 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
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 interface HealthCheckFinding {
@ -32,8 +21,8 @@ export interface HealthCheckReport {
infoCount: number;
}
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; // 50 MB
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
function safeExists(p: string): boolean {
try {
@ -52,9 +41,6 @@ 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 {
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
try {
@ -66,12 +52,8 @@ 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 {
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;
if (typeof statfs !== "function") {
return null;
@ -123,12 +105,9 @@ function countConfiguredProviders(settings: AppSettings): { count: number; provi
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 {
const findings: HealthCheckFinding[] = [];
// ── 1. Download directory ───────────────────────────────────────────────
const outputDir = String(settings.outputDir || "").trim();
if (!outputDir) {
findings.push({
@ -152,7 +131,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
});
} else {
// Check available disk space only when the directory is actually usable
const freeBytes = getFreeDiskSpaceBytes(outputDir);
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
const freeMb = Math.round(freeBytes / (1024 * 1024));
@ -165,7 +143,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
}
}
// ── 2. Provider-Credentials ─────────────────────────────────────────────
const { count, providers } = countConfiguredProviders(settings);
if (count === 0) {
findings.push({
@ -182,7 +159,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
});
}
// ── 3. State-File-Groesse ──────────────────────────────────────────────
if (safeExists(storagePaths.sessionFile)) {
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
@ -196,7 +172,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
}
}
// ── 4. Storage-Basis-Verzeichnis muss beschreibbar sein (fuer Logs) ────
if (!safeExists(storagePaths.baseDir)) {
findings.push({
severity: "ERROR",

View File

@ -120,7 +120,6 @@ function normalizeColumnOrder(raw: unknown): string[] {
result.push(col);
}
}
// "name" is mandatory — ensure it's always present
if (!seen.has("name")) {
result.unshift("name");
}
@ -309,7 +308,6 @@ function normalizeProviderOrder(
if (Array.isArray(raw) && raw.length > 0) {
list = raw;
} else {
// Migrate from old primary/secondary/tertiary
const candidates = [legacyPrimary, legacySecondary, legacyTertiary].filter(
(v) => v && String(v).trim() && String(v).trim() !== "none"
);
@ -347,7 +345,6 @@ export function normalizeSettings(settings: AppSettings): AppSettings {
const currentUsageDay = getProviderUsageDayKey();
const megaLogin = asText(settings.megaLogin);
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();
if (!megaCredentials && megaLogin && megaPassword) {
megaCredentials = `${megaLogin}:${megaPassword}`;
@ -575,7 +572,6 @@ function ensureBaseDir(baseDir: string): void {
}
}
/** JSON replacer that sanitizes NaN/Infinity to null to prevent file corruption. */
function safeJsonReplacer(_key: string, value: unknown): unknown {
if (typeof value === "number" && !Number.isFinite(value)) {
return null;
@ -599,12 +595,8 @@ function readSettingsFile(filePath: string): AppSettings | null {
});
return sanitizeCredentialPersistence(merged);
} 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 || "";
if (code === "ENOENT") {
// file doesn't exist — normal on first run
} 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 || "?"}`);
} else {
@ -790,7 +782,6 @@ export function loadSettings(paths: StoragePaths): AppSettings {
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile);
} catch {
// ignore restore write failure
}
return backupLoaded;
}
@ -821,18 +812,15 @@ function sessionBackupPath(sessionFile: string): string {
}
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"]);
for (const item of Object.values(session.items)) {
if (ACTIVE_STATUSES.has(item.status)) {
item.status = "queued";
item.lastError = "";
}
// Always clear stale speed values
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"]);
for (const pkg of Object.values(session.packages)) {
if (ACTIVE_PKG_STATUSES.has(pkg.status)) {
@ -841,7 +829,6 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
pkg.postProcessLabel = undefined;
}
// Clear stale session-level running/paused flags
session.running = false;
session.paused = false;
@ -850,9 +837,6 @@ export function normalizeLoadedSessionTransientFields(session: SessionState): Se
function readSessionFile(filePath: string): SessionState | null {
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 session = normalizeLoadedSessionTransientFields(normalizeLoadedSession(parsed));
const pkgCount = Object.keys(session.packages).length;
@ -872,12 +856,10 @@ function readSessionFile(filePath: string): SessionState | null {
export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
ensureBaseDir(paths.baseDir);
// Create a backup of the existing config before overwriting
if (fs.existsSync(paths.configFile)) {
try {
fs.copyFileSync(paths.configFile, `${paths.configFile}.bak`);
} catch {
// Best-effort backup; proceed even if it fails
}
}
const persisted = sanitizeCredentialPersistence(normalizeSettings(settings));
@ -887,7 +869,7 @@ export function saveSettings(paths: StoragePaths, settings: AppSettings): void {
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.configFile);
} catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
try { fs.rmSync(tempPath, { force: true }); } catch { }
throw error;
}
}
@ -956,11 +938,6 @@ export function loadSession(paths: StoragePaths): SessionState {
ensureBaseDir(paths.baseDir);
const backupFile = sessionBackupPath(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) {
const hasRecoverable = fs.existsSync(backupFile)
|| fs.existsSync(sessionTempPath(paths.sessionFile, "sync"))
@ -974,7 +951,6 @@ export function loadSession(paths: StoragePaths): SessionState {
const primary = primaryExists ? readSessionFile(paths.sessionFile) : null;
// If primary loaded but is empty, check if backup has packages (safety net)
if (primary) {
const primaryPkgCount = Object.keys(primary.packages).length;
if (primaryPkgCount === 0 && fs.existsSync(backupFile)) {
@ -989,7 +965,6 @@ export function loadSession(paths: StoragePaths): SessionState {
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch {
// ignore restore write failure
}
return backup;
}
@ -1007,12 +982,10 @@ export function loadSession(paths: StoragePaths): SessionState {
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch {
// ignore restore write failure
}
return backup;
}
// Last resort: try to recover from temp files left by interrupted writes
for (const kind of ["sync", "async"] as const) {
const tmpPath = sessionTempPath(paths.sessionFile, kind);
if (fs.existsSync(tmpPath)) {
@ -1023,7 +996,6 @@ export function loadSession(paths: StoragePaths): SessionState {
const payload = JSON.stringify({ ...tmpSession, updatedAt: Date.now() }, safeJsonReplacer);
fs.writeFileSync(paths.sessionFile, payload, "utf8");
} catch {
// ignore restore write failure
}
return tmpSession;
}
@ -1041,7 +1013,6 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
try {
fs.copyFileSync(paths.sessionFile, sessionBackupPath(paths.sessionFile));
} catch {
// Best-effort backup; proceed even if it fails
}
}
const payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
@ -1050,7 +1021,7 @@ export function saveSession(paths: StoragePaths, session: SessionState): void {
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.sessionFile);
} catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
try { fs.rmSync(tempPath, { force: true }); } catch { }
throw error;
}
}
@ -1064,7 +1035,6 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
await fsp.copyFile(paths.sessionFile, sessionBackupPath(paths.sessionFile)).catch(() => {});
const tempPath = sessionTempPath(paths.sessionFile, "async");
await fsp.writeFile(tempPath, payload, "utf8");
// If a synchronous save occurred after this async save started, discard the stale write
if (generation < syncSaveGeneration) {
await fsp.rm(tempPath, { force: true }).catch(() => {});
return;
@ -1088,11 +1058,6 @@ async function writeSessionPayload(paths: StoragePaths, payload: string, generat
async function saveSessionPayloadAsync(paths: StoragePaths, payload: string, generation: number): Promise<void> {
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 };
return;
}
@ -1118,8 +1083,6 @@ export function cancelPendingAsyncSaves(): 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 payload = JSON.stringify({ ...session, updatedAt: Date.now() }, safeJsonReplacer);
await saveSessionPayloadAsync(paths, payload, generation);
@ -1180,7 +1143,7 @@ export function saveHistory(paths: StoragePaths, entries: HistoryEntry[]): void
fs.writeFileSync(tempPath, payload, "utf8");
syncRenameWithExdevFallback(tempPath, paths.historyFile);
} catch (error) {
try { fs.rmSync(tempPath, { force: true }); } catch { /* ignore */ }
try { fs.rmSync(tempPath, { force: true }); } catch { }
throw error;
}
}
@ -1223,7 +1186,6 @@ export function clearHistory(paths: StoragePaths): void {
try {
fs.unlinkSync(paths.historyFile);
} catch {
// ignore
}
}
}

View File

@ -52,7 +52,6 @@ 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 {
if (!fs.existsSync(dirPath)) {
return 0;
@ -68,7 +67,7 @@ function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string,
zip.addLocalFile(fullPath, zipRoot, entry.name);
added += 1;
}
} catch { /* ignorieren */ }
} catch { }
}
return added;
}
@ -187,16 +186,11 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
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;
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, "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) {
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
}

View File

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

View File

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

View File

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

View File

@ -115,8 +115,6 @@ interface AccountDialogState {
megaAccounts: MegaDialogAccount[];
megaNewLogin: 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[];
}
@ -467,17 +465,12 @@ function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[]
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"]);
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
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 inOrder = new Set(ordered);
// Füge neue Provider hinten an, die noch nicht in der Reihenfolge sind
for (const p of active) {
if (!inOrder.has(p)) ordered.push(p);
}
@ -609,7 +602,6 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
case "megadebrid-api":
case "megadebrid-web": {
// Populate megaAccounts from megaCredentials, or build from legacy megaLogin/megaPassword
let megaToken = (settings.megaCredentials || "").trim();
if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) {
megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`;
@ -1288,7 +1280,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
maxSpeed = Math.max(maxSpeed, 1024 * 1024);
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
// Measure widest label to set dynamic left padding
ctx.font = "11px 'Manrope', sans-serif";
let maxLabelWidth = 0;
for (let i = 0; i <= 5; i += 1) {
@ -1371,12 +1362,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
}, [running, paused]);
useEffect(() => {
// Always draw once on mount / when running/paused state changes so the
// chart shows the latest history.
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) {
return;
}
@ -1387,7 +1373,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
}, [drawChart, running, paused]);
useEffect(() => {
// Only record samples while the session is running and not paused
if (!running || paused) return;
const now = Date.now();
@ -1443,7 +1428,6 @@ function createScheduleId(): string {
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[] {
const sorted = [...order];
sorted.sort((a, b) => {
@ -1578,10 +1562,6 @@ export function App(): ReactElement {
const settingsDraftRevisionRef = useRef(0);
const panelDirtyRevisionRef = useRef(0);
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 snapshotRef = useRef(snapshot);
snapshotRef.current = snapshot;
@ -1616,7 +1596,6 @@ export function App(): ReactElement {
const [showAllPackages, setShowAllPackages] = useState(false);
const [actionBusy, setActionBusy] = 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 actionBusyRef = useRef(false);
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -1691,7 +1670,6 @@ export function App(): ReactElement {
window.addEventListener("mouseup", stopAccountColumnResize);
}, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]);
// Load history when tab changes to history
useEffect(() => {
if (tab !== "history") return;
const loadHistory = async (): Promise<void> => {
@ -1713,7 +1691,6 @@ export function App(): ReactElement {
try {
window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths));
} catch {
// Ignore local persistence failures for optional UI state.
}
}, [accountColumnWidths]);
@ -1722,16 +1699,10 @@ export function App(): ReactElement {
try {
window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY);
} catch {
// Ignore local persistence failures for optional UI state.
}
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(
() => (snapshot.settings.columnOrder || []).join("|"),
[snapshot.settings.columnOrder]
@ -1741,7 +1712,6 @@ export function App(): ReactElement {
if (order && order.length > 0) {
setColumnOrder(order);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnOrderKey]);
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
@ -1917,7 +1887,6 @@ export function App(): ReactElement {
if (!mountedRef.current) {
return;
}
// Seed the master snapshot — incoming delta payloads will merge into this.
masterSnapshotRef.current = state;
setSnapshot(state);
if (state.settings.columnOrder?.length > 0) {
@ -1940,14 +1909,6 @@ export function App(): ReactElement {
showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800);
});
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;
const master = masterSnapshotRef.current;
if (wireState.payloadKind === "delta" && master) {
@ -2151,11 +2112,6 @@ export function App(): ReactElement {
const hiddenPackageCount = shouldLimitPackageRendering
? Math.max(0, totalPackageCount - packages.length)
: 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)
? snapshot.session.items
: null;
@ -2193,7 +2149,6 @@ export function App(): ReactElement {
void loadAllDebridHostInfo(true);
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
// Auto-expand packages that are currently extracting (only once per extraction cycle)
useEffect(() => {
const extractingPkgIds: string[] = [];
const currentlyExtracting = new Set<string>();
@ -2210,7 +2165,6 @@ export function App(): ReactElement {
}
}
}
// Reset tracking for packages no longer extracting
for (const id of autoExpandedPkgsRef.current) {
if (!currentlyExtracting.has(id)) {
autoExpandedPkgsRef.current.delete(id);
@ -2231,9 +2185,6 @@ export function App(): ReactElement {
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(() =>
Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()),
[settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]);
@ -2244,10 +2195,8 @@ export function App(): ReactElement {
const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0);
// Dynamische Provider-Reihenfolge (ersetzt altes primary/secondary/tertiary)
const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]);
// Setzt providerOrder + backwards-kompatible Felder synchron
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
settingsDraftRevisionRef.current += 1;
panelDirtyRevisionRef.current += 1;
@ -3332,7 +3281,7 @@ export function App(): ReactElement {
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
let shouldRename = false;
setEditingPackageId((prev) => {
if (prev !== packageId) return prev; // already finished (e.g. blur after Enter key)
if (prev !== packageId) return prev;
shouldRename = true;
return null;
});
@ -3418,8 +3367,6 @@ export function App(): ReactElement {
pendingPackageOrderRef.current = [...order];
pendingPackageOrderAtRef.current = Date.now();
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) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
@ -3428,7 +3375,6 @@ export function App(): ReactElement {
pendingPackageOrderRef.current = null;
pendingPackageOrderAtRef.current = 0;
packageOrderRef.current = serverPackageOrderRef.current;
// Rollback: restore original order from server
setSnapshot((prev) => {
if (!prev) return prev;
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
@ -3578,7 +3524,6 @@ export function App(): ReactElement {
const dragDidMoveRef = useRef(false);
const lastClickedIdRef = useRef<string | null>(null);
// Flat list of all visible IDs (package headers + their visible items) in display order
const visibleOrderIds = useMemo(() => {
const ids: string[] = [];
for (const pkg of visiblePackages) {
@ -3595,7 +3540,7 @@ export function App(): ReactElement {
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
if (dragDidMoveRef.current) return; // drag handled it, skip click
if (dragDidMoveRef.current) return;
if (shiftKey && lastClickedIdRef.current) {
const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current);
const targetIdx = visibleOrderIds.indexOf(id);
@ -3642,7 +3587,6 @@ export function App(): ReactElement {
if (!dragSelectRef.current) return;
if (!dragDidMoveRef.current) {
dragDidMoveRef.current = true;
// Add anchor item now that we know it's a drag
const anchor = dragAnchorRef.current;
if (anchor) {
setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; });
@ -3655,7 +3599,6 @@ export function App(): ReactElement {
const sel = selectedIds;
const currentPackages = snapshotRef.current.session.packages;
const currentItems = snapshotRef.current.session.items;
// Multi-select: collect links from all selected packages/items
if (sel.size > 1) {
const allLinks: { name: string; url: string }[] = [];
for (const id of sel) {
@ -3785,7 +3728,6 @@ export function App(): ReactElement {
useEffect(() => {
if (!colHeaderCtx) return;
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 (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return;
setColHeaderCtx(null);
@ -3856,7 +3798,6 @@ export function App(): ReactElement {
if (e.key === "Escape") {
const target = e.target as HTMLElement;
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 (tabRef.current === "downloads") setSelectedIds(new Set());
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
@ -4524,7 +4465,7 @@ export function App(): ReactElement {
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
</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 }); }}>
{columnOrder.map((col) => {
const def = COLUMN_DEFS[col];
@ -5713,8 +5654,6 @@ export function App(): ReactElement {
const nextAccounts = [...prev.megaAccounts, { login, password }];
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) {
void runMegaAccountCheck(login, password);
}
@ -6139,7 +6078,6 @@ export function App(): ReactElement {
if (isVisible) {
newOrder = columnOrder.filter((c) => c !== col);
} else {
// Insert at original default position relative to existing columns
newOrder = [...columnOrder];
const defaultIdx = ALL_COLUMN_KEYS.indexOf(col);
let insertAt = newOrder.length;
@ -6338,8 +6276,6 @@ 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 {
const statusText = String(item.fullStatus || "").trim();
if (statusText === "Wartet") return "";
@ -6365,9 +6301,6 @@ interface ItemRowProps {
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 handleClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
@ -6385,10 +6318,7 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
e.stopPropagation();
onContextMenu(packageId, item.id, e.clientX, e.clientY);
}, [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]);
// Memoize the displayed status so we don't compute it twice (title + body)
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
@ -6453,7 +6383,6 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
</div>
);
}, (prev, next) => {
// Skip re-render unless something visible actually changed for THIS item.
if (prev.item !== next.item) {
const a = prev.item;
const b = next.item;
@ -6521,8 +6450,6 @@ 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 {
// 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(() => {
let done = 0;
let failed = 0;
@ -6688,10 +6615,6 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
|| prev.gridTemplate !== next.gridTemplate) {
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) {
for (const itemId of next.pkg.itemIds) {
if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) {

View File

@ -70,8 +70,6 @@ body,
gap: 8px;
}
/* ── Menu Bar ───────────────────────────────────────────────── */
.menu-bar {
display: flex;
align-items: center;
@ -278,8 +276,6 @@ body,
white-space: nowrap;
}
.control-strip {
display: flex;
justify-content: space-between;
@ -660,7 +656,7 @@ body,
.pkg-column-header {
display: grid;
/* grid-template-columns set via inline style from columnOrder */
gap: 8px;
padding: 4px 10px;
background: color-mix(in srgb, var(--card) 58%, transparent);
@ -699,7 +695,7 @@ body,
.pkg-columns {
display: grid;
/* grid-template-columns set via inline style from columnOrder */
gap: 8px;
align-items: center;
min-width: 0;
@ -1491,7 +1487,6 @@ body,
margin: -2px 0 10px;
}
.key-stats-popup {
width: min(1360px, calc(100vw - 20px));
max-width: min(1360px, calc(100vw - 20px));
@ -2185,7 +2180,6 @@ body,
color: #0a0f1a;
}
.pkg-toggle {
display: inline-flex;
align-items: center;
@ -2278,7 +2272,6 @@ body,
background: linear-gradient(90deg, #22c55e, #4ade80);
}
/* History Tab */
.history-view {
display: flex;
flex-direction: column;
@ -2379,7 +2372,7 @@ td {
.item-row {
display: grid;
/* grid-template-columns set via inline style from columnOrder */
gap: 8px;
align-items: center;
margin: 0 -10px;
@ -3139,8 +3132,6 @@ td {
}
}
/* ── Account validity + premium badges (account check) ───────────────── */
.account-board-header-actions { display: flex; gap: 8px; align-items: center; }
.account-validity-badge {
display: inline-block;
@ -3158,7 +3149,6 @@ td {
.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); }
/* ── Live account-rotation panel ─────────────────────────────────────── */
.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-event {

View File

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

View File

@ -39,11 +39,6 @@ export function getMegaDebridAccountLabel(index: number): string {
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[] {
const seen = new Set<string>();
const lines = String(raw || "")
@ -60,7 +55,6 @@ export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaD
login = line.slice(0, colonIdx).trim();
password = line.slice(colonIdx + 1).trim();
} else {
// Legacy format: just a login, use the provided fallback password
login = line;
password = legacyPassword;
}

View File

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

View File

@ -53,25 +53,16 @@ export interface DownloadStats {
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 {
accountId: string;
provider: "megadebrid" | "debridlink";
label: string;
maskedLogin: string;
/** Login worked (credentials accepted by the provider). */
valid: boolean;
/** Currently a paying/premium account. */
isPremium: boolean;
/** Epoch ms when premium expires; null = unknown, 0 = no premium. */
premiumUntilMs: number | null;
email?: string;
/** Human-readable one-line summary for the badge tooltip. */
message: string;
/** Epoch ms of the last check. */
checkedAt: number;
}
@ -157,8 +148,6 @@ export interface AppSettings {
megaDebridAccountDailyLimitBytes: Record<string, number>;
megaDebridAccountDailyUsageBytes: 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>;
providerDailyUsageDay: string;
scheduledStartEpochMs: number;
@ -242,16 +231,12 @@ export interface ContainerImportResult {
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 {
id: string;
at: number;
level: "INFO" | "WARN" | "ERROR";
provider: string;
accountLabel: string;
/** OK | FAILED | FATAL | SKIP_COOLDOWN | SKIP_DISABLED | SKIP_DAILY_LIMIT | TEST | ... */
event: string;
reason?: string;
category?: string;
@ -272,17 +257,9 @@ export interface UiSnapshot {
clipboardActive: boolean;
reconnectSeconds: 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";
/** Item IDs to remove from the renderer's master state when payloadKind="delta". */
removedItemIds?: string[];
/** Package IDs to remove from the renderer's master state when payloadKind="delta". */
removedPackageIds?: string[];
/** Most-recent account/key rotation events (newest first), for the live
* rotation panel. Always sent on full snapshots. */
rotationEvents?: RotationEvent[];
}

View File

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

View File

@ -10,7 +10,6 @@ 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("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" });
// simulate an await boundary — ALS must survive it
await Promise.resolve();
});
@ -23,7 +22,6 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
it("does not leak events to the sink outside the run() scope", () => {
const captured: RotationEvent[] = [];
// No active sink here
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
expect(captured).toHaveLength(0);
});
@ -43,7 +41,6 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
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(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
@ -54,7 +51,6 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "TEST");
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
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 === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
});

View File

@ -14,10 +14,6 @@ import {
} from "../src/main/download-manager";
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)", () => {
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
@ -78,7 +74,6 @@ describe("decideAutoRenameBaseName (shared naming decision — used by auto-rena
});
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(
["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"],
"awa-diethundermans02e16hd.mkv",
@ -91,7 +86,6 @@ 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)", () => {
// Clean source S01E09 in a folder that says E08 → must NOT rename to E08.
const decision = decideAutoRenameBaseName(
["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"],
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv",
@ -115,9 +109,6 @@ 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", () => {
// 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 decision = decideAutoRenameBaseName(
[epFolder, "scn2-cstl7"],
@ -131,9 +122,6 @@ 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)", () => {
// 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 = [
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET",
"kig.hdtv.7p-001",
@ -189,14 +177,12 @@ describe("hasMeaningfulSeriesPrefix", () => {
describe("looksLikeObfuscatedSceneFileName", () => {
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("scn-dthund7-S02E06.mkv")).toBe(true);
expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true);
});
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("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);
@ -208,7 +194,6 @@ describe("looksLikeObfuscatedSceneFileName", () => {
});
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);
});
});
@ -218,31 +203,22 @@ describe("extractEpisodeToken (extended formats)", () => {
expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01");
expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05");
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.S10E100.mkv")).toBe("S10E100");
});
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.S01E01.1080p.mkv")).toBe("S01E01");
});
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.5x265.x265.mkv")).toBeNull();
// SxxExx still wins ahead of phantom xX matches.
expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01");
});
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();
});
});
@ -491,7 +467,6 @@ describe("buildAutoRenameBaseName", () => {
expect(result).toBeNull();
});
// Edge cases
it("handles 2160p quality token", () => {
const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv");
expect(result).toBe("Show.S01E01.REPACK.2160p-4sf");
@ -509,12 +484,10 @@ describe("buildAutoRenameBaseName", () => {
it("handles high season and episode numbers", () => {
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!).toContain("S99E999");
});
// Real-world scene release patterns
it("real-world: German series with dots", () => {
const result = buildAutoRenameBaseName(
"Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ",
@ -579,18 +552,13 @@ describe("buildAutoRenameBaseName", () => {
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", () => {
// 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");
// "mkv" should not be treated as part of the filename match
expect(result).not.toBeNull();
expect(result!).toContain("S01E01");
});
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");
expect(token).toBe("S01E01");
});
@ -608,23 +576,19 @@ describe("buildAutoRenameBaseName", () => {
"Show.S01E05.720p-4sf",
"show.s01e05.720p"
);
// Must NOT produce "Show.S01E05.720p.S01E05-4sf" (double episode bug)
expect(result).toBe("Show.S01E05.720p-4sf");
});
it("handles folder with only -4sf suffix (edge case)", () => {
const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv");
// Extreme edge case - sanitizeFilename trims leading dots
expect(result).not.toBeNull();
expect(result!).toContain("S01E01");
expect(result!).toContain("-4sf");
expect(result!).not.toContain(".S01E01.S01E01"); // no duplication
expect(result!).not.toContain(".S01E01.S01E01");
});
it("sanitizes special characters from result", () => {
// sanitizeFilename should strip dangerous chars
const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv");
// The colon should be sanitized away
expect(result).not.toBeNull();
expect(result!).not.toContain(":");
});
@ -888,7 +852,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
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)", () => {
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
["Mystery Road S02"],
@ -916,7 +879,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
"myst.road.de.dl.hdtv.7p-s02e05",
{ 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");
});
@ -1002,11 +964,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
});
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(
[
"3MH.web.7p-101",
@ -1015,8 +972,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
{ forceEpisodeForSeasonFolder: true }
);
// Helper limitation: returns the malformed folder name unchanged.
// The download-manager safety net catches this at runtime.
if (result !== null) {
expect(typeof result).toBe("string");
}
@ -1027,7 +982,6 @@ describe("isBonusContent (numbered episodes are never bonus)", () => {
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", () => {
// Der gemeldete Bug: Revenge.2011.S04E19.Interview wurde als Bonus verworfen.
const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
const fp = `${pkgDir}/${name}/${name}.mkv`;
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
@ -1066,9 +1020,6 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
const hash = "c284d9d9072eaf3ac314d05f951dd115";
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 decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
expect(decision).toEqual({ kind: "rename", baseName: folder });
@ -1081,7 +1032,6 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
const db = decideAutoRenameBaseName([fb, hash], "safari-fm-s04e08b.avi", "safari-fm-s04e08b", hash, hash);
expect(da).toEqual({ kind: "rename", baseName: fa });
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);
});
@ -1092,8 +1042,6 @@ 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)", () => {
// "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);
expect(decision.kind).toBe("skip");
});
@ -1106,11 +1054,6 @@ 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", () => {
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 pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG";
@ -1118,14 +1061,10 @@ 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", () => {
const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder);
expect(decision.kind).toBe("skip");
// NICHT der verkrueppelte "...-GTVG.S01E01"-Name.
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)", () => {
// 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 seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP";
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder);
@ -1140,9 +1079,6 @@ 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", () => {
// 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 seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP";
const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP";

View File

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

View File

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

View File

@ -42,7 +42,6 @@ describe("cleanup", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
tempDirs.push(dir);
// Create nested directory structure with archive files
const sub1 = path.join(dir, "season1");
const sub2 = path.join(dir, "season1", "extras");
fs.mkdirSync(sub2, { recursive: true });
@ -51,17 +50,15 @@ describe("cleanup", () => {
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
fs.writeFileSync(path.join(sub2, "bonus.zip"), "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(sub2, "subtitle.srt"), "subtitle content");
const removed = cleanupCancelledPackageArtifacts(dir);
expect(removed).toBe(4); // 2 rar parts + zip + 7z
expect(removed).toBe(4);
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(sub2, "bonus.zip"))).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(sub2, "subtitle.srt"))).toBe(true);
});
@ -70,23 +67,17 @@ describe("cleanup", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
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");
// 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");
// Regular text file that doesn't match the link pattern should be kept
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");
// .dlc files should always be removed
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
const removed = await removeDownloadLinkArtifacts(dir);
expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc
expect(removed).toBeGreaterThanOrEqual(3);
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, "container.dlc"))).toBe(false);
// Non-matching files should be kept
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
});

View File

@ -22,9 +22,7 @@ describe("container", () => {
const oversizedFilePath = path.join(dir, "oversized.dlc");
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");
// 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..."));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
@ -38,7 +36,6 @@ describe("container", () => {
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[0].name).toBe("valid");
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
@ -60,17 +57,14 @@ describe("container", () => {
tempDirs.push(dir);
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"));
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("service.jdownloader.org")) {
// Mock local RC service failure (returning 404)
return new Response("", { status: 404 });
}
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
// Mock dcrypt fallback success
return new Response("http://fallback.com/1", { status: 200 });
}
return new Response("", { status: 404 });
@ -81,7 +75,6 @@ describe("container", () => {
expect(result).toHaveLength(1);
expect(result[0].name).toBe("fallback");
expect(result[0].links).toEqual(["http://fallback.com/1"]);
// Should have tried both!
expect(fetchSpy).toHaveBeenCalledTimes(2);
});
@ -135,7 +128,6 @@ describe("container", () => {
expect(result).toHaveLength(1);
expect(result[0].name).toBe("big-dlc");
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);
});

View File

@ -424,14 +424,10 @@ describe("debrid service", () => {
});
}
// Only count calls to /downloader/add (the unrestrict endpoint)
if (url.includes("/downloader/add")) {
unrestrictAuthHeaders.push(authHeader);
// Read the body to know which link is being unrestricted
const bodyText = init?.body ? String(init.body) : "";
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) {
return new Response(JSON.stringify({
success: false,
@ -454,19 +450,14 @@ describe("debrid service", () => {
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");
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;
const r2 = await service.unrestrictLink("https://rapidgator.net/file/second");
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-two"]);
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;
const r3 = await service.unrestrictLink("https://uploaded.net/file/third");
expect(unrestrictAuthHeaders).toEqual(["Bearer dl-key-one"]);
@ -523,10 +514,7 @@ describe("debrid service", () => {
const result = await service.unrestrictLink("https://rapidgator.net/file/example");
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");
// Key-two served the link successfully, so it's "ready".
expect(getDebridLinkKeyRuntimeStateForTests(key2Id)).toBe("ready");
});
@ -694,7 +682,6 @@ describe("debrid service", () => {
const service = new DebridService(settings);
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"]);
});
@ -1267,7 +1254,6 @@ describe("debrid service", () => {
autoProviderFallback: true
};
// API returns 404 for connectUser → API fails, falls back to web
const fetchSpy = vi.fn(async () => new Response("not-found", { status: 404 }));
globalThis.fetch = fetchSpy as unknown as typeof fetch;
@ -1366,7 +1352,6 @@ describe("debrid service", () => {
autoProviderFallback: false
};
// API connect fails fast → falls through to web fallback
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
const megaWeb = vi.fn((_link: string, signal?: AbortSignal): Promise<never> => new Promise((_, reject) => {
@ -1394,9 +1379,6 @@ describe("debrid service", () => {
});
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 = {
...defaultSettings(),
token: "",
@ -1413,17 +1395,14 @@ describe("debrid service", () => {
autoProviderFallback: false
};
// API-Connect schlaegt schnell fehl -> Web-Pfad (megaWeb) pro Account.
globalThis.fetch = (async () => new Response("error", { status: 500 })) as typeof fetch;
let webCalls = 0;
const megaWeb = vi.fn(async (_link: string, _signal?: AbortSignal) => {
webCalls += 1;
// Account 1: liefert bei jedem seiner REQUEST_RETRIES-Versuche den Tageslimit-Fehler.
if (webCalls <= 3) {
throw new Error("Mega-Web: daily limit reached (Tageslimit erreicht)");
}
// Account 2: hat noch Kontingent -> loest den Link auf.
return {
fileName: "rotated-to-acc2.rar",
directUrl: "https://mega-web.example/rotated-to-acc2.rar",
@ -1435,17 +1414,11 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
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");
// acc1 wurde versucht (und fiel mit Limit-Fehler), dann acc2 erfolgreich.
expect(webCalls).toBeGreaterThanOrEqual(4);
}, 30000);
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 = {
...defaultSettings(),
token: "",
@ -1454,7 +1427,7 @@ describe("debrid service", () => {
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1\nuser2:pass2",
megaDebridDisabledAccountIds: [getMegaDebridAccountId("user1")], // acc1 deaktiviert
megaDebridDisabledAccountIds: [getMegaDebridAccountId("user1")],
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
@ -1475,18 +1448,12 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
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.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);
}, 20000);
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 = {
...defaultSettings(),
token: "",
@ -1518,15 +1485,10 @@ describe("debrid service", () => {
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
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);
}, 20000);
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 = {
...defaultSettings(),
token: "",
@ -1548,55 +1510,40 @@ describe("debrid service", () => {
const megaWeb = vi.fn(async (_link: string, _signal: AbortSignal | undefined, account?: { login: string; password: string }) => {
accountsSeen.push(account?.login);
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.");
}
// Account 2 (eigene Creds) loest auf.
return { fileName: "ok.rar", directUrl: "https://mega-web.example/ok.rar", fileSize: null, retriesUsed: 0 };
});
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
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("user2");
// Und der funktionierende Account 2 loest auf.
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
expect(result.directUrl).toBe("https://mega-web.example/ok.rar");
}, 20000);
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`;
expect(MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART).toBe(3);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1); // 1. Blip -> NICHT parken
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2); // 2. -> NICHT parken
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3); // 3. -> Schwelle erreicht -> parken
// Ein Erfolg/anderer Fehlertyp setzt die Streak zurueck (Account wieder frisch).
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(2);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(3);
clearMegaDebridEmptyResponseStreak(key);
expect(recordMegaDebridEmptyResponseStreak(key)).toBe(1);
});
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`;
primeMegaDebridUntilRestartForTests(key);
const now = getMegaDebridAccountCooldownState(key);
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;
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 () => {
// 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 = {
...defaultSettings(),
token: "",
@ -1614,8 +1561,6 @@ describe("debrid service", () => {
};
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");
primeMegaDebridUntilRestartForTests(`${user1}:api`);
primeMegaDebridUntilRestartForTests(`${user1}:web`);
@ -1629,17 +1574,12 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
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).toContain("user2");
expect((result as { sourceAccountId?: string }).sourceAccountId).toBe(getMegaDebridAccountId("user2"));
}, 20000);
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 = {
...defaultSettings(),
token: "",
@ -1667,16 +1607,10 @@ describe("debrid service", () => {
const service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
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();
}, 20000);
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 = {
...defaultSettings(),
token: "",
@ -1684,7 +1618,7 @@ describe("debrid service", () => {
allDebridToken: "",
megaLogin: "user1",
megaPassword: "pass1",
megaCredentials: "user1:pass1", // genau EIN Account
megaCredentials: "user1:pass1",
megaDebridPreferApi: false,
providerOrder: [] as const,
providerPrimary: "megadebrid" as const,
@ -1694,21 +1628,16 @@ describe("debrid service", () => {
};
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`;
// Streak schon EINS unter der Schwelle (2 vorherige leere Antworten) — noch NICHT geparkt.
recordMegaDebridEmptyResponseStreak(key);
recordMegaDebridEmptyResponseStreak(key);
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 service = new DebridService(settings, { megaWebUnrestrict: megaWeb });
await service.unrestrictLink("https://rapidgator.net/file/wiring").catch(() => undefined);
// Der echte Fehlversuch tippte die Streak auf die Schwelle -> Park bis Neustart.
expect(megaWeb).toHaveBeenCalled(); // Account wurde wirklich getestet (nicht vorab geparkt)
expect(megaWeb).toHaveBeenCalled();
expect(getMegaDebridAccountCooldownState(key)?.untilRestart).toBe(true);
}, 20000);
@ -2082,11 +2011,9 @@ describe("normalizeResolvedFilename", () => {
});
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");
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");
expect(entityTagResult).toBe("File .part1.rar");
});
@ -2109,7 +2036,6 @@ describe("normalizeResolvedFilename", () => {
});
it("handles combined transforms", () => {
// "Download file" prefix stripped, &amp; decoded to &, "- Rapidgator" suffix stripped
expect(normalizeResolvedFilename("Download file Show.S01E01.part01.rar - Rapidgator"))
.toBe("Show.S01E01.part01.rar");
});

View File

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

View File

@ -24,7 +24,6 @@ afterEach(() => {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore
}
}
createdTmpDirs.length = 0;
@ -50,8 +49,6 @@ describe("desktop-rename-log", () => {
});
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();
initDesktopRenameLog(desktop);
const logPath = getDesktopRenameLogPath() as string;
@ -60,7 +57,6 @@ describe("desktop-rename-log", () => {
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
expect(fs.existsSync(logPath)).toBe(false);
// Naechster Vorgang muss Ordner UND Datei (mit Header) selbstheilend neu anlegen.
logDesktopRename("INFO", "ZeileB");
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
expect(fs.existsSync(logPath)).toBe(true);
@ -80,7 +76,7 @@ describe("desktop-rename-log", () => {
const dir = tmpDesktop();
const source = path.join(dir, "scn-xyz.part1.rar");
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
fs.writeFileSync(target, "data"); // Post-Rename-Zustand: Ziel da, Quelle weg.
fs.writeFileSync(target, "data");
const v = verifyRename(source, target);
expect(v.ok).toBe(true);
@ -102,7 +98,6 @@ describe("desktop-rename-log", () => {
});
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 source = path.join(dir, "src.rar");
const target = path.join(dir, "dst.rar");

View File

@ -26,8 +26,6 @@ describe("download-completion", () => {
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 });
// 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)", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
expect(result.ok).toBe(false);
@ -57,7 +55,6 @@ describe("download-completion", () => {
it("accepts provider-metadata download and flags size mismatch", () => {
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
// provider-metadata: shorter than expected -> underflow rejected
expect(result.ok).toBe(false);
});
});

View File

@ -2453,8 +2453,6 @@ describe("download manager", () => {
await waitFor(() => !manager.getSnapshot().session.running, 25000);
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?.downloadedBytes).toBeGreaterThanOrEqual(actual.length);
} finally {
@ -3292,7 +3290,6 @@ describe("download manager", () => {
expect(item?.status).toBe("completed");
expect(item?.targetPath).toBe(existingTargetPath);
expect(sawResumeRange).toBe(true);
// Allow ALLOCATION_UNIT_SIZE (4096) tolerance for write-flush timing on Windows
const fileSize = fs.statSync(existingTargetPath).size;
expect(fileSize).toBeGreaterThanOrEqual(binary.length - 4096);
expect(fileSize).toBeLessThanOrEqual(binary.length);
@ -3578,9 +3575,6 @@ describe("download manager", () => {
const item = manager.getSnapshot().session.items[itemId];
expect(item?.status).toBe("completed");
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).toBeLessThanOrEqual(binary.length);
expect(unrestrictCalls).toBeGreaterThanOrEqual(1);
@ -4401,7 +4395,6 @@ describe("download manager", () => {
for (const [index, archiveName] of archiveNames.entries()) {
const targetPath = path.join(outputDir, archiveName);
// Write garbage content (no valid archive signature) — simulates corrupt download
fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA));
session.items[itemIds[index]!] = {
id: itemIds[index]!,
@ -4450,7 +4443,6 @@ describe("download manager", () => {
"hybrid"
);
// Invalid archive signature = genuine corruption → force re-download
expect(changed).toBe(2);
for (const itemId of itemIds) {
const item = session.items[itemId]!;
@ -4494,7 +4486,6 @@ describe("download manager", () => {
for (const [index, archiveName] of archiveNames.entries()) {
const targetPath = path.join(outputDir, archiveName);
// Write file with valid RAR5 signature — simulates wrong password, not corruption
const content = Buffer.alloc(archiveSize, 0);
Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content);
fs.writeFileSync(targetPath, content);
@ -4545,7 +4536,6 @@ describe("download manager", () => {
"hybrid"
);
// Valid RAR signature = file is structurally intact → wrong password, don't re-download
expect(changed).toBe(0);
for (const itemId of itemIds) {
const item = session.items[itemId]!;
@ -6129,9 +6119,6 @@ describe("download manager", () => {
const item = Object.values(manager.getSnapshot().session.items)[0];
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") {
expect(directCalls).toBeGreaterThan(1);
}
@ -6145,7 +6132,6 @@ describe("download manager", () => {
it("accepts small .sfv metadata files without rejecting them as suspicious", async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
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 server = http.createServer((req, res) => {
@ -9359,12 +9345,6 @@ describe("download manager", () => {
}, 20000);
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-"));
tempDirs.push(root);
@ -9375,7 +9355,7 @@ describe("download manager", () => {
const mkvName = "grp-freshshow.s01e07-720p.mkv";
const mkvPath = path.join(extractDir, mkvName);
fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7)); // mtime = jetzt → "frisch"
fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7));
const session = emptySession();
const packageId = `${packageName}-pkg`;
@ -9410,18 +9390,14 @@ describe("download manager", () => {
session,
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;
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);
expect(fs.existsSync(libPath)).toBe(false);
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);
expect(fs.existsSync(libPath)).toBe(true);
expect(fs.existsSync(mkvPath)).toBe(false);
@ -9430,19 +9406,12 @@ describe("download manager", () => {
}, 20000);
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-"));
tempDirs.push(root);
const packageName = "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV";
const outputDir = path.join(root, "downloads", 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 epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true });
@ -9474,7 +9443,7 @@ describe("download manager", () => {
outputDir: path.join(root, "downloads"),
extractDir: path.join(root, "extract"),
autoExtract: true,
autoRename4sf4sj: true, // Umbenennen AN — wie in der echten User-Config
autoRename4sf4sj: true,
collectMkvToLibrary: true,
mkvLibraryDir,
enableIntegrityCheck: false,
@ -9484,13 +9453,10 @@ describe("download manager", () => {
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);
const cleanLibPath = path.join(mkvLibraryDir, `${episodeFolder}.mkv`);
const rawLibPath = path.join(mkvLibraryDir, rawName);
// Library-Datei heisst SAUBER, nicht roh; Quelle ist weg.
expect(fs.existsSync(cleanLibPath)).toBe(true);
expect(fs.existsSync(rawLibPath)).toBe(false);
expect(fs.existsSync(rawPath)).toBe(false);
@ -9499,16 +9465,12 @@ describe("download manager", () => {
}, 20000);
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-"));
tempDirs.push(root);
const packageName = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
const outputDir = path.join(root, "downloads", packageName); // = "Unfertig"-Aequivalent
const extractDir = path.join(root, "extract", packageName); // bleibt leer/fehlt
const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(outputDir, { recursive: true });
const rawName = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
@ -9555,27 +9517,18 @@ describe("download manager", () => {
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, 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);
void manager;
}, 20000);
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-"));
tempDirs.push(root);
const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC";
const outputDir = path.join(root, "downloads", 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 epDir = path.join(extractDir, episodeFolder);
fs.mkdirSync(epDir, { recursive: true });
@ -9618,7 +9571,6 @@ describe("download manager", () => {
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(epDir, epName))).toBe(false);
@ -9626,9 +9578,6 @@ describe("download manager", () => {
}, 20000);
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-"));
tempDirs.push(root);
@ -9636,7 +9585,6 @@ describe("download manager", () => {
const outputDir = path.join(root, "downloads", packageName);
const extractDir = path.join(root, "extract", packageName);
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 bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv";
fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1));
@ -9678,7 +9626,6 @@ describe("download manager", () => {
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, bonusName))).toBe(false);
expect(fs.existsSync(path.join(extractDir, bonusName))).toBe(true);
@ -9687,10 +9634,6 @@ describe("download manager", () => {
}, 20000);
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-"));
tempDirs.push(root);
@ -9743,7 +9686,6 @@ describe("download manager", () => {
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, `${folderB}.avi`))).toBe(true);
expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08a.avi"))).toBe(false);
@ -9753,10 +9695,6 @@ describe("download manager", () => {
}, 20000);
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-"));
tempDirs.push(root);
@ -9767,7 +9705,6 @@ describe("download manager", () => {
const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv";
const epDir = path.join(extractDir, epFolder);
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));
const session = emptySession();
@ -9806,11 +9743,9 @@ describe("download manager", () => {
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);
const mangled = `${epFolder}.S01E01.mkv`;
expect(fs.existsSync(path.join(mkvLibraryDir, mangled))).toBe(false);
// Nichts mit dem Episoden-Titel im Library-Ordner.
const inLib = fs.readdirSync(mkvLibraryDir);
expect(inLib.some((n) => /Hinter\.dem\.Himmel/i.test(n))).toBe(false);
@ -9818,30 +9753,18 @@ describe("download manager", () => {
}, 20000);
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-"));
tempDirs.push(root);
const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake";
const outputDir = path.join(root, "downloads", 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");
fs.mkdirSync(epFolder, { recursive: true });
const sceneName = "awa-testshow02e05hd.mkv";
const scenePath = path.join(epFolder, sceneName);
fs.writeFileSync(scenePath, Buffer.alloc(4096, 5)); // mtime = jetzt → "frisch"
fs.writeFileSync(scenePath, Buffer.alloc(4096, 5));
const session = emptySession();
const packageId = `${packageName}-pkg`;
@ -9876,22 +9799,15 @@ describe("download manager", () => {
session,
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;
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`);
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).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(sceneLibPath)).toBe(false);
@ -9899,11 +9815,6 @@ describe("download manager", () => {
}, 20000);
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-"));
tempDirs.push(root);
@ -9915,7 +9826,7 @@ describe("download manager", () => {
fs.mkdirSync(outputDir, { recursive: true });
const sceneName = "awa-testshow02e05hd.mkv";
fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5)); // mtime = jetzt → "frisch"
fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5));
const session = emptySession();
const packageId = `${packageName}-pkg`;
@ -9953,9 +9864,6 @@ describe("download manager", () => {
(manager as any).fileStabilizeMinAgeMs = 30_000;
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);
expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true);
@ -9973,7 +9881,6 @@ describe("download manager", () => {
const extractDir = path.join(root, "extract", packageName);
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 directMkvPath = path.join(outputDir, directMkvName);
fs.writeFileSync(directMkvPath, Buffer.alloc(2048, 1));
@ -10039,20 +9946,13 @@ describe("download manager", () => {
await waitFor(() => fs.existsSync(libraryPath), 12000);
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);
// Quelle ist weg (verschoben).
expect(fs.existsSync(directMkvPath)).toBe(false);
void manager;
}, 20000);
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-"));
tempDirs.push(root);
@ -10062,7 +9962,6 @@ describe("download manager", () => {
fs.mkdirSync(outputDir, { recursive: true });
fs.mkdirSync(extractDir, { recursive: true });
// outputDir: S02-RAR-Set noch NICHT entpackt (pending). Muss erhalten bleiben.
const s02Parts = [
"Ugly.Americans.S02.COMPLETE.German.part1.rar",
"Ugly.Americans.S02.COMPLETE.German.part2.rar",
@ -10071,10 +9970,8 @@ describe("download manager", () => {
for (const part of s02Parts) {
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"));
// extractDir: S01 wurde bereits entpackt → MKVs liegen hier.
const s01Mkvs = [
"Ugly.Americans.S01E01.German.mkv",
"Ugly.Americans.S01E02.German.mkv"
@ -10118,14 +10015,11 @@ describe("download manager", () => {
createStoragePaths(path.join(root, "state"))
);
// Direkt aufrufen (umgeht die volle Download/Extract-Pipeline).
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]);
// S01-MKVs sind in der Library angekommen.
for (const mkv of s01Mkvs) {
expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true);
}
// KRITISCH: S02-RAR-Parts im outputDir wurden NICHT geloescht.
for (const part of s02Parts) {
expect(fs.existsSync(path.join(outputDir, part))).toBe(true);
}
@ -10142,7 +10036,6 @@ describe("download manager", () => {
const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(outputDir, { recursive: true });
// Build archive containing one real episode + several bonus files in an Extras subdirectory
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.S04Extras.720p.BluRay.x264-TSCC/Schrotflinte.mkv", Buffer.from("bonus-1"));
@ -10209,22 +10102,18 @@ describe("download manager", () => {
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");
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, "Die.Autoexplosion.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");
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, "White.House.mkv"))).toBe(true);
// The real episode must be in the library and removed from extract
expect(fs.existsSync(flattenedEpisode)).toBe(true);
}, 20000);
@ -10237,7 +10126,6 @@ describe("download manager", () => {
const extractDir = path.join(root, "extract", packageName);
fs.mkdirSync(outputDir, { recursive: true });
// Mix of dot-separated bonus subdirs - must all be detected as bonus
const zip = new AdmZip();
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"));
@ -10308,13 +10196,11 @@ describe("download manager", () => {
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv");
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, "AnotherClip.mkv"))).toBe(false);
expect(fs.existsSync(path.join(mkvLibraryDir, "DeletedClip.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.Behind.The.Scenes", "AnotherClip.mkv"))).toBe(true);
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Deleted.Scenes", "DeletedClip.mkv"))).toBe(true);
@ -10998,7 +10884,6 @@ describe("download manager", () => {
tempDirs.push(root);
const binary = Buffer.alloc(256 * 1024, 7);
// Slow server: delivers data in chunks with delay
const server = http.createServer((req, res) => {
if ((req.url || "") !== "/slow-dl") {
res.statusCode = 404;
@ -11008,7 +10893,6 @@ describe("download manager", () => {
res.statusCode = 200;
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", String(binary.length));
// Send first half, then delay
res.write(binary.subarray(0, Math.floor(binary.length / 4)));
const timer = setTimeout(() => {
if (!res.writableEnded && !res.destroyed) {
@ -11065,23 +10949,19 @@ describe("download manager", () => {
manager.addPackages([{ name: "hang-test", links: ["https://dummy/hang-test"] }]);
// Step 1: Start and wait for download to begin
await manager.start();
await waitFor(() => {
const items = Object.values(manager.getSnapshot().session.items);
return items.some((item) => item.status === "downloading");
}, 12000);
// Step 2: Stop — do NOT wait for running=false
manager.stop();
// Step 3: Immediately disable the active provider
manager.setSettings({
...settings,
disabledProviders: ["realdebrid"]
});
// Step 4: Start again immediately — must resolve (not hang)
const startPromise = manager.start();
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]);
@ -11112,9 +10992,6 @@ describe("download manager", () => {
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) => ({
name: `bulk-pkg-${pkgIdx}`,
links: Array.from({ length: 25 }, (_, linkIdx) => `https://dummy/bulk-${pkgIdx}-${linkIdx}.rar`)
@ -11127,25 +11004,14 @@ describe("download manager", () => {
expect(result.addedPackages).toBe(60);
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);
// 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 itemLogFiles = fs.existsSync(itemLogsDir)
? fs.readdirSync(itemLogsDir).filter((f) => f.startsWith("item_") && f.endsWith(".txt"))
: [];
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 pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt"));
expect(pkgLogFiles.length).toBe(60);
@ -11160,8 +11026,6 @@ describe("download manager", () => {
initItemLogs(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 episodes = [
{ folder: "Test.Show.S02E01.Pilot.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e01hd.mkv" },
@ -11204,24 +11068,15 @@ describe("download manager", () => {
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([
(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 n2).toBe("number");
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) {
const dir = path.join(extractDir, ep.folder);
const files = fs.readdirSync(dir);
@ -11243,9 +11098,6 @@ describe("download manager", () => {
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";
let concurrent = 0;
let maxConcurrent = 0;
@ -11268,9 +11120,7 @@ describe("download manager", () => {
expect(r2).toBe("done-20");
expect(r3).toBe("done-30");
expect(r4).toBe("done-10");
// Crucial: never more than 1 operation in flight at a time.
expect(maxConcurrent).toBe(1);
// Chain slot cleared after the last op completed.
expect((manager as any).packageFileOpChain.has(pkgId)).toBe(false);
});
@ -11311,7 +11161,6 @@ describe("download manager", () => {
const renamed = await (manager as any).autoRenameExtractedVideoFiles(sharedDir, pkg);
expect(renamed).toBe(0);
// File must remain untouched — no rename performed.
expect(fs.existsSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"))).toBe(true);
});
@ -11353,13 +11202,8 @@ describe("download manager", () => {
await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg);
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.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");
});
@ -11392,10 +11236,8 @@ describe("download manager", () => {
expect(renamed).toBe(1);
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
const files = fs.readdirSync(epFolder);
// Video renamed.
expect(files).toContain(`${expectedBase}.mkv`);
expect(files).not.toContain("awa-testshow02e05hd.mkv");
// Companions renamed alongside.
expect(files).toContain(`${expectedBase}.srt`);
expect(files).toContain(`${expectedBase}.de.srt`);
expect(files).toContain(`${expectedBase}.nfo`);
@ -11431,7 +11273,6 @@ describe("download manager", () => {
expect(renamed).toBe(2);
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
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}.2.mkv`);
expect(files).not.toContain("awa-testshow02e05hd.mkv");

View File

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

View File

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

View File

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

View File

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

View File

@ -6,23 +6,18 @@ describe("logTimestamp", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
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}$/);
// The whole point: NOT the old UTC "...Z" format that showed 17:29 instead of 19:29.
expect(formatted.endsWith("Z")).toBe(false);
});
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
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());
});
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
const instant = new Date("2026-05-31T17:29:43.605Z");
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"));
});
});

View File

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

View File

@ -33,7 +33,6 @@ describe("mega-web-fallback", () => {
}
if (urlStr.includes("form=debrid")) {
// The POST to generate the code
return new Response(`
<div class="acp-box">
<h3>Link: https://mega.debrid/link1</h3>
@ -43,7 +42,6 @@ describe("mega-web-fallback", () => {
}
if (urlStr.includes("ajax=debrid")) {
// Polling endpoint
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
}
@ -56,7 +54,6 @@ describe("mega-web-fallback", () => {
expect(result).not.toBeNull();
expect(result?.directUrl).toBe("https://mega.direct/123");
expect(result?.fileName).toBe("link1");
// Calls: 1. Login POST, 2. Verify GET, 3. Generate POST, 4. Polling POST
expect(fetchCallCount).toBe(4);
});
@ -83,17 +80,11 @@ describe("mega-web-fallback", () => {
}) as unknown as typeof fetch;
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);
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 () => {
// 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;
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
@ -106,7 +97,6 @@ describe("mega-web-fallback", () => {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
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 });
}
if (urlStr.includes("ajax=debrid")) {
@ -118,7 +108,6 @@ describe("mega-web-fallback", () => {
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);
// Ohne Code wird gar nicht erst gepollt — die Meldung kommt direkt von der Seite.
expect(ajaxCalls).toBe(0);
});
@ -145,9 +134,7 @@ describe("mega-web-fallback", () => {
return new Response("Not found", { status: 404 });
}) as unknown as typeof fetch;
// getCredentials liefert den DEFAULT/Legacy-Account ...
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" });
expect(result?.directUrl).toBe("https://mega.direct/ok");
expect(loginsUsed).toContain("account2");
@ -158,7 +145,7 @@ describe("mega-web-fallback", () => {
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
const urlStr = String(url);
if (urlStr.includes("form=login")) {
const headers = new Headers(); // No cookie
const headers = new Headers();
return new Response("", { headers, status: 200 });
}
return new Response("Not found", { status: 404 });
@ -179,7 +166,6 @@ describe("mega-web-fallback", () => {
return new Response("", { headers, status: 200 });
}
if (urlStr.includes("page=debrideur")) {
// Missing form!
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
}
return new Response("Not found", { status: 404 });
@ -205,7 +191,6 @@ describe("mega-web-fallback", () => {
return new Response('<form id="debridForm"></form>', { status: 200 });
}
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("Not found", { status: 404 });
@ -214,7 +199,6 @@ describe("mega-web-fallback", () => {
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
const result = await fallback.unrestrict("http://mega.debrid/file");
// Generation fails -> resets cookie -> tries again -> fails again -> returns null
expect(result).toBeNull();
});

View File

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

View File

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

View File

@ -12,12 +12,6 @@ import {
saveSessionAsync
} 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[] = [];
afterEach(() => {
@ -66,7 +60,6 @@ function makeItem(id: string, packageId: string): DownloadItem {
};
}
/** Build a session whose package set is exactly `ids`. */
function sessionWith(ids: string[]): SessionState {
const s = emptySession();
for (const id of ids) {
@ -91,8 +84,6 @@ describe("session restart loss", () => {
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 queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
saveSession(paths, sessionWith(["A", "B", "C"]));

View File

@ -13,7 +13,6 @@ afterEach(() => {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore cleanup errors
}
}
});
@ -88,7 +87,6 @@ describe("runStartupHealthCheck", () => {
it("flags large state files", () => {
const { outputDir, paths } = makeTempBase();
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));
const settings = {
@ -103,7 +101,6 @@ describe("runStartupHealthCheck", () => {
it("flags missing base dir as ERROR", () => {
const { outputDir, paths } = makeTempBase();
// Intentionally DON'T create baseDir.
const settings = {
...defaultSettings(),

View File

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

View File

@ -10,14 +10,6 @@ import { createStoragePaths, emptySession, loadSession } from "../src/main/stora
import { shutdownItemLogs } from "../src/main/item-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 originalFetch = globalThis.fetch;
@ -47,9 +39,6 @@ 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> }> {
const openTimers = new Set<NodeJS.Timeout>();
const openResponses = new Set<http.ServerResponse>();
@ -68,7 +57,6 @@ async function startTricklingServer(): Promise<{ directUrl: string; stop: () =>
try {
res.write(Buffer.alloc(16 * 1024, 9));
} catch {
// socket gone
}
}, 100);
openTimers.add(timer);
@ -94,7 +82,6 @@ async function startTricklingServer(): Promise<{ directUrl: string; stop: () =>
try {
res.destroy();
} catch {
// ignore
}
}
openResponses.clear();
@ -157,7 +144,6 @@ describe("update restart resume", () => {
const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0];
expect(item).toBeTruthy();
// Documents the loss of resumability: cancelled items are not auto-resumed.
expect(item.status).toBe("cancelled");
} finally {
await serverStop();
@ -169,7 +155,6 @@ describe("update restart resume", () => {
tempDirs.push(root);
const { manager, paths, serverStop } = await driveActiveDownload(root);
try {
// Mirrors AppController.installUpdate(): park downloads, then sync-persist.
manager.stop({ parkForRestart: true });
manager.persistNowSync();
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
@ -178,7 +163,6 @@ describe("update restart resume", () => {
const reloaded = loadSession(paths);
const item = Object.values(reloaded.items)[0];
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(item.status).toBe("queued");
} finally {

View File

@ -614,7 +614,6 @@ describe("parseVersionParts", () => {
});
it("handles version with pre-release suffix", () => {
// Non-numeric suffixes are stripped per part
expect(parseVersionParts("1.2.3-beta")).toEqual([1, 2, 3]);
expect(parseVersionParts("1.2.3rc1")).toEqual([1, 2, 3]);
});

View File

@ -92,7 +92,6 @@ describe("utils", () => {
const result = sanitizeFilename(longName);
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
// The function should return a non-empty string and not crash
expect(result).toBe(longName);
});
@ -100,7 +99,6 @@ describe("utils", () => {
const result = formatEta(999999);
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
// 999999 seconds = 277h 46m 39s
expect(result).toBe("277:46:39");
});
@ -113,28 +111,22 @@ describe("utils", () => {
it("extracts filenames from URLs with encoded characters", () => {
expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar");
// %C3%A9 decodes to e-acute (UTF-8), which is preserved
expect(filenameFromUrl("https://example.com/t%C3%A9st%20file.zip")).toBe("t\u00e9st file.zip");
expect(filenameFromUrl("https://example.com/dl?filename=Movie%20Name%20S01E01.mkv")).toBe("Movie Name S01E01.mkv");
// Malformed percent-encoding should not crash
const result = filenameFromUrl("https://example.com/%ZZ%invalid");
expect(typeof result).toBe("string");
expect(result.length).toBeGreaterThan(0);
});
it("handles looksLikeOpaqueFilename edge cases", () => {
// Empty string -> sanitizeFilename returns "Paket" which is not opaque
expect(looksLikeOpaqueFilename("")).toBe(false);
expect(looksLikeOpaqueFilename("a")).toBe(false);
expect(looksLikeOpaqueFilename("ab")).toBe(false);
expect(looksLikeOpaqueFilename("abc")).toBe(false);
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
// 24-char hex string is opaque (matches /^[a-f0-9]{24,}$/)
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
// Short hex strings (< 24 chars) are NOT considered opaque
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
// Real filename with extension
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
});
});