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:
parent
f3159b9c6e
commit
3ed3877ac9
13
.gitignore
vendored
13
.gitignore
vendored
@ -19,7 +19,6 @@ apply_update.cmd
|
|||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
.github/
|
.github/
|
||||||
docs/plans/
|
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
|
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -29,7 +28,6 @@ npm-debug.log*
|
|||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# Forgejo deployment runtime files
|
|
||||||
deploy/forgejo/.env
|
deploy/forgejo/.env
|
||||||
deploy/forgejo/forgejo/
|
deploy/forgejo/forgejo/
|
||||||
deploy/forgejo/postgres/
|
deploy/forgejo/postgres/
|
||||||
@ -38,3 +36,14 @@ deploy/forgejo/caddy/config/
|
|||||||
deploy/forgejo/caddy/logs/
|
deploy/forgejo/caddy/logs/
|
||||||
deploy/forgejo/backups/
|
deploy/forgejo/backups/
|
||||||
.secrets
|
.secrets
|
||||||
|
|
||||||
|
*.log.old
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
rust-postprocess/
|
||||||
|
electron-postprocess/
|
||||||
|
python-postprocess/
|
||||||
|
scripts/*.py
|
||||||
|
scripts/*.ps1
|
||||||
|
scripts/*.md
|
||||||
|
scripts/fix-library-renames.mjs
|
||||||
|
|||||||
@ -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 1–5 · 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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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 & 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 & 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. ·
|
|
||||||
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>
|
|
||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
# Intensive Analyse: Pausen zwischen Pack-Entpackungen (10–15 Sekunden)
|
|
||||||
|
|
||||||
**Nur Analyse – keine Code-Änderungen.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Problem
|
|
||||||
|
|
||||||
Nach dem Entpacken eines Packs (z.B. 3 Parts einer Serie) passiert ca. 10–15 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 10–15 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 2208–2284)
|
|
||||||
|
|
||||||
- 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 2286–2328)
|
|
||||||
|
|
||||||
- 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 (10–15 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 10–15 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. 3761–3804).
|
|
||||||
- Post-Processing-Task: `runPackagePostProcessing` → `handlePackagePostProcessing` (ca. 3806–3854, 6544–6916).
|
|
||||||
- Hybrid: `runHybridExtraction` (ca. 6374–6542), inkl. `await autoRenameExtractedVideoFiles` (6490).
|
|
||||||
- Final: `handlePackagePostProcessing` nach `extractPackageArchives` (6697–6916): `recordPackageHistory`, `void runDeferredPostExtraction`, dann return.
|
|
||||||
- Extractor: `extractPackageArchives` (extractor.ts, ca. 1880–2353), Nested 2208–2284, Post-Cleanup 2286–2328.
|
|
||||||
- Rename: `autoRenameExtractedVideoFiles` (download-manager.ts, 2173–2312), nutzt `collectVideoFiles` (rekursiv).
|
|
||||||
- MKV/Cleanup: `collectMkvFilesToLibrary` (2448), `cleanupRemainingArchiveArtifacts` (2353), `runDeferredPostExtraction` (6922–6965).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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).
|
|
||||||
@ -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`
|
|
||||||
79822
rd_downloader.log.old
79822
rd_downloader.log.old
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,18 @@
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const { rcedit } = require("rcedit");
|
const { rcedit } = require("rcedit");
|
||||||
|
|
||||||
module.exports = async function afterPack(context) {
|
module.exports = async function afterPack(context) {
|
||||||
const productFilename = context.packager?.appInfo?.productFilename;
|
const productFilename = context.packager?.appInfo?.productFilename;
|
||||||
if (!productFilename) {
|
if (!productFilename) {
|
||||||
console.warn(" • rcedit: skipped — productFilename not available");
|
console.warn(" • rcedit: skipped — productFilename not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
const exePath = path.join(context.appOutDir, `${productFilename}.exe`);
|
||||||
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
const iconPath = path.resolve(__dirname, "..", "assets", "app_icon.ico");
|
||||||
console.log(` • rcedit: patching icon → ${exePath}`);
|
console.log(` • rcedit: patching icon → ${exePath}`);
|
||||||
try {
|
try {
|
||||||
await rcedit(exePath, { icon: iconPath });
|
await rcedit(exePath, { icon: iconPath });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(` • rcedit: failed — ${String(error)}`);
|
console.warn(` • rcedit: failed — ${String(error)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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) {
|
async function callMegaDebrid(link) {
|
||||||
if (!megaCookie) {
|
if (!megaCookie) {
|
||||||
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {
|
const loginRes = await fetch("https://www.mega-debrid.eu/index.php?form=login", {
|
||||||
|
|||||||
@ -116,7 +116,6 @@ function getGiteaRepo() {
|
|||||||
}
|
}
|
||||||
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
|
return { remote, ...parsed, baseUrl: `${preferredProtocol}//${parsed.host}` };
|
||||||
} catch {
|
} catch {
|
||||||
// try next remote
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,9 +255,6 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
|
|||||||
target_commitish: "main",
|
target_commitish: "main",
|
||||||
name: tag,
|
name: tag,
|
||||||
body: notes || `Release ${tag}`,
|
body: notes || `Release ${tag}`,
|
||||||
// Als Draft anlegen — der Auto-Updater ueberspringt Drafts. So wird das Release erst
|
|
||||||
// NACH dem Asset-Upload (unten via PATCH draft:false) "latest"; ein Update-Check kann
|
|
||||||
// nie ein Release ohne Setup/latest.yml sehen ("Setup-Asset nicht gefunden").
|
|
||||||
draft: true,
|
draft: true,
|
||||||
prerelease: false
|
prerelease: false
|
||||||
};
|
};
|
||||||
@ -266,8 +262,6 @@ async function createOrGetRelease(baseApi, tag, authHeader, notes) {
|
|||||||
if (created.ok) {
|
if (created.ok) {
|
||||||
return created.body;
|
return created.body;
|
||||||
}
|
}
|
||||||
// Gitea can return 409/422/500 UNIQUE when the release was already partially created.
|
|
||||||
// Retry the GET — it may now exist.
|
|
||||||
if (created.status === 409 || created.status === 422 || created.status === 500) {
|
if (created.status === 409 || created.status === 422 || created.status === 500) {
|
||||||
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
|
const retry = await apiRequest("GET", `${baseApi}/releases/tags/${encodeURIComponent(tag)}`, authHeader);
|
||||||
if (retry.ok) {
|
if (retry.ok) {
|
||||||
@ -285,11 +279,6 @@ async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, f
|
|||||||
const fileSize = fs.statSync(filePath).size;
|
const fileSize = fs.statSync(filePath).size;
|
||||||
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
|
const uploadUrl = `${baseApi}/releases/${releaseId}/assets?name=${encodeURIComponent(fileName)}`;
|
||||||
|
|
||||||
// Grosse Assets (~80MB Setup/portable) brechen gelegentlich mitten im Upload ab
|
|
||||||
// (Netzwerk-Reset oder 5xx). Da das Release vorher als Draft angelegt wird, bleibt ein
|
|
||||||
// Fehlschlag hier unsichtbar — aber der Release ist dann unvollstaendig. Deshalb je Asset
|
|
||||||
// bis zu MAX_ATTEMPTS Versuche mit Backoff; ein konsumierter Stream laesst sich nicht
|
|
||||||
// erneut senden, also pro Versuch einen FRISCHEN createReadStream.
|
|
||||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
||||||
const fileStream = fs.createReadStream(filePath);
|
const fileStream = fs.createReadStream(filePath);
|
||||||
let response;
|
let response;
|
||||||
@ -331,7 +320,6 @@ async function uploadReleaseAssets(baseApi, releaseId, authHeader, releaseDir, f
|
|||||||
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
|
process.stdout.write(`Skipped existing asset: ${fileName}\n`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// 5xx = transient -> neu versuchen; 4xx (ausser 409/422) = echter Fehler -> abbrechen.
|
|
||||||
if (response.status >= 500 && attempt < MAX_ATTEMPTS) {
|
if (response.status >= 500 && attempt < MAX_ATTEMPTS) {
|
||||||
process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
|
process.stdout.write(`Upload ${fileName} fehlgeschlagen (${response.status}, Versuch ${attempt}/${MAX_ATTEMPTS}), neuer Versuch...\n`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
|
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
|
||||||
@ -390,9 +378,6 @@ async function main() {
|
|||||||
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
|
const release = await createOrGetRelease(baseApi, tag, authHeader, releaseNotes);
|
||||||
await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files);
|
await uploadReleaseAssets(baseApi, release.id, authHeader, assets.releaseDir, assets.files);
|
||||||
|
|
||||||
// Erst JETZT veroeffentlichen (draft:false), nachdem ALLE Assets oben hochgeladen sind.
|
|
||||||
// Davor war das Release den ganzen Upload ueber sichtbar, aber ohne latest.yml/Setup →
|
|
||||||
// Auto-Update-Checks in diesem Fenster scheiterten mit "Setup-Asset nicht gefunden".
|
|
||||||
const published = await apiRequest("PATCH", `${baseApi}/releases/${release.id}`, authHeader, JSON.stringify({ draft: false }));
|
const published = await apiRequest("PATCH", `${baseApi}/releases/${release.id}`, authHeader, JSON.stringify({ draft: false }));
|
||||||
if (!published.ok) {
|
if (!published.ok) {
|
||||||
throw new Error(`Failed to publish release (${published.status}): ${JSON.stringify(published.body)}`);
|
throw new Error(`Failed to publish release (${published.status}): ${JSON.stringify(published.body)}`);
|
||||||
|
|||||||
@ -4,18 +4,6 @@ import { parseDebridLinkApiKeys, type DebridLinkApiKeyEntry } from "../shared/de
|
|||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { compactErrorText } from "./utils";
|
import { compactErrorText } from "./utils";
|
||||||
|
|
||||||
/**
|
|
||||||
* Account-Validity + Premium-Check fuer Multi-Account-Provider.
|
|
||||||
*
|
|
||||||
* Standalone (eigene fetch-Calls, kein Import aus debrid.ts) damit es ohne
|
|
||||||
* Zirkular-Abhaengigkeit von der "Check all"-IPC und beim Programmstart genutzt
|
|
||||||
* werden kann.
|
|
||||||
*
|
|
||||||
* Verifizierte API-Felder (Live-Probe):
|
|
||||||
* - Mega-Debrid connectUser -> { response_code:"ok", token, vip_end (Unix-ts), email }
|
|
||||||
* - Debrid-Link /account/infos -> { success, value: { accountType, premiumLeft (s), username } }
|
|
||||||
*/
|
|
||||||
|
|
||||||
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
const MEGA_DEBRID_API = "https://www.mega-debrid.eu/api.php";
|
||||||
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
|
const DEBRID_LINK_API = "https://debrid-link.com/api/v2";
|
||||||
const CHECK_USER_AGENT =
|
const CHECK_USER_AGENT =
|
||||||
@ -55,7 +43,6 @@ function formatRemaining(premiumUntilMs: number | null, now: number): string {
|
|||||||
return `Premium noch ${hours} Std`;
|
return `Premium noch ${hours} Std`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check a single Mega-Debrid account via connectUser. */
|
|
||||||
export async function checkMegaDebridAccount(
|
export async function checkMegaDebridAccount(
|
||||||
account: MegaDebridAccountEntry,
|
account: MegaDebridAccountEntry,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
@ -87,7 +74,6 @@ export async function checkMegaDebridAccount(
|
|||||||
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
|
const reason = String(payload.response_text || payload.response_code || "Login abgelehnt");
|
||||||
return { ...base, message: `Ungueltiger Login: ${reason}` };
|
return { ...base, message: `Ungueltiger Login: ${reason}` };
|
||||||
}
|
}
|
||||||
// vip_end is a Unix timestamp (seconds). 0 / missing => no premium.
|
|
||||||
const vipEndRaw = Number(payload.vip_end || 0);
|
const vipEndRaw = Number(payload.vip_end || 0);
|
||||||
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
|
const premiumUntilMs = Number.isFinite(vipEndRaw) && vipEndRaw > 0 ? vipEndRaw * 1000 : 0;
|
||||||
const isPremium = premiumUntilMs > now;
|
const isPremium = premiumUntilMs > now;
|
||||||
@ -110,7 +96,6 @@ export async function checkMegaDebridAccount(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Check a single Debrid-Link API key via /account/infos. */
|
|
||||||
export async function checkDebridLinkKey(
|
export async function checkDebridLinkKey(
|
||||||
key: DebridLinkApiKeyEntry,
|
key: DebridLinkApiKeyEntry,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
@ -138,7 +123,6 @@ export async function checkDebridLinkKey(
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
const payload = parseJsonSafe(text);
|
const payload = parseJsonSafe(text);
|
||||||
if (!response.ok || !payload) {
|
if (!response.ok || !payload) {
|
||||||
// 401 = bad/expired token
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
|
return { ...base, message: "Ungueltiger API-Key (nicht autorisiert)" };
|
||||||
}
|
}
|
||||||
@ -149,7 +133,6 @@ export async function checkDebridLinkKey(
|
|||||||
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
|
return { ...base, message: `Ungueltiger API-Key: ${reason}` };
|
||||||
}
|
}
|
||||||
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
|
const value = (payload.value && typeof payload.value === "object" ? payload.value : payload) as Record<string, unknown>;
|
||||||
// premiumLeft = seconds of premium remaining. accountType>0 also indicates premium.
|
|
||||||
const premiumLeftSec = Number(value.premiumLeft || 0);
|
const premiumLeftSec = Number(value.premiumLeft || 0);
|
||||||
const accountType = Number(value.accountType || 0);
|
const accountType = Number(value.accountType || 0);
|
||||||
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
|
const premiumUntilMs = Number.isFinite(premiumLeftSec) && premiumLeftSec > 0 ? now + premiumLeftSec * 1000 : 0;
|
||||||
@ -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(
|
export async function checkAllDebridAccounts(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
@ -185,9 +166,6 @@ export async function checkAllDebridAccounts(
|
|||||||
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
const megaAccounts = parseMegaDebridAccounts(settings.megaCredentials || "", settings.megaPassword || "");
|
||||||
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
const debridLinkKeys = parseDebridLinkApiKeys(settings.debridLinkApiKeys || "");
|
||||||
|
|
||||||
// Each task is a thunk so we can throttle concurrency. Firing all accounts at
|
|
||||||
// once (e.g. 9+ Debrid-Link keys) can trip provider rate-limits and produce
|
|
||||||
// false "invalid" badges, so cap at CHECK_CONCURRENCY parallel checks.
|
|
||||||
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
const taskFns: Array<() => Promise<DebridAccountStatus>> = [
|
||||||
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
...megaAccounts.map((account) => () => checkMegaDebridAccount(account, signal, now)),
|
||||||
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
...debridLinkKeys.map((key) => () => checkDebridLinkKey(key, signal, now))
|
||||||
@ -203,7 +181,6 @@ export async function checkAllDebridAccounts(
|
|||||||
|
|
||||||
const CHECK_CONCURRENCY = 4;
|
const CHECK_CONCURRENCY = 4;
|
||||||
|
|
||||||
/** Run thunks with a bounded number in flight, preserving result order. */
|
|
||||||
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
async function runWithConcurrency<T>(taskFns: Array<() => Promise<T>>, limit: number): Promise<T[]> {
|
||||||
const results: T[] = new Array(taskFns.length);
|
const results: T[] = new Array(taskFns.length);
|
||||||
let nextIndex = 0;
|
let nextIndex = 0;
|
||||||
|
|||||||
@ -4,51 +4,30 @@ import path from "node:path";
|
|||||||
import { AsyncLocalStorage } from "node:async_hooks";
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import type { RotationEvent } from "../shared/types";
|
import type { RotationEvent } from "../shared/types";
|
||||||
|
|
||||||
/** Item-scoped sink: while a single item's link-unrestrict runs, the
|
|
||||||
* download-manager wraps it in runWithRotationItemSink() so EVERY rotation
|
|
||||||
* event for that item (Account 1 wird versucht, fehlgeschlagen, → Account 2)
|
|
||||||
* lands in that item's own log — exactly where the user looks. AsyncLocalStorage
|
|
||||||
* keeps this correct even with 8 items unrestricting in parallel: each runs in
|
|
||||||
* its own async context, so events never cross-attribute. */
|
|
||||||
export type RotationItemSink = (event: RotationEvent) => void;
|
export type RotationItemSink = (event: RotationEvent) => void;
|
||||||
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
const rotationItemContext = new AsyncLocalStorage<RotationItemSink>();
|
||||||
|
|
||||||
/** Run `fn` with an item-scoped rotation sink active for its whole async chain. */
|
|
||||||
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
export function runWithRotationItemSink<T>(sink: RotationItemSink, fn: () => Promise<T>): Promise<T> {
|
||||||
return rotationItemContext.run(sink, fn);
|
return rotationItemContext.run(sink, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dedicated log file for multi-account/key rotation events:
|
|
||||||
* Mega-Debrid account selection, Debrid-Link key selection, per-attempt
|
|
||||||
* test result, cooldown set, fallback to next account/key, etc.
|
|
||||||
* Separate from rd_downloader.log so the user can see the rotation flow
|
|
||||||
* without the noise of normal download activity. */
|
|
||||||
|
|
||||||
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
type RotationLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
/** In-memory ring buffer of the most recent rotation events so the UI can show
|
|
||||||
* a live "which account was tried and why it failed" panel — the same events
|
|
||||||
* written to account-rotation.log, but surfaced to the renderer via snapshot. */
|
|
||||||
const ROTATION_EVENT_RING_MAX = 60;
|
const ROTATION_EVENT_RING_MAX = 60;
|
||||||
const rotationEventRing: RotationEvent[] = [];
|
const rotationEventRing: RotationEvent[] = [];
|
||||||
let rotationEventSeq = 0;
|
let rotationEventSeq = 0;
|
||||||
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
let rotationEventListener: ((event: RotationEvent) => void) | null = null;
|
||||||
|
|
||||||
/** Register a callback fired whenever a new rotation event is recorded (used by
|
|
||||||
* the download-manager to push a fresh snapshot to the UI immediately). */
|
|
||||||
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
export function setRotationEventListener(listener: ((event: RotationEvent) => void) | null): void {
|
||||||
rotationEventListener = listener;
|
rotationEventListener = listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the recent rotation events, newest first. */
|
|
||||||
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
export function getRecentRotationEvents(limit = ROTATION_EVENT_RING_MAX): RotationEvent[] {
|
||||||
const slice = rotationEventRing.slice(-limit);
|
const slice = rotationEventRing.slice(-limit);
|
||||||
slice.reverse();
|
slice.reverse();
|
||||||
return slice;
|
return slice;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Events that are noise for the UI panel (per-attempt TEST markers). The panel
|
|
||||||
* focuses on outcomes: OK / FAILED / FATAL / skips. */
|
|
||||||
function isUiRelevantRotationEvent(event: string): boolean {
|
function isUiRelevantRotationEvent(event: string): boolean {
|
||||||
return event !== "TEST";
|
return event !== "TEST";
|
||||||
}
|
}
|
||||||
@ -75,20 +54,14 @@ function pushRotationEvent(
|
|||||||
next: fields && fields.next != null ? String(fields.next) : undefined
|
next: fields && fields.next != null ? String(fields.next) : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
// Always route to the item-scoped sink (if any) — the per-item log wants the
|
|
||||||
// FULL trail including "TEST" (Account X wird versucht), so the user sees the
|
|
||||||
// rotation right where they look.
|
|
||||||
const itemSink = rotationItemContext.getStore();
|
const itemSink = rotationItemContext.getStore();
|
||||||
if (itemSink) {
|
if (itemSink) {
|
||||||
try {
|
try {
|
||||||
itemSink(entry);
|
itemSink(entry);
|
||||||
} catch {
|
} catch {
|
||||||
// never let item logging break the rotation flow
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The global UI panel ring + live push skip noisy per-attempt TEST markers;
|
|
||||||
// it focuses on outcomes (OK / FAILED / FATAL / skips).
|
|
||||||
if (!isUiRelevantRotationEvent(event)) {
|
if (!isUiRelevantRotationEvent(event)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -100,7 +73,6 @@ function pushRotationEvent(
|
|||||||
try {
|
try {
|
||||||
rotationEventListener(entry);
|
rotationEventListener(entry);
|
||||||
} catch {
|
} catch {
|
||||||
// never let a UI push break the rotation flow
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -147,11 +119,9 @@ function rotateIfNeeded(filePath: string): void {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,7 +134,6 @@ function cleanupOldBackup(filePath: string): void {
|
|||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} 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(
|
export function logAccountRotation(
|
||||||
level: RotationLevel,
|
level: RotationLevel,
|
||||||
provider: string,
|
provider: string,
|
||||||
@ -204,7 +166,6 @@ export function logAccountRotation(
|
|||||||
event: string,
|
event: string,
|
||||||
fields?: Record<string, unknown>
|
fields?: Record<string, unknown>
|
||||||
): void {
|
): void {
|
||||||
// Surface to the UI ring buffer regardless of whether the file log is ready.
|
|
||||||
pushRotationEvent(level, provider, accountLabel, event, fields);
|
pushRotationEvent(level, provider, accountLabel, event, fields);
|
||||||
if (!rotationLogPath) {
|
if (!rotationLogPath) {
|
||||||
return;
|
return;
|
||||||
@ -217,7 +178,6 @@ export function logAccountRotation(
|
|||||||
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
const head = `${logTimestamp()} [${level}] ${provider} | ${accountLabel} | ${event}`;
|
||||||
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
fs.appendFileSync(rotationLogPath, `${head}${formatFields(fields)}\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -239,7 +199,6 @@ export function shutdownAccountRotationLog(): void {
|
|||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
rotationLogPath = null;
|
rotationLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -243,12 +243,10 @@ export class AllDebridWebFallback {
|
|||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -46,11 +46,9 @@ function rotateIfNeeded(filePath: string): void {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +61,6 @@ function cleanupOldBackup(filePath: string): void {
|
|||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +97,6 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
|
|||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,7 +114,6 @@ export function shutdownAuditLog(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
auditLogPath = null;
|
auditLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +1,15 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
// Fixed app key — like JDownloader 2: deterministic, works on any machine.
|
|
||||||
// Not meant to protect against reverse-engineering, just prevents casual
|
|
||||||
// plaintext snooping when someone opens the backup file.
|
|
||||||
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
const APP_KEY_MATERIAL = "MDD-v2-backup-aes256gcm-2026";
|
||||||
const ALGORITHM = "aes-256-gcm";
|
const ALGORITHM = "aes-256-gcm";
|
||||||
const IV_LENGTH = 12; // 96-bit IV for GCM
|
const IV_LENGTH = 12;
|
||||||
const AUTH_TAG_LENGTH = 16;
|
const AUTH_TAG_LENGTH = 16;
|
||||||
const MAGIC = Buffer.from("MDD1"); // file signature
|
const MAGIC = Buffer.from("MDD1");
|
||||||
|
|
||||||
function deriveKey(): Buffer {
|
function deriveKey(): Buffer {
|
||||||
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
return crypto.createHash("sha256").update(APP_KEY_MATERIAL).digest();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Encrypt a UTF-8 string into an MDD backup buffer.
|
|
||||||
* Format: MAGIC(4) | IV(12) | AUTH_TAG(16) | CIPHERTEXT(…)
|
|
||||||
*/
|
|
||||||
export function encryptBackup(plaintext: string): Buffer {
|
export function encryptBackup(plaintext: string): Buffer {
|
||||||
const key = deriveKey();
|
const key = deriveKey();
|
||||||
const iv = crypto.randomBytes(IV_LENGTH);
|
const iv = crypto.randomBytes(IV_LENGTH);
|
||||||
@ -26,10 +19,6 @@ export function encryptBackup(plaintext: string): Buffer {
|
|||||||
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
return Buffer.concat([MAGIC, iv, authTag, encrypted]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decrypt an MDD backup buffer back to a UTF-8 string.
|
|
||||||
* Throws on invalid/corrupted data.
|
|
||||||
*/
|
|
||||||
export function decryptBackup(data: Buffer): string {
|
export function decryptBackup(data: Buffer): string {
|
||||||
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
if (data.length < MAGIC.length + IV_LENGTH + AUTH_TAG_LENGTH) {
|
||||||
throw new Error("Backup-Datei zu kurz oder ungültig");
|
throw new Error("Backup-Datei zu kurz oder ungültig");
|
||||||
|
|||||||
@ -212,18 +212,15 @@ export class BestDebridWebFallback {
|
|||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
// nothing to clean up
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getPartition(): string {
|
private getPartition(): string {
|
||||||
@ -344,7 +341,6 @@ export class BestDebridWebFallback {
|
|||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cache clear failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,6 @@ export function cleanupCancelledPackageArtifacts(packageDir: string): number {
|
|||||||
fs.rmSync(full, { force: true });
|
fs.rmSync(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -84,7 +83,6 @@ export async function cleanupCancelledPackageArtifactsAsync(
|
|||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -150,7 +148,6 @@ export async function removeDownloadLinkArtifacts(
|
|||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -240,7 +237,6 @@ export async function removeSampleArtifacts(
|
|||||||
await fs.promises.rm(full, { force: true });
|
await fs.promises.rm(full, { force: true });
|
||||||
removedFiles += 1;
|
removedFiles += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -263,7 +259,6 @@ export async function removeSampleArtifacts(
|
|||||||
removedFiles += filesInDir;
|
removedFiles += filesInDir;
|
||||||
removedDirs += 1;
|
removedDirs += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,130 +1,130 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { AppSettings } from "../shared/types";
|
import { AppSettings } from "../shared/types";
|
||||||
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
import { getProviderUsageDayKey } from "../shared/provider-daily-limits";
|
||||||
import packageJson from "../../package.json";
|
import packageJson from "../../package.json";
|
||||||
|
|
||||||
export const APP_NAME = "Multi Debrid Downloader";
|
export const APP_NAME = "Multi Debrid Downloader";
|
||||||
export const APP_VERSION: string = packageJson.version;
|
export const APP_VERSION: string = packageJson.version;
|
||||||
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
|
export const API_BASE_URL = "https://api.real-debrid.com/rest/1.0";
|
||||||
|
|
||||||
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
export const DCRYPT_UPLOAD_URL = "https://dcrypt.it/decrypt/upload";
|
||||||
export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste";
|
export const DCRYPT_PASTE_URL = "https://dcrypt.it/decrypt/paste";
|
||||||
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
|
export const DLC_SERVICE_URL = "https://service.jdownloader.org/dlcrypt/service.php?srcType=dlc&destType=pylo&data={KEY}";
|
||||||
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
|
export const DLC_AES_KEY = Buffer.from("cb99b5cbc24db398", "utf8");
|
||||||
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
|
export const DLC_AES_IV = Buffer.from("9bc24cb995cb8db3", "utf8");
|
||||||
|
|
||||||
export const REQUEST_RETRIES = 3;
|
export const REQUEST_RETRIES = 3;
|
||||||
export const CHUNK_SIZE = 512 * 1024;
|
export const CHUNK_SIZE = 512 * 1024;
|
||||||
|
|
||||||
export const WRITE_BUFFER_SIZE = 512 * 1024; // 512 KB write buffer (JDownloader: 500 KB)
|
export const WRITE_BUFFER_SIZE = 512 * 1024;
|
||||||
export const WRITE_FLUSH_TIMEOUT_MS = 2000; // 2s flush timeout
|
export const WRITE_FLUSH_TIMEOUT_MS = 2000;
|
||||||
export const ALLOCATION_UNIT_SIZE = 4096; // 4 KB NTFS alignment
|
export const ALLOCATION_UNIT_SIZE = 4096;
|
||||||
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 STREAM_HIGH_WATER_MARK = 512 * 1024;
|
||||||
export const DISK_BUSY_THRESHOLD_MS = 300; // Internal detection threshold for disk backpressure
|
export const DISK_BUSY_THRESHOLD_MS = 300;
|
||||||
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500; // Delay UI/log display for brief disk-write spikes
|
export const DISK_BUSY_STATUS_THRESHOLD_MS = 500;
|
||||||
|
|
||||||
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
|
export const SAMPLE_DIR_NAMES = new Set(["sample", "samples"]);
|
||||||
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
|
export const SAMPLE_VIDEO_EXTENSIONS = new Set([".mkv", ".mp4", ".avi", ".mov", ".wmv", ".m4v", ".ts", ".m2ts", ".webm"]);
|
||||||
export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
|
export const LINK_ARTIFACT_EXTENSIONS = new Set([".url", ".webloc", ".dlc", ".rsdf", ".ccf"]);
|
||||||
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
export const SAMPLE_TOKEN_RE = /(^|[._\-\s])sample([._\-\s]|$)/i;
|
||||||
|
|
||||||
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]);
|
export const ARCHIVE_TEMP_EXTENSIONS = new Set([".rar", ".zip", ".7z", ".tmp", ".part", ".tar", ".gz", ".bz2", ".xz", ".rev"]);
|
||||||
export const RAR_SPLIT_RE = /\.r\d{2,3}$/i;
|
export const RAR_SPLIT_RE = /\.r\d{2,3}$/i;
|
||||||
|
|
||||||
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
|
export const MAX_MANIFEST_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
export const MAX_LINK_ARTIFACT_BYTES = 256 * 1024;
|
||||||
export const SPEED_WINDOW_SECONDS = 1;
|
export const SPEED_WINDOW_SECONDS = 1;
|
||||||
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
export const CLIPBOARD_POLL_INTERVAL_MS = 2000;
|
||||||
|
|
||||||
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader";
|
export const DEFAULT_UPDATE_REPO = "Administrator/real-debrid-downloader";
|
||||||
|
|
||||||
export function defaultSettings(): AppSettings {
|
export function defaultSettings(): AppSettings {
|
||||||
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
const baseDir = path.join(os.homedir(), "Downloads", "RealDebrid");
|
||||||
return {
|
return {
|
||||||
token: "",
|
token: "",
|
||||||
realDebridUseWebLogin: false,
|
realDebridUseWebLogin: false,
|
||||||
megaLogin: "",
|
megaLogin: "",
|
||||||
megaPassword: "",
|
megaPassword: "",
|
||||||
megaCredentials: "",
|
megaCredentials: "",
|
||||||
megaDebridApiEnabled: false,
|
megaDebridApiEnabled: false,
|
||||||
megaDebridWebEnabled: false,
|
megaDebridWebEnabled: false,
|
||||||
megaDebridPreferApi: true,
|
megaDebridPreferApi: true,
|
||||||
bestToken: "",
|
bestToken: "",
|
||||||
bestDebridUseWebLogin: false,
|
bestDebridUseWebLogin: false,
|
||||||
allDebridToken: "",
|
allDebridToken: "",
|
||||||
allDebridUseWebLogin: false,
|
allDebridUseWebLogin: false,
|
||||||
ddownloadLogin: "",
|
ddownloadLogin: "",
|
||||||
ddownloadPassword: "",
|
ddownloadPassword: "",
|
||||||
oneFichierApiKey: "",
|
oneFichierApiKey: "",
|
||||||
debridLinkApiKeys: "",
|
debridLinkApiKeys: "",
|
||||||
debridLinkDisabledKeyIds: [],
|
debridLinkDisabledKeyIds: [],
|
||||||
linkSnappyLogin: "",
|
linkSnappyLogin: "",
|
||||||
linkSnappyPassword: "",
|
linkSnappyPassword: "",
|
||||||
archivePasswordList: "",
|
archivePasswordList: "",
|
||||||
rememberToken: true,
|
rememberToken: true,
|
||||||
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
|
providerOrder: ["realdebrid", "megadebrid-api", "bestdebrid"],
|
||||||
providerPrimary: "realdebrid",
|
providerPrimary: "realdebrid",
|
||||||
providerSecondary: "megadebrid-api",
|
providerSecondary: "megadebrid-api",
|
||||||
providerTertiary: "bestdebrid",
|
providerTertiary: "bestdebrid",
|
||||||
autoProviderFallback: true,
|
autoProviderFallback: true,
|
||||||
outputDir: baseDir,
|
outputDir: baseDir,
|
||||||
packageName: "",
|
packageName: "",
|
||||||
autoExtract: true,
|
autoExtract: true,
|
||||||
autoRename4sf4sj: false,
|
autoRename4sf4sj: false,
|
||||||
extractDir: path.join(baseDir, "_entpackt"),
|
extractDir: path.join(baseDir, "_entpackt"),
|
||||||
collectMkvToLibrary: false,
|
collectMkvToLibrary: false,
|
||||||
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
mkvLibraryDir: path.join(baseDir, "_mkv"),
|
||||||
createExtractSubfolder: true,
|
createExtractSubfolder: true,
|
||||||
hybridExtract: true,
|
hybridExtract: true,
|
||||||
cleanupMode: "none",
|
cleanupMode: "none",
|
||||||
extractConflictMode: "overwrite",
|
extractConflictMode: "overwrite",
|
||||||
removeLinkFilesAfterExtract: false,
|
removeLinkFilesAfterExtract: false,
|
||||||
removeSamplesAfterExtract: false,
|
removeSamplesAfterExtract: false,
|
||||||
enableIntegrityCheck: true,
|
enableIntegrityCheck: true,
|
||||||
autoResumeOnStart: true,
|
autoResumeOnStart: true,
|
||||||
autoReconnect: false,
|
autoReconnect: false,
|
||||||
reconnectWaitSeconds: 45,
|
reconnectWaitSeconds: 45,
|
||||||
completedCleanupPolicy: "never",
|
completedCleanupPolicy: "never",
|
||||||
maxParallel: 4,
|
maxParallel: 4,
|
||||||
maxParallelExtract: 2,
|
maxParallelExtract: 2,
|
||||||
retryLimit: 0,
|
retryLimit: 0,
|
||||||
speedLimitEnabled: false,
|
speedLimitEnabled: false,
|
||||||
speedLimitKbps: 0,
|
speedLimitKbps: 0,
|
||||||
speedLimitMode: "global",
|
speedLimitMode: "global",
|
||||||
updateRepo: DEFAULT_UPDATE_REPO,
|
updateRepo: DEFAULT_UPDATE_REPO,
|
||||||
autoUpdateCheck: true,
|
autoUpdateCheck: true,
|
||||||
clipboardWatch: false,
|
clipboardWatch: false,
|
||||||
minimizeToTray: false,
|
minimizeToTray: false,
|
||||||
theme: "dark" as const,
|
theme: "dark" as const,
|
||||||
collapseNewPackages: true,
|
collapseNewPackages: true,
|
||||||
historyRetentionMode: "permanent",
|
historyRetentionMode: "permanent",
|
||||||
accountListShowDetailedDebridLinkKeys: false,
|
accountListShowDetailedDebridLinkKeys: false,
|
||||||
autoSortPackagesByProgress: true,
|
autoSortPackagesByProgress: true,
|
||||||
autoSkipExtracted: false,
|
autoSkipExtracted: false,
|
||||||
hideExtractedItems: true,
|
hideExtractedItems: true,
|
||||||
confirmDeleteSelection: true,
|
confirmDeleteSelection: true,
|
||||||
totalDownloadedAllTime: 0,
|
totalDownloadedAllTime: 0,
|
||||||
totalCompletedFilesAllTime: 0,
|
totalCompletedFilesAllTime: 0,
|
||||||
totalRuntimeAllTimeMs: 0,
|
totalRuntimeAllTimeMs: 0,
|
||||||
bandwidthSchedules: [],
|
bandwidthSchedules: [],
|
||||||
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
columnOrder: ["name", "size", "progress", "hoster", "account", "prio", "status", "speed"],
|
||||||
extractCpuPriority: "high",
|
extractCpuPriority: "high",
|
||||||
autoExtractWhenStopped: true,
|
autoExtractWhenStopped: true,
|
||||||
disabledProviders: [],
|
disabledProviders: [],
|
||||||
hosterRouting: {},
|
hosterRouting: {},
|
||||||
providerDailyLimitBytes: {},
|
providerDailyLimitBytes: {},
|
||||||
providerDailyUsageBytes: {},
|
providerDailyUsageBytes: {},
|
||||||
providerTotalUsageBytes: {},
|
providerTotalUsageBytes: {},
|
||||||
debridLinkApiKeyDailyLimitBytes: {},
|
debridLinkApiKeyDailyLimitBytes: {},
|
||||||
debridLinkApiKeyDailyUsageBytes: {},
|
debridLinkApiKeyDailyUsageBytes: {},
|
||||||
debridLinkApiKeyTotalUsageBytes: {},
|
debridLinkApiKeyTotalUsageBytes: {},
|
||||||
megaDebridDisabledAccountIds: [],
|
megaDebridDisabledAccountIds: [],
|
||||||
megaDebridAccountDailyLimitBytes: {},
|
megaDebridAccountDailyLimitBytes: {},
|
||||||
megaDebridAccountDailyUsageBytes: {},
|
megaDebridAccountDailyUsageBytes: {},
|
||||||
megaDebridAccountTotalUsageBytes: {},
|
megaDebridAccountTotalUsageBytes: {},
|
||||||
debridAccountStatuses: {},
|
debridAccountStatuses: {},
|
||||||
providerDailyUsageDay: getProviderUsageDayKey(),
|
providerDailyUsageDay: getProviderUsageDayKey(),
|
||||||
scheduledStartEpochMs: 0
|
scheduledStartEpochMs: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,13 +113,11 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
|||||||
try {
|
try {
|
||||||
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
fileName = Buffer.from(fnMatch[1].trim(), "base64").toString("utf8").trim();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
links.push(url);
|
links.push(url);
|
||||||
fileNames.push(sanitizeFilename(fileName));
|
fileNames.push(sanitizeFilename(fileName));
|
||||||
} catch {
|
} catch {
|
||||||
// skip broken entries
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,7 +130,6 @@ function parsePackagesFromDlcXml(xml: string): ParsedPackageInput[] {
|
|||||||
links.push(url);
|
links.push(url);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// skip broken entries
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 ONEFICHIER_URL_RE = /^https?:\/\/(?:www\.)?(?:1fichier\.com|alterupload\.com|cjoint\.net|desfichiers\.com|dfichiers\.com|megadl\.fr|mesfichiers\.org|piecejointe\.net|pjointe\.com|tenvoi\.com|dl4free\.com)\/\?([a-z0-9]{5,20})$/i;
|
||||||
|
|
||||||
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
|
const DEBRID_LINK_API_BASE = "https://debrid-link.com/api/v2";
|
||||||
/** Truly key-wide quota errors: the whole key is exhausted regardless of host. */
|
|
||||||
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
|
const DEBRID_LINK_KEY_QUOTA_ERRORS = new Set(["maxLink", "maxData"]);
|
||||||
/** Per-(key, host) quota errors: only this host is exhausted for this key — the
|
|
||||||
* key remains usable for other hosters. */
|
|
||||||
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
|
const DEBRID_LINK_HOST_QUOTA_ERRORS = new Set(["maxLinkHost", "maxDataHost"]);
|
||||||
/** Backward-compat union — includes BOTH key-wide and per-host quota codes.
|
|
||||||
* Use this only for "is it a quota error of any kind?" checks; for behavior
|
|
||||||
* branches use the more specific sets above. */
|
|
||||||
const DEBRID_LINK_QUOTA_ERRORS = new Set([...DEBRID_LINK_KEY_QUOTA_ERRORS, ...DEBRID_LINK_HOST_QUOTA_ERRORS]);
|
const DEBRID_LINK_QUOTA_ERRORS = new Set([...DEBRID_LINK_KEY_QUOTA_ERRORS, ...DEBRID_LINK_HOST_QUOTA_ERRORS]);
|
||||||
const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]);
|
const DEBRID_LINK_INVALID_TOKEN_ERRORS = new Set(["badToken", "hidedToken", "expired_token"]);
|
||||||
const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]);
|
const DEBRID_LINK_RATE_LIMIT_ERRORS = new Set(["floodDetected"]);
|
||||||
const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]);
|
const DEBRID_LINK_RETRYABLE_ERRORS = new Set(["internalError", "server_error"]);
|
||||||
const DEBRID_LINK_PROVIDER_WIDE_ERRORS = new Set(["notDebrid"]);
|
const DEBRID_LINK_PROVIDER_WIDE_ERRORS = new Set(["notDebrid"]);
|
||||||
/** Errors where the key can't handle this link — skip to next key immediately, no retries */
|
|
||||||
const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
|
const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
|
||||||
"disabledServerHost",
|
"disabledServerHost",
|
||||||
"notFreeHost",
|
"notFreeHost",
|
||||||
@ -48,7 +41,6 @@ const DEBRID_LINK_SKIP_KEY_ERRORS = new Set([
|
|||||||
"fileNotAvailable"
|
"fileNotAvailable"
|
||||||
]);
|
]);
|
||||||
const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "badFilePassword", "fileNotFound", "hostNotValid"]);
|
const DEBRID_LINK_FATAL_LINK_ERRORS = new Set(["badArguments", "badFileUrl", "badFilePassword", "fileNotFound", "hostNotValid"]);
|
||||||
/** Per-key cooldown cache: keyId → expiry timestamp. Parallel items skip keys that recently failed. */
|
|
||||||
const debridLinkKeyCooldowns = new Map<string, number>();
|
const debridLinkKeyCooldowns = new Map<string, number>();
|
||||||
type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
|
type DebridLinkCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
|
||||||
type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory };
|
type DebridLinkCooldownDetail = { message: string; category: DebridLinkCooldownCategory };
|
||||||
@ -60,7 +52,7 @@ type DebridLinkRuntimeStatus = {
|
|||||||
};
|
};
|
||||||
const debridLinkKeyCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
|
const debridLinkKeyCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
|
||||||
const debridLinkKeyRuntimeStatuses = new Map<string, DebridLinkRuntimeStatus>();
|
const debridLinkKeyRuntimeStatuses = new Map<string, DebridLinkRuntimeStatus>();
|
||||||
const DEBRID_LINK_KEY_COOLDOWN_MS = 120_000; // 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_INVALID_KEY_COOLDOWN_MS = 60 * 60 * 1000;
|
||||||
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;
|
const DEBRID_LINK_RATE_LIMIT_COOLDOWN_MS = 60 * 60 * 1000;
|
||||||
|
|
||||||
@ -72,9 +64,6 @@ export function resetDebridLinkRuntimeStateForTests(): void {
|
|||||||
debridLinkKeyHostCooldownDetails.clear();
|
debridLinkKeyHostCooldownDetails.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop all Debrid-Link cooldown/runtime entries for key IDs that are no
|
|
||||||
* longer in the active key set. Called when settings change so removed
|
|
||||||
* keys don't keep blocking the system if they're re-added later. */
|
|
||||||
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
|
export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): void {
|
||||||
for (const keyId of debridLinkKeyCooldowns.keys()) {
|
for (const keyId of debridLinkKeyCooldowns.keys()) {
|
||||||
if (!activeKeyIds.has(keyId)) {
|
if (!activeKeyIds.has(keyId)) {
|
||||||
@ -87,9 +76,6 @@ export function pruneDebridLinkRuntimeStateForKeys(activeKeyIds: Set<string>): v
|
|||||||
debridLinkKeyRuntimeStatuses.delete(keyId);
|
debridLinkKeyRuntimeStatuses.delete(keyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Per-(key, host) cooldown keys have format `${keyId}|${hoster}` — drop any
|
|
||||||
// whose keyId is no longer in the active set so removed keys don't keep
|
|
||||||
// memory state around if they're re-added later.
|
|
||||||
for (const stateKey of debridLinkKeyHostCooldowns.keys()) {
|
for (const stateKey of debridLinkKeyHostCooldowns.keys()) {
|
||||||
const sepIdx = stateKey.indexOf("|");
|
const sepIdx = stateKey.indexOf("|");
|
||||||
const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey;
|
const keyId = sepIdx >= 0 ? stateKey.slice(0, sepIdx) : stateKey;
|
||||||
@ -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 {
|
export function pruneExpiredDebridLinkRuntimeState(now = Date.now()): number {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
const grace = 60 * 60 * 1000; // keep 1h grace for debugging
|
const grace = 60 * 60 * 1000;
|
||||||
for (const [keyId, until] of debridLinkKeyCooldowns) {
|
for (const [keyId, until] of debridLinkKeyCooldowns) {
|
||||||
if (until + grace < now) {
|
if (until + grace < now) {
|
||||||
debridLinkKeyCooldowns.delete(keyId);
|
debridLinkKeyCooldowns.delete(keyId);
|
||||||
@ -178,13 +161,6 @@ function setDebridLinkKeyCooldownState(
|
|||||||
clearDebridLinkKeyCooldownState(keyId);
|
clearDebridLinkKeyCooldownState(keyId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Cooldown set: max-wins. When 8 parallel items hit floodDetected on the
|
|
||||||
// same key, each computes its own retry-after and calls setDebridLinkKey
|
|
||||||
// CooldownState. Without max-wins, the LAST setter could shorten the
|
|
||||||
// cooldown (e.g. one item got a 1h Retry-After header, another got the
|
|
||||||
// default 2 min — without max-wins the 2 min would overwrite the 1h).
|
|
||||||
// Quota and rate_limit categories take priority over generic temporary
|
|
||||||
// cooldowns regardless of duration to preserve the more-specific signal.
|
|
||||||
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
|
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
|
||||||
const existingUntil = Number(debridLinkKeyCooldowns.get(keyId) || 0);
|
const existingUntil = Number(debridLinkKeyCooldowns.get(keyId) || 0);
|
||||||
const existingDetail = debridLinkKeyCooldownDetails.get(keyId);
|
const existingDetail = debridLinkKeyCooldownDetails.get(keyId);
|
||||||
@ -192,7 +168,6 @@ function setDebridLinkKeyCooldownState(
|
|||||||
const existingIsStrongCategory = existingDetail
|
const existingIsStrongCategory = existingDetail
|
||||||
? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
|
? (existingDetail.category === "rate_limit" || existingDetail.category === "quota" || existingDetail.category === "invalid")
|
||||||
: false;
|
: false;
|
||||||
// Keep existing if it's still active and either lasts longer or has a stronger category
|
|
||||||
if (existingUntil > Date.now()) {
|
if (existingUntil > Date.now()) {
|
||||||
if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
|
if (existingUntil >= newUntil && (!newIsStrongCategory || existingIsStrongCategory)) {
|
||||||
return;
|
return;
|
||||||
@ -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 debridLinkKeyHostCooldowns = new Map<string, number>();
|
||||||
const debridLinkKeyHostCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
|
const debridLinkKeyHostCooldownDetails = new Map<string, DebridLinkCooldownDetail>();
|
||||||
|
|
||||||
@ -251,8 +223,6 @@ function setDebridLinkKeyHostCooldownState(
|
|||||||
category: DebridLinkCooldownCategory
|
category: DebridLinkCooldownCategory
|
||||||
): void {
|
): void {
|
||||||
if (!hoster) {
|
if (!hoster) {
|
||||||
// Fall back to key-wide cooldown when we can't determine the hoster — better
|
|
||||||
// a slightly broader block than letting the key thrash on the same failure.
|
|
||||||
setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category);
|
setDebridLinkKeyCooldownState(keyId, cooldownMs, message, category);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -261,10 +231,6 @@ function setDebridLinkKeyHostCooldownState(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
|
const stateKey = makeDebridLinkKeyHostCooldownKey(keyId, hoster);
|
||||||
// Same max-wins semantics as setDebridLinkKeyCooldownState — parallel items
|
|
||||||
// hitting maxDataHost on the same (key, host) shouldn't shorten an existing
|
|
||||||
// longer cooldown. Strong categories (quota / rate_limit / invalid) win over
|
|
||||||
// generic temporary regardless of duration.
|
|
||||||
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
|
const newUntil = Date.now() + Math.max(1000, Math.floor(cooldownMs));
|
||||||
const existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
|
const existingUntil = Number(debridLinkKeyHostCooldowns.get(stateKey) || 0);
|
||||||
const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey);
|
const existingDetail = debridLinkKeyHostCooldownDetails.get(stateKey);
|
||||||
@ -282,8 +248,6 @@ function setDebridLinkKeyHostCooldownState(
|
|||||||
}
|
}
|
||||||
debridLinkKeyHostCooldowns.set(stateKey, newUntil);
|
debridLinkKeyHostCooldowns.set(stateKey, newUntil);
|
||||||
debridLinkKeyHostCooldownDetails.set(stateKey, { message, category });
|
debridLinkKeyHostCooldownDetails.set(stateKey, { message, category });
|
||||||
// Intentionally NOT updating setDebridLinkKeyRuntimeStatus here — the key
|
|
||||||
// is still healthy for other hosters, only this (key, host) is blocked.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDebridLinkKeyHostCooldownState(
|
function getDebridLinkKeyHostCooldownState(
|
||||||
@ -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 MegaDebridCooldownCategory = "invalid" | "rate_limit" | "quota" | "temporary" | "skip";
|
||||||
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
|
type MegaDebridCooldownDetail = { until: number; message: string; category: MegaDebridCooldownCategory; untilRestart?: boolean };
|
||||||
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
|
const megaDebridAccountCooldowns = new Map<string, MegaDebridCooldownDetail>();
|
||||||
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000; // 2 min cooldown per failed account
|
const MEGA_DEBRID_ACCOUNT_COOLDOWN_MS = 120_000;
|
||||||
const MEGA_DEBRID_INVALID_ACCOUNT_COOLDOWN_MS = 60 * 60 * 1000;
|
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>();
|
const megaDebridEmptyResponseStreaks = new Map<string, number>();
|
||||||
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
|
export const MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART = 3;
|
||||||
|
|
||||||
/** Verbucht eine "Antwort leer"-Antwort fuer den Account-Key und liefert die neue
|
|
||||||
* Streak-Laenge. Ab MEGA_DEBRID_EMPTY_STREAK_UNTIL_RESTART parkt der Aufrufer den
|
|
||||||
* Account bis Neustart. Exportiert fuer deterministische Tests. */
|
|
||||||
export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
|
export function recordMegaDebridEmptyResponseStreak(accountId: string): number {
|
||||||
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
|
const streak = (megaDebridEmptyResponseStreaks.get(accountId) || 0) + 1;
|
||||||
megaDebridEmptyResponseStreaks.set(accountId, streak);
|
megaDebridEmptyResponseStreaks.set(accountId, streak);
|
||||||
return streak;
|
return streak;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Setzt die "Antwort leer"-Streak zurueck (bei Erfolg oder einem anderen Fehlertyp). */
|
|
||||||
export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
|
export function clearMegaDebridEmptyResponseStreak(accountId: string): void {
|
||||||
megaDebridEmptyResponseStreaks.delete(accountId);
|
megaDebridEmptyResponseStreaks.delete(accountId);
|
||||||
}
|
}
|
||||||
@ -353,7 +301,6 @@ export function resetMegaDebridRuntimeStateForTests(): void {
|
|||||||
megaDebridEmptyResponseStreaks.clear();
|
megaDebridEmptyResponseStreaks.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Periodic cleanup of expired Mega-Debrid cooldown entries. */
|
|
||||||
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
|
export function pruneExpiredMegaDebridRuntimeState(now = Date.now()): number {
|
||||||
let removed = 0;
|
let removed = 0;
|
||||||
const grace = 60 * 60 * 1000;
|
const grace = 60 * 60 * 1000;
|
||||||
@ -370,7 +317,6 @@ export function primeMegaDebridRuntimeCooldownForTests(accountId: string, cooldo
|
|||||||
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
|
setMegaDebridAccountCooldownState(accountId, cooldownMs, message, "temporary");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parkt einen Account-Key bis Neustart (Tageslimit). Exportiert fuer Tests. */
|
|
||||||
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis Neustart gesperrt"): void {
|
export function primeMegaDebridUntilRestartForTests(accountId: string, message = "Tageslimit (Test) — bis Neustart gesperrt"): void {
|
||||||
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
|
setMegaDebridAccountCooldownState(accountId, 0, message, "quota", true);
|
||||||
}
|
}
|
||||||
@ -387,9 +333,6 @@ function setMegaDebridAccountCooldownState(
|
|||||||
untilRestart = false
|
untilRestart = false
|
||||||
): void {
|
): void {
|
||||||
if (untilRestart) {
|
if (untilRestart) {
|
||||||
// Bis-Neustart-Sperre: never expires in-process (Number.MAX_SAFE_INTEGER liegt
|
|
||||||
// ausserhalb des gueltigen Date-Bereichs → Anzeige wird via untilRestart-Flag
|
|
||||||
// gesondert behandelt, nicht ueber new Date(until)).
|
|
||||||
megaDebridAccountCooldowns.set(accountId, {
|
megaDebridAccountCooldowns.set(accountId, {
|
||||||
until: Number.MAX_SAFE_INTEGER,
|
until: Number.MAX_SAFE_INTEGER,
|
||||||
message,
|
message,
|
||||||
@ -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[] {
|
export function getAvailableMegaDebridAccounts(settings: AppSettings, epochMs = Date.now()): MegaDebridAccountEntry[] {
|
||||||
return getMegaDebridAccountList(settings).filter(
|
return getMegaDebridAccountList(settings).filter(
|
||||||
(entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
|
(entry) => !isMegaDebridAccountDisabled(settings, entry.id) && !isMegaDebridAccountDailyLimitReached(settings, entry.id, epochMs)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves the full list of Mega-Debrid accounts from settings (multi-account or legacy single). */
|
|
||||||
function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] {
|
function getMegaDebridAccountList(settings: AppSettings): MegaDebridAccountEntry[] {
|
||||||
// Multi-account format: newline-separated "login:password" pairs in megaCredentials
|
|
||||||
const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
const multiAccounts = parseMegaDebridAccounts(settings.megaCredentials || "");
|
||||||
if (multiAccounts.length > 0) {
|
if (multiAccounts.length > 0) {
|
||||||
return multiAccounts;
|
return multiAccounts;
|
||||||
}
|
}
|
||||||
// Backward compat: single legacy megaLogin/megaPassword
|
|
||||||
if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
|
if (settings.megaLogin?.trim() && settings.megaPassword?.trim()) {
|
||||||
return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
|
return parseMegaDebridAccounts(settings.megaLogin.trim(), settings.megaPassword.trim());
|
||||||
}
|
}
|
||||||
@ -575,7 +514,6 @@ function parseRetryAfterMs(value: string | null): number {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap at 1 hour — floodDetected can mandate "retry after 1 hour"
|
|
||||||
const maxRetryMs = 60 * 60 * 1000;
|
const maxRetryMs = 60 * 60 * 1000;
|
||||||
const asSeconds = Number(text);
|
const asSeconds = Number(text);
|
||||||
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
if (Number.isFinite(asSeconds) && asSeconds >= 0) {
|
||||||
@ -1416,8 +1354,6 @@ export function extractRapidgatorFilenameFromHtml(html: string): string {
|
|||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = html.match(pattern);
|
const match = html.match(pattern);
|
||||||
// Some patterns have multiple capture groups for attribute-order independence;
|
|
||||||
// pick the first non-empty group.
|
|
||||||
const raw = match?.[1] || match?.[2] || "";
|
const raw = match?.[1] || match?.[2] || "";
|
||||||
const normalized = normalizeResolvedFilename(raw);
|
const normalized = normalizeResolvedFilename(raw);
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
@ -1499,12 +1435,10 @@ async function readResponseTextLimited(response: Response, maxBytes: number, sig
|
|||||||
try {
|
try {
|
||||||
await reader.cancel();
|
await reader.cancel();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
reader.releaseLock();
|
reader.releaseLock();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1536,7 +1470,7 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
|
|||||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
try { await response.body?.cancel(); } catch { }
|
||||||
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
|
if (shouldRetryStatus(response.status) && attempt < REQUEST_RETRIES + 2) {
|
||||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
@ -1552,11 +1486,11 @@ async function resolveRapidgatorFilename(link: string, signal?: AbortSignal): Pr
|
|||||||
&& !contentType.includes("text/plain")
|
&& !contentType.includes("text/plain")
|
||||||
&& !contentType.includes("text/xml")
|
&& !contentType.includes("text/xml")
|
||||||
&& !contentType.includes("application/xml")) {
|
&& !contentType.includes("application/xml")) {
|
||||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
try { await response.body?.cancel(); } catch { }
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
|
if (!contentType && Number.isFinite(contentLength) && contentLength > RAPIDGATOR_SCAN_MAX_BYTES) {
|
||||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
try { await response.body?.cancel(); } catch { }
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1613,7 +1547,6 @@ export async function checkRapidgatorOnline(
|
|||||||
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
|
"Accept-Language": "en-US,en;q=0.9,de;q=0.8"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fast path: HEAD request (no body download, much faster)
|
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||||
@ -1634,30 +1567,26 @@ export async function checkRapidgatorOnline(
|
|||||||
if (!finalUrl.includes(fileId)) {
|
if (!finalUrl.includes(fileId)) {
|
||||||
return { online: false, fileName: "", fileSize: null };
|
return { online: false, fileName: "", fileSize: null };
|
||||||
}
|
}
|
||||||
// HEAD 200 + URL still contains file ID → online
|
|
||||||
const fileName = filenameFromRapidgatorUrlPath(link);
|
const fileName = filenameFromRapidgatorUrlPath(link);
|
||||||
return { online: true, fileName, fileSize: null };
|
return { online: true, fileName, fileSize: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-OK, non-404: retry or give up
|
|
||||||
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
|
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
|
||||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HEAD inconclusive — fall through to GET
|
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorText = compactErrorText(error);
|
const errorText = compactErrorText(error);
|
||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) throw error;
|
||||||
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
|
if (attempt > REQUEST_RETRIES || !isRetryableErrorText(errorText)) {
|
||||||
break; // fall through to GET
|
break;
|
||||||
}
|
}
|
||||||
await sleepWithSignal(retryDelay(attempt), signal);
|
await sleepWithSignal(retryDelay(attempt), signal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: GET request (downloads HTML, more thorough)
|
|
||||||
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
for (let attempt = 1; attempt <= REQUEST_RETRIES + 1; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||||
@ -1670,12 +1599,12 @@ export async function checkRapidgatorOnline(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
try { await response.body?.cancel(); } catch { }
|
||||||
return { online: false, fileName: "", fileSize: null };
|
return { online: false, fileName: "", fileSize: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
try { await response.body?.cancel(); } catch { }
|
||||||
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
|
if (shouldRetryStatus(response.status) && attempt <= REQUEST_RETRIES) {
|
||||||
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
await sleepWithSignal(retryDelayForResponse(response, attempt), signal);
|
||||||
continue;
|
continue;
|
||||||
@ -1685,7 +1614,7 @@ export async function checkRapidgatorOnline(
|
|||||||
|
|
||||||
const finalUrl = response.url || link;
|
const finalUrl = response.url || link;
|
||||||
if (!finalUrl.includes(fileId)) {
|
if (!finalUrl.includes(fileId)) {
|
||||||
try { await response.body?.cancel(); } catch { /* drain socket */ }
|
try { await response.body?.cancel(); } catch { }
|
||||||
return { online: false, fileName: "", fileSize: null };
|
return { online: false, fileName: "", fileSize: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1739,14 +1668,10 @@ class MegaDebridClient {
|
|||||||
|
|
||||||
private allowApiFallback: boolean;
|
private allowApiFallback: boolean;
|
||||||
|
|
||||||
/** Per-account API token cache: login (lowercase) → { token, timestamp } */
|
|
||||||
private static cachedApiTokens = new Map<string, { token: string; at: number }>();
|
private static cachedApiTokens = new Map<string, { token: string; at: number }>();
|
||||||
|
|
||||||
/** Per-account pending connect deduplication: login (lowercase) → promise */
|
|
||||||
private static pendingConnects = new Map<string, Promise<string | null>>();
|
private static pendingConnects = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
/** Clear cached tokens for accounts whose login is no longer in the given set.
|
|
||||||
* Called when settings change so removed accounts don't keep stale tokens. */
|
|
||||||
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
|
public static pruneCachedTokensNotIn(activeLogins: Iterable<string>): void {
|
||||||
const keep = new Set<string>();
|
const keep = new Set<string>();
|
||||||
for (const login of activeLogins) {
|
for (const login of activeLogins) {
|
||||||
@ -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 {
|
public static clearCachedApiToken(login: string): void {
|
||||||
const key = String(login || "").toLowerCase();
|
const key = String(login || "").toLowerCase();
|
||||||
MegaDebridClient.cachedApiTokens.delete(key);
|
MegaDebridClient.cachedApiTokens.delete(key);
|
||||||
@ -1786,13 +1709,11 @@ class MegaDebridClient {
|
|||||||
|
|
||||||
private async connectApi(signal?: AbortSignal): Promise<string | null> {
|
private async connectApi(signal?: AbortSignal): Promise<string | null> {
|
||||||
const key = this.cacheKey;
|
const key = this.cacheKey;
|
||||||
// Return cached token if fresh (max 20 min)
|
|
||||||
const cached = MegaDebridClient.cachedApiTokens.get(key);
|
const cached = MegaDebridClient.cachedApiTokens.get(key);
|
||||||
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
|
if (cached && cached.token && Date.now() - cached.at < 20 * 60 * 1000) {
|
||||||
return cached.token;
|
return cached.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate parallel connectUser calls — only one in-flight request per account
|
|
||||||
const pending = MegaDebridClient.pendingConnects.get(key);
|
const pending = MegaDebridClient.pendingConnects.get(key);
|
||||||
if (pending) {
|
if (pending) {
|
||||||
return pending;
|
return pending;
|
||||||
@ -1855,7 +1776,6 @@ class MegaDebridClient {
|
|||||||
});
|
});
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
// Token might be invalid, clear cache
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
this.clearTokenCache();
|
this.clearTokenCache();
|
||||||
}
|
}
|
||||||
@ -1863,7 +1783,6 @@ class MegaDebridClient {
|
|||||||
}
|
}
|
||||||
const payload = parseJsonSafe(text);
|
const payload = parseJsonSafe(text);
|
||||||
if (!payload || payload.response_code !== "ok") {
|
if (!payload || payload.response_code !== "ok") {
|
||||||
// Token expired — clear cache for next attempt
|
|
||||||
if (payload && String(payload.response_code || "").includes("token")) {
|
if (payload && String(payload.response_code || "").includes("token")) {
|
||||||
this.clearTokenCache();
|
this.clearTokenCache();
|
||||||
}
|
}
|
||||||
@ -1915,10 +1834,6 @@ class MegaDebridClient {
|
|||||||
if (!lastError) {
|
if (!lastError) {
|
||||||
lastError = "Mega-Web Antwort leer";
|
lastError = "Mega-Web Antwort leer";
|
||||||
}
|
}
|
||||||
// Don't retry permanent hoster errors (dead link, file removed, etc.) — and
|
|
||||||
// don't hammer a "Kein Server für diesen Hoster" (account hoster quota) message:
|
|
||||||
// immediate retries are futile (the limit persists) and waste the shared
|
|
||||||
// rotation budget, so break and let the rotation move to the next account.
|
|
||||||
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.test(lastError)) {
|
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(lastError) || MEGA_DEBRID_NO_SERVER_RE.test(lastError)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -1954,12 +1869,6 @@ class MegaDebridClient {
|
|||||||
return this.unrestrictViaWeb(link, signal);
|
return this.unrestrictViaWeb(link, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Multi-account rotation for Mega-Debrid, following the same pattern as Debrid-Link multi-key rotation.
|
|
||||||
* Iterates through all configured accounts, skipping disabled/daily-limited/cooldown accounts.
|
|
||||||
* On success: clears cooldown, returns result with sourceAccountId/sourceAccountLabel.
|
|
||||||
* On failure: classifies error, sets cooldown, tries next account.
|
|
||||||
*/
|
|
||||||
public static async unrestrictWithAccounts(
|
public static async unrestrictWithAccounts(
|
||||||
settings: AppSettings,
|
settings: AppSettings,
|
||||||
mode: "api" | "web",
|
mode: "api" | "web",
|
||||||
@ -1986,11 +1895,8 @@ class MegaDebridClient {
|
|||||||
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
|
const providerName = `Mega-Debrid ${mode === "api" ? "API" : "Web"}`;
|
||||||
const linkShort = String(link || "").slice(0, 80);
|
const linkShort = String(link || "").slice(0, 80);
|
||||||
|
|
||||||
// Always start from first account — use first available, skip disabled/limited/cooldown.
|
|
||||||
for (let idx = 0; idx < accounts.length; idx += 1) {
|
for (let idx = 0; idx < accounts.length; idx += 1) {
|
||||||
const account = accounts[idx];
|
const account = accounts[idx];
|
||||||
// Always show account number — even with 1 account — so user can tell at a
|
|
||||||
// glance which account is in play. Format: "(Account 2/3, fa**david@...)"
|
|
||||||
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
|
const accountLabel = ` (${account.label}/${totalAccounts}, ${account.maskedLogin})`;
|
||||||
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
|
const rotationLabel = `${account.label}/${totalAccounts} (${account.maskedLogin})`;
|
||||||
|
|
||||||
@ -2004,7 +1910,6 @@ class MegaDebridClient {
|
|||||||
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
|
logAccountRotation("INFO", providerName, rotationLabel, "SKIP_DAILY_LIMIT", { reason: "local daily limit reached" });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Cooldown key includes mode so API failures don't block Web attempts
|
|
||||||
const cooldownKey = `${account.id}:${mode}`;
|
const cooldownKey = `${account.id}:${mode}`;
|
||||||
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
|
const accountCooldownState = getMegaDebridAccountCooldownState(cooldownKey);
|
||||||
if (accountCooldownState) {
|
if (accountCooldownState) {
|
||||||
@ -2021,9 +1926,6 @@ class MegaDebridClient {
|
|||||||
until: untilStr
|
until: untilStr
|
||||||
});
|
});
|
||||||
cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`);
|
cooldownFailures.push(`Mega-Debrid${accountLabel}: ${accountCooldownState.message}`);
|
||||||
// Eine Bis-Neustart-Sperre traegt NICHT zu earliestCooldownUntil bei: es gibt
|
|
||||||
// keinen sinnvollen endlichen Retry-Zeitpunkt (der Account kommt erst nach
|
|
||||||
// Neustart zurueck). Sonst wuerde MAX_SAFE_INTEGER einen absurden Retry-Timer setzen.
|
|
||||||
if (accountCooldownState.untilRestart) {
|
if (accountCooldownState.untilRestart) {
|
||||||
parkedUntilRestartSeen = true;
|
parkedUntilRestartSeen = true;
|
||||||
} else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
|
} else if (!earliestCooldownUntil || accountCooldownState.until < earliestCooldownUntil) {
|
||||||
@ -2032,9 +1934,6 @@ class MegaDebridClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLEAR per-account TEST log line BEFORE the network call, so the user
|
|
||||||
// can always see exactly which account is currently being tested for
|
|
||||||
// link generation — even if the call hangs or times out.
|
|
||||||
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
|
logger.info(`Mega-Debrid${accountLabel}: TESTE Account fuer Link-Generierung...`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
|
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
|
||||||
const testStartedAt = Date.now();
|
const testStartedAt = Date.now();
|
||||||
@ -2063,11 +1962,6 @@ class MegaDebridClient {
|
|||||||
const elapsedMs = Date.now() - testStartedAt;
|
const elapsedMs = Date.now() - testStartedAt;
|
||||||
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
|
failures.push(`Mega-Debrid${accountLabel}: ${failure.message}`);
|
||||||
|
|
||||||
// "Antwort leer"-Streak fuehren: ein tageslimitierter Account antwortet
|
|
||||||
// PERSISTENT leer (siehe Kommentar an megaDebridEmptyResponseStreaks). Erreicht
|
|
||||||
// der Account die Schwelle, wird er bis Neustart geparkt — statt alle 20s neu
|
|
||||||
// getestet zu werden. failure.untilRestart deckt zusaetzlich den Fall ab, dass
|
|
||||||
// generate() die "Kein Server"-Meldung doch mal direkt liefert.
|
|
||||||
let parkUntilRestart = false;
|
let parkUntilRestart = false;
|
||||||
let parkMessage = failure.message;
|
let parkMessage = failure.message;
|
||||||
if (failure.limitSignal) {
|
if (failure.limitSignal) {
|
||||||
@ -2101,7 +1995,6 @@ class MegaDebridClient {
|
|||||||
: failure.cooldownMs > 0
|
: failure.cooldownMs > 0
|
||||||
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
|
? `, Cooldown ${Math.ceil(failure.cooldownMs / 1000)}s`
|
||||||
: "";
|
: "";
|
||||||
// Find the next account that will be tried (for clearer log)
|
|
||||||
let nextLabel = "ENDE";
|
let nextLabel = "ENDE";
|
||||||
for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) {
|
for (let nextIdx = idx + 1; nextIdx < accounts.length; nextIdx += 1) {
|
||||||
const nextAcc = accounts[nextIdx];
|
const nextAcc = accounts[nextIdx];
|
||||||
@ -2128,9 +2021,6 @@ class MegaDebridClient {
|
|||||||
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
|
throw new Error(`mega_debrid_cooldown:${retryMs}:${cooldownFailures.join(" | ")}`);
|
||||||
}
|
}
|
||||||
if (parkedUntilRestartSeen) {
|
if (parkedUntilRestartSeen) {
|
||||||
// Alle (verbliebenen) Accounts haben ihr Tageslimit erreicht und sind bis
|
|
||||||
// Neustart gesperrt. KEIN mega_debrid_cooldown:<ms> — es gibt keinen sinnvollen
|
|
||||||
// Retry-Zeitpunkt; ein endlicher Timer wuerde nur erneut leer pollen.
|
|
||||||
throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis Neustart gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
|
throw new Error(`Mega-Debrid: Alle Accounts am Tageslimit (bis Neustart gesperrt)${cooldownFailures.length > 0 ? ` | ${cooldownFailures.join(" | ")}` : ""}`);
|
||||||
}
|
}
|
||||||
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
|
throw new Error("Mega-Debrid: Kein aktiver Account verfuegbar");
|
||||||
@ -2138,21 +2028,15 @@ class MegaDebridClient {
|
|||||||
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
|
throw new Error(failures.join(" | ") || "Mega-Debrid: Kein aktiver Account verfuegbar");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Classify error from a single Mega-Debrid account attempt.
|
|
||||||
* Returns whether the error is fatal (stop all accounts) and how long to cool down.
|
|
||||||
*/
|
|
||||||
private static classifyAccountFailure(
|
private static classifyAccountFailure(
|
||||||
error: unknown
|
error: unknown
|
||||||
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
): { fatal: boolean; cooldownMs: number; message: string; category: MegaDebridCooldownCategory; limitSignal?: boolean } {
|
||||||
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
const errorText = compactErrorText(error).replace(/^Error:\s*/i, "");
|
||||||
|
|
||||||
// Abort — don't retry other accounts
|
|
||||||
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
|
if (/aborted/i.test(errorText) && !/timeout/i.test(errorText)) {
|
||||||
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
|
return { fatal: true, cooldownMs: 0, message: errorText, category: "temporary" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auth/login failures — long cooldown, try next account
|
|
||||||
if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) {
|
if (/login|password|auth|credentials|unauthorized|forbidden/i.test(errorText) || /connectUser/i.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -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)) {
|
if (/permanent ungültig|hosternotavailable|file.?not.?found|file.?unavailable|link.?is.?dead/i.test(errorText)) {
|
||||||
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
|
return { fatal: true, cooldownMs: 0, message: errorText, category: "skip" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quota/limit errors — cooldown, try next account
|
|
||||||
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
|
if (/quota|limit|exceeded|bandwidth/i.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -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)) {
|
if (MEGA_DEBRID_NO_SERVER_RE.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -2192,7 +2069,6 @@ class MegaDebridClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rate limit
|
|
||||||
if (/rate.?limit|too.?many|429/i.test(errorText)) {
|
if (/rate.?limit|too.?many|429/i.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -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)) {
|
if (/antwort\s+leer|empty\s+response|leere\s+antwort/i.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
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)) {
|
if (isRetryableErrorText(errorText) || /timeout|network|fetch|socket/i.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -2229,7 +2097,6 @@ class MegaDebridClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unknown errors — short cooldown, try next account (non-fatal)
|
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: 30_000,
|
cooldownMs: 30_000,
|
||||||
@ -2660,8 +2527,6 @@ export async function fetchDebridLinkHostLimits(apiKeysRaw: string, host = "rapi
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Debrid-Link Client ──
|
|
||||||
|
|
||||||
class DebridLinkClient {
|
class DebridLinkClient {
|
||||||
private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>;
|
private apiKeys: ReturnType<typeof parseDebridLinkApiKeys>;
|
||||||
|
|
||||||
@ -2689,12 +2554,8 @@ class DebridLinkClient {
|
|||||||
const linkShort = String(link || "").slice(0, 80);
|
const linkShort = String(link || "").slice(0, 80);
|
||||||
const linkHoster = extractHosterFromUrl(link);
|
const linkHoster = extractHosterFromUrl(link);
|
||||||
|
|
||||||
// Always start from first key — use first available, skip disabled/limited/cooldown.
|
|
||||||
// This ensures all parallel items use the same key until it's actually exhausted.
|
|
||||||
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
|
for (let keyIdx = 0; keyIdx < this.apiKeys.length; keyIdx += 1) {
|
||||||
const apiKey = this.apiKeys[keyIdx];
|
const apiKey = this.apiKeys[keyIdx];
|
||||||
// Always show key number — even with 1 key — so user can tell at a
|
|
||||||
// glance which key is in play. Format: "(Key 2/3, abc***xyz)"
|
|
||||||
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
|
const keyLabel = ` (${apiKey.label}/${totalKeys}, ${apiKey.masked})`;
|
||||||
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
|
const rotationLabel = `${apiKey.label}/${totalKeys} (${apiKey.masked})`;
|
||||||
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
|
if (isDebridLinkApiKeyDisabled(settings, apiKey.id)) {
|
||||||
@ -2722,9 +2583,6 @@ class DebridLinkClient {
|
|||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Per-(key, host) cooldown — set when a previous attempt for THIS host
|
|
||||||
// returned maxLinkHost / maxDataHost. The key itself is healthy for other
|
|
||||||
// hosters, so we only skip it for this specific link.
|
|
||||||
const hostCooldownState = linkHoster ? getDebridLinkKeyHostCooldownState(apiKey.id, linkHoster) : null;
|
const hostCooldownState = linkHoster ? getDebridLinkKeyHostCooldownState(apiKey.id, linkHoster) : null;
|
||||||
if (hostCooldownState) {
|
if (hostCooldownState) {
|
||||||
const untilStr = new Date(hostCooldownState.until).toLocaleTimeString();
|
const untilStr = new Date(hostCooldownState.until).toLocaleTimeString();
|
||||||
@ -2742,9 +2600,6 @@ class DebridLinkClient {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLEAR per-key TEST log line BEFORE the network call, so the user
|
|
||||||
// can always see exactly which key is currently being tested for
|
|
||||||
// link generation — even if the call hangs or times out.
|
|
||||||
logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
|
logger.info(`Debrid-Link${keyLabel}: TESTE Key fuer Link-Generierung...`);
|
||||||
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
|
logAccountRotation("INFO", providerName, rotationLabel, "TEST", { link: linkShort });
|
||||||
const testStartedAt = Date.now();
|
const testStartedAt = Date.now();
|
||||||
@ -2778,10 +2633,6 @@ class DebridLinkClient {
|
|||||||
failures.push(`Debrid-Link${keyLabel}: ${failure.message}`);
|
failures.push(`Debrid-Link${keyLabel}: ${failure.message}`);
|
||||||
if (failure.cooldownMs > 0) {
|
if (failure.cooldownMs > 0) {
|
||||||
if (failure.hostOnly) {
|
if (failure.hostOnly) {
|
||||||
// Per-(key, host) quota — block only this combination, not the
|
|
||||||
// whole key. The key remains "ready" for other hosters. If the
|
|
||||||
// hoster couldn't be parsed from the URL, the helper falls back
|
|
||||||
// to a key-wide cooldown (better safe than thrashing).
|
|
||||||
setDebridLinkKeyHostCooldownState(
|
setDebridLinkKeyHostCooldownState(
|
||||||
apiKey.id,
|
apiKey.id,
|
||||||
failure.hoster || "",
|
failure.hoster || "",
|
||||||
@ -2797,10 +2648,6 @@ class DebridLinkClient {
|
|||||||
if (failure.category === "invalid") {
|
if (failure.category === "invalid") {
|
||||||
setDebridLinkKeyRuntimeStatus(apiKey.id, "invalid", failure.message);
|
setDebridLinkKeyRuntimeStatus(apiKey.id, "invalid", failure.message);
|
||||||
} else if (failure.category !== "skip") {
|
} else if (failure.category !== "skip") {
|
||||||
// "skip" means the LINK or HOST is unavailable (fileNotAvailable,
|
|
||||||
// disabledServerHost, notFreeHost, freeServerOverload, ...), NOT
|
|
||||||
// that the key is broken. The key responded normally — leave its
|
|
||||||
// runtime status alone so the UI doesn't flag it as errored.
|
|
||||||
setDebridLinkKeyRuntimeStatus(apiKey.id, "error", failure.message);
|
setDebridLinkKeyRuntimeStatus(apiKey.id, "error", failure.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2814,8 +2661,6 @@ class DebridLinkClient {
|
|||||||
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
|
throw new Error(`Debrid-Link${keyLabel}: ${failure.message}`);
|
||||||
}
|
}
|
||||||
if (failure.providerWide) {
|
if (failure.providerWide) {
|
||||||
// Host-level issue (e.g. notDebrid) — rotating to other keys is pointless.
|
|
||||||
// Break immediately and apply a longer cooldown (5 min) to avoid burning all keys.
|
|
||||||
const providerWideCooldownMs = 5 * 60 * 1000;
|
const providerWideCooldownMs = 5 * 60 * 1000;
|
||||||
logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
|
logger.warn(`Debrid-Link${keyLabel}: ${failure.message} (provider-wide, ueberspringe verbleibende Keys, Cooldown ${providerWideCooldownMs / 1000}s)`);
|
||||||
logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", {
|
logAccountRotation("ERROR", providerName, rotationLabel, "PROVIDER_WIDE", {
|
||||||
@ -2827,11 +2672,9 @@ class DebridLinkClient {
|
|||||||
});
|
});
|
||||||
throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`);
|
throw new Error(`debrid_link_cooldown:${providerWideCooldownMs}:Debrid-Link${keyLabel}: ${failure.message}`);
|
||||||
}
|
}
|
||||||
// Track consecutive transport failures (timeout/network) to detect cascades.
|
|
||||||
const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
|
const isTransport = isRetryableErrorText(failure.message) && !(error instanceof DebridLinkApiError);
|
||||||
consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
|
consecutiveTransportFailures = isTransport ? consecutiveTransportFailures + 1 : 0;
|
||||||
if (consecutiveTransportFailures >= 2) {
|
if (consecutiveTransportFailures >= 2) {
|
||||||
// 2+ keys timed out in a row — likely a server/network issue, not key-specific.
|
|
||||||
const cascadeCooldownMs = 3 * 60 * 1000;
|
const cascadeCooldownMs = 3 * 60 * 1000;
|
||||||
logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
|
logger.warn(`Debrid-Link: ${consecutiveTransportFailures} Transport-Fehler in Folge, ueberspringe verbleibende Keys, Cooldown ${cascadeCooldownMs / 1000}s`);
|
||||||
logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", {
|
logAccountRotation("ERROR", providerName, rotationLabel, "TRANSPORT_CASCADE", {
|
||||||
@ -2842,7 +2685,6 @@ class DebridLinkClient {
|
|||||||
});
|
});
|
||||||
throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
|
throw new Error(`debrid_link_cooldown:${cascadeCooldownMs}:Debrid-Link: Transport-Kaskade (${consecutiveTransportFailures}x)`);
|
||||||
}
|
}
|
||||||
// Find the next key that will be tried (for clearer log)
|
|
||||||
let nextLabel = "ENDE";
|
let nextLabel = "ENDE";
|
||||||
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
|
for (let nextIdx = keyIdx + 1; nextIdx < this.apiKeys.length; nextIdx += 1) {
|
||||||
const nextKey = this.apiKeys[nextIdx];
|
const nextKey = this.apiKeys[nextIdx];
|
||||||
@ -2968,8 +2810,6 @@ class DebridLinkClient {
|
|||||||
return chosen;
|
return chosen;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Poll up to 5 times with 2s delay — Debrid-Link sometimes needs a few
|
|
||||||
// seconds to generate the download URL after /downloader/add.
|
|
||||||
const maxPolls = 5;
|
const maxPolls = 5;
|
||||||
for (let poll = 0; poll < maxPolls; poll++) {
|
for (let poll = 0; poll < maxPolls; poll++) {
|
||||||
if (signal?.aborted) {
|
if (signal?.aborted) {
|
||||||
@ -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;
|
return (await this.fetchDownloaderEntry(apiKey, id, signal)) || chosen;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3045,9 +2884,6 @@ class DebridLinkClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (DEBRID_LINK_HOST_QUOTA_ERRORS.has(code)) {
|
if (DEBRID_LINK_HOST_QUOTA_ERRORS.has(code)) {
|
||||||
// Per-(key, host) quota — only this host is exhausted for this key.
|
|
||||||
// The key remains usable for other hosters, so we mark the failure
|
|
||||||
// hostOnly and let the rotation loop apply a per-(key, host) cooldown.
|
|
||||||
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
|
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
|
||||||
const hosterRaw = extractHosterFromUrl(link);
|
const hosterRaw = extractHosterFromUrl(link);
|
||||||
const hosterLabel = hosterRaw || "host";
|
const hosterLabel = hosterRaw || "host";
|
||||||
@ -3061,7 +2897,6 @@ class DebridLinkClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (DEBRID_LINK_KEY_QUOTA_ERRORS.has(code)) {
|
if (DEBRID_LINK_KEY_QUOTA_ERRORS.has(code)) {
|
||||||
// Key-wide quota — whole key is exhausted, blocks all hosters.
|
|
||||||
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
|
const cooldownMs = await this.fetchQuotaCooldownMs(apiKey, signal);
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -3071,7 +2906,6 @@ class DebridLinkClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
|
if (DEBRID_LINK_PROVIDER_WIDE_ERRORS.has(code)) {
|
||||||
// notDebrid = host-level issue — affects ALL keys equally, do NOT rotate.
|
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
cooldownMs: DEBRID_LINK_KEY_COOLDOWN_MS,
|
||||||
@ -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)) {
|
if (/keine gueltige download-url/i.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -3122,11 +2954,6 @@ class DebridLinkClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) {
|
if (isRetryableErrorText(errorText) || /debrid-link.*(json|html)/i.test(errorText)) {
|
||||||
// Distinguish a single transient transport error (timeout, network blip,
|
|
||||||
// ECONNRESET) from a real API/server problem. Single timeouts shouldn't
|
|
||||||
// park a key for 2 full minutes — that just delays parallel work for
|
|
||||||
// no reason. Use a short 15s cooldown for transport, full 2min only
|
|
||||||
// for things that look like server-side faults (5xx HTML pages, etc).
|
|
||||||
const isTransport = /timeout|network|fetch failed|aborted|econnreset|enotfound|etimedout|socket/i.test(errorText)
|
const isTransport = /timeout|network|fetch failed|aborted|econnreset|enotfound|etimedout|socket/i.test(errorText)
|
||||||
&& !(error instanceof DebridLinkApiError);
|
&& !(error instanceof DebridLinkApiError);
|
||||||
return {
|
return {
|
||||||
@ -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)) {
|
if (errorText && /success.*false|kein.*json|empty.*response/i.test(errorText)) {
|
||||||
return {
|
return {
|
||||||
fatal: false,
|
fatal: false,
|
||||||
@ -3156,8 +2980,6 @@ class DebridLinkClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── LinkSnappy Client ──
|
|
||||||
|
|
||||||
class LinkSnappyClient {
|
class LinkSnappyClient {
|
||||||
private username: string;
|
private username: string;
|
||||||
private password: string;
|
private password: string;
|
||||||
@ -3249,7 +3071,6 @@ class LinkSnappyClient {
|
|||||||
if (!directUrl) {
|
if (!directUrl) {
|
||||||
throw new Error("LinkSnappy: Keine Download-URL in Antwort");
|
throw new Error("LinkSnappy: Keine Download-URL in Antwort");
|
||||||
}
|
}
|
||||||
// LinkSnappy liefert http:// URLs – auf https:// upgraden (deren Server unterstützt beides)
|
|
||||||
if (directUrl.startsWith("http://")) {
|
if (directUrl.startsWith("http://")) {
|
||||||
directUrl = directUrl.replace("http://", "https://");
|
directUrl = directUrl.replace("http://", "https://");
|
||||||
}
|
}
|
||||||
@ -3300,8 +3121,6 @@ function parseFileSizeString(s: string): number {
|
|||||||
return Math.floor(num * (multipliers[unit] || 1));
|
return Math.floor(num * (multipliers[unit] || 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 1Fichier Client ──
|
|
||||||
|
|
||||||
class OneFichierClient {
|
class OneFichierClient {
|
||||||
private apiKey: string;
|
private apiKey: string;
|
||||||
|
|
||||||
@ -3375,7 +3194,6 @@ class DdownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async webLogin(signal?: AbortSignal): Promise<void> {
|
private async webLogin(signal?: AbortSignal): Promise<void> {
|
||||||
// Step 1: GET login page to extract form token
|
|
||||||
const loginPageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/login.html`, {
|
const loginPageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/login.html`, {
|
||||||
headers: { "User-Agent": DDOWNLOAD_WEB_UA },
|
headers: { "User-Agent": DDOWNLOAD_WEB_UA },
|
||||||
redirect: "manual",
|
redirect: "manual",
|
||||||
@ -3385,7 +3203,6 @@ class DdownloadClient {
|
|||||||
const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/);
|
const tokenMatch = loginPageHtml.match(/name="token" value="([^"]+)"/);
|
||||||
const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; ");
|
const pageCookies = (loginPageRes.headers.getSetCookie?.() || []).map((c: string) => c.split(";")[0]).join("; ");
|
||||||
|
|
||||||
// Step 2: POST login
|
|
||||||
const body = new URLSearchParams({
|
const body = new URLSearchParams({
|
||||||
op: "login",
|
op: "login",
|
||||||
token: tokenMatch?.[1] || "",
|
token: tokenMatch?.[1] || "",
|
||||||
@ -3406,8 +3223,7 @@ class DdownloadClient {
|
|||||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drain body
|
try { await loginRes.text(); } catch { }
|
||||||
try { await loginRes.text(); } catch { /* ignore */ }
|
|
||||||
|
|
||||||
const setCookies = loginRes.headers.getSetCookie?.() || [];
|
const setCookies = loginRes.headers.getSetCookie?.() || [];
|
||||||
const xfss = setCookies.find((c: string) => c.startsWith("xfss="));
|
const xfss = setCookies.find((c: string) => c.startsWith("xfss="));
|
||||||
@ -3430,12 +3246,10 @@ class DdownloadClient {
|
|||||||
try {
|
try {
|
||||||
if (signal?.aborted) throw new Error("aborted:debrid");
|
if (signal?.aborted) throw new Error("aborted:debrid");
|
||||||
|
|
||||||
// Login if no session yet
|
|
||||||
if (!this.cookies) {
|
if (!this.cookies) {
|
||||||
await this.webLogin(signal);
|
await this.webLogin(signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: GET file page to extract form fields
|
|
||||||
const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, {
|
const filePageRes = await fetch(`${DDOWNLOAD_WEB_BASE}/${fileCode}`, {
|
||||||
headers: {
|
headers: {
|
||||||
"User-Agent": DDOWNLOAD_WEB_UA,
|
"User-Agent": DDOWNLOAD_WEB_UA,
|
||||||
@ -3445,10 +3259,9 @@ class DdownloadClient {
|
|||||||
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
signal: withTimeoutSignal(signal, API_TIMEOUT_MS)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Premium with direct downloads enabled → redirect immediately
|
|
||||||
if (filePageRes.status >= 300 && filePageRes.status < 400) {
|
if (filePageRes.status >= 300 && filePageRes.status < 400) {
|
||||||
const directUrl = filePageRes.headers.get("location") || "";
|
const directUrl = filePageRes.headers.get("location") || "";
|
||||||
try { await filePageRes.text(); } catch { /* drain */ }
|
try { await filePageRes.text(); } catch { }
|
||||||
if (directUrl) {
|
if (directUrl) {
|
||||||
return {
|
return {
|
||||||
fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
|
fileName: filenameFromUrl(directUrl) || filenameFromUrl(link),
|
||||||
@ -3462,18 +3275,15 @@ class DdownloadClient {
|
|||||||
|
|
||||||
const html = await filePageRes.text();
|
const html = await filePageRes.text();
|
||||||
|
|
||||||
// Check for file not found
|
|
||||||
if (/File Not Found|file was removed|file was banned/i.test(html)) {
|
if (/File Not Found|file was removed|file was banned/i.test(html)) {
|
||||||
throw new Error("DDownload: Datei nicht gefunden");
|
throw new Error("DDownload: Datei nicht gefunden");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract form fields
|
|
||||||
const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode;
|
const idVal = html.match(/name="id" value="([^"]+)"/)?.[1] || fileCode;
|
||||||
const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || "";
|
const randVal = html.match(/name="rand" value="([^"]+)"/)?.[1] || "";
|
||||||
const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+)</);
|
const fileNameMatch = html.match(/class="file-info-name"[^>]*>([^<]+)</);
|
||||||
const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link);
|
const fileName = fileNameMatch?.[1]?.trim() || filenameFromUrl(link);
|
||||||
|
|
||||||
// Step 2: POST download2 for premium download
|
|
||||||
const dlBody = new URLSearchParams({
|
const dlBody = new URLSearchParams({
|
||||||
op: "download2",
|
op: "download2",
|
||||||
id: idVal,
|
id: idVal,
|
||||||
@ -3498,7 +3308,7 @@ class DdownloadClient {
|
|||||||
|
|
||||||
if (dlRes.status >= 300 && dlRes.status < 400) {
|
if (dlRes.status >= 300 && dlRes.status < 400) {
|
||||||
const directUrl = dlRes.headers.get("location") || "";
|
const directUrl = dlRes.headers.get("location") || "";
|
||||||
try { await dlRes.text(); } catch { /* drain */ }
|
try { await dlRes.text(); } catch { }
|
||||||
if (directUrl) {
|
if (directUrl) {
|
||||||
return {
|
return {
|
||||||
fileName: fileName || filenameFromUrl(directUrl),
|
fileName: fileName || filenameFromUrl(directUrl),
|
||||||
@ -3511,7 +3321,6 @@ class DdownloadClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const dlHtml = await dlRes.text();
|
const dlHtml = await dlRes.text();
|
||||||
// Try to find direct URL in response HTML
|
|
||||||
const directMatch = dlHtml.match(/https?:\/\/[a-z0-9]+\.(?:dstorage\.org|ddownload\.com|ddl\.to|ucdn\.to)[^\s"'<>]+/i);
|
const directMatch = dlHtml.match(/https?:\/\/[a-z0-9]+\.(?:dstorage\.org|ddownload\.com|ddl\.to|ucdn\.to)[^\s"'<>]+/i);
|
||||||
if (directMatch) {
|
if (directMatch) {
|
||||||
return {
|
return {
|
||||||
@ -3523,7 +3332,6 @@ class DdownloadClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for error messages
|
|
||||||
const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)</i);
|
const errMatch = dlHtml.match(/class="err"[^>]*>([^<]+)</i);
|
||||||
if (errMatch) {
|
if (errMatch) {
|
||||||
throw new Error(`DDownload: ${errMatch[1].trim()}`);
|
throw new Error(`DDownload: ${errMatch[1].trim()}`);
|
||||||
@ -3535,7 +3343,6 @@ class DdownloadClient {
|
|||||||
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
if (signal?.aborted || (/aborted/i.test(lastError) && !/timeout/i.test(lastError))) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Re-login on auth errors
|
|
||||||
if (/login|session|cookie/i.test(lastError)) {
|
if (/login|session|cookie/i.test(lastError)) {
|
||||||
this.cookies = "";
|
this.cookies = "";
|
||||||
}
|
}
|
||||||
@ -3571,10 +3378,6 @@ export class DebridService {
|
|||||||
const prev = this.settings;
|
const prev = this.settings;
|
||||||
this.settings = cloneSettings(next);
|
this.settings = cloneSettings(next);
|
||||||
|
|
||||||
// Invalidate cached provider clients whose credentials/keys changed.
|
|
||||||
// Without this, switching API keys or session-cookie-bound accounts
|
|
||||||
// (LinkSnappy, Ddownload) would keep using the previous Client instance
|
|
||||||
// — which holds the OLD session cookies — until the app is restarted.
|
|
||||||
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) {
|
if (prev.debridLinkApiKeys !== next.debridLinkApiKeys) {
|
||||||
this.cachedDebridLinkClient = null;
|
this.cachedDebridLinkClient = null;
|
||||||
this.cachedDebridLinkKey = "";
|
this.cachedDebridLinkKey = "";
|
||||||
@ -3588,12 +3391,6 @@ export class DebridService {
|
|||||||
this.cachedDdownloadKey = "";
|
this.cachedDdownloadKey = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mega-Debrid token cache (static, module-level): tokens are keyed by
|
|
||||||
// login (lowercase). When credentials change, drop tokens for logins
|
|
||||||
// that are no longer in the active account list, AND force-clear any
|
|
||||||
// login whose password changed. Otherwise stale tokens linger up to
|
|
||||||
// 20 minutes and the new credentials won't be tried until the cached
|
|
||||||
// token starts returning 401/403.
|
|
||||||
const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || "");
|
const prevAccounts = parseMegaDebridAccounts(prev.megaCredentials || "", prev.megaPassword || "");
|
||||||
const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || "");
|
const nextAccounts = parseMegaDebridAccounts(next.megaCredentials || "", next.megaPassword || "");
|
||||||
const nextLogins = new Set<string>();
|
const nextLogins = new Set<string>();
|
||||||
@ -3602,17 +3399,13 @@ export class DebridService {
|
|||||||
nextLogins.add(acc.login.toLowerCase());
|
nextLogins.add(acc.login.toLowerCase());
|
||||||
nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password);
|
nextPasswordByLogin.set(acc.login.toLowerCase(), acc.password);
|
||||||
}
|
}
|
||||||
// Drop tokens for logins no longer present
|
|
||||||
MegaDebridClient.pruneCachedTokensNotIn(nextLogins);
|
MegaDebridClient.pruneCachedTokensNotIn(nextLogins);
|
||||||
// For logins still present but with a changed password, force-clear the token
|
|
||||||
for (const prevAcc of prevAccounts) {
|
for (const prevAcc of prevAccounts) {
|
||||||
const loginKey = prevAcc.login.toLowerCase();
|
const loginKey = prevAcc.login.toLowerCase();
|
||||||
if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) {
|
if (nextLogins.has(loginKey) && nextPasswordByLogin.get(loginKey) !== prevAcc.password) {
|
||||||
MegaDebridClient.clearCachedApiToken(prevAcc.login);
|
MegaDebridClient.clearCachedApiToken(prevAcc.login);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Also prune module-level Debrid-Link cooldowns for keys that no longer exist —
|
|
||||||
// otherwise a key removed and re-added later would still show its old cooldown.
|
|
||||||
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
|
const nextDebridLinkKeyIds = new Set<string>(parseDebridLinkApiKeys(next.debridLinkApiKeys || "").map((entry) => entry.id));
|
||||||
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
|
pruneDebridLinkRuntimeStateForKeys(nextDebridLinkKeyIds);
|
||||||
}
|
}
|
||||||
@ -3683,14 +3476,9 @@ export class DebridService {
|
|||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// ignore and continue with host page fallback
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mega.nz Pre-Resolve via Public API (kein Mega-Debrid-Quota-Verbrauch).
|
|
||||||
// Liefert echten Filename sobald Links in die Queue kommen, anstatt erst
|
|
||||||
// beim Unrestrict. Concurrency 4 — Mega's Public API ist tolerant gegen
|
|
||||||
// kleine Bursts.
|
|
||||||
const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link));
|
const megaLinks = unresolved.filter((link) => !clean.has(link) && isMegaFileUrl(link));
|
||||||
if (megaLinks.length > 0) {
|
if (megaLinks.length > 0) {
|
||||||
await runWithConcurrency(megaLinks, 4, async (link) => {
|
await runWithConcurrency(megaLinks, 4, async (link) => {
|
||||||
@ -3704,8 +3492,6 @@ export class DebridService {
|
|||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// Schluck — Public API kann fehlen oder rate-limiten; faellt auf
|
|
||||||
// den normalen Mega-Debrid Unrestrict-Pfad zurueck.
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -3766,7 +3552,6 @@ export class DebridService {
|
|||||||
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
public async unrestrictLink(link: string, signal?: AbortSignal, settingsSnapshot?: AppSettings): Promise<ProviderUnrestrictedLink> {
|
||||||
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
const settings = settingsSnapshot ? cloneSettings(settingsSnapshot) : cloneSettings(this.settings);
|
||||||
|
|
||||||
// Hoster-Zuordnung: prüfe ob für diesen Hoster ein bestimmter Provider konfiguriert ist
|
|
||||||
const routing = settings.hosterRouting || {};
|
const routing = settings.hosterRouting || {};
|
||||||
const hosterKey = extractHosterFromUrl(link);
|
const hosterKey = extractHosterFromUrl(link);
|
||||||
if (hosterKey && routing[hosterKey]) {
|
if (hosterKey && routing[hosterKey]) {
|
||||||
@ -3795,7 +3580,6 @@ export class DebridService {
|
|||||||
throw new Error(`Hoster-Zuordnung fehlgeschlagen (${hosterKey} → ${PROVIDER_LABELS[routedProvider]}): ${errorText}`);
|
throw new Error(`Hoster-Zuordnung fehlgeschlagen (${hosterKey} → ${PROVIDER_LABELS[routedProvider]}): ${errorText}`);
|
||||||
}
|
}
|
||||||
logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`);
|
logger.warn(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} fehlgeschlagen, Fallback auf Provider-Kette: ${errorText}`);
|
||||||
// Fall through to normal provider chain
|
|
||||||
}
|
}
|
||||||
} else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) {
|
} else if (this.isProviderConfiguredFor(settings, routedProvider) && this.isProviderDailyLimited(settings, routedProvider)) {
|
||||||
logger.info(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`);
|
logger.info(`Hoster-Zuordnung ${hosterKey} → ${PROVIDER_LABELS[routedProvider]} übersprungen (${this.formatProviderLimitMessage(settings, routedProvider)})`);
|
||||||
@ -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")) {
|
if (ONEFICHIER_URL_RE.test(link) && this.isProviderSelectableFor(settings, "onefichier")) {
|
||||||
try {
|
try {
|
||||||
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
const result = await this.unrestrictViaProvider(settings, "onefichier", link, signal);
|
||||||
@ -3819,13 +3601,9 @@ export class DebridService {
|
|||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// Fall through to normal provider chain
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DDownload is a direct file hoster, not a debrid service.
|
|
||||||
// If the link is a ddownload.com/ddl.to URL and the account is configured,
|
|
||||||
// use DDownload directly before trying any debrid providers.
|
|
||||||
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
|
if (DDOWNLOAD_URL_RE.test(link) && this.isProviderSelectableFor(settings, "ddownload")) {
|
||||||
try {
|
try {
|
||||||
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
const result = await this.unrestrictViaProvider(settings, "ddownload", link, signal);
|
||||||
@ -3839,11 +3617,9 @@ export class DebridService {
|
|||||||
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
if (signal?.aborted || (/aborted/i.test(errorText) && !/timeout/i.test(errorText))) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
// Fall through to normal provider chain (debrid services may also support ddownload links)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamische Reihenfolge: providerOrder hat Vorrang, Fallback auf altes primary/secondary/tertiary
|
|
||||||
const order: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
|
const order: DebridProvider[] = (settings.providerOrder && settings.providerOrder.length > 0)
|
||||||
? uniqueProviderOrder(settings.providerOrder)
|
? uniqueProviderOrder(settings.providerOrder)
|
||||||
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
|
: toProviderOrder(settings.providerPrimary, settings.providerSecondary, settings.providerTertiary);
|
||||||
|
|||||||
@ -116,7 +116,6 @@ function getPort(baseDir: string): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return DEFAULT_PORT;
|
return DEFAULT_PORT;
|
||||||
}
|
}
|
||||||
@ -135,7 +134,6 @@ function getHost(baseDir: string): string {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return DEFAULT_HOST;
|
return DEFAULT_HOST;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,7 +53,6 @@ function readPort(baseDir: string): number {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return DEFAULT_PORT;
|
return DEFAULT_PORT;
|
||||||
}
|
}
|
||||||
@ -71,7 +70,6 @@ function readHost(baseDir: string): string {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return DEFAULT_HOST;
|
return DEFAULT_HOST;
|
||||||
}
|
}
|
||||||
@ -158,7 +156,6 @@ function getDirectorySizeInfo(dirPath: string, skipPath?: string | null): Suppor
|
|||||||
bytes += fs.statSync(fullPath).size;
|
bytes += fs.statSync(fullPath).size;
|
||||||
fileCount += 1;
|
fileCount += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore unreadable files
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,26 +2,6 @@ import fs from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { logTimestamp } from "./log-timestamp";
|
import { logTimestamp } from "./log-timestamp";
|
||||||
|
|
||||||
/**
|
|
||||||
* Session-eigenes Rename-Protokoll auf dem DESKTOP des Nutzers.
|
|
||||||
*
|
|
||||||
* Ziel (User-Anforderung): bei zukuenftigen Renaming-Problemen eine luekenlose,
|
|
||||||
* sofort auffindbare Uebersicht haben — JEDER Umbenenn-/Verschiebevorgang wird
|
|
||||||
* protokolliert UND danach verifiziert (liegt die Datei wirklich unter dem
|
|
||||||
* Zielnamen auf der Platte? ist die Quelle weg?). Nur weil fs.rename "ok" meldet,
|
|
||||||
* heisst das nicht, dass das Ergebnis stimmt (Gross-/Kleinschreibung, Unicode-
|
|
||||||
* Normalisierung, halb-fertiger EXDEV-Copy ohne geloeschte Quelle, ...).
|
|
||||||
*
|
|
||||||
* - Pro Programm-Sitzung eine eigene Datei: <Desktop>/Downloader-Log/rename-session_<ts>.txt
|
|
||||||
* - Der Ordner wird beim Start angelegt UND vor JEDEM Schreibvorgang selbstheilend
|
|
||||||
* neu angelegt (mkdir recursive) — wird er zur Laufzeit geloescht, ist er beim
|
|
||||||
* naechsten Rename sofort wieder da, inkl. neu geschriebenem Session-Header.
|
|
||||||
* - Synchroner Append (wie rename-log.ts), kein gepufferter Flush: Renames sind
|
|
||||||
* selten genug, und so gibt es kein "geloescht-waehrend-Flush"-Zeitfenster.
|
|
||||||
* - Schlaegt das Logging fehl, wird der Fehler verschluckt — Logging darf einen
|
|
||||||
* Download niemals abbrechen.
|
|
||||||
*/
|
|
||||||
|
|
||||||
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
type DesktopRenameLevel = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
const FOLDER_NAME = "Downloader-Log";
|
const FOLDER_NAME = "Downloader-Log";
|
||||||
@ -30,8 +10,6 @@ let logDir: string | null = null;
|
|||||||
let logFilePath: string | null = null;
|
let logFilePath: string | null = null;
|
||||||
let sessionHeader = "";
|
let sessionHeader = "";
|
||||||
|
|
||||||
/** Lokaler Zeitstempel fuer den DATEINAMEN (keine Doppelpunkte — unter Windows
|
|
||||||
* in Dateinamen verboten): YYYY-MM-DD_HH-MM-SS in lokaler Zeit. */
|
|
||||||
function fileTimestamp(date: Date = new Date()): string {
|
function fileTimestamp(date: Date = new Date()): string {
|
||||||
const pad = (value: number): string => String(value).padStart(2, "0");
|
const pad = (value: number): string => String(value).padStart(2, "0");
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}_`
|
||||||
@ -65,9 +43,6 @@ function formatFields(fields?: Record<string, unknown>): string {
|
|||||||
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stellt sicher, dass Ordner UND Session-Datei existieren (selbstheilend, auch
|
|
||||||
* wenn beides zur Laufzeit geloescht wurde). Gibt false zurueck, wenn das
|
|
||||||
* Logging nicht initialisiert ist oder das Anlegen scheitert. */
|
|
||||||
function ensureWritable(): boolean {
|
function ensureWritable(): boolean {
|
||||||
if (!logDir || !logFilePath) {
|
if (!logDir || !logFilePath) {
|
||||||
return false;
|
return false;
|
||||||
@ -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 {
|
export function initDesktopRenameLog(desktopDir: string | null | undefined): void {
|
||||||
try {
|
try {
|
||||||
const base = String(desktopDir || "").trim();
|
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 {
|
export function logDesktopRename(level: DesktopRenameLevel, message: string, fields?: Record<string, unknown>): void {
|
||||||
if (!ensureWritable() || !logFilePath) {
|
if (!ensureWritable() || !logFilePath) {
|
||||||
return;
|
return;
|
||||||
@ -118,7 +87,6 @@ export function logDesktopRename(level: DesktopRenameLevel, message: string, fie
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
fs.appendFileSync(logFilePath, `${logTimestamp()} [${level}] ${message}${formatFields(fields)}\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// Logging darf einen Download niemals abbrechen.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -138,7 +106,6 @@ export function shutdownDesktopRenameLog(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(logFilePath, `=== Rename-Session beendet: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logDir = null;
|
logDir = null;
|
||||||
@ -146,34 +113,16 @@ export function shutdownDesktopRenameLog(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RenameVerification {
|
export interface RenameVerification {
|
||||||
/** Gesamtergebnis: Datei liegt unter dem EXAKT erwarteten Namen vor und (sofern kein
|
|
||||||
* In-Place-Rename) die Quelle ist verschwunden. */
|
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
/** Empfohlenes Log-Level: ERROR (Rename nicht vollzogen / falscher Name),
|
|
||||||
* WARN (vollzogen, aber Schreibweise nicht pruefbar), INFO (alles ok). */
|
|
||||||
level: "INFO" | "WARN" | "ERROR";
|
level: "INFO" | "WARN" | "ERROR";
|
||||||
/** Zieldatei (egal welche Schreibweise) auf der Platte vorhanden? */
|
|
||||||
targetExists: boolean;
|
targetExists: boolean;
|
||||||
/** Tatsaechlicher Name auf der Platte (Gross-/Kleinschreibung wie wirklich
|
|
||||||
* gespeichert), oder null wenn nicht gefunden / Verzeichnis nicht lesbar. */
|
|
||||||
onDiskName: string | null;
|
onDiskName: string | null;
|
||||||
/** onDiskName === erwarteter Zielname (exakt, case-sensitive)? */
|
|
||||||
nameMatches: boolean;
|
nameMatches: boolean;
|
||||||
/** Quelldatei verschwunden (Rename wirklich vollzogen, kein halb-fertiger Copy)? */
|
|
||||||
sourceGone: boolean;
|
sourceGone: boolean;
|
||||||
/** Groesse der Zieldatei in Bytes, oder null. */
|
|
||||||
targetSize: number | null;
|
targetSize: number | null;
|
||||||
/** Menschenlesbarer Grund, wenn nicht sauber INFO. */
|
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Repliziert download-manager.toWindowsLongPathIfNeeded (ein Import waere zirkulaer:
|
|
||||||
* download-manager -> desktop-rename-log). Node fs-Aufrufe scheitern unter Windows fuer
|
|
||||||
* absolute Pfade >=248 Zeichen, sofern nicht mit \\?\ / \\?\UNC\ praefixiert — und genau
|
|
||||||
* solche langen Scene-Release-Pfade benennt diese App um. OHNE dieses Prefix wuerden
|
|
||||||
* statSync/readdirSync in der Verifikation auf langen Pfaden faelschlich scheitern
|
|
||||||
* (falsches "Ziel nicht gefunden" UND falsches "Quelle weg" -> falsches OK, das einen
|
|
||||||
* halb-fertigen Verschiebevorgang maskiert). */
|
|
||||||
function toLongPath(filePath: string): string {
|
function toLongPath(filePath: string): string {
|
||||||
const absolute = path.resolve(String(filePath || ""));
|
const absolute = path.resolve(String(filePath || ""));
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
@ -191,9 +140,6 @@ function toLongPath(filePath: string): string {
|
|||||||
return `\\\\?\\${absolute}`;
|
return `\\\\?\\${absolute}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Echter On-Disk-Name (korrekte Schreibweise) fuer `requested` aus den
|
|
||||||
* Verzeichnis-Eintraegen, oder null wenn das Verzeichnis nicht lesbar war
|
|
||||||
* (entries===null) bzw. nichts passt. */
|
|
||||||
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
function resolveOnDiskName(requested: string, entries: string[] | null): string | null {
|
||||||
if (entries === null) {
|
if (entries === null) {
|
||||||
return null;
|
return null;
|
||||||
@ -204,8 +150,6 @@ function resolveOnDiskName(requested: string, entries: string[] | null): string
|
|||||||
|| requested;
|
|| requested;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Baut das Verifikations-Ergebnis aus den (sync ODER async) erhobenen Roh-Fakten.
|
|
||||||
* `dirEntries`=null bedeutet "Zielverzeichnis war nicht lesbar". */
|
|
||||||
function buildVerification(
|
function buildVerification(
|
||||||
sourcePath: string,
|
sourcePath: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
@ -215,8 +159,6 @@ function buildVerification(
|
|||||||
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
const dirReadFailed = facts.targetExists && facts.dirEntries === null;
|
||||||
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
const onDiskName = facts.targetExists ? resolveOnDiskName(requested, facts.dirEntries) : null;
|
||||||
|
|
||||||
// In-Place-Rename (reine Gross-/Kleinschreibungs-Korrektur auf case-insensitivem FS):
|
|
||||||
// Quelle == Ziel -> "Quelle weg" gilt nicht.
|
|
||||||
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
const samePath = path.resolve(sourcePath).toLowerCase() === path.resolve(targetPath).toLowerCase();
|
||||||
const sourceGone = samePath ? true : !facts.sourceExists;
|
const sourceGone = samePath ? true : !facts.sourceExists;
|
||||||
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
const nameMatches = facts.targetExists && !dirReadFailed && onDiskName === requested;
|
||||||
@ -235,7 +177,6 @@ function buildVerification(
|
|||||||
level = "ERROR";
|
level = "ERROR";
|
||||||
}
|
}
|
||||||
if (level === "INFO" && dirReadFailed) {
|
if (level === "INFO" && dirReadFailed) {
|
||||||
// Datei da + Quelle weg, aber Schreibweise ungeprueft — KEIN stilles OK.
|
|
||||||
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
problems.push("Zielverzeichnis nicht lesbar — Schreibweise nicht verifiziert");
|
||||||
level = "WARN";
|
level = "WARN";
|
||||||
}
|
}
|
||||||
@ -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 {
|
export function verifyRename(sourcePath: string, targetPath: string): RenameVerification {
|
||||||
const longTarget = toLongPath(targetPath);
|
const longTarget = toLongPath(targetPath);
|
||||||
let targetExists = false;
|
let targetExists = false;
|
||||||
@ -285,9 +222,6 @@ export function verifyRename(sourcePath: string, targetPath: string): RenameVeri
|
|||||||
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
return buildVerification(sourcePath, targetPath, { targetExists, targetSize, dirEntries, sourceExists });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Asynchrone Verifikation — fuer den Media-Rename-Hot-Path (renamePathWithExdevFallback),
|
|
||||||
* damit KEIN synchrones statSync/readdirSync den Electron-Main-Loop in Saison-Pack-
|
|
||||||
* Rename-Schleifen blockiert (Projekt-Regel: kein sync I/O in Hot Paths). Wirft nie. */
|
|
||||||
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
export async function verifyRenameAsync(sourcePath: string, targetPath: string): Promise<RenameVerification> {
|
||||||
const longTarget = toLongPath(targetPath);
|
const longTarget = toLongPath(targetPath);
|
||||||
let targetExists = false;
|
let targetExists = false;
|
||||||
|
|||||||
@ -127,11 +127,6 @@ export function validateDownloadedFileCompletion(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (args.plan.source === "stream-end") {
|
if (args.plan.source === "stream-end") {
|
||||||
// H3: Kein Content-Length, keine Provider-Größe UND 0 Bytes empfangen → der
|
|
||||||
// Hoster hat die Verbindung sofort geschlossen. Das ist ein fehlgeschlagener
|
|
||||||
// Download, kein gültiges "fertig" — sonst gilt eine leere Datei als komplett
|
|
||||||
// und es gibt keinen Auto-Redownload. Verhält sich jetzt wie der bereits
|
|
||||||
// behandelte Fall actualBytes<=0 mit bekannter Größe (oben).
|
|
||||||
if (actualBytes <= 0) {
|
if (actualBytes <= 0) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,3 @@
|
|||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 1 — Imports & Konstanten
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
@ -47,10 +43,6 @@ const EXTRACTOR_PROBE_TIMEOUT_MS = 8_000;
|
|||||||
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
|
const DEFAULT_EXTRACT_CPU_BUDGET_PERCENT = 80;
|
||||||
let currentExtractCpuPriority: string | undefined;
|
let currentExtractCpuPriority: string | undefined;
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 2 — Types & Interfaces
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export interface ExtractOptions {
|
export interface ExtractOptions {
|
||||||
packageDir: string;
|
packageDir: string;
|
||||||
targetDir: string;
|
targetDir: string;
|
||||||
@ -169,20 +161,15 @@ interface DaemonRequest {
|
|||||||
passwordCount: number;
|
passwordCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 3 — Subst Drive Mapping (Windows long-path workaround)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
const activeSubstDrives = new Set<string>();
|
const activeSubstDrives = new Set<string>();
|
||||||
|
|
||||||
function findFreeSubstDrive(): string | null {
|
function findFreeSubstDrive(): string | null {
|
||||||
if (process.platform !== "win32") return null;
|
if (process.platform !== "win32") return null;
|
||||||
for (let code = 90; code >= 71; code--) { // Z to G
|
for (let code = 90; code >= 71; code--) {
|
||||||
const letter = String.fromCharCode(code);
|
const letter = String.fromCharCode(code);
|
||||||
if (activeSubstDrives.has(letter)) continue;
|
if (activeSubstDrives.has(letter)) continue;
|
||||||
try {
|
try {
|
||||||
fs.accessSync(`${letter}:\\`);
|
fs.accessSync(`${letter}:\\`);
|
||||||
// Drive exists, skip
|
|
||||||
} catch {
|
} catch {
|
||||||
return letter;
|
return letter;
|
||||||
}
|
}
|
||||||
@ -226,14 +213,9 @@ export function cleanupStaleSubstDrives(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore — subst cleanup is best-effort
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 4 — Archiv-Erkennung & Kandidaten
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> {
|
export async function detectArchiveSignature(filePath: string): Promise<ArchiveSignature> {
|
||||||
let fd: fs.promises.FileHandle | null = null;
|
let fd: fs.promises.FileHandle | null = null;
|
||||||
try {
|
try {
|
||||||
@ -368,7 +350,6 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
|
|||||||
return !fileNamesLower.has(`${fileName}.001`.toLowerCase());
|
return !fileNamesLower.has(`${fileName}.001`.toLowerCase());
|
||||||
});
|
});
|
||||||
const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath));
|
const tarCompressed = files.filter((filePath) => /\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(filePath));
|
||||||
// Generic .001 splits (HJSplit etc.) — exclude already-recognized .zip.001 and .7z.001
|
|
||||||
const genericSplit = files.filter((filePath) => {
|
const genericSplit = files.filter((filePath) => {
|
||||||
const fileName = archiveDetectionName(filePath).toLowerCase();
|
const fileName = archiveDetectionName(filePath).toLowerCase();
|
||||||
if (!/\.001$/.test(fileName)) return false;
|
if (!/\.001$/.test(fileName)) return false;
|
||||||
@ -406,10 +387,6 @@ export async function findArchiveCandidates(packageDir: string): Promise<string[
|
|||||||
return unique;
|
return unique;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 5 — Cleanup & Dateisystem
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function escapeRegex(value: string): string {
|
function escapeRegex(value: string): string {
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
}
|
}
|
||||||
@ -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 COMPANION_EXTS_RE = /\.(?:sfv|nfo|nzb|md5|sha1|sha256|crc|srr)$/i;
|
||||||
const addCompanions = (stemRe: string): void => {
|
const addCompanions = (stemRe: string): void => {
|
||||||
for (const candidate of filesInDir) {
|
for (const candidate of filesInDir) {
|
||||||
@ -504,12 +479,10 @@ export function collectArchiveCleanupTargets(sourceArchivePath: string, director
|
|||||||
return Array.from(targets);
|
return Array.from(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tar compound archives (.tar.gz, .tar.bz2, .tar.xz, .tgz, .tbz2, .txz)
|
|
||||||
if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) {
|
if (/\.(?:tar\.(?:gz|bz2|xz)|tgz|tbz2|txz)$/i.test(fileName)) {
|
||||||
return Array.from(targets);
|
return Array.from(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic .NNN split files (HJSplit etc.)
|
|
||||||
const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i);
|
const genericSplit = fileName.match(/^(.*)\.(\d{3})$/i);
|
||||||
if (genericSplit) {
|
if (genericSplit) {
|
||||||
const stem = escapeRegex(genericSplit[1]);
|
const stem = escapeRegex(genericSplit[1]);
|
||||||
@ -572,7 +545,6 @@ export async function cleanupArchives(
|
|||||||
index += 1;
|
index += 1;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
@ -595,7 +567,6 @@ export async function cleanupArchives(
|
|||||||
await fs.promises.rm(filePath, { force: true });
|
await fs.promises.rm(filePath, { force: true });
|
||||||
removed += 1;
|
removed += 1;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
@ -684,16 +655,11 @@ export async function removeEmptyDirectoryTree(rootDir: string): Promise<number>
|
|||||||
removed += 1;
|
removed += 1;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 6 — Passwort-Management (LRU-Cache & Kandidaten)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function packagePasswordCacheKey(packageDir: string, packageId?: string): string {
|
function packagePasswordCacheKey(packageDir: string, packageId?: string): string {
|
||||||
const normalizedPackageId = String(packageId || "").trim();
|
const normalizedPackageId = String(packageId || "").trim();
|
||||||
if (normalizedPackageId) {
|
if (normalizedPackageId) {
|
||||||
@ -715,7 +681,6 @@ function readCachedPackagePassword(cacheKey: string): string {
|
|||||||
if (!cached) {
|
if (!cached) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
// Refresh insertion order to keep recently used package caches alive.
|
|
||||||
packageLearnedPasswords.delete(cacheKey);
|
packageLearnedPasswords.delete(cacheKey);
|
||||||
packageLearnedPasswords.set(cacheKey, cached);
|
packageLearnedPasswords.set(cacheKey, cached);
|
||||||
return cached;
|
return cached;
|
||||||
@ -742,24 +707,6 @@ function clearCachedPackagePassword(cacheKey: string): void {
|
|||||||
packageLearnedPasswords.delete(cacheKey);
|
packageLearnedPasswords.delete(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Setzt den Extractor-Zustand zurück, wenn der User die Archiv-Passwortliste
|
|
||||||
* ändert. Repliziert, was ein App-Neustart am Extractor-Subsystem tut:
|
|
||||||
* - leert den In-Memory Learned-Password-Cache (gelernte Passwörter aller Pakete)
|
|
||||||
* - fährt den langlebigen JVM-Daemon herunter (sofern nicht gerade beschäftigt),
|
|
||||||
* damit die nächste Extraktion mit einem frischen Prozess + frischen Passwörtern
|
|
||||||
* startet.
|
|
||||||
*
|
|
||||||
* Hintergrund: User-Report — ein neu hinzugefügtes Passwort griff bei "Jetzt
|
|
||||||
* entpacken" erst NACH App-Neustart. Die gesamte TS/Java-Kette propagiert die
|
|
||||||
* Liste pro Request korrekt; die einzige zustandsbehaftete Komponente, die ein
|
|
||||||
* Neustart zurücksetzt (und dieser Aufruf ebenfalls), ist der Daemon-Prozess.
|
|
||||||
*
|
|
||||||
* Bewusst KEIN Shutdown eines beschäftigten Daemons: läuft gerade eine Extraktion
|
|
||||||
* (z.B. weil Settings während des Entpackens gespeichert werden), bleibt sie
|
|
||||||
* unangetastet — der nächste Lauf bekommt dann ggf. noch den alten Daemon, aber
|
|
||||||
* der häufige Fall (Liste im Leerlauf ändern) wird sauber abgedeckt.
|
|
||||||
*/
|
|
||||||
export function resetExtractorCachesForPasswordChange(): { learnedCleared: number; daemonRestarted: boolean } {
|
export function resetExtractorCachesForPasswordChange(): { learnedCleared: number; daemonRestarted: boolean } {
|
||||||
const learnedCleared = packageLearnedPasswords.size;
|
const learnedCleared = packageLearnedPasswords.size;
|
||||||
packageLearnedPasswords.clear();
|
packageLearnedPasswords.clear();
|
||||||
@ -820,10 +767,6 @@ function prioritizePassword(passwords: string[], successful: string): string[] {
|
|||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 7 — Fehler-Klassifizierung
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export function cleanErrorText(text: string): string {
|
export function cleanErrorText(text: string): string {
|
||||||
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
const normalized = String(text || "").replace(/\s+/g, " ").trim();
|
||||||
if (normalized.length <= 500) {
|
if (normalized.length <= 500) {
|
||||||
@ -964,10 +907,6 @@ function isJvmRuntimeMissingError(errorText: string): boolean {
|
|||||||
|| text.includes("enoent");
|
|| text.includes("enoent");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 8 — Backend-Modus (auto / jvm / legacy)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export function resolveExtractorBackendMode(
|
export function resolveExtractorBackendMode(
|
||||||
rawValue?: string | null,
|
rawValue?: string | null,
|
||||||
isVitestEnv = Boolean(process.env.VITEST)
|
isVitestEnv = Boolean(process.env.VITEST)
|
||||||
@ -993,9 +932,6 @@ export function resolveExtractorBackendModeForArchive(
|
|||||||
if (requestedMode !== "auto") {
|
if (requestedMode !== "auto") {
|
||||||
return requestedMode;
|
return requestedMode;
|
||||||
}
|
}
|
||||||
// On Windows, multipart RAR extraction feels significantly snappier with the
|
|
||||||
// native CLI path than with the JVM backend, and we already harden that path
|
|
||||||
// with subst + flat-mode fallback.
|
|
||||||
if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) {
|
if (String(platform || "").toLowerCase() === "win32" && isRarArchivePath(archivePath)) {
|
||||||
return "legacy";
|
return "legacy";
|
||||||
}
|
}
|
||||||
@ -1014,10 +950,6 @@ function isRarArchivePath(filePath: string): boolean {
|
|||||||
return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || ""));
|
return /\.(?:rar|r\d{2,3})$/i.test(String(filePath || ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 9 — Native Extractor Resolution (7-Zip / WinRAR)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function is7zCommand(command: string): boolean {
|
function is7zCommand(command: string): boolean {
|
||||||
const lower = command.toLowerCase();
|
const lower = command.toLowerCase();
|
||||||
return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar");
|
return lower.includes("7z") && !lower.includes("unrar") && !lower.includes("winrar");
|
||||||
@ -1229,12 +1161,6 @@ async function findAlternativeExtractor(currentCommand: string, archivePath = ""
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 10 — CPU / Thread / Priority
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
/** Compute a safe JVM -Xmx value based on available physical RAM.
|
|
||||||
* Reserves 4 GB for Windows + Electron + other processes, caps at 16 GB. */
|
|
||||||
function jvmMaxHeapArg(): string {
|
function jvmMaxHeapArg(): string {
|
||||||
const totalGb = os.totalmem() / (1024 ** 3);
|
const totalGb = os.totalmem() / (1024 ** 3);
|
||||||
const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16));
|
const heapGb = Math.max(1, Math.min(Math.floor(totalGb - 4), 16));
|
||||||
@ -1301,21 +1227,15 @@ function lowerExtractProcessPriority(childPid: number | undefined, cpuPriority?:
|
|||||||
try {
|
try {
|
||||||
os.setPriority(pid, extractOsPriority(cpuPriority));
|
os.setPriority(pid, extractOsPriority(cpuPriority));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore: priority lowering is best-effort
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 11 — Prozess-Ausführung (spawn, kill, progress parsing)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
||||||
const pid = Number(child.pid || 0);
|
const pid = Number(child.pid || 0);
|
||||||
if (!Number.isFinite(pid) || pid <= 0) {
|
if (!Number.isFinite(pid) || pid <= 0) {
|
||||||
try {
|
try {
|
||||||
child.kill();
|
child.kill();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1330,14 +1250,12 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
|||||||
try {
|
try {
|
||||||
child.kill();
|
child.kill();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
child.kill();
|
child.kill();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@ -1346,7 +1264,6 @@ function killProcessTree(child: { pid?: number; kill: () => void }): void {
|
|||||||
try {
|
try {
|
||||||
child.kill();
|
child.kill();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1503,10 +1420,6 @@ function runExtractCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 12 — JVM Backend & Daemon
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
let cachedJvmLayout: JvmExtractorLayout | null | undefined;
|
let cachedJvmLayout: JvmExtractorLayout | null | undefined;
|
||||||
let cachedJvmLayoutNullSince = 0;
|
let cachedJvmLayoutNullSince = 0;
|
||||||
const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000;
|
const JVM_LAYOUT_NULL_TTL_MS = 5 * 60 * 1000;
|
||||||
@ -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 daemonProcess: ChildProcess | null = null;
|
||||||
let daemonReady = false;
|
let daemonReady = false;
|
||||||
let daemonBusy = false;
|
let daemonBusy = false;
|
||||||
@ -1645,8 +1554,8 @@ let daemonLayout: JvmExtractorLayout | null = null;
|
|||||||
|
|
||||||
export function shutdownDaemon(): void {
|
export function shutdownDaemon(): void {
|
||||||
if (daemonProcess) {
|
if (daemonProcess) {
|
||||||
try { daemonProcess.stdin?.end(); } catch { /* ignore */ }
|
try { daemonProcess.stdin?.end(); } catch { }
|
||||||
try { killProcessTree(daemonProcess); } catch { /* ignore */ }
|
try { killProcessTree(daemonProcess); } catch { }
|
||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
}
|
}
|
||||||
daemonReady = false;
|
daemonReady = false;
|
||||||
@ -1822,7 +1731,6 @@ function startDaemon(layout: JvmExtractorLayout): boolean {
|
|||||||
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
|
usedPassword: req.parseState.usedPassword, backend: req.parseState.backend
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Clean up tmp dir
|
|
||||||
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
|
fs.rm(jvmTmpDir, { recursive: true, force: true }, () => {});
|
||||||
daemonProcess = null;
|
daemonProcess = null;
|
||||||
daemonReady = false;
|
daemonReady = false;
|
||||||
@ -1845,7 +1753,6 @@ function isDaemonAvailable(layout: JvmExtractorLayout): boolean {
|
|||||||
return Boolean(daemonProcess && daemonReady && !daemonBusy);
|
return Boolean(daemonProcess && daemonReady && !daemonBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Wait for the daemon to become ready (boot phase) or free (busy phase), with timeout. */
|
|
||||||
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
|
function waitForDaemonReady(maxWaitMs: number, signal?: AbortSignal): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
@ -1958,14 +1865,12 @@ async function runJvmExtractCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try persistent daemon first — saves ~5s JVM boot per archive
|
|
||||||
if (isDaemonAvailable(layout)) {
|
if (isDaemonAvailable(layout)) {
|
||||||
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
lowerExtractProcessPriority(daemonProcess?.pid, currentExtractCpuPriority);
|
||||||
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
|
logger.info(`JVM Daemon: Sofort verfügbar, sende Request für ${path.basename(archivePath)} (pwCandidates=${passwordCandidates.length})`);
|
||||||
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
|
return sendDaemonRequest(archivePath, targetDir, conflictMode, passwordCandidates, onArchiveProgress, signal, timeoutMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Daemon exists but is still booting or busy — wait up to 15s for it
|
|
||||||
if (daemonProcess) {
|
if (daemonProcess) {
|
||||||
const reason = !daemonReady ? "booting" : "busy";
|
const reason = !daemonReady ? "booting" : "busy";
|
||||||
const waitStartedAt = Date.now();
|
const waitStartedAt = Date.now();
|
||||||
@ -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)}`);
|
logger.warn(`JVM Daemon: Timeout nach ${waitedMs}ms beim Warten — Fallback auf neuen Prozess für ${path.basename(archivePath)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: spawn a new JVM process (daemon not available after waiting)
|
|
||||||
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
|
logger.info(`JVM Spawn: Neuer Prozess für ${path.basename(archivePath)}`);
|
||||||
|
|
||||||
const mode = effectiveConflictMode(conflictMode);
|
const mode = effectiveConflictMode(conflictMode);
|
||||||
@ -2149,10 +2053,6 @@ async function runJvmExtractCommand(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 13 — Legacy Extraction (buildExternalExtractArgs, runExternalExtract*)
|
|
||||||
// ════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export function buildExternalExtractArgs(
|
export function buildExternalExtractArgs(
|
||||||
command: string,
|
command: string,
|
||||||
archivePath: string,
|
archivePath: string,
|
||||||
@ -2179,7 +2079,6 @@ export function buildExternalExtractArgs(
|
|||||||
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
return ["x", "-y", overwrite, pass, archivePath, `-o${targetDir}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delay helper for extraction retries
|
|
||||||
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
const extractRetryDelay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
async function runExternalExtractInner(
|
async function runExternalExtractInner(
|
||||||
@ -2213,7 +2112,6 @@ async function runExternalExtractInner(
|
|||||||
let createErrorText = "";
|
let createErrorText = "";
|
||||||
let createErrorPassword = "";
|
let createErrorPassword = "";
|
||||||
|
|
||||||
// Skip normal extraction loop if flat mode is already known to be needed for this package
|
|
||||||
if (forceFlatMode) {
|
if (forceFlatMode) {
|
||||||
logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
logger.info(`Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
||||||
onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
onLog?.("INFO", `Flat-Modus direkt (gespeichert vom vorherigen Archiv): ${path.basename(archivePath)}`);
|
||||||
@ -2330,8 +2228,6 @@ async function runExternalExtractInner(
|
|||||||
lastError = result.errorText;
|
lastError = result.errorText;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some archives store internal paths with a leading \, causing invalid \\ paths.
|
|
||||||
// Retry in flat mode ("e" instead of "x") which strips all archive paths.
|
|
||||||
const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : "");
|
const pathCreateError = createErrorText || (lastError.includes("Cannot create") ? lastError : "");
|
||||||
if (pathCreateError) {
|
if (pathCreateError) {
|
||||||
const flatPasswords = createErrorPassword
|
const flatPasswords = createErrorPassword
|
||||||
@ -2455,7 +2351,6 @@ async function runExternalExtract(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a short drive mapping for legacy native extractors on Windows.
|
|
||||||
subst = createSubstMapping(targetDir);
|
subst = createSubstMapping(targetDir);
|
||||||
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
const effectiveTargetDir = subst ? `${subst.drive}:\\` : targetDir;
|
||||||
if (subst) {
|
if (subst) {
|
||||||
@ -2508,7 +2403,6 @@ async function runExternalExtract(
|
|||||||
const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password";
|
const isCrcOrWrongPw = initialLegacyCategory === "crc_error" || initialLegacyCategory === "wrong_password";
|
||||||
let finalLegacyError: Error;
|
let finalLegacyError: Error;
|
||||||
|
|
||||||
// Retry once after a short delay to let Windows flush freshly completed archive parts.
|
|
||||||
if (isCrcOrWrongPw && !signal?.aborted) {
|
if (isCrcOrWrongPw && !signal?.aborted) {
|
||||||
const retryDelayMs = 2500;
|
const retryDelayMs = 2500;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@ -2634,10 +2528,6 @@ async function runExternalExtract(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 14 – ZIP Extraction (AdmZip)
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function isZipSafetyGuardError(error: unknown): boolean {
|
function isZipSafetyGuardError(error: unknown): boolean {
|
||||||
const text = String(error || "").toLowerCase();
|
const text = String(error || "").toLowerCase();
|
||||||
return text.includes("path traversal")
|
return text.includes("path traversal")
|
||||||
@ -2726,9 +2616,6 @@ async function extractZipArchive(archivePath: string, targetDir: string, conflic
|
|||||||
let outputKey = pathSetKey(outputPath);
|
let outputKey = pathSetKey(outputPath);
|
||||||
|
|
||||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||||
// TOCTOU note: There is a small race between access and writeFile below.
|
|
||||||
// This is acceptable here because zip extraction is single-threaded and we need
|
|
||||||
// the exists check to implement skip/rename conflict resolution semantics.
|
|
||||||
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
|
const outputExists = usedOutputs.has(outputKey) || await fs.promises.access(outputPath).then(() => true, () => false);
|
||||||
if (outputExists) {
|
if (outputExists) {
|
||||||
if (mode === "skip") {
|
if (mode === "skip") {
|
||||||
@ -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> {
|
async function estimateArchivesTotalBytes(candidates: string[]): Promise<number> {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of candidates) {
|
||||||
@ -2789,7 +2672,7 @@ async function estimateArchivesTotalBytes(candidates: string[]): Promise<number>
|
|||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
try {
|
try {
|
||||||
total += (await fs.promises.stat(part)).size;
|
total += (await fs.promises.stat(part)).size;
|
||||||
} catch { /* missing part, ignore */ }
|
} catch { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
@ -2852,7 +2735,6 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
|
|||||||
try {
|
try {
|
||||||
totalBytes += (await fs.promises.stat(filePath)).size;
|
totalBytes += (await fs.promises.stat(filePath)).size;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing parts
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (totalBytes <= 0) {
|
if (totalBytes <= 0) {
|
||||||
@ -2866,10 +2748,6 @@ async function computeExtractTimeoutMs(archivePath: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 16 – Resume State
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function extractProgressFilePath(packageDir: string, packageId?: string): string {
|
function extractProgressFilePath(packageDir: string, packageId?: string): string {
|
||||||
if (packageId) {
|
if (packageId) {
|
||||||
return path.join(packageDir, `.rd_extract_progress_${packageId}.json`);
|
return path.join(packageDir, `.rd_extract_progress_${packageId}.json`);
|
||||||
@ -2905,7 +2783,6 @@ async function writeExtractResumeState(packageDir: string, completedArchives: Se
|
|||||||
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
|
const tmpPath = progressPath + "." + Date.now() + "." + Math.random().toString(36).slice(2, 8) + ".tmp";
|
||||||
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
await fs.promises.writeFile(tmpPath, JSON.stringify(payload, null, 2), "utf8");
|
||||||
await fs.promises.rename(tmpPath, progressPath).catch(async () => {
|
await fs.promises.rename(tmpPath, progressPath).catch(async () => {
|
||||||
// rename may fail if another writer renamed tmpPath first (parallel workers)
|
|
||||||
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
await fs.promises.rm(tmpPath, { force: true }).catch(() => {});
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -2917,14 +2794,9 @@ export async function clearExtractResumeState(packageDir: string, packageId?: st
|
|||||||
try {
|
try {
|
||||||
await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true });
|
await fs.promises.rm(extractProgressFilePath(packageDir, packageId), { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 17 – Progress & Conflict Helpers
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
function emitExtractLog(
|
function emitExtractLog(
|
||||||
onLog: ExtractOptions["onLog"] | undefined,
|
onLog: ExtractOptions["onLog"] | undefined,
|
||||||
level: "INFO" | "WARN" | "ERROR",
|
level: "INFO" | "WARN" | "ERROR",
|
||||||
@ -2950,10 +2822,6 @@ function effectiveConflictMode(conflictMode: ConflictMode): "overwrite" | "skip"
|
|||||||
return "skip";
|
return "skip";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
// Sektion 18 – extractPackageArchives (Orchestrierung)
|
|
||||||
// ══════════════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
|
export async function extractPackageArchives(options: ExtractOptions): Promise<{ extracted: number; failed: number; lastError: string }> {
|
||||||
if (options.signal?.aborted) {
|
if (options.signal?.aborted) {
|
||||||
throw new Error("aborted:extract");
|
throw new Error("aborted:extract");
|
||||||
@ -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}`);
|
logger.info(`Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||||
options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
options.onLog?.("INFO", `Entpacken gestartet: packageDir=${options.packageDir}, targetDir=${options.targetDir}, archives=${candidates.length}${options.onlyArchives ? ` (hybrid, gesamt=${allCandidates.length})` : ""}, cleanupMode=${options.cleanupMode}, conflictMode=${options.conflictMode}`);
|
||||||
|
|
||||||
// Disk space pre-check
|
|
||||||
if (candidates.length > 0) {
|
if (candidates.length > 0) {
|
||||||
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
|
options.onProgress?.({ current: 0, total: candidates.length, percent: 0, archiveName: "Speicherplatz prüfen...", phase: "preparing" });
|
||||||
try {
|
try {
|
||||||
await fs.promises.mkdir(options.targetDir, { recursive: true });
|
await fs.promises.mkdir(options.targetDir, { recursive: true });
|
||||||
} catch { /* ignore */ }
|
} catch { }
|
||||||
await checkDiskSpaceForExtraction(options.targetDir, candidates);
|
await checkDiskSpaceForExtraction(options.targetDir, candidates);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3096,9 +2963,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
|
|
||||||
emitProgress(extracted, "", "extracting");
|
emitProgress(extracted, "", "extracting");
|
||||||
|
|
||||||
// Emit "done" progress for archives already completed via resume state
|
|
||||||
// so the caller's onProgress handler can mark their items as "Done" immediately
|
|
||||||
// rather than leaving them as "Entpacken - Ausstehend" until all extraction finishes.
|
|
||||||
for (const archivePath of candidates) {
|
for (const archivePath of candidates) {
|
||||||
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
if (resumeCompleted.has(archiveNameKey(path.basename(archivePath)))) {
|
||||||
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
|
emitProgress(extracted, path.basename(archivePath), "extracting", 100, 0, undefined, { archiveDone: true, archiveSuccess: true });
|
||||||
@ -3131,8 +2995,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
}, 1100);
|
}, 1100);
|
||||||
const hybrid = Boolean(options.hybridMode);
|
const hybrid = Boolean(options.hybridMode);
|
||||||
// Before the first successful extraction, filename-derived candidates are useful.
|
|
||||||
// After a known password is learned, try that first to avoid per-archive delays.
|
|
||||||
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
const filenamePasswords = archiveFilenamePasswords(archiveName);
|
||||||
const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
|
const nonEmptyBasePasswords = passwordCandidates.filter((p) => p !== "");
|
||||||
const orderedNonEmpty = learnedPassword
|
const orderedNonEmpty = learnedPassword
|
||||||
@ -3150,7 +3012,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
emitProgress(extracted + failed, archiveName, "extracting", archivePercent, Date.now() - archiveStartedAt);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate generic .001 splits via file signature before attempting extraction
|
|
||||||
const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName);
|
const isGenericSplit = /\.\d{3}$/i.test(archiveName) && !/\.(zip|7z)\.\d{3}$/i.test(archiveName);
|
||||||
if (isGenericSplit) {
|
if (isGenericSplit) {
|
||||||
const sig = await detectArchiveSignature(archivePath);
|
const sig = await detectArchiveSignature(archivePath);
|
||||||
@ -3185,7 +3046,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
// Set module-level priority before each extract call (race-safe: spawn is synchronous)
|
|
||||||
currentExtractCpuPriority = options.extractCpuPriority;
|
currentExtractCpuPriority = options.extractCpuPriority;
|
||||||
const ext = path.extname(archivePath).toLowerCase();
|
const ext = path.extname(archivePath).toLowerCase();
|
||||||
if (ext === ".zip") {
|
if (ext === ".zip") {
|
||||||
@ -3297,7 +3157,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
if (options.signal?.aborted || noExtractorEncountered) break;
|
if (options.signal?.aborted || noExtractorEncountered) break;
|
||||||
await extractSingleArchive(archivePath);
|
await extractSingleArchive(archivePath);
|
||||||
}
|
}
|
||||||
// Count remaining archives as failed when no extractor was found
|
|
||||||
if (noExtractorEncountered) {
|
if (noExtractorEncountered) {
|
||||||
const remaining = candidates.length - (extracted + failed);
|
const remaining = candidates.length - (extracted + failed);
|
||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
@ -3306,8 +3165,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Password discovery: extract first archive serially to find the correct password,
|
|
||||||
// then run remaining archives in parallel with the promoted password order.
|
|
||||||
let parallelQueue = pendingCandidates;
|
let parallelQueue = pendingCandidates;
|
||||||
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
|
if (passwordCandidates.length > 1 && pendingCandidates.length > 1) {
|
||||||
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
logger.info(`Passwort-Discovery: Extrahiere erstes Archiv seriell (${passwordCandidates.length} Passwort-Kandidaten)...`);
|
||||||
@ -3318,7 +3175,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errText = String(err);
|
const errText = String(err);
|
||||||
if (/aborted:extract/i.test(errText)) throw err;
|
if (/aborted:extract/i.test(errText)) throw err;
|
||||||
// noextractor:skipped — handled by noExtractorEncountered flag below
|
|
||||||
}
|
}
|
||||||
parallelQueue = pendingCandidates.slice(1);
|
parallelQueue = pendingCandidates.slice(1);
|
||||||
if (parallelQueue.length > 0) {
|
if (parallelQueue.length > 0) {
|
||||||
@ -3327,7 +3183,6 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) {
|
if (parallelQueue.length > 0 && !options.signal?.aborted && !noExtractorEncountered) {
|
||||||
// Parallel extraction pool: N workers pull from a shared queue
|
|
||||||
const queue = [...parallelQueue];
|
const queue = [...parallelQueue];
|
||||||
let nextIdx = 0;
|
let nextIdx = 0;
|
||||||
let abortError: Error | null = null;
|
let abortError: Error | null = null;
|
||||||
@ -3342,24 +3197,20 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errText = String(error);
|
const errText = String(error);
|
||||||
if (errText.includes("noextractor:skipped")) {
|
if (errText.includes("noextractor:skipped")) {
|
||||||
break; // handled by noExtractorEncountered flag after the pool
|
break;
|
||||||
}
|
}
|
||||||
if (isExtractAbortError(errText)) {
|
if (isExtractAbortError(errText)) {
|
||||||
abortError = error instanceof Error ? error : new Error(errText);
|
abortError = error instanceof Error ? error : new Error(errText);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Non-abort errors are already handled inside extractSingleArchive
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const workerCount = Math.min(maxParallel, parallelQueue.length);
|
const workerCount = Math.min(maxParallel, parallelQueue.length);
|
||||||
logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`);
|
logger.info(`Parallele Extraktion: ${workerCount} gleichzeitige Worker für ${parallelQueue.length} Archive`);
|
||||||
// Snapshot passwordCandidates before parallel extraction to avoid concurrent mutation.
|
|
||||||
// Each worker reads the same promoted order from the serial password-discovery pass.
|
|
||||||
const frozenPasswords = [...passwordCandidates];
|
const frozenPasswords = [...passwordCandidates];
|
||||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||||
// Restore passwordCandidates from frozen snapshot (parallel mutations are discarded).
|
|
||||||
passwordCandidates = frozenPasswords;
|
passwordCandidates = frozenPasswords;
|
||||||
|
|
||||||
if (abortError) throw new Error("aborted:extract");
|
if (abortError) throw new Error("aborted:extract");
|
||||||
@ -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) {
|
if (failed > 0 && extracted > 0) {
|
||||||
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
|
const failedArchives = parallelQueue.filter((ap) => !extractedArchives.has(ap) && !resumeCompleted.has(archiveNameKey(path.basename(ap))));
|
||||||
if (failedArchives.length > 0) {
|
if (failedArchives.length > 0) {
|
||||||
@ -3404,14 +3250,12 @@ export async function extractPackageArchives(options: ExtractOptions): Promise<{
|
|||||||
for (const archivePath of failedArchives) {
|
for (const archivePath of failedArchives) {
|
||||||
if (options.signal?.aborted || noExtractorEncountered) break;
|
if (options.signal?.aborted || noExtractorEncountered) break;
|
||||||
try {
|
try {
|
||||||
// Reset failed count for this archive before retry
|
|
||||||
failed -= 1;
|
failed -= 1;
|
||||||
await extractSingleArchive(archivePath);
|
await extractSingleArchive(archivePath);
|
||||||
retryRecovered += 1;
|
retryRecovered += 1;
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
const errText = String(retryError);
|
const errText = String(retryError);
|
||||||
if (isExtractAbortError(errText)) throw retryError;
|
if (isExtractAbortError(errText)) throw retryError;
|
||||||
// extractSingleArchive already incremented failed and logged the error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (retryRecovered > 0) {
|
if (retryRecovered > 0) {
|
||||||
@ -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) {
|
if (extracted > 0 && failed === 0 && !options.skipPostCleanup && !options.onlyArchives) {
|
||||||
try {
|
try {
|
||||||
const nestedCandidates = (await findArchiveCandidates(options.targetDir))
|
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 });
|
await fs.promises.rm(options.targetDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -94,7 +94,6 @@ function flushPending(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, chunk, "utf8");
|
fs.appendFileSync(logPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,11 +123,9 @@ async function cleanupOldItemLogs(dir: string): Promise<void> {
|
|||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore locked/missing files
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing dir
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -226,7 +223,6 @@ export function shutdownItemLogs(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(logPath, `=== Item-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pendingLinesByItem.clear();
|
pendingLinesByItem.clear();
|
||||||
|
|||||||
@ -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 {
|
export function logTimestamp(date: Date = new Date()): string {
|
||||||
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
|
const pad = (value: number, length = 2): string => String(value).padStart(length, "0");
|
||||||
// getTimezoneOffset() liefert Minuten, die man zur LOKALEN Zeit ADDIEREN muss, um
|
|
||||||
// UTC zu erhalten — also negiert = Offset der lokalen Zone gegenüber UTC.
|
|
||||||
const offsetMinutes = -date.getTimezoneOffset();
|
const offsetMinutes = -date.getTimezoneOffset();
|
||||||
const sign = offsetMinutes >= 0 ? "+" : "-";
|
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||||
const absOffset = Math.abs(offsetMinutes);
|
const absOffset = Math.abs(offsetMinutes);
|
||||||
|
|||||||
@ -70,7 +70,6 @@ function writeStderr(text: string): void {
|
|||||||
try {
|
try {
|
||||||
process.stderr.write(text);
|
process.stderr.write(text);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore stderr failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,11 +135,9 @@ function rotateIfNeeded(filePath: string): void {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore - file may not exist yet
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,7 +157,6 @@ async function rotateIfNeededAsync(filePath: string): Promise<void> {
|
|||||||
await fs.promises.rm(backup, { force: true }).catch(() => {});
|
await fs.promises.rm(backup, { force: true }).catch(() => {});
|
||||||
await fs.promises.rename(filePath, backup);
|
await fs.promises.rename(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore - file may not exist yet
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -215,7 +211,7 @@ function write(level: "INFO" | "WARN" | "ERROR", message: string): void {
|
|||||||
pendingChars += line.length;
|
pendingChars += line.length;
|
||||||
|
|
||||||
for (const listener of logListeners) {
|
for (const listener of logListeners) {
|
||||||
try { listener(line); } catch { /* ignore */ }
|
try { listener(line); } catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
while (pendingChars > LOG_BUFFER_LIMIT_CHARS && pendingLines.length > 1) {
|
||||||
|
|||||||
1473
src/main/main.ts
1473
src/main/main.ts
File diff suppressed because it is too large
Load Diff
@ -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";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
const MEGA_API_BASE = "https://g.api.mega.co.nz/cs";
|
||||||
@ -53,7 +37,6 @@ export function parseMegaUrl(url: string): ParsedMegaLink | null {
|
|||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const id = m[1];
|
const id = m[1];
|
||||||
const rawKey = base64UrlDecode(m[2]);
|
const rawKey = base64UrlDecode(m[2]);
|
||||||
// Files: 32 Bytes (256 bit). Folders: 16 Bytes — wir behandeln nur Files.
|
|
||||||
if (!rawKey || rawKey.length !== 32) return null;
|
if (!rawKey || rawKey.length !== 32) return null;
|
||||||
return { id, rawKey };
|
return { id, rawKey };
|
||||||
}
|
}
|
||||||
@ -123,8 +106,6 @@ export async function resolveMegaFilename(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mega gibt entweder ein Array mit File-Infos oder eine numerische Error-ID
|
|
||||||
// zurueck (z.B. -9 ENOENT, -11 EACCESS, -14 EKEY, -16 EBLOCKED, -25 EOVERQUOTA).
|
|
||||||
if (typeof payload === "number") return null;
|
if (typeof payload === "number") return null;
|
||||||
if (!Array.isArray(payload) || payload.length === 0) return null;
|
if (!Array.isArray(payload) || payload.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@ -1,463 +1,437 @@
|
|||||||
import { UnrestrictedLink } from "./realdebrid";
|
import { UnrestrictedLink } from "./realdebrid";
|
||||||
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
import { compactErrorText, filenameFromUrl, sleep } from "./utils";
|
||||||
|
|
||||||
type MegaCredentials = {
|
type MegaCredentials = {
|
||||||
login: string;
|
login: string;
|
||||||
password: string;
|
password: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CodeEntry = {
|
type CodeEntry = {
|
||||||
code: string;
|
code: string;
|
||||||
linkHint: string;
|
linkHint: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login";
|
const LOGIN_URL = "https://www.mega-debrid.eu/index.php?form=login";
|
||||||
const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid";
|
const DEBRID_URL = "https://www.mega-debrid.eu/index.php?form=debrid";
|
||||||
const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json";
|
const DEBRID_AJAX_URL = "https://www.mega-debrid.eu/index.php?ajax=debrid&json";
|
||||||
const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de";
|
const DEBRID_REFERER = "https://www.mega-debrid.eu/index.php?page=debrideur&lang=de";
|
||||||
|
|
||||||
/**
|
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;
|
||||||
* 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
|
function normalizeLink(link: string): string {
|
||||||
* nicht bedient wird). KEIN Session-/Leer-Fall — der Account soll schnell scheitern,
|
return link.trim().toLowerCase();
|
||||||
* damit die Multi-Account-Rotation sofort zum nächsten (nicht limitierten) Account
|
}
|
||||||
* wechselt, statt re-Login + Retry-Sturm das geteilte Unrestrict-Budget zu fressen.
|
|
||||||
*/
|
function parseSetCookieFromHeaders(headers: Headers): string {
|
||||||
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;
|
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
||||||
|
if (typeof getSetCookie === "function") {
|
||||||
function normalizeLink(link: string): string {
|
const values = getSetCookie.call(headers)
|
||||||
return link.trim().toLowerCase();
|
.map((entry) => entry.split(";")[0].trim())
|
||||||
}
|
.filter(Boolean);
|
||||||
|
if (values.length > 0) {
|
||||||
function parseSetCookieFromHeaders(headers: Headers): string {
|
return values.join("; ");
|
||||||
const getSetCookie = (headers as unknown as { getSetCookie?: () => string[] }).getSetCookie;
|
}
|
||||||
if (typeof getSetCookie === "function") {
|
}
|
||||||
const values = getSetCookie.call(headers)
|
|
||||||
.map((entry) => entry.split(";")[0].trim())
|
const raw = headers.get("set-cookie") || "";
|
||||||
.filter(Boolean);
|
if (!raw) {
|
||||||
if (values.length > 0) {
|
return "";
|
||||||
return values.join("; ");
|
}
|
||||||
}
|
return raw
|
||||||
}
|
.split(/,(?=[^;=]+?=)/g)
|
||||||
|
.map((chunk) => chunk.split(";")[0].trim())
|
||||||
const raw = headers.get("set-cookie") || "";
|
.filter(Boolean)
|
||||||
if (!raw) {
|
.join("; ");
|
||||||
return "";
|
}
|
||||||
}
|
|
||||||
return raw
|
const PERMANENT_HOSTER_ERRORS = [
|
||||||
.split(/,(?=[^;=]+?=)/g)
|
"hosternotavailable",
|
||||||
.map((chunk) => chunk.split(";")[0].trim())
|
"filenotfound",
|
||||||
.filter(Boolean)
|
"file_unavailable",
|
||||||
.join("; ");
|
"file not found",
|
||||||
}
|
"link is dead",
|
||||||
|
"file has been removed",
|
||||||
const PERMANENT_HOSTER_ERRORS = [
|
"file has been deleted",
|
||||||
"hosternotavailable",
|
"file was deleted",
|
||||||
"filenotfound",
|
"file was removed",
|
||||||
"file_unavailable",
|
"not available",
|
||||||
"file not found",
|
"file is no longer available"
|
||||||
"link is dead",
|
];
|
||||||
"file has been removed",
|
|
||||||
"file has been deleted",
|
function parsePageErrors(html: string): string[] {
|
||||||
"file was deleted",
|
const errors: string[] = [];
|
||||||
"file was removed",
|
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
|
||||||
"not available",
|
let m: RegExpExecArray | null;
|
||||||
"file is no longer available"
|
while ((m = errorRegex.exec(html)) !== null) {
|
||||||
];
|
const text = m[1].replace(/^Fehler:\s*/i, "").trim();
|
||||||
|
if (text) {
|
||||||
function parsePageErrors(html: string): string[] {
|
errors.push(text);
|
||||||
const errors: string[] = [];
|
}
|
||||||
const errorRegex = /class=["'][^"']*\berror\b[^"']*["'][^>]*>([^<]+)</gi;
|
}
|
||||||
let m: RegExpExecArray | null;
|
return errors;
|
||||||
while ((m = errorRegex.exec(html)) !== null) {
|
}
|
||||||
const text = m[1].replace(/^Fehler:\s*/i, "").trim();
|
|
||||||
if (text) {
|
function isPermanentHosterError(errors: string[]): string | null {
|
||||||
errors.push(text);
|
for (const err of errors) {
|
||||||
}
|
const lower = err.toLowerCase();
|
||||||
}
|
for (const pattern of PERMANENT_HOSTER_ERRORS) {
|
||||||
return errors;
|
if (lower.includes(pattern)) {
|
||||||
}
|
return err;
|
||||||
|
}
|
||||||
function isPermanentHosterError(errors: string[]): string | null {
|
}
|
||||||
for (const err of errors) {
|
}
|
||||||
const lower = err.toLowerCase();
|
return null;
|
||||||
for (const pattern of PERMANENT_HOSTER_ERRORS) {
|
}
|
||||||
if (lower.includes(pattern)) {
|
|
||||||
return err;
|
function parseCodes(html: string): CodeEntry[] {
|
||||||
}
|
const entries: CodeEntry[] = [];
|
||||||
}
|
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
|
||||||
}
|
let cardMatch: RegExpExecArray | null;
|
||||||
return null;
|
while ((cardMatch = cardRegex.exec(html)) !== null) {
|
||||||
}
|
const block = cardMatch[0];
|
||||||
|
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
|
||||||
function parseCodes(html: string): CodeEntry[] {
|
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
|
||||||
const entries: CodeEntry[] = [];
|
if (!code) {
|
||||||
const cardRegex = /<div[^>]*class=['"][^'"]*acp-box[^'"]*['"][^>]*>[\s\S]*?<\/div>/gi;
|
continue;
|
||||||
let cardMatch: RegExpExecArray | null;
|
}
|
||||||
while ((cardMatch = cardRegex.exec(html)) !== null) {
|
entries.push({ code, linkHint: normalizeLink(linkTitle) });
|
||||||
const block = cardMatch[0];
|
}
|
||||||
const linkTitle = (block.match(/<h3>\s*Link:\s*([^<]+)<\/h3>/i)?.[1] || "").trim();
|
|
||||||
const code = block.match(/processDebrid\(\d+,'([^']+)',0\)/i)?.[1] || "";
|
if (entries.length === 0) {
|
||||||
if (!code) {
|
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
|
||||||
continue;
|
let m: RegExpExecArray | null;
|
||||||
}
|
while ((m = fallbackRegex.exec(html)) !== null) {
|
||||||
entries.push({ code, linkHint: normalizeLink(linkTitle) });
|
entries.push({ code: m[1], linkHint: "" });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (entries.length === 0) {
|
|
||||||
const fallbackRegex = /processDebrid\(\d+,'([^']+)',0\)/gi;
|
return entries;
|
||||||
let m: RegExpExecArray | null;
|
}
|
||||||
while ((m = fallbackRegex.exec(html)) !== null) {
|
|
||||||
entries.push({ code: m[1], linkHint: "" });
|
function pickCode(entries: CodeEntry[], link: string): string {
|
||||||
}
|
if (entries.length === 0) {
|
||||||
}
|
return "";
|
||||||
|
}
|
||||||
return entries;
|
const target = normalizeLink(link);
|
||||||
}
|
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
|
||||||
|
return (match?.code || entries[0].code || "").trim();
|
||||||
function pickCode(entries: CodeEntry[], link: string): string {
|
}
|
||||||
if (entries.length === 0) {
|
|
||||||
return "";
|
function parseDebridJson(text: string): { link: string; text: string } | null {
|
||||||
}
|
try {
|
||||||
const target = normalizeLink(link);
|
const parsed = JSON.parse(text) as { link?: string; text?: string };
|
||||||
const match = entries.find((entry) => entry.linkHint && entry.linkHint.includes(target));
|
return {
|
||||||
return (match?.code || entries[0].code || "").trim();
|
link: String(parsed.link || ""),
|
||||||
}
|
text: String(parsed.text || "")
|
||||||
|
};
|
||||||
function parseDebridJson(text: string): { link: string; text: string } | null {
|
} catch {
|
||||||
try {
|
return null;
|
||||||
const parsed = JSON.parse(text) as { link?: string; text?: string };
|
}
|
||||||
return {
|
}
|
||||||
link: String(parsed.link || ""),
|
|
||||||
text: String(parsed.text || "")
|
function abortError(): Error {
|
||||||
};
|
return new Error("aborted:mega-web");
|
||||||
} catch {
|
}
|
||||||
return null;
|
|
||||||
}
|
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
||||||
}
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||||
|
if (!signal) {
|
||||||
function abortError(): Error {
|
return timeoutSignal;
|
||||||
return new Error("aborted:mega-web");
|
}
|
||||||
}
|
return AbortSignal.any([signal, timeoutSignal]);
|
||||||
|
}
|
||||||
function withTimeoutSignal(signal: AbortSignal | undefined, timeoutMs: number): AbortSignal {
|
|
||||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
function throwIfAborted(signal?: AbortSignal): void {
|
||||||
if (!signal) {
|
if (signal?.aborted) {
|
||||||
return timeoutSignal;
|
throw abortError();
|
||||||
}
|
}
|
||||||
return AbortSignal.any([signal, timeoutSignal]);
|
}
|
||||||
}
|
|
||||||
|
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
function throwIfAborted(signal?: AbortSignal): void {
|
if (!signal) {
|
||||||
if (signal?.aborted) {
|
await sleep(ms);
|
||||||
throw abortError();
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (signal.aborted) {
|
||||||
|
throw abortError();
|
||||||
async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void> {
|
}
|
||||||
if (!signal) {
|
|
||||||
await sleep(ms);
|
await new Promise<void>((resolve, reject) => {
|
||||||
return;
|
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
||||||
}
|
timer = null;
|
||||||
if (signal.aborted) {
|
signal.removeEventListener("abort", onAbort);
|
||||||
throw abortError();
|
resolve();
|
||||||
}
|
}, Math.max(0, ms));
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
const onAbort = (): void => {
|
||||||
let timer: NodeJS.Timeout | null = setTimeout(() => {
|
if (timer) {
|
||||||
timer = null;
|
clearTimeout(timer);
|
||||||
signal.removeEventListener("abort", onAbort);
|
timer = null;
|
||||||
resolve();
|
}
|
||||||
}, Math.max(0, ms));
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(abortError());
|
||||||
const onAbort = (): void => {
|
};
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
timer = null;
|
});
|
||||||
}
|
}
|
||||||
signal.removeEventListener("abort", onAbort);
|
|
||||||
reject(abortError());
|
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
};
|
if (!signal) {
|
||||||
|
return promise;
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
}
|
||||||
});
|
if (signal.aborted) {
|
||||||
}
|
throw abortError();
|
||||||
|
}
|
||||||
async function raceWithAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
||||||
if (!signal) {
|
return new Promise<T>((resolve, reject) => {
|
||||||
return promise;
|
let settled = false;
|
||||||
}
|
|
||||||
if (signal.aborted) {
|
const onAbort = (): void => {
|
||||||
throw abortError();
|
if (settled) {
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
return new Promise<T>((resolve, reject) => {
|
settled = true;
|
||||||
let settled = false;
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
reject(abortError());
|
||||||
const onAbort = (): void => {
|
};
|
||||||
if (settled) {
|
|
||||||
return;
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
}
|
|
||||||
settled = true;
|
promise.then((value) => {
|
||||||
signal.removeEventListener("abort", onAbort);
|
if (settled) {
|
||||||
reject(abortError());
|
return;
|
||||||
};
|
}
|
||||||
|
settled = true;
|
||||||
signal.addEventListener("abort", onAbort, { once: true });
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
resolve(value);
|
||||||
promise.then((value) => {
|
}, (error) => {
|
||||||
if (settled) {
|
if (settled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settled = true;
|
settled = true;
|
||||||
signal.removeEventListener("abort", onAbort);
|
signal.removeEventListener("abort", onAbort);
|
||||||
resolve(value);
|
reject(error);
|
||||||
}, (error) => {
|
});
|
||||||
if (settled) {
|
});
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
settled = true;
|
export class MegaWebFallback {
|
||||||
signal.removeEventListener("abort", onAbort);
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
reject(error);
|
|
||||||
});
|
private getCredentials: () => MegaCredentials;
|
||||||
});
|
|
||||||
}
|
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
||||||
|
|
||||||
export class MegaWebFallback {
|
public constructor(getCredentials: () => MegaCredentials) {
|
||||||
private queue: Promise<unknown> = Promise.resolve();
|
this.getCredentials = getCredentials;
|
||||||
|
}
|
||||||
private getCredentials: () => MegaCredentials;
|
|
||||||
|
public async unrestrict(
|
||||||
// Per-Login Session-Cache: login(lowercase) → { cookie, setAt }. Multi-Account-
|
link: string,
|
||||||
// Rotation: jeder Account nutzt SEINE eigene Session. Frueher gab es nur EINE
|
signal?: AbortSignal,
|
||||||
// geteilte Cookie-Session → der Web-Unrestrict lief fuer JEDEN rotierten Account mit
|
account?: { login: string; password: string }
|
||||||
// den Creds des ersten/Legacy-Accounts (settings.megaLogin); der naechste Account
|
): Promise<UnrestrictedLink | null> {
|
||||||
// wurde nie wirklich verwendet (Rotation war wirkungslos).
|
const overallSignal = withTimeoutSignal(signal, 180000);
|
||||||
private sessions = new Map<string, { cookie: string; setAt: number }>();
|
return this.runExclusive(async () => {
|
||||||
|
throwIfAborted(overallSignal);
|
||||||
public constructor(getCredentials: () => MegaCredentials) {
|
const creds = (account && account.login.trim() && account.password.trim())
|
||||||
this.getCredentials = getCredentials;
|
? account
|
||||||
}
|
: this.getCredentials();
|
||||||
|
if (!creds.login.trim() || !creds.password.trim()) {
|
||||||
public async unrestrict(
|
return null;
|
||||||
link: string,
|
}
|
||||||
signal?: AbortSignal,
|
const key = creds.login.trim().toLowerCase();
|
||||||
account?: { login: string; password: string }
|
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||||
): Promise<UnrestrictedLink | null> {
|
|
||||||
const overallSignal = withTimeoutSignal(signal, 180000);
|
let generated = await this.generate(link, cookie, overallSignal);
|
||||||
return this.runExclusive(async () => {
|
if (!generated) {
|
||||||
throwIfAborted(overallSignal);
|
this.sessions.delete(key);
|
||||||
// Per-Account-Creds aus der Rotation bevorzugen; sonst Legacy-Default.
|
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
||||||
const creds = (account && account.login.trim() && account.password.trim())
|
generated = await this.generate(link, cookie, overallSignal);
|
||||||
? account
|
if (!generated) {
|
||||||
: this.getCredentials();
|
return null;
|
||||||
if (!creds.login.trim() || !creds.password.trim()) {
|
}
|
||||||
return null;
|
}
|
||||||
}
|
return {
|
||||||
const key = creds.login.trim().toLowerCase();
|
directUrl: generated.directUrl,
|
||||||
let cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
fileName: generated.fileName || filenameFromUrl(link),
|
||||||
|
fileSize: null,
|
||||||
let generated = await this.generate(link, cookie, overallSignal);
|
retriesUsed: 0
|
||||||
if (!generated) {
|
};
|
||||||
// Session evtl. abgelaufen → fuer DIESEN Login neu einloggen + einmal erneut.
|
}, overallSignal);
|
||||||
this.sessions.delete(key);
|
}
|
||||||
cookie = await this.ensureSession(key, creds.login, creds.password, overallSignal);
|
|
||||||
generated = await this.generate(link, cookie, overallSignal);
|
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||||
if (!generated) {
|
const existing = this.sessions.get(key);
|
||||||
return null;
|
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
|
||||||
}
|
return existing.cookie;
|
||||||
}
|
}
|
||||||
return {
|
const cookie = await this.login(login, password, signal);
|
||||||
directUrl: generated.directUrl,
|
this.sessions.set(key, { cookie, setAt: Date.now() });
|
||||||
fileName: generated.fileName || filenameFromUrl(link),
|
return cookie;
|
||||||
fileSize: null,
|
}
|
||||||
retriesUsed: 0
|
|
||||||
};
|
public invalidateSession(): void {
|
||||||
}, overallSignal);
|
this.sessions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Liefert ein gueltiges Session-Cookie fuer den gegebenen Login (aus Cache oder
|
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||||
* via frischem Login). Cache-TTL 20 min. */
|
const queuedAt = Date.now();
|
||||||
private async ensureSession(key: string, login: string, password: string, signal?: AbortSignal): Promise<string> {
|
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
||||||
const existing = this.sessions.get(key);
|
const guardedJob = async (): Promise<T> => {
|
||||||
if (existing && existing.cookie && Date.now() - existing.setAt <= 20 * 60 * 1000) {
|
throwIfAborted(signal);
|
||||||
return existing.cookie;
|
const waited = Date.now() - queuedAt;
|
||||||
}
|
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
||||||
const cookie = await this.login(login, password, signal);
|
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
||||||
this.sessions.set(key, { cookie, setAt: Date.now() });
|
}
|
||||||
return cookie;
|
return job();
|
||||||
}
|
};
|
||||||
|
const run = this.queue.then(guardedJob, guardedJob);
|
||||||
public invalidateSession(): void {
|
this.queue = run.then(() => undefined, () => undefined);
|
||||||
this.sessions.clear();
|
return raceWithAbort(run, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async runExclusive<T>(job: () => Promise<T>, signal?: AbortSignal): Promise<T> {
|
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
||||||
const queuedAt = Date.now();
|
throwIfAborted(signal);
|
||||||
const QUEUE_WAIT_TIMEOUT_MS = 90000;
|
const response = await fetch(LOGIN_URL, {
|
||||||
const guardedJob = async (): Promise<T> => {
|
method: "POST",
|
||||||
throwIfAborted(signal);
|
headers: {
|
||||||
const waited = Date.now() - queuedAt;
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
if (waited > QUEUE_WAIT_TIMEOUT_MS) {
|
"User-Agent": "Mozilla/5.0"
|
||||||
throw new Error(`Mega-Web Queue-Timeout (${Math.floor(waited / 1000)}s gewartet)`);
|
},
|
||||||
}
|
body: new URLSearchParams({
|
||||||
return job();
|
login,
|
||||||
};
|
password,
|
||||||
const run = this.queue.then(guardedJob, guardedJob);
|
remember: "on"
|
||||||
this.queue = run.then(() => undefined, () => undefined);
|
}),
|
||||||
return raceWithAbort(run, signal);
|
redirect: "manual",
|
||||||
}
|
signal: withTimeoutSignal(signal, 30000)
|
||||||
|
});
|
||||||
private async login(login: string, password: string, signal?: AbortSignal): Promise<string> {
|
|
||||||
throwIfAborted(signal);
|
const cookie = parseSetCookieFromHeaders(response.headers);
|
||||||
const response = await fetch(LOGIN_URL, {
|
if (!cookie) {
|
||||||
method: "POST",
|
throw new Error("Mega-Web Login liefert kein Session-Cookie");
|
||||||
headers: {
|
}
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"User-Agent": "Mozilla/5.0"
|
const verify = await fetch(DEBRID_REFERER, {
|
||||||
},
|
method: "GET",
|
||||||
body: new URLSearchParams({
|
headers: {
|
||||||
login,
|
"User-Agent": "Mozilla/5.0",
|
||||||
password,
|
Cookie: cookie,
|
||||||
remember: "on"
|
Referer: DEBRID_REFERER
|
||||||
}),
|
},
|
||||||
redirect: "manual",
|
signal: withTimeoutSignal(signal, 30000)
|
||||||
signal: withTimeoutSignal(signal, 30000)
|
});
|
||||||
});
|
const verifyHtml = await verify.text();
|
||||||
|
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
|
||||||
const cookie = parseSetCookieFromHeaders(response.headers);
|
if (!hasDebridForm) {
|
||||||
if (!cookie) {
|
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
||||||
throw new Error("Mega-Web Login liefert kein Session-Cookie");
|
}
|
||||||
}
|
|
||||||
|
return cookie;
|
||||||
const verify = await fetch(DEBRID_REFERER, {
|
}
|
||||||
method: "GET",
|
|
||||||
headers: {
|
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
|
||||||
"User-Agent": "Mozilla/5.0",
|
throwIfAborted(signal);
|
||||||
Cookie: cookie,
|
const page = await fetch(DEBRID_URL, {
|
||||||
Referer: DEBRID_REFERER
|
method: "POST",
|
||||||
},
|
headers: {
|
||||||
signal: withTimeoutSignal(signal, 30000)
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
});
|
"User-Agent": "Mozilla/5.0",
|
||||||
const verifyHtml = await verify.text();
|
Cookie: cookie,
|
||||||
const hasDebridForm = /id=["']debridForm["']/i.test(verifyHtml) || /name=["']links["']/i.test(verifyHtml);
|
Referer: DEBRID_REFERER
|
||||||
if (!hasDebridForm) {
|
},
|
||||||
throw new Error("Mega-Web Login ungültig oder Session blockiert");
|
body: new URLSearchParams({
|
||||||
}
|
links: link,
|
||||||
|
password: "",
|
||||||
return cookie;
|
showLinks: "1"
|
||||||
}
|
}),
|
||||||
|
signal: withTimeoutSignal(signal, 30000)
|
||||||
private async generate(link: string, cookie: string, signal?: AbortSignal): Promise<{ directUrl: string; fileName: string } | null> {
|
});
|
||||||
throwIfAborted(signal);
|
|
||||||
const page = await fetch(DEBRID_URL, {
|
const html = await page.text();
|
||||||
method: "POST",
|
|
||||||
headers: {
|
const pageErrors = parsePageErrors(html);
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
const permanentError = isPermanentHosterError(pageErrors);
|
||||||
"User-Agent": "Mozilla/5.0",
|
if (permanentError) {
|
||||||
Cookie: cookie,
|
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
|
||||||
Referer: DEBRID_REFERER
|
}
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
|
||||||
links: link,
|
if (noServerError) {
|
||||||
password: "",
|
throw new Error(`Mega-Web: ${noServerError}`);
|
||||||
showLinks: "1"
|
}
|
||||||
}),
|
|
||||||
signal: withTimeoutSignal(signal, 30000)
|
const code = pickCode(parseCodes(html), link);
|
||||||
});
|
if (!code) {
|
||||||
|
return null;
|
||||||
const html = await page.text();
|
}
|
||||||
|
|
||||||
// Check for permanent hoster errors before looking for debrid codes
|
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
||||||
const pageErrors = parsePageErrors(html);
|
throwIfAborted(signal);
|
||||||
const permanentError = isPermanentHosterError(pageErrors);
|
const res = await fetch(DEBRID_AJAX_URL, {
|
||||||
if (permanentError) {
|
method: "POST",
|
||||||
throw new Error(`Mega-Web: Link permanent ungültig (${permanentError})`);
|
headers: {
|
||||||
}
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"User-Agent": "Mozilla/5.0",
|
||||||
// Tageslimit dieses Accounts: die DEBRID-Seite enthaelt dann KEINEN processDebrid-
|
Cookie: cookie,
|
||||||
// Code (parseCodes leer → wir wuerden gleich null/"Antwort leer" liefern). Steht die
|
Referer: DEBRID_REFERER
|
||||||
// "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.
|
body: new URLSearchParams({
|
||||||
const noServerError = pageErrors.find((err) => MEGA_DEBRID_NO_SERVER_RE.test(err));
|
code,
|
||||||
if (noServerError) {
|
autodl: "0"
|
||||||
throw new Error(`Mega-Web: ${noServerError}`);
|
}),
|
||||||
}
|
signal: withTimeoutSignal(signal, 15000)
|
||||||
|
});
|
||||||
const code = pickCode(parseCodes(html), link);
|
|
||||||
if (!code) {
|
const text = (await res.text()).trim();
|
||||||
return null;
|
if (text === "reload") {
|
||||||
}
|
await sleepWithSignal(650, signal);
|
||||||
|
continue;
|
||||||
for (let attempt = 1; attempt <= 60; attempt += 1) {
|
}
|
||||||
throwIfAborted(signal);
|
if (text === "false") {
|
||||||
const res = await fetch(DEBRID_AJAX_URL, {
|
return null;
|
||||||
method: "POST",
|
}
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
const parsed = parseDebridJson(text);
|
||||||
"User-Agent": "Mozilla/5.0",
|
if (!parsed) {
|
||||||
Cookie: cookie,
|
return null;
|
||||||
Referer: DEBRID_REFERER
|
}
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
if (!parsed.link) {
|
||||||
code,
|
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
|
||||||
autodl: "0"
|
await sleepWithSignal(1200, signal);
|
||||||
}),
|
continue;
|
||||||
signal: withTimeoutSignal(signal, 15000)
|
}
|
||||||
});
|
const serverMsg = (parsed.text || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||||
|
if (serverMsg && MEGA_DEBRID_NO_SERVER_RE.test(serverMsg)) {
|
||||||
const text = (await res.text()).trim();
|
throw new Error(`Mega-Web: ${serverMsg}`);
|
||||||
if (text === "reload") {
|
}
|
||||||
await sleepWithSignal(650, signal);
|
return null;
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
if (text === "false") {
|
const fromText = parsed.text
|
||||||
return null;
|
.replace(/<[^>]*>/g, " ")
|
||||||
}
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
const parsed = parseDebridJson(text);
|
|
||||||
if (!parsed) {
|
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
|
||||||
return null;
|
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
|
||||||
}
|
return {
|
||||||
|
directUrl: parsed.link,
|
||||||
if (!parsed.link) {
|
fileName
|
||||||
if (/hoster does not respond correctly|could not be done for this moment/i.test(parsed.text || "")) {
|
};
|
||||||
await sleepWithSignal(1200, signal);
|
}
|
||||||
continue;
|
|
||||||
}
|
return null;
|
||||||
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()
|
public dispose(): void {
|
||||||
// ein + pollt erneut (Retry-Sturm), was bei einem limitierten Account zwecklos
|
this.sessions.clear();
|
||||||
// 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}`);
|
export function compactMegaWebError(error: unknown): string {
|
||||||
}
|
return compactErrorText(error);
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const fromText = parsed.text
|
|
||||||
.replace(/<[^>]*>/g, " ")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
const nameMatch = fromText.match(/([\w .\-\[\]\(\)]+\.(?:rar|r\d{2}|zip|7z|mkv|mp4|avi|mp3|flac))/i);
|
|
||||||
const fileName = (nameMatch?.[1] || filenameFromUrl(link)).trim();
|
|
||||||
return {
|
|
||||||
directUrl: parsed.link,
|
|
||||||
fileName
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public dispose(): void {
|
|
||||||
this.sessions.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function compactMegaWebError(error: unknown): string {
|
|
||||||
return compactErrorText(error);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -93,7 +93,6 @@ function flushPending(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, chunk, "utf8");
|
fs.appendFileSync(logPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -123,11 +122,9 @@ async function cleanupOldPackageLogs(dir: string): Promise<void> {
|
|||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore locked/missing files
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing dir
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -224,7 +221,6 @@ export function shutdownPackageLogs(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(logPath, `=== Paket-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pendingLinesByPackage.clear();
|
pendingLinesByPackage.clear();
|
||||||
|
|||||||
@ -158,12 +158,10 @@ export class RealDebridWebFallback {
|
|||||||
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
storages: ["cookies", "indexdb", "localstorage", "serviceworkers", "cachestorage"]
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await currentSession.clearCache();
|
await currentSession.clearCache();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -320,7 +318,6 @@ export class RealDebridWebFallback {
|
|||||||
return this.rememberToken(token);
|
return this.rememberToken(token);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore window scraping errors and fall back to session fetch
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@ -330,14 +327,12 @@ export class RealDebridWebFallback {
|
|||||||
try {
|
try {
|
||||||
await this.extractApiTokenFromWindow(window);
|
await this.extractApiTokenFromWindow(window);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore best-effort token warmup failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
private async extractApiToken(signal?: AbortSignal): Promise<string | null> {
|
||||||
throwIfAborted(signal);
|
throwIfAborted(signal);
|
||||||
|
|
||||||
// Return cached token if fresh (max 30 min)
|
|
||||||
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
if (this.cachedToken && Date.now() - this.cachedTokenAt < 30 * 60 * 1000) {
|
||||||
return this.cachedToken;
|
return this.cachedToken;
|
||||||
}
|
}
|
||||||
@ -399,7 +394,6 @@ export class RealDebridWebFallback {
|
|||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
// Token expired or revoked — invalidate cache
|
|
||||||
this.cachedToken = "";
|
this.cachedToken = "";
|
||||||
this.cachedTokenAt = 0;
|
this.cachedTokenAt = 0;
|
||||||
return { kind: "login_required" };
|
return { kind: "login_required" };
|
||||||
|
|||||||
@ -82,8 +82,6 @@ async function sleepWithSignal(ms: number, signal?: AbortSignal): Promise<void>
|
|||||||
await sleep(ms);
|
await sleep(ms);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Check before entering the Promise constructor to avoid a race where the timer
|
|
||||||
// resolves before the aborted check runs (especially when ms=0).
|
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
throw new Error("aborted");
|
throw new Error("aborted");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,11 +46,9 @@ function rotateIfNeeded(filePath: string): void {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,7 +61,6 @@ function cleanupOldBackup(filePath: string): void {
|
|||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +97,6 @@ export function logRenameEvent(level: RenameLogLevel, message: string, fields?:
|
|||||||
"utf8"
|
"utf8"
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -118,7 +114,6 @@ export function shutdownRenameLog(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(renameLogPath, `=== Rename-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
renameLogPath = null;
|
renameLogPath = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,6 @@ function flushPending(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
fs.appendFileSync(sessionLogPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore write errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,11 +66,9 @@ async function cleanupOldSessionLogs(dir: string, maxAgeDays: number): Promise<v
|
|||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore - file may be locked
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore - dir may not exist
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,19 +106,16 @@ export function shutdownSessionLog(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush any pending lines
|
|
||||||
if (flushTimer) {
|
if (flushTimer) {
|
||||||
clearTimeout(flushTimer);
|
clearTimeout(flushTimer);
|
||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
flushPending();
|
flushPending();
|
||||||
|
|
||||||
// Write closing line
|
|
||||||
const isoTimestamp = logTimestamp();
|
const isoTimestamp = logTimestamp();
|
||||||
try {
|
try {
|
||||||
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
fs.appendFileSync(sessionLogPath, `=== Session beendet: ${isoTimestamp} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setLogListener(null);
|
setLogListener(null);
|
||||||
|
|||||||
@ -5,17 +5,6 @@ import { parseDebridLinkApiKeys } from "../shared/debrid-link-keys";
|
|||||||
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
import { parseMegaDebridAccounts } from "../shared/mega-debrid-accounts";
|
||||||
import { StoragePaths } from "./storage";
|
import { StoragePaths } from "./storage";
|
||||||
|
|
||||||
/** Startup Health-Check: runs once at app boot and surfaces potential problem
|
|
||||||
* states BEFORE the user hits them mid-download.
|
|
||||||
*
|
|
||||||
* Goals:
|
|
||||||
* - Warn on missing / unreachable download directory
|
|
||||||
* - Warn on low disk space (< 5 GB free)
|
|
||||||
* - Warn when no debrid provider is configured (app is effectively offline)
|
|
||||||
* - Warn when state file is suspiciously large (>50 MB → pruning recommended)
|
|
||||||
*
|
|
||||||
* Non-goals: blocking startup. The check only logs — the app continues. */
|
|
||||||
|
|
||||||
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
export type HealthCheckSeverity = "INFO" | "WARN" | "ERROR";
|
||||||
|
|
||||||
export interface HealthCheckFinding {
|
export interface HealthCheckFinding {
|
||||||
@ -32,8 +21,8 @@ export interface HealthCheckReport {
|
|||||||
infoCount: number;
|
infoCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024; // 5 GB
|
const LOW_DISK_SPACE_BYTES = 5 * 1024 * 1024 * 1024;
|
||||||
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024; // 50 MB
|
const LARGE_STATE_FILE_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
function safeExists(p: string): boolean {
|
function safeExists(p: string): boolean {
|
||||||
try {
|
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 {
|
function isWritable(dir: string): boolean {
|
||||||
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
const probe = path.join(dir, `.rddl-health-probe-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
||||||
try {
|
try {
|
||||||
@ -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 {
|
function getFreeDiskSpaceBytes(target: string): number | null {
|
||||||
try {
|
try {
|
||||||
// fs.statfsSync is available on Node 18.15+; on Windows it still maps to
|
|
||||||
// the underlying volume so it works for download dirs on any drive.
|
|
||||||
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
const statfs = (fs as unknown as { statfsSync?: (p: string) => { bavail: bigint; bsize: bigint } }).statfsSync;
|
||||||
if (typeof statfs !== "function") {
|
if (typeof statfs !== "function") {
|
||||||
return null;
|
return null;
|
||||||
@ -123,12 +105,9 @@ function countConfiguredProviders(settings: AppSettings): { count: number; provi
|
|||||||
return { count: providers.length, providers };
|
return { count: providers.length, providers };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Pure check function: takes inputs, returns findings. Kept side-effect-free
|
|
||||||
* so it's trivial to unit-test — the caller handles logging / persistence. */
|
|
||||||
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
export function runStartupHealthCheck(settings: AppSettings, storagePaths: StoragePaths): HealthCheckReport {
|
||||||
const findings: HealthCheckFinding[] = [];
|
const findings: HealthCheckFinding[] = [];
|
||||||
|
|
||||||
// ── 1. Download directory ───────────────────────────────────────────────
|
|
||||||
const outputDir = String(settings.outputDir || "").trim();
|
const outputDir = String(settings.outputDir || "").trim();
|
||||||
if (!outputDir) {
|
if (!outputDir) {
|
||||||
findings.push({
|
findings.push({
|
||||||
@ -152,7 +131,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
|||||||
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
hint: "Rechte pruefen oder anderen Ordner waehlen. Downloads werden sonst direkt scheitern."
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Check available disk space only when the directory is actually usable
|
|
||||||
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
const freeBytes = getFreeDiskSpaceBytes(outputDir);
|
||||||
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
if (freeBytes !== null && freeBytes < LOW_DISK_SPACE_BYTES) {
|
||||||
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
const freeMb = Math.round(freeBytes / (1024 * 1024));
|
||||||
@ -165,7 +143,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 2. Provider-Credentials ─────────────────────────────────────────────
|
|
||||||
const { count, providers } = countConfiguredProviders(settings);
|
const { count, providers } = countConfiguredProviders(settings);
|
||||||
if (count === 0) {
|
if (count === 0) {
|
||||||
findings.push({
|
findings.push({
|
||||||
@ -182,7 +159,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. State-File-Groesse ──────────────────────────────────────────────
|
|
||||||
if (safeExists(storagePaths.sessionFile)) {
|
if (safeExists(storagePaths.sessionFile)) {
|
||||||
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
const sizeBytes = getFileSizeBytes(storagePaths.sessionFile);
|
||||||
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
if (sizeBytes > LARGE_STATE_FILE_BYTES) {
|
||||||
@ -196,7 +172,6 @@ export function runStartupHealthCheck(settings: AppSettings, storagePaths: Stora
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 4. Storage-Basis-Verzeichnis muss beschreibbar sein (fuer Logs) ────
|
|
||||||
if (!safeExists(storagePaths.baseDir)) {
|
if (!safeExists(storagePaths.baseDir)) {
|
||||||
findings.push({
|
findings.push({
|
||||||
severity: "ERROR",
|
severity: "ERROR",
|
||||||
|
|||||||
2420
src/main/storage.ts
2420
src/main/storage.ts
File diff suppressed because it is too large
Load Diff
@ -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 {
|
function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string, maxAgeMs: number): number {
|
||||||
if (!fs.existsSync(dirPath)) {
|
if (!fs.existsSync(dirPath)) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -68,7 +67,7 @@ function addRecentDirectoryFiles(zip: AdmZip, dirPath: string, zipRoot: string,
|
|||||||
zip.addLocalFile(fullPath, zipRoot, entry.name);
|
zip.addLocalFile(fullPath, zipRoot, entry.name);
|
||||||
added += 1;
|
added += 1;
|
||||||
}
|
}
|
||||||
} catch { /* ignorieren */ }
|
} catch { }
|
||||||
}
|
}
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
@ -187,16 +186,11 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string, op
|
|||||||
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
|
||||||
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
|
||||||
|
|
||||||
// Granulare Per-Item/-Package/-Session-Logs nur der letzten 8h.
|
|
||||||
// Vorher wurden alle logs-Unterordner rekursiv gepackt → tausende Item-Logs
|
|
||||||
// → 200+ MB, unhandlich zum Verschicken. Mit 8h-Fenster bleibt das Bundle
|
|
||||||
// klein genug und enthaelt alles fuer aktuelle Fehler + Rename-Probleme.
|
|
||||||
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
const SUPPORT_BUNDLE_LOG_WINDOW_MS = 8 * 60 * 60 * 1000;
|
||||||
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
|
||||||
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
addRecentDirectoryFiles(zip, path.join(baseDir, "package-logs"), "logs/package-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||||
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
addRecentDirectoryFiles(zip, path.join(baseDir, "item-logs"), "logs/item-logs", SUPPORT_BUNDLE_LOG_WINDOW_MS);
|
||||||
|
|
||||||
// Live-Logs der aktiven Queue (aktuelle Session) immer vollstaendig mitsichern.
|
|
||||||
for (const packageId of packageIds) {
|
for (const packageId of packageIds) {
|
||||||
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
addFileIfExists(zip, manager.getPackageLogPath(packageId) || getPackageLogPath(packageId), `logs/live/package-${packageId}.txt`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -64,7 +64,6 @@ function flushPending(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
fs.appendFileSync(traceLogPath, chunk, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,11 +77,9 @@ function rotateIfNeeded(filePath: string): void {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
fs.renameSync(filePath, backup);
|
fs.renameSync(filePath, backup);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,7 +92,6 @@ function cleanupOldBackup(filePath: string): void {
|
|||||||
fs.rmSync(backup, { force: true });
|
fs.rmSync(backup, { force: true });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,7 +159,6 @@ function persistTraceConfig(): void {
|
|||||||
try {
|
try {
|
||||||
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
fs.writeFileSync(traceConfigPath, `${JSON.stringify(traceConfig, null, 2)}\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,7 +305,6 @@ export function shutdownTraceLog(): void {
|
|||||||
try {
|
try {
|
||||||
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
fs.appendFileSync(traceLogPath, `=== Trace-Log Ende: ${logTimestamp()} ===\n`, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
traceLogPath = null;
|
traceLogPath = null;
|
||||||
traceConfigPath = null;
|
traceConfigPath = null;
|
||||||
|
|||||||
@ -8,8 +8,6 @@ import { UpdateCheckResult, UpdateInstallProgress, UpdateInstallResult } from ".
|
|||||||
import { compactErrorText, humanSize } from "./utils";
|
import { compactErrorText, humanSize } from "./utils";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
// ─── Constants ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const RELEASE_FETCH_TIMEOUT_MS = 12_000;
|
const RELEASE_FETCH_TIMEOUT_MS = 12_000;
|
||||||
const CONNECT_TIMEOUT_MS = 30_000;
|
const CONNECT_TIMEOUT_MS = 30_000;
|
||||||
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
|
const DOWNLOAD_BODY_IDLE_TIMEOUT_MS = 45_000;
|
||||||
@ -18,8 +16,6 @@ const RETRY_DELAY_MS = 1_500;
|
|||||||
const MAX_DOWNLOAD_PASSES = 3;
|
const MAX_DOWNLOAD_PASSES = 3;
|
||||||
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
const USER_AGENT = `RD-Node-Downloader/${APP_VERSION}`;
|
||||||
|
|
||||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type UpdateSource = {
|
type UpdateSource = {
|
||||||
name: string;
|
name: string;
|
||||||
webBase: string;
|
webBase: string;
|
||||||
@ -40,8 +36,6 @@ type ExpectedDigest = {
|
|||||||
encoding: "hex" | "base64";
|
encoding: "hex" | "base64";
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Update Sources ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const UPDATE_SOURCES: UpdateSource[] = [
|
const UPDATE_SOURCES: UpdateSource[] = [
|
||||||
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" },
|
{ name: "git24", webBase: "https://git.24-music.de", apiBase: "https://git.24-music.de/api/v1" },
|
||||||
{ name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" },
|
{ name: "codeberg", webBase: "https://codeberg.org", apiBase: "https://codeberg.org/api/v1" },
|
||||||
@ -52,23 +46,16 @@ const PRIMARY_SOURCE = UPDATE_SOURCES[0];
|
|||||||
const WEB_BASE = PRIMARY_SOURCE.webBase;
|
const WEB_BASE = PRIMARY_SOURCE.webBase;
|
||||||
const API_BASE = PRIMARY_SOURCE.apiBase;
|
const API_BASE = PRIMARY_SOURCE.apiBase;
|
||||||
|
|
||||||
// ─── Module State ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
let activeAbortController: AbortController | null = null;
|
let activeAbortController: AbortController | null = null;
|
||||||
|
|
||||||
// ─── Progress Helper ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
|
function emitProgress(cb: UpdateProgressCallback | undefined, progress: UpdateInstallProgress): void {
|
||||||
if (!cb) return;
|
if (!cb) return;
|
||||||
try {
|
try {
|
||||||
cb(progress);
|
cb(progress);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore renderer callback errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Version Utilities ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function parseVersionParts(version: string): number[] {
|
export function parseVersionParts(version: string): number[] {
|
||||||
const cleaned = version.replace(/^v/i, "").trim();
|
const cleaned = version.replace(/^v/i, "").trim();
|
||||||
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
|
return cleaned.split(".").map((part) => Number(part.replace(/[^0-9].*$/, "") || "0"));
|
||||||
@ -87,8 +74,6 @@ export function isRemoteNewer(currentVersion: string, latestVersion: string): bo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Repository Normalization ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function isValidRepoPart(value: string): boolean {
|
function isValidRepoPart(value: string): boolean {
|
||||||
const part = String(value || "").trim();
|
const part = String(value || "").trim();
|
||||||
if (!part || part === "." || part === ".." || part.includes("..")) return false;
|
if (!part || part === "." || part === ".." || part.includes("..")) return false;
|
||||||
@ -127,15 +112,12 @@ export function normalizeUpdateRepo(repo: string): string {
|
|||||||
if (result) return result;
|
if (result) return result;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// not a URL, try as plain text
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = extractOwnerRepo(raw);
|
const result = extractOwnerRepo(raw);
|
||||||
return result || DEFAULT_UPDATE_REPO;
|
return result || DEFAULT_UPDATE_REPO;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Network Utilities ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } {
|
function timeoutController(ms: number): { signal: AbortSignal; clear: () => void } {
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms);
|
const timer = setTimeout(() => ctrl.abort(new Error(`timeout:${ms}`)), ms);
|
||||||
@ -190,25 +172,18 @@ function getBodyIdleTimeout(): number {
|
|||||||
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
|
return DOWNLOAD_BODY_IDLE_TIMEOUT_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Digest Parsing & Verification ─────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// SHA-256 = 32 bytes → hex: 64 chars, base64: 43-44 chars (+ up to 1 padding =)
|
|
||||||
// SHA-512 = 64 bytes → hex: 128 chars, base64: 86-88 chars (+ up to 2 padding =)
|
|
||||||
|
|
||||||
function normalizeBase64(raw: string): string {
|
function normalizeBase64(raw: string): string {
|
||||||
return String(raw || "")
|
return String(raw || "")
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/-/g, "+") // URL-safe → standard
|
.replace(/-/g, "+")
|
||||||
.replace(/_/g, "/") // URL-safe → standard
|
.replace(/_/g, "/")
|
||||||
.replace(/=+$/g, ""); // strip padding for consistent comparison
|
.replace(/=+$/g, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
||||||
const text = String(raw || "").trim();
|
const text = String(raw || "").trim();
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
||||||
// ── Prefixed: sha256:<value> ──
|
|
||||||
|
|
||||||
const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
|
const pre256hex = text.match(/^sha256:([a-fA-F0-9]{64})$/i);
|
||||||
if (pre256hex) {
|
if (pre256hex) {
|
||||||
return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" };
|
return { algorithm: "sha256", digest: pre256hex[1].toLowerCase(), encoding: "hex" };
|
||||||
@ -219,8 +194,6 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
|||||||
return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" };
|
return { algorithm: "sha256", digest: normalizeBase64(pre256b64[1]), encoding: "base64" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Prefixed: sha512:<value> ──
|
|
||||||
|
|
||||||
const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
|
const pre512hex = text.match(/^sha512:([a-fA-F0-9]{128})$/i);
|
||||||
if (pre512hex) {
|
if (pre512hex) {
|
||||||
return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" };
|
return { algorithm: "sha512", digest: pre512hex[1].toLowerCase(), encoding: "hex" };
|
||||||
@ -231,8 +204,6 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
|||||||
return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" };
|
return { algorithm: "sha512", digest: normalizeBase64(pre512b64[1]), encoding: "base64" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plain hex ──
|
|
||||||
|
|
||||||
if (/^[a-fA-F0-9]{64}$/.test(text)) {
|
if (/^[a-fA-F0-9]{64}$/.test(text)) {
|
||||||
return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" };
|
return { algorithm: "sha256", digest: text.toLowerCase(), encoding: "hex" };
|
||||||
}
|
}
|
||||||
@ -240,8 +211,6 @@ export function parseExpectedDigest(raw: string): ExpectedDigest | null {
|
|||||||
return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" };
|
return { algorithm: "sha512", digest: text.toLowerCase(), encoding: "hex" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Plain base64 (SHA-512 first since it's longer → won't accidentally match SHA-256) ──
|
|
||||||
|
|
||||||
const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
const plain512b64 = text.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
||||||
if (plain512b64) {
|
if (plain512b64) {
|
||||||
return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" };
|
return { algorithm: "sha512", digest: normalizeBase64(plain512b64[1]), encoding: "base64" };
|
||||||
@ -271,8 +240,6 @@ async function hashFile(filePath: string, algorithm: "sha256" | "sha512", encodi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── latest.yml Parsing ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function normalizeNameForMatch(value: string): string {
|
function normalizeNameForMatch(value: string): string {
|
||||||
const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || "";
|
const name = String(value || "").trim().split(/[\\/]/g).filter(Boolean).pop() || "";
|
||||||
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||||
@ -284,10 +251,8 @@ function stripYamlQuotes(raw: string): string {
|
|||||||
|
|
||||||
function extractSha512Value(raw: string): string {
|
function extractSha512Value(raw: string): string {
|
||||||
const stripped = stripYamlQuotes(raw);
|
const stripped = stripYamlQuotes(raw);
|
||||||
// Base64 SHA-512: 86-88 chars + optional padding
|
|
||||||
const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
const b64 = stripped.match(/^([A-Za-z0-9+/_-]{86,88}={0,2})$/);
|
||||||
if (b64) return b64[1];
|
if (b64) return b64[1];
|
||||||
// Hex SHA-512: exactly 128 hex chars
|
|
||||||
const hex = stripped.match(/^([a-fA-F0-9]{128})$/);
|
const hex = stripped.match(/^([a-fA-F0-9]{128})$/);
|
||||||
if (hex) return hex[1];
|
if (hex) return hex[1];
|
||||||
return "";
|
return "";
|
||||||
@ -305,28 +270,24 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
|
|||||||
for (const rawLine of lines) {
|
for (const rawLine of lines) {
|
||||||
const line = String(rawLine);
|
const line = String(rawLine);
|
||||||
|
|
||||||
// File entry URL (inside files: array)
|
|
||||||
const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
|
const fileUrlItem = line.match(/^\s*-\s*url\s*:\s*(.+)\s*$/i);
|
||||||
if (fileUrlItem?.[1]) {
|
if (fileUrlItem?.[1]) {
|
||||||
currentFileUrl = stripYamlQuotes(fileUrlItem[1]);
|
currentFileUrl = stripYamlQuotes(fileUrlItem[1]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level or non-array URL
|
|
||||||
const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
|
const urlMatch = line.match(/^\s*url\s*:\s*(.+)\s*$/i);
|
||||||
if (urlMatch?.[1]) {
|
if (urlMatch?.[1]) {
|
||||||
currentFileUrl = stripYamlQuotes(urlMatch[1]);
|
currentFileUrl = stripYamlQuotes(urlMatch[1]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level path
|
|
||||||
const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
|
const pathMatch = line.match(/^\s*path\s*:\s*(.+)\s*$/i);
|
||||||
if (pathMatch?.[1]) {
|
if (pathMatch?.[1]) {
|
||||||
topLevelPath = stripYamlQuotes(pathMatch[1]);
|
topLevelPath = stripYamlQuotes(pathMatch[1]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SHA-512 value (handles quoted and unquoted)
|
|
||||||
const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i);
|
const shaMatch = line.match(/^\s*sha512\s*:\s*(.+)\s*$/i);
|
||||||
if (!shaMatch?.[1]) continue;
|
if (!shaMatch?.[1]) continue;
|
||||||
|
|
||||||
@ -345,7 +306,6 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
|
|||||||
if (!topLevelSha) topLevelSha = sha;
|
if (!topLevelSha) topLevelSha = sha;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try matching via top-level path
|
|
||||||
if (target && topLevelPath && topLevelSha) {
|
if (target && topLevelPath && topLevelSha) {
|
||||||
if (normalizeNameForMatch(topLevelPath) === target) {
|
if (normalizeNameForMatch(topLevelPath) === target) {
|
||||||
return topLevelSha;
|
return topLevelSha;
|
||||||
@ -355,8 +315,6 @@ function parseSha512FromLatestYml(content: string, setupAssetName: string): stri
|
|||||||
return topLevelSha || firstFileSha || "";
|
return topLevelSha || firstFileSha || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Installer Verification ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function verifyBinaryShape(filePath: string): Promise<void> {
|
async function verifyBinaryShape(filePath: string): Promise<void> {
|
||||||
const stats = await fs.promises.stat(filePath);
|
const stats = await fs.promises.stat(filePath);
|
||||||
if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) {
|
if (!Number.isFinite(stats.size) || stats.size < 128 * 1024) {
|
||||||
@ -402,8 +360,6 @@ async function verifyDownloadedInstaller(filePath: string, digestRaw: string): P
|
|||||||
logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`);
|
logger.info(`${expected.algorithm.toUpperCase()} Integrität bestätigt`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Release API ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function fetchRelease(repo: string, endpoint: string): Promise<{
|
async function fetchRelease(repo: string, endpoint: string): Promise<{
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
status: number;
|
status: number;
|
||||||
@ -475,8 +431,6 @@ function parseReleasePayload(payload: Record<string, unknown>, fallbackUrl: stri
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Download Candidates ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function uniqueStrings(values: string[]): string[] {
|
function uniqueStrings(values: string[]): string[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
@ -555,8 +509,6 @@ function deriveFileName(check: UpdateCheckResult, url: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Error Classification ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function httpStatusFromError(error: unknown): number {
|
function httpStatusFromError(error: unknown): number {
|
||||||
const match = String(error || "").match(/HTTP\s+(\d{3})/i);
|
const match = String(error || "").match(/HTTP\s+(\d{3})/i);
|
||||||
return match ? Number(match[1]) : 0;
|
return match ? Number(match[1]) : 0;
|
||||||
@ -585,8 +537,6 @@ function isIntegrityError(error: unknown): boolean {
|
|||||||
return text.includes("integrit") || text.includes("mismatch");
|
return text.includes("integrit") || text.includes("mismatch");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Sleep ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||||
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
if (!signal) return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
if (signal.aborted) throw new Error("aborted:update_shutdown");
|
if (signal.aborted) throw new Error("aborted:update_shutdown");
|
||||||
@ -611,8 +561,6 @@ async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Download Engine ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function downloadFile(
|
async function downloadFile(
|
||||||
url: string,
|
url: string,
|
||||||
targetPath: string,
|
targetPath: string,
|
||||||
@ -623,7 +571,6 @@ async function downloadFile(
|
|||||||
|
|
||||||
logger.info(`Update-Download versucht: ${url}`);
|
logger.info(`Update-Download versucht: ${url}`);
|
||||||
|
|
||||||
// Connect with timeout
|
|
||||||
const tc = timeoutController(CONNECT_TIMEOUT_MS);
|
const tc = timeoutController(CONNECT_TIMEOUT_MS);
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
@ -640,11 +587,9 @@ async function downloadFile(
|
|||||||
throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
|
throw new Error(`Update Download fehlgeschlagen (HTTP ${response.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse content-length
|
|
||||||
const clRaw = Number(response.headers.get("content-length") || NaN);
|
const clRaw = Number(response.headers.get("content-length") || NaN);
|
||||||
const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null;
|
const totalBytes = Number.isFinite(clRaw) && clRaw > 0 ? Math.max(0, Math.floor(clRaw)) : null;
|
||||||
|
|
||||||
// Progress tracking
|
|
||||||
let downloadedBytes = 0;
|
let downloadedBytes = 0;
|
||||||
let lastProgressAt = 0;
|
let lastProgressAt = 0;
|
||||||
|
|
||||||
@ -663,13 +608,11 @@ async function downloadFile(
|
|||||||
|
|
||||||
reportProgress(true);
|
reportProgress(true);
|
||||||
|
|
||||||
// Prepare filesystem
|
|
||||||
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
const tempPath = `${targetPath}.tmp`;
|
const tempPath = `${targetPath}.tmp`;
|
||||||
const writeStream = fs.createWriteStream(tempPath);
|
const writeStream = fs.createWriteStream(tempPath);
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
|
|
||||||
// Idle timeout tracking
|
|
||||||
const idleMs = getBodyIdleTimeout();
|
const idleMs = getBodyIdleTimeout();
|
||||||
let idleTimer: NodeJS.Timeout | null = null;
|
let idleTimer: NodeJS.Timeout | null = null;
|
||||||
let idleTimedOut = false;
|
let idleTimedOut = false;
|
||||||
@ -691,7 +634,6 @@ async function downloadFile(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Stream body to disk
|
|
||||||
try {
|
try {
|
||||||
resetIdle();
|
resetIdle();
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@ -721,25 +663,21 @@ async function downloadFile(
|
|||||||
clearIdle();
|
clearIdle();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush and close write stream
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
writeStream.end(() => resolve());
|
writeStream.end(() => resolve());
|
||||||
writeStream.on("error", reject);
|
writeStream.on("error", reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle idle timeout on clean reader exit
|
|
||||||
if (idleTimedOut) {
|
if (idleTimedOut) {
|
||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||||
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
|
throw new Error(`Update Download Body Timeout nach ${Math.ceil(idleMs / 1000)}s`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify completeness
|
|
||||||
if (totalBytes && downloadedBytes !== totalBytes) {
|
if (totalBytes && downloadedBytes !== totalBytes) {
|
||||||
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
await fs.promises.rm(tempPath, { force: true }).catch(() => {});
|
||||||
throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
|
throw new Error(`Update Download unvollständig (${downloadedBytes} / ${totalBytes} Bytes)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Atomic rename temp → final
|
|
||||||
await fs.promises.rename(tempPath, targetPath);
|
await fs.promises.rename(tempPath, targetPath);
|
||||||
reportProgress(true);
|
reportProgress(true);
|
||||||
logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`);
|
logger.info(`Update-Download abgeschlossen: ${targetPath} (${downloadedBytes} Bytes)`);
|
||||||
@ -803,8 +741,6 @@ async function downloadFromCandidates(
|
|||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Asset Resolution Helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function resolveAssetFromApi(repo: string, tag: string): Promise<{
|
async function resolveAssetFromApi(repo: string, tag: string): Promise<{
|
||||||
setupAssetUrl: string;
|
setupAssetUrl: string;
|
||||||
setupAssetName: string;
|
setupAssetName: string;
|
||||||
@ -827,7 +763,6 @@ async function resolveAssetFromApi(repo: string, tag: string): Promise<{
|
|||||||
setupAssetDigest: setup.digest,
|
setupAssetDigest: setup.digest,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// try next endpoint
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@ -863,14 +798,11 @@ async function resolveDigestFromYml(repo: string, tag: string, setupName: string
|
|||||||
const sha = parseSha512FromLatestYml(yamlText, setupName);
|
const sha = parseSha512FromLatestYml(yamlText, setupName);
|
||||||
if (sha) return `sha512:${sha}`;
|
if (sha) return `sha512:${sha}`;
|
||||||
} catch {
|
} catch {
|
||||||
// try next endpoint
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Public API ────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function buildInstallerLaunchArgs(): string[] {
|
export function buildInstallerLaunchArgs(): string[] {
|
||||||
return ["/S", "--updated", "--force-run"];
|
return ["/S", "--updated", "--force-run"];
|
||||||
}
|
}
|
||||||
@ -903,7 +835,6 @@ export async function installLatestUpdate(
|
|||||||
prechecked?: UpdateCheckResult,
|
prechecked?: UpdateCheckResult,
|
||||||
onProgress?: UpdateProgressCallback,
|
onProgress?: UpdateProgressCallback,
|
||||||
): Promise<UpdateInstallResult> {
|
): Promise<UpdateInstallResult> {
|
||||||
// Prevent concurrent updates
|
|
||||||
if (activeAbortController && !activeAbortController.signal.aborted) {
|
if (activeAbortController && !activeAbortController.signal.aborted) {
|
||||||
emitProgress(onProgress, {
|
emitProgress(onProgress, {
|
||||||
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
|
stage: "error", percent: null, downloadedBytes: 0, totalBytes: null,
|
||||||
@ -916,7 +847,6 @@ export async function installLatestUpdate(
|
|||||||
activeAbortController = abortCtrl;
|
activeAbortController = abortCtrl;
|
||||||
const safeRepo = normalizeUpdateRepo(repo);
|
const safeRepo = normalizeUpdateRepo(repo);
|
||||||
|
|
||||||
// Resolve update check
|
|
||||||
const check = prechecked && !prechecked.error
|
const check = prechecked && !prechecked.error
|
||||||
? prechecked
|
? prechecked
|
||||||
: await checkGitHubUpdate(safeRepo);
|
: await checkGitHubUpdate(safeRepo);
|
||||||
@ -939,7 +869,6 @@ export async function installLatestUpdate(
|
|||||||
return { started: false, message: "Kein neues Update verfügbar" };
|
return { started: false, message: "Kein neues Update verfügbar" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mutable effective state for enrichment
|
|
||||||
let effective: UpdateCheckResult = {
|
let effective: UpdateCheckResult = {
|
||||||
...check,
|
...check,
|
||||||
setupAssetUrl: String(check.setupAssetUrl || ""),
|
setupAssetUrl: String(check.setupAssetUrl || ""),
|
||||||
@ -947,7 +876,6 @@ export async function installLatestUpdate(
|
|||||||
setupAssetDigest: String(check.setupAssetDigest || ""),
|
setupAssetDigest: String(check.setupAssetDigest || ""),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Enrich: resolve asset from API if needed
|
|
||||||
if (!effective.setupAssetUrl || !effective.setupAssetDigest) {
|
if (!effective.setupAssetUrl || !effective.setupAssetDigest) {
|
||||||
const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
|
const refreshed = await resolveAssetFromApi(safeRepo, effective.latestTag);
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
@ -960,7 +888,6 @@ export async function installLatestUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich: resolve digest from latest.yml if still missing
|
|
||||||
if (!effective.setupAssetDigest && effective.setupAssetUrl) {
|
if (!effective.setupAssetDigest && effective.setupAssetUrl) {
|
||||||
const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
|
const digest = await resolveDigestFromYml(safeRepo, effective.latestTag, effective.setupAssetName || "");
|
||||||
if (digest) {
|
if (digest) {
|
||||||
@ -969,7 +896,6 @@ export async function installLatestUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build download candidates
|
|
||||||
let candidates = buildCandidates(safeRepo, effective);
|
let candidates = buildCandidates(safeRepo, effective);
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
activeAbortController = null;
|
activeAbortController = null;
|
||||||
@ -991,7 +917,6 @@ export async function installLatestUpdate(
|
|||||||
|
|
||||||
if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");
|
if (abortCtrl.signal.aborted) throw new Error("aborted:update_shutdown");
|
||||||
|
|
||||||
// ── Download + verify with retry passes ──
|
|
||||||
let verified = false;
|
let verified = false;
|
||||||
let lastVerifyError: unknown = null;
|
let lastVerifyError: unknown = null;
|
||||||
let integrityError: unknown = null;
|
let integrityError: unknown = null;
|
||||||
@ -1030,7 +955,6 @@ export async function installLatestUpdate(
|
|||||||
|
|
||||||
if (verified) break;
|
if (verified) break;
|
||||||
|
|
||||||
// Refresh candidates on 404 or integrity mismatch
|
|
||||||
const status = httpStatusFromError(lastVerifyError);
|
const status = httpStatusFromError(lastVerifyError);
|
||||||
const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null);
|
const shouldRefresh = pass < MAX_DOWNLOAD_PASSES - 1 && (status === 404 || integrityError !== null);
|
||||||
if (!shouldRefresh) break;
|
if (!shouldRefresh) break;
|
||||||
@ -1073,7 +997,6 @@ export async function installLatestUpdate(
|
|||||||
throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen");
|
throw integrityError || lastVerifyError || new Error("Update-Download fehlgeschlagen");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Launch installer ──
|
|
||||||
emitProgress(onProgress, {
|
emitProgress(onProgress, {
|
||||||
stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null,
|
stage: "launching", percent: 100, downloadedBytes: 0, totalBytes: null,
|
||||||
message: "Starte stille Update-Installation",
|
message: "Starte stille Update-Installation",
|
||||||
@ -1099,7 +1022,6 @@ export async function installLatestUpdate(
|
|||||||
try {
|
try {
|
||||||
await fs.promises.rm(targetPath, { force: true });
|
await fs.promises.rm(targetPath, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
const releaseUrl = String(effective.releaseUrl || "").trim();
|
const releaseUrl = String(effective.releaseUrl || "").trim();
|
||||||
const hint = releaseUrl ? ` – Manuell: ${releaseUrl}` : "";
|
const hint = releaseUrl ? ` – Manuell: ${releaseUrl}` : "";
|
||||||
|
|||||||
@ -319,7 +319,6 @@ export function hasRecentWindowsMinidumps(): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -1,114 +1,114 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
import {
|
import {
|
||||||
AddLinksPayload,
|
AddLinksPayload,
|
||||||
AllDebridHostInfo,
|
AllDebridHostInfo,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
DebridAccountStatus,
|
DebridAccountStatus,
|
||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
UpdateInstallProgress
|
UpdateInstallProgress
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
import { ElectronApi } from "../shared/preload-api";
|
import { ElectronApi } from "../shared/preload-api";
|
||||||
|
|
||||||
const api: ElectronApi = {
|
const api: ElectronApi = {
|
||||||
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
getSnapshot: (): Promise<UiSnapshot> => ipcRenderer.invoke(IPC_CHANNELS.GET_SNAPSHOT),
|
||||||
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
getVersion: (): Promise<string> => ipcRenderer.invoke(IPC_CHANNELS.GET_VERSION),
|
||||||
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
|
checkUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_UPDATES),
|
||||||
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
installUpdate: () => ipcRenderer.invoke(IPC_CHANNELS.INSTALL_UPDATE),
|
||||||
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_EXTERNAL, url),
|
||||||
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
updateSettings: (settings: Partial<AppSettings>): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_SETTINGS, settings),
|
||||||
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
|
resetProviderDailyUsage: (provider: DebridProvider): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PROVIDER_DAILY_USAGE, provider),
|
||||||
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
|
resetDebridLinkApiKeyDailyUsage: (keyId: string): Promise<AppSettings> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DEBRID_LINK_API_KEY_DAILY_USAGE, keyId),
|
||||||
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
addLinks: (payload: AddLinksPayload): Promise<{ addedPackages: number; addedLinks: number; invalidCount: number }> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
ipcRenderer.invoke(IPC_CHANNELS.ADD_LINKS, payload),
|
||||||
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
addContainers: (filePaths: string[]): Promise<{ addedPackages: number; addedLinks: number }> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
|
ipcRenderer.invoke(IPC_CHANNELS.ADD_CONTAINERS, filePaths),
|
||||||
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
|
getStartConflicts: (): Promise<StartConflictEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_START_CONFLICTS),
|
||||||
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
|
resolveStartConflict: (packageId: string, policy: DuplicatePolicy): Promise<StartConflictResolutionResult> =>
|
||||||
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
|
ipcRenderer.invoke(IPC_CHANNELS.RESOLVE_START_CONFLICT, packageId, policy),
|
||||||
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
|
clearAll: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_ALL),
|
||||||
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
|
start: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START),
|
||||||
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
|
startPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_PACKAGES, packageIds),
|
||||||
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
stop: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.STOP),
|
||||||
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
togglePause: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PAUSE),
|
||||||
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
cancelPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CANCEL_PACKAGE, packageId),
|
||||||
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
|
renamePackage: (packageId: string, newName: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RENAME_PACKAGE, packageId, newName),
|
||||||
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
reorderPackages: (packageIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REORDER_PACKAGES, packageIds),
|
||||||
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
removeItem: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_ITEM, itemId),
|
||||||
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
|
togglePackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_PACKAGE, packageId),
|
||||||
exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds),
|
exportPackageSelection: (packageIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_PACKAGE_SELECTION, packageIds),
|
||||||
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
|
exportItemSelection: (itemIds: string[]) => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_ITEM_SELECTION, itemIds),
|
||||||
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
|
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
|
||||||
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
|
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
|
||||||
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||||
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
resetSessionStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_SESSION_STATS),
|
||||||
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
resetDownloadStats: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_DOWNLOAD_STATS),
|
||||||
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
restart: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESTART),
|
||||||
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
quit: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.QUIT),
|
||||||
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
exportBackup: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_BACKUP),
|
||||||
importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
importBackup: (): Promise<{ restored: boolean; message: string }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BACKUP),
|
||||||
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
exportSupportBundle: (): Promise<{ saved: boolean; filePath?: string }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_SUPPORT_BUNDLE),
|
||||||
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
openLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_LOG),
|
||||||
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
openAuditLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_AUDIT_LOG),
|
||||||
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
openRenameLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_RENAME_LOG),
|
||||||
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
openSessionLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_SESSION_LOG),
|
||||||
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
|
||||||
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
|
||||||
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
|
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
|
||||||
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
|
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
|
||||||
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
||||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
||||||
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
||||||
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||||
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
getAllDebridHostInfo: (): Promise<AllDebridHostInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_ALLDEBRID_HOST_INFO),
|
||||||
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
getDebridLinkHostLimits: (): Promise<DebridLinkHostLimitInfo[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBRIDLINK_HOST_LIMITS),
|
||||||
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
|
checkDebridAccounts: (): Promise<DebridAccountStatus[]> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_DEBRID_ACCOUNTS),
|
||||||
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
|
checkMegaDebridAccount: (login: string, password: string): Promise<DebridAccountStatus | null> => ipcRenderer.invoke(IPC_CHANNELS.CHECK_MEGA_DEBRID_ACCOUNT, login, password),
|
||||||
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
retryExtraction: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RETRY_EXTRACTION, packageId),
|
||||||
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
extractNow: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.EXTRACT_NOW, packageId),
|
||||||
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
resetPackage: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_PACKAGE, packageId),
|
||||||
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
getHistory: (): Promise<HistoryEntry[]> => ipcRenderer.invoke(IPC_CHANNELS.GET_HISTORY),
|
||||||
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
clearHistory: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.CLEAR_HISTORY),
|
||||||
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
|
removeHistoryEntry: (entryId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.REMOVE_HISTORY_ENTRY, entryId),
|
||||||
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
|
setPackagePriority: (packageId: string, priority: PackagePriority): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SET_PACKAGE_PRIORITY, packageId, priority),
|
||||||
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
|
skipItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.SKIP_ITEMS, itemIds),
|
||||||
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
|
resetItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.RESET_ITEMS, itemIds),
|
||||||
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
|
startItems: (itemIds: string[]): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.START_ITEMS, itemIds),
|
||||||
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
onStateUpdate: (callback: (snapshot: UiSnapshot) => void): (() => void) => {
|
||||||
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
const listener = (_event: unknown, snapshot: UiSnapshot): void => callback(snapshot);
|
||||||
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
ipcRenderer.on(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
|
ipcRenderer.removeListener(IPC_CHANNELS.STATE_UPDATE, listener);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
|
onClipboardDetected: (callback: (links: string[]) => void): (() => void) => {
|
||||||
const listener = (_event: unknown, links: string[]): void => callback(links);
|
const listener = (_event: unknown, links: string[]): void => callback(links);
|
||||||
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
ipcRenderer.on(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
ipcRenderer.removeListener(IPC_CHANNELS.CLIPBOARD_DETECTED, listener);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
|
onUpdateInstallProgress: (callback: (progress: UpdateInstallProgress) => void): (() => void) => {
|
||||||
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
|
const listener = (_event: unknown, progress: UpdateInstallProgress): void => callback(progress);
|
||||||
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
ipcRenderer.on(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||||
return () => {
|
return () => {
|
||||||
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
ipcRenderer.removeListener(IPC_CHANNELS.UPDATE_INSTALL_PROGRESS, listener);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("rd", api);
|
contextBridge.exposeInMainWorld("rd", api);
|
||||||
|
|||||||
@ -115,8 +115,6 @@ interface AccountDialogState {
|
|||||||
megaAccounts: MegaDialogAccount[];
|
megaAccounts: MegaDialogAccount[];
|
||||||
megaNewLogin: string;
|
megaNewLogin: string;
|
||||||
megaNewPassword: string;
|
megaNewPassword: string;
|
||||||
// IDs der im Bearbeiten-Dialog (temporär) deaktivierten Mega-Debrid-Accounts.
|
|
||||||
// Draft-State: wird erst beim Speichern in settings.megaDebridDisabledAccountIds übernommen.
|
|
||||||
megaDisabledIds: string[];
|
megaDisabledIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -467,17 +465,12 @@ function getActiveProvidersFromSettings(settings: AppSettings): DebridProvider[]
|
|||||||
return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p));
|
return getConfiguredProvidersFromSettings(settings).filter((p) => !disabled.has(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Leitet die aktive Provider-Reihenfolge aus providerOrder ab,
|
|
||||||
// gefiltert auf tatsächlich konfigurierte und nicht deaktivierte Provider.
|
|
||||||
// Direkt-Hoster (onefichier, ddownload) werden ausgeschlossen.
|
|
||||||
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
|
const DIRECT_HOSTERS: ReadonlySet<DebridProvider> = new Set(["onefichier", "ddownload"]);
|
||||||
|
|
||||||
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
|
function normalizeProviderOrderForSettings(settings: AppSettings): DebridProvider[] {
|
||||||
const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p)));
|
const active = new Set(getActiveProvidersFromSettings(settings).filter((p) => !DIRECT_HOSTERS.has(p)));
|
||||||
// Behalte bestehende Reihenfolge aus providerOrder, filtere nicht-konfigurierte heraus
|
|
||||||
const ordered = (settings.providerOrder || []).filter((p) => active.has(p));
|
const ordered = (settings.providerOrder || []).filter((p) => active.has(p));
|
||||||
const inOrder = new Set(ordered);
|
const inOrder = new Set(ordered);
|
||||||
// Füge neue Provider hinten an, die noch nicht in der Reihenfolge sind
|
|
||||||
for (const p of active) {
|
for (const p of active) {
|
||||||
if (!inOrder.has(p)) ordered.push(p);
|
if (!inOrder.has(p)) ordered.push(p);
|
||||||
}
|
}
|
||||||
@ -609,7 +602,6 @@ function createAccountDialogState(mode: "create" | "edit", kind: AccountKind | n
|
|||||||
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
return { mode, kind, token: "", login: "", password: "", dailyLimitGb, keyDailyLimitGbById: {}, ...baseMega };
|
||||||
case "megadebrid-api":
|
case "megadebrid-api":
|
||||||
case "megadebrid-web": {
|
case "megadebrid-web": {
|
||||||
// Populate megaAccounts from megaCredentials, or build from legacy megaLogin/megaPassword
|
|
||||||
let megaToken = (settings.megaCredentials || "").trim();
|
let megaToken = (settings.megaCredentials || "").trim();
|
||||||
if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) {
|
if (!megaToken && settings.megaLogin.trim() && settings.megaPassword.trim()) {
|
||||||
megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`;
|
megaToken = `${settings.megaLogin.trim()}:${settings.megaPassword.trim()}`;
|
||||||
@ -1288,7 +1280,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
maxSpeed = Math.max(maxSpeed, 1024 * 1024);
|
maxSpeed = Math.max(maxSpeed, 1024 * 1024);
|
||||||
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
|
const niceMax = Math.pow(2, Math.ceil(Math.log2(maxSpeed)));
|
||||||
|
|
||||||
// Measure widest label to set dynamic left padding
|
|
||||||
ctx.font = "11px 'Manrope', sans-serif";
|
ctx.font = "11px 'Manrope', sans-serif";
|
||||||
let maxLabelWidth = 0;
|
let maxLabelWidth = 0;
|
||||||
for (let i = 0; i <= 5; i += 1) {
|
for (let i = 0; i <= 5; i += 1) {
|
||||||
@ -1371,12 +1362,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
}, [running, paused]);
|
}, [running, paused]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Always draw once on mount / when running/paused state changes so the
|
|
||||||
// chart shows the latest history.
|
|
||||||
drawChart();
|
drawChart();
|
||||||
// Only schedule periodic redraws while actively downloading — when
|
|
||||||
// stopped or paused the speed history doesn't change, so polling
|
|
||||||
// every 250ms would just burn CPU on the renderer process.
|
|
||||||
if (!running || paused) {
|
if (!running || paused) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1387,7 +1373,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
}, [drawChart, running, paused]);
|
}, [drawChart, running, paused]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only record samples while the session is running and not paused
|
|
||||||
if (!running || paused) return;
|
if (!running || paused) return;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@ -1443,7 +1428,6 @@ function createScheduleId(): string {
|
|||||||
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
return `schedule-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
|
function sortPackageOrderBySize(order: string[], packages: Record<string, PackageEntry>, items: Record<string, DownloadItem>, descending: boolean): string[] {
|
||||||
const sorted = [...order];
|
const sorted = [...order];
|
||||||
sorted.sort((a, b) => {
|
sorted.sort((a, b) => {
|
||||||
@ -1578,10 +1562,6 @@ export function App(): ReactElement {
|
|||||||
const settingsDraftRevisionRef = useRef(0);
|
const settingsDraftRevisionRef = useRef(0);
|
||||||
const panelDirtyRevisionRef = useRef(0);
|
const panelDirtyRevisionRef = useRef(0);
|
||||||
const latestStateRef = useRef<UiSnapshot | null>(null);
|
const latestStateRef = useRef<UiSnapshot | null>(null);
|
||||||
// Master state used to apply incoming delta payloads. The wire format from
|
|
||||||
// the main process sends only changed items/packages (with payloadKind="delta")
|
|
||||||
// most of the time and a full snapshot every 30s for safety. Without this
|
|
||||||
// master, we'd only see the changed slice each emit.
|
|
||||||
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
const masterSnapshotRef = useRef<UiSnapshot | null>(null);
|
||||||
const snapshotRef = useRef(snapshot);
|
const snapshotRef = useRef(snapshot);
|
||||||
snapshotRef.current = snapshot;
|
snapshotRef.current = snapshot;
|
||||||
@ -1616,7 +1596,6 @@ export function App(): ReactElement {
|
|||||||
const [showAllPackages, setShowAllPackages] = useState(false);
|
const [showAllPackages, setShowAllPackages] = useState(false);
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
const [accountCheckBusy, setAccountCheckBusy] = useState(false);
|
||||||
// Account-IDs, die gerade beim Hinzufügen einzeln geprüft werden (Mega-Debrid).
|
|
||||||
const [megaCheckingIds, setMegaCheckingIds] = useState<Set<string>>(() => new Set());
|
const [megaCheckingIds, setMegaCheckingIds] = useState<Set<string>>(() => new Set());
|
||||||
const actionBusyRef = useRef(false);
|
const actionBusyRef = useRef(false);
|
||||||
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const actionUnlockTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@ -1691,7 +1670,6 @@ export function App(): ReactElement {
|
|||||||
window.addEventListener("mouseup", stopAccountColumnResize);
|
window.addEventListener("mouseup", stopAccountColumnResize);
|
||||||
}, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]);
|
}, [accountColumnWidths, onAccountColumnResizeMove, stopAccountColumnResize]);
|
||||||
|
|
||||||
// Load history when tab changes to history
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tab !== "history") return;
|
if (tab !== "history") return;
|
||||||
const loadHistory = async (): Promise<void> => {
|
const loadHistory = async (): Promise<void> => {
|
||||||
@ -1713,7 +1691,6 @@ export function App(): ReactElement {
|
|||||||
try {
|
try {
|
||||||
window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths));
|
window.localStorage.setItem(ACCOUNT_COLUMN_STORAGE_KEY, JSON.stringify(accountColumnWidths));
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore local persistence failures for optional UI state.
|
|
||||||
}
|
}
|
||||||
}, [accountColumnWidths]);
|
}, [accountColumnWidths]);
|
||||||
|
|
||||||
@ -1722,16 +1699,10 @@ export function App(): ReactElement {
|
|||||||
try {
|
try {
|
||||||
window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY);
|
window.localStorage.removeItem(ACCOUNT_COLUMN_STORAGE_KEY);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore local persistence failures for optional UI state.
|
|
||||||
}
|
}
|
||||||
showToast("Accounts-Spalten zurückgesetzt", 1800);
|
showToast("Accounts-Spalten zurückgesetzt", 1800);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync column order from settings. Avoid JSON.stringify on every render
|
|
||||||
// (which was a 7-element array stringify per snapshot tick). A simple
|
|
||||||
// join() is one O(n) string concat without Object/Array allocation overhead,
|
|
||||||
// and useMemo caches the resulting key so React only sees a new dep when the
|
|
||||||
// contents actually changed.
|
|
||||||
const columnOrderKey = useMemo(
|
const columnOrderKey = useMemo(
|
||||||
() => (snapshot.settings.columnOrder || []).join("|"),
|
() => (snapshot.settings.columnOrder || []).join("|"),
|
||||||
[snapshot.settings.columnOrder]
|
[snapshot.settings.columnOrder]
|
||||||
@ -1741,7 +1712,6 @@ export function App(): ReactElement {
|
|||||||
if (order && order.length > 0) {
|
if (order && order.length > 0) {
|
||||||
setColumnOrder(order);
|
setColumnOrder(order);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [columnOrderKey]);
|
}, [columnOrderKey]);
|
||||||
|
|
||||||
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
const currentCollectorTab = collectorTabs.find((t) => t.id === activeCollectorTab) ?? collectorTabs[0];
|
||||||
@ -1917,7 +1887,6 @@ export function App(): ReactElement {
|
|||||||
if (!mountedRef.current) {
|
if (!mountedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Seed the master snapshot — incoming delta payloads will merge into this.
|
|
||||||
masterSnapshotRef.current = state;
|
masterSnapshotRef.current = state;
|
||||||
setSnapshot(state);
|
setSnapshot(state);
|
||||||
if (state.settings.columnOrder?.length > 0) {
|
if (state.settings.columnOrder?.length > 0) {
|
||||||
@ -1940,14 +1909,6 @@ export function App(): ReactElement {
|
|||||||
showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800);
|
showToast(`Snapshot konnte nicht geladen werden: ${String(error)}`, 2800);
|
||||||
});
|
});
|
||||||
unsubscribe = window.rd.onStateUpdate((wireState) => {
|
unsubscribe = window.rd.onStateUpdate((wireState) => {
|
||||||
// Merge delta payloads into the master snapshot. Full payloads replace
|
|
||||||
// the master entirely (initial sync + periodic 30s resync).
|
|
||||||
// NOTE: `settings` and `rotationEvents` are NOT delta-filtered — every emit
|
|
||||||
// (full or delta) carries the complete `settings` object and recent
|
|
||||||
// rotationEvents. The account-validity badges read
|
|
||||||
// `snapshot.settings.debridAccountStatuses` and the rotation panel reads
|
|
||||||
// `snapshot.rotationEvents`; if `settings` is ever delta-optimized, both
|
|
||||||
// must keep flowing on every emit or those views go stale.
|
|
||||||
let merged: UiSnapshot;
|
let merged: UiSnapshot;
|
||||||
const master = masterSnapshotRef.current;
|
const master = masterSnapshotRef.current;
|
||||||
if (wireState.payloadKind === "delta" && master) {
|
if (wireState.payloadKind === "delta" && master) {
|
||||||
@ -2151,11 +2112,6 @@ export function App(): ReactElement {
|
|||||||
const hiddenPackageCount = shouldLimitPackageRendering
|
const hiddenPackageCount = shouldLimitPackageRendering
|
||||||
? Math.max(0, totalPackageCount - packages.length)
|
? Math.max(0, totalPackageCount - packages.length)
|
||||||
: 0;
|
: 0;
|
||||||
// The sort-by-progress logic only runs when the session is running AND auto-sort
|
|
||||||
// is enabled AND there's more than one package. When any of those isn't true,
|
|
||||||
// the items reference is irrelevant — passing null here makes useMemo skip the
|
|
||||||
// re-evaluation that previously fired on EVERY item update (progress, status,
|
|
||||||
// speed) even when the sort would have returned the original `packages` array.
|
|
||||||
const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1)
|
const sortRelevantItems = (snapshot.session.running && settingsDraft.autoSortPackagesByProgress && packages.length > 1)
|
||||||
? snapshot.session.items
|
? snapshot.session.items
|
||||||
: null;
|
: null;
|
||||||
@ -2193,7 +2149,6 @@ export function App(): ReactElement {
|
|||||||
void loadAllDebridHostInfo(true);
|
void loadAllDebridHostInfo(true);
|
||||||
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
|
}, [settingsSubTab, hasSavedAllDebridAccount, snapshot.settings.allDebridToken, snapshot.settings.allDebridUseWebLogin, loadAllDebridHostInfo]);
|
||||||
|
|
||||||
// Auto-expand packages that are currently extracting (only once per extraction cycle)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const extractingPkgIds: string[] = [];
|
const extractingPkgIds: string[] = [];
|
||||||
const currentlyExtracting = new Set<string>();
|
const currentlyExtracting = new Set<string>();
|
||||||
@ -2210,7 +2165,6 @@ export function App(): ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Reset tracking for packages no longer extracting
|
|
||||||
for (const id of autoExpandedPkgsRef.current) {
|
for (const id of autoExpandedPkgsRef.current) {
|
||||||
if (!currentlyExtracting.has(id)) {
|
if (!currentlyExtracting.has(id)) {
|
||||||
autoExpandedPkgsRef.current.delete(id);
|
autoExpandedPkgsRef.current.delete(id);
|
||||||
@ -2231,9 +2185,6 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]);
|
const configuredProviders = useMemo(() => getActiveProvidersFromSettings(settingsDraft), [settingsDraft]);
|
||||||
|
|
||||||
// DDownload is a direct file hoster (not a debrid service) and is used automatically
|
|
||||||
// for ddownload.com/ddl.to URLs. It counts as a configured account but does not
|
|
||||||
// appear in the primary/secondary/tertiary provider dropdowns.
|
|
||||||
const hasDdownloadAccount = useMemo(() =>
|
const hasDdownloadAccount = useMemo(() =>
|
||||||
Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()),
|
Boolean((settingsDraft.ddownloadLogin || "").trim() && (settingsDraft.ddownloadPassword || "").trim()),
|
||||||
[settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]);
|
[settingsDraft.ddownloadLogin, settingsDraft.ddownloadPassword]);
|
||||||
@ -2244,10 +2195,8 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0);
|
const totalConfiguredAccounts = configuredProviders.length + (hasDdownloadAccount ? 1 : 0) + (hasOneFichierAccount ? 1 : 0);
|
||||||
|
|
||||||
// Dynamische Provider-Reihenfolge (ersetzt altes primary/secondary/tertiary)
|
|
||||||
const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]);
|
const activeProviderOrder = useMemo(() => normalizeProviderOrderForSettings(settingsDraft), [settingsDraft]);
|
||||||
|
|
||||||
// Setzt providerOrder + backwards-kompatible Felder synchron
|
|
||||||
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
|
const setProviderOrder = useCallback((newOrder: DebridProvider[]) => {
|
||||||
settingsDraftRevisionRef.current += 1;
|
settingsDraftRevisionRef.current += 1;
|
||||||
panelDirtyRevisionRef.current += 1;
|
panelDirtyRevisionRef.current += 1;
|
||||||
@ -3332,7 +3281,7 @@ export function App(): ReactElement {
|
|||||||
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
|
const onPackageFinishEdit = useCallback((packageId: string, currentName: string, nextName: string): void => {
|
||||||
let shouldRename = false;
|
let shouldRename = false;
|
||||||
setEditingPackageId((prev) => {
|
setEditingPackageId((prev) => {
|
||||||
if (prev !== packageId) return prev; // already finished (e.g. blur after Enter key)
|
if (prev !== packageId) return prev;
|
||||||
shouldRename = true;
|
shouldRename = true;
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@ -3418,8 +3367,6 @@ export function App(): ReactElement {
|
|||||||
pendingPackageOrderRef.current = [...order];
|
pendingPackageOrderRef.current = [...order];
|
||||||
pendingPackageOrderAtRef.current = Date.now();
|
pendingPackageOrderAtRef.current = Date.now();
|
||||||
packageOrderRef.current = [...order];
|
packageOrderRef.current = [...order];
|
||||||
// Optimistic UI update ? apply the new order immediately so the user
|
|
||||||
// sees the change without waiting for the backend round-trip.
|
|
||||||
setSnapshot((prev) => {
|
setSnapshot((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
|
return { ...prev, session: { ...prev.session, packageOrder: [...order] } };
|
||||||
@ -3428,7 +3375,6 @@ export function App(): ReactElement {
|
|||||||
pendingPackageOrderRef.current = null;
|
pendingPackageOrderRef.current = null;
|
||||||
pendingPackageOrderAtRef.current = 0;
|
pendingPackageOrderAtRef.current = 0;
|
||||||
packageOrderRef.current = serverPackageOrderRef.current;
|
packageOrderRef.current = serverPackageOrderRef.current;
|
||||||
// Rollback: restore original order from server
|
|
||||||
setSnapshot((prev) => {
|
setSnapshot((prev) => {
|
||||||
if (!prev) return prev;
|
if (!prev) return prev;
|
||||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
||||||
@ -3578,7 +3524,6 @@ export function App(): ReactElement {
|
|||||||
const dragDidMoveRef = useRef(false);
|
const dragDidMoveRef = useRef(false);
|
||||||
const lastClickedIdRef = useRef<string | null>(null);
|
const lastClickedIdRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Flat list of all visible IDs (package headers + their visible items) in display order
|
|
||||||
const visibleOrderIds = useMemo(() => {
|
const visibleOrderIds = useMemo(() => {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const pkg of visiblePackages) {
|
for (const pkg of visiblePackages) {
|
||||||
@ -3595,7 +3540,7 @@ export function App(): ReactElement {
|
|||||||
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
|
}, [visiblePackages, collapsedPackages, itemsByPackage, snapshot.settings.hideExtractedItems]);
|
||||||
|
|
||||||
const onSelectId = useCallback((id: string, ctrlKey: boolean, shiftKey: boolean): void => {
|
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) {
|
if (shiftKey && lastClickedIdRef.current) {
|
||||||
const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current);
|
const anchorIdx = visibleOrderIds.indexOf(lastClickedIdRef.current);
|
||||||
const targetIdx = visibleOrderIds.indexOf(id);
|
const targetIdx = visibleOrderIds.indexOf(id);
|
||||||
@ -3642,7 +3587,6 @@ export function App(): ReactElement {
|
|||||||
if (!dragSelectRef.current) return;
|
if (!dragSelectRef.current) return;
|
||||||
if (!dragDidMoveRef.current) {
|
if (!dragDidMoveRef.current) {
|
||||||
dragDidMoveRef.current = true;
|
dragDidMoveRef.current = true;
|
||||||
// Add anchor item now that we know it's a drag
|
|
||||||
const anchor = dragAnchorRef.current;
|
const anchor = dragAnchorRef.current;
|
||||||
if (anchor) {
|
if (anchor) {
|
||||||
setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; });
|
setSelectedIds((prev) => { if (prev.has(anchor)) return prev; const next = new Set(prev); next.add(anchor); return next; });
|
||||||
@ -3655,7 +3599,6 @@ export function App(): ReactElement {
|
|||||||
const sel = selectedIds;
|
const sel = selectedIds;
|
||||||
const currentPackages = snapshotRef.current.session.packages;
|
const currentPackages = snapshotRef.current.session.packages;
|
||||||
const currentItems = snapshotRef.current.session.items;
|
const currentItems = snapshotRef.current.session.items;
|
||||||
// Multi-select: collect links from all selected packages/items
|
|
||||||
if (sel.size > 1) {
|
if (sel.size > 1) {
|
||||||
const allLinks: { name: string; url: string }[] = [];
|
const allLinks: { name: string; url: string }[] = [];
|
||||||
for (const id of sel) {
|
for (const id of sel) {
|
||||||
@ -3785,7 +3728,6 @@ export function App(): ReactElement {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!colHeaderCtx) return;
|
if (!colHeaderCtx) return;
|
||||||
const close = (e: MouseEvent): void => {
|
const close = (e: MouseEvent): void => {
|
||||||
// Don't close if click is inside the menu or on the header bar (re-position instead)
|
|
||||||
if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return;
|
if (colHeaderCtxRef.current && colHeaderCtxRef.current.contains(e.target as Node)) return;
|
||||||
if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return;
|
if (colHeaderBarRef.current && colHeaderBarRef.current.contains(e.target as Node)) return;
|
||||||
setColHeaderCtx(null);
|
setColHeaderCtx(null);
|
||||||
@ -3856,7 +3798,6 @@ export function App(): ReactElement {
|
|||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
if (target.tagName !== "INPUT" && target.tagName !== "TEXTAREA") {
|
||||||
// Don't clear selection if an overlay is open ? let the overlay close first
|
|
||||||
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
if (document.querySelector(".ctx-menu") || document.querySelector(".modal-backdrop")) return;
|
||||||
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
if (tabRef.current === "downloads") setSelectedIds(new Set());
|
||||||
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
else if (tabRef.current === "history") setSelectedHistoryIds(new Set());
|
||||||
@ -4524,7 +4465,7 @@ export function App(): ReactElement {
|
|||||||
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
|
{snapshot.session.reconnectReason && <span> ({snapshot.session.reconnectReason})</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Action buttons moved to footer */}
|
{}
|
||||||
<div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}>
|
<div ref={colHeaderBarRef} className="pkg-column-header" style={{ gridTemplateColumns: gridTemplate }} onContextMenu={(e) => { e.preventDefault(); setColHeaderCtx({ x: e.clientX, y: e.clientY }); }}>
|
||||||
{columnOrder.map((col) => {
|
{columnOrder.map((col) => {
|
||||||
const def = COLUMN_DEFS[col];
|
const def = COLUMN_DEFS[col];
|
||||||
@ -5713,8 +5654,6 @@ export function App(): ReactElement {
|
|||||||
const nextAccounts = [...prev.megaAccounts, { login, password }];
|
const nextAccounts = [...prev.megaAccounts, { login, password }];
|
||||||
return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) };
|
return { ...prev, megaAccounts: nextAccounts, megaNewLogin: "", megaNewPassword: "", token: serializeMegaDebridAccounts(nextAccounts) };
|
||||||
});
|
});
|
||||||
// Sofort beim Anlegen pruefen (Gueltigkeit + Premium-Restlaufzeit) —
|
|
||||||
// Badge aktualisiert sich via Snapshot, ohne Tab schliessen / "Alle pruefen".
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
void runMegaAccountCheck(login, password);
|
void runMegaAccountCheck(login, password);
|
||||||
}
|
}
|
||||||
@ -6139,7 +6078,6 @@ export function App(): ReactElement {
|
|||||||
if (isVisible) {
|
if (isVisible) {
|
||||||
newOrder = columnOrder.filter((c) => c !== col);
|
newOrder = columnOrder.filter((c) => c !== col);
|
||||||
} else {
|
} else {
|
||||||
// Insert at original default position relative to existing columns
|
|
||||||
newOrder = [...columnOrder];
|
newOrder = [...columnOrder];
|
||||||
const defaultIdx = ALL_COLUMN_KEYS.indexOf(col);
|
const defaultIdx = ALL_COLUMN_KEYS.indexOf(col);
|
||||||
let insertAt = newOrder.length;
|
let insertAt = newOrder.length;
|
||||||
@ -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 {
|
function computeDisplayedItemStatus(item: DownloadItem, sessionRunning: boolean): string {
|
||||||
const statusText = String(item.fullStatus || "").trim();
|
const statusText = String(item.fullStatus || "").trim();
|
||||||
if (statusText === "Wartet") return "";
|
if (statusText === "Wartet") return "";
|
||||||
@ -6365,9 +6301,6 @@ interface ItemRowProps {
|
|||||||
onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void;
|
onContextMenu: (packageId: string, itemId: string | undefined, x: number, y: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Per-item row, memoized so a status update on one item doesn't re-render
|
|
||||||
* every other item in the same package (the bottleneck on packages with
|
|
||||||
* many episodes). Custom equality only checks the fields actually rendered. */
|
|
||||||
const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement {
|
const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunning, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onContextMenu }: ItemRowProps): ReactElement {
|
||||||
const handleClick = useCallback((e: React.MouseEvent) => {
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@ -6385,10 +6318,7 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onContextMenu(packageId, item.id, e.clientX, e.clientY);
|
onContextMenu(packageId, item.id, e.clientX, e.clientY);
|
||||||
}, [packageId, item.id, onContextMenu]);
|
}, [packageId, item.id, onContextMenu]);
|
||||||
// Memoize the date string so it doesn't get re-formatted on every re-render
|
|
||||||
// when only progress/speed changed but createdAt is stable.
|
|
||||||
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
|
const formattedCreatedAt = useMemo(() => formatDateTime(item.createdAt), [item.createdAt]);
|
||||||
// Memoize the displayed status so we don't compute it twice (title + body)
|
|
||||||
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
|
const displayStatus = useMemo(() => computeDisplayedItemStatus(item, sessionRunning), [item, sessionRunning]);
|
||||||
const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
|
const statusTitle = displayStatus ? (item.retries > 0 ? `${displayStatus} ? R${item.retries}` : displayStatus) : "";
|
||||||
|
|
||||||
@ -6453,7 +6383,6 @@ const ItemRow = memo(function ItemRow({ item, packageId, isSelected, sessionRunn
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, (prev, next) => {
|
}, (prev, next) => {
|
||||||
// Skip re-render unless something visible actually changed for THIS item.
|
|
||||||
if (prev.item !== next.item) {
|
if (prev.item !== next.item) {
|
||||||
const a = prev.item;
|
const a = prev.item;
|
||||||
const b = next.item;
|
const b = next.item;
|
||||||
@ -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 {
|
const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripeVariant, isFirst, isLast, isEditing, editingName, collapsed, hideExtractedItems, sessionRunning, selectedIds, columnOrder, gridTemplate, onSelect, onSelectMouseDown, onSelectMouseEnter, onStartEdit, onFinishEdit, onEditChange, onToggleCollapse, onCancel, onMoveUp, onMoveDown, onToggle, onRemoveItem, onContextMenu, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
||||||
// Single-pass aggregation: replaces 5 separate filter()/some() + 2 reduce() calls.
|
|
||||||
// For a package with N items this is O(N) instead of O(7N) per render.
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
let done = 0;
|
let done = 0;
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@ -6688,10 +6615,6 @@ const PackageCard = memo(function PackageCard({ pkg, items, packageSpeed, stripe
|
|||||||
|| prev.gridTemplate !== next.gridTemplate) {
|
|| prev.gridTemplate !== next.gridTemplate) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// selectedIds is a Set that gets a new reference on every selection change
|
|
||||||
// anywhere in the app. Only re-render this card if the selection state
|
|
||||||
// changed for an item that ACTUALLY belongs to this package — that way
|
|
||||||
// selecting an item in a different package doesn't re-render all 200+ cards.
|
|
||||||
if (prev.selectedIds !== next.selectedIds) {
|
if (prev.selectedIds !== next.selectedIds) {
|
||||||
for (const itemId of next.pkg.itemIds) {
|
for (const itemId of next.pkg.itemIds) {
|
||||||
if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) {
|
if (prev.selectedIds.has(itemId) !== next.selectedIds.has(itemId)) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2
src/renderer/vite-env.d.ts
vendored
2
src/renderer/vite-env.d.ts
vendored
@ -1,5 +1,3 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
import type { ElectronApi } from "../shared/preload-api";
|
import type { ElectronApi } from "../shared/preload-api";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@ -1,70 +1,70 @@
|
|||||||
export const IPC_CHANNELS = {
|
export const IPC_CHANNELS = {
|
||||||
GET_SNAPSHOT: "app:get-snapshot",
|
GET_SNAPSHOT: "app:get-snapshot",
|
||||||
GET_VERSION: "app:get-version",
|
GET_VERSION: "app:get-version",
|
||||||
CHECK_UPDATES: "app:check-updates",
|
CHECK_UPDATES: "app:check-updates",
|
||||||
INSTALL_UPDATE: "app:install-update",
|
INSTALL_UPDATE: "app:install-update",
|
||||||
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
UPDATE_INSTALL_PROGRESS: "app:update-install-progress",
|
||||||
OPEN_EXTERNAL: "app:open-external",
|
OPEN_EXTERNAL: "app:open-external",
|
||||||
UPDATE_SETTINGS: "app:update-settings",
|
UPDATE_SETTINGS: "app:update-settings",
|
||||||
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
RESET_PROVIDER_DAILY_USAGE: "app:reset-provider-daily-usage",
|
||||||
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
RESET_DEBRID_LINK_API_KEY_DAILY_USAGE: "app:reset-debrid-link-api-key-daily-usage",
|
||||||
ADD_LINKS: "queue:add-links",
|
ADD_LINKS: "queue:add-links",
|
||||||
ADD_CONTAINERS: "queue:add-containers",
|
ADD_CONTAINERS: "queue:add-containers",
|
||||||
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
GET_START_CONFLICTS: "queue:get-start-conflicts",
|
||||||
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
RESOLVE_START_CONFLICT: "queue:resolve-start-conflict",
|
||||||
CLEAR_ALL: "queue:clear-all",
|
CLEAR_ALL: "queue:clear-all",
|
||||||
START: "queue:start",
|
START: "queue:start",
|
||||||
START_PACKAGES: "queue:start-packages",
|
START_PACKAGES: "queue:start-packages",
|
||||||
STOP: "queue:stop",
|
STOP: "queue:stop",
|
||||||
TOGGLE_PAUSE: "queue:toggle-pause",
|
TOGGLE_PAUSE: "queue:toggle-pause",
|
||||||
CANCEL_PACKAGE: "queue:cancel-package",
|
CANCEL_PACKAGE: "queue:cancel-package",
|
||||||
RENAME_PACKAGE: "queue:rename-package",
|
RENAME_PACKAGE: "queue:rename-package",
|
||||||
REORDER_PACKAGES: "queue:reorder-packages",
|
REORDER_PACKAGES: "queue:reorder-packages",
|
||||||
REMOVE_ITEM: "queue:remove-item",
|
REMOVE_ITEM: "queue:remove-item",
|
||||||
TOGGLE_PACKAGE: "queue:toggle-package",
|
TOGGLE_PACKAGE: "queue:toggle-package",
|
||||||
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
|
EXPORT_PACKAGE_SELECTION: "queue:export-package-selection",
|
||||||
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
|
EXPORT_ITEM_SELECTION: "queue:export-item-selection",
|
||||||
EXPORT_QUEUE: "queue:export",
|
EXPORT_QUEUE: "queue:export",
|
||||||
IMPORT_QUEUE: "queue:import",
|
IMPORT_QUEUE: "queue:import",
|
||||||
PICK_FOLDER: "dialog:pick-folder",
|
PICK_FOLDER: "dialog:pick-folder",
|
||||||
PICK_CONTAINERS: "dialog:pick-containers",
|
PICK_CONTAINERS: "dialog:pick-containers",
|
||||||
STATE_UPDATE: "state:update",
|
STATE_UPDATE: "state:update",
|
||||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||||
GET_SESSION_STATS: "stats:get-session-stats",
|
GET_SESSION_STATS: "stats:get-session-stats",
|
||||||
RESET_SESSION_STATS: "stats:reset-session",
|
RESET_SESSION_STATS: "stats:reset-session",
|
||||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||||
RESTART: "app:restart",
|
RESTART: "app:restart",
|
||||||
QUIT: "app:quit",
|
QUIT: "app:quit",
|
||||||
EXPORT_BACKUP: "app:export-backup",
|
EXPORT_BACKUP: "app:export-backup",
|
||||||
IMPORT_BACKUP: "app:import-backup",
|
IMPORT_BACKUP: "app:import-backup",
|
||||||
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
EXPORT_SUPPORT_BUNDLE: "app:export-support-bundle",
|
||||||
OPEN_LOG: "app:open-log",
|
OPEN_LOG: "app:open-log",
|
||||||
OPEN_AUDIT_LOG: "app:open-audit-log",
|
OPEN_AUDIT_LOG: "app:open-audit-log",
|
||||||
OPEN_RENAME_LOG: "app:open-rename-log",
|
OPEN_RENAME_LOG: "app:open-rename-log",
|
||||||
OPEN_SESSION_LOG: "app:open-session-log",
|
OPEN_SESSION_LOG: "app:open-session-log",
|
||||||
OPEN_TRACE_LOG: "app:open-trace-log",
|
OPEN_TRACE_LOG: "app:open-trace-log",
|
||||||
OPEN_PACKAGE_LOG: "app:open-package-log",
|
OPEN_PACKAGE_LOG: "app:open-package-log",
|
||||||
OPEN_ITEM_LOG: "app:open-item-log",
|
OPEN_ITEM_LOG: "app:open-item-log",
|
||||||
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
|
||||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
GET_TRACE_CONFIG: "app:get-trace-config",
|
||||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
||||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||||
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
GET_ALLDEBRID_HOST_INFO: "app:get-alldebrid-host-info",
|
||||||
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
GET_DEBRIDLINK_HOST_LIMITS: "app:get-debridlink-host-limits",
|
||||||
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
CHECK_DEBRID_ACCOUNTS: "app:check-debrid-accounts",
|
||||||
CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account",
|
CHECK_MEGA_DEBRID_ACCOUNT: "app:check-mega-debrid-account",
|
||||||
RETRY_EXTRACTION: "queue:retry-extraction",
|
RETRY_EXTRACTION: "queue:retry-extraction",
|
||||||
EXTRACT_NOW: "queue:extract-now",
|
EXTRACT_NOW: "queue:extract-now",
|
||||||
RESET_PACKAGE: "queue:reset-package",
|
RESET_PACKAGE: "queue:reset-package",
|
||||||
GET_HISTORY: "history:get",
|
GET_HISTORY: "history:get",
|
||||||
CLEAR_HISTORY: "history:clear",
|
CLEAR_HISTORY: "history:clear",
|
||||||
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
REMOVE_HISTORY_ENTRY: "history:remove-entry",
|
||||||
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
SET_PACKAGE_PRIORITY: "queue:set-package-priority",
|
||||||
SKIP_ITEMS: "queue:skip-items",
|
SKIP_ITEMS: "queue:skip-items",
|
||||||
RESET_ITEMS: "queue:reset-items",
|
RESET_ITEMS: "queue:reset-items",
|
||||||
START_ITEMS: "queue:start-items"
|
START_ITEMS: "queue:start-items"
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -39,11 +39,6 @@ export function getMegaDebridAccountLabel(index: number): string {
|
|||||||
return `Account ${index + 1}`;
|
return `Account ${index + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse newline-separated "login:password" pairs.
|
|
||||||
* Falls back to treating the entire string as a single login if no colon
|
|
||||||
* is found (backward compat with old megaLogin field).
|
|
||||||
*/
|
|
||||||
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaDebridAccountEntry[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const lines = String(raw || "")
|
const lines = String(raw || "")
|
||||||
@ -60,7 +55,6 @@ export function parseMegaDebridAccounts(raw: string, legacyPassword = ""): MegaD
|
|||||||
login = line.slice(0, colonIdx).trim();
|
login = line.slice(0, colonIdx).trim();
|
||||||
password = line.slice(colonIdx + 1).trim();
|
password = line.slice(colonIdx + 1).trim();
|
||||||
} else {
|
} else {
|
||||||
// Legacy format: just a login, use the provided fallback password
|
|
||||||
login = line;
|
login = line;
|
||||||
password = legacyPassword;
|
password = legacyPassword;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -250,8 +250,6 @@ export function addDebridLinkApiKeyTotalUsageBytes(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Mega-Debrid per-account limits ──
|
|
||||||
|
|
||||||
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
export function isMegaDebridAccountDisabled(settings: ProviderDailySettings, accountId: string): boolean {
|
||||||
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
return Array.isArray(settings.megaDebridDisabledAccountIds) && settings.megaDebridDisabledAccountIds.includes(accountId);
|
||||||
}
|
}
|
||||||
|
|||||||
1023
src/shared/types.ts
1023
src/shared/types.ts
File diff suppressed because it is too large
Load Diff
@ -21,7 +21,7 @@ function mockFetchOnce(status: number, body: unknown): void {
|
|||||||
})) as unknown as typeof fetch);
|
})) as unknown as typeof fetch);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOW = 1_700_000_000_000; // fixed epoch ms
|
const NOW = 1_700_000_000_000;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
@ -29,7 +29,7 @@ afterEach(() => {
|
|||||||
|
|
||||||
describe("checkMegaDebridAccount", () => {
|
describe("checkMegaDebridAccount", () => {
|
||||||
it("reports valid + premium from vip_end (future Unix ts)", async () => {
|
it("reports valid + premium from vip_end (future Unix ts)", async () => {
|
||||||
const futureSec = Math.floor(NOW / 1000) + 30 * 24 * 60 * 60; // +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" });
|
mockFetchOnce(200, { response_code: "ok", response_text: "User logged", token: "t", vip_end: String(futureSec), email: "a@b.de" });
|
||||||
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
const st = await checkMegaDebridAccount(megaAccount(), undefined, NOW);
|
||||||
expect(st.valid).toBe(true);
|
expect(st.valid).toBe(true);
|
||||||
@ -80,7 +80,7 @@ describe("checkMegaDebridAccount", () => {
|
|||||||
|
|
||||||
describe("checkDebridLinkKey", () => {
|
describe("checkDebridLinkKey", () => {
|
||||||
it("reports valid + premium from premiumLeft seconds", async () => {
|
it("reports valid + premium from premiumLeft seconds", async () => {
|
||||||
const premiumLeft = 60 * 24 * 60 * 60; // 60 days in seconds
|
const premiumLeft = 60 * 24 * 60 * 60;
|
||||||
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
|
mockFetchOnce(200, { success: true, value: { username: "u", accountType: 1, premiumLeft } });
|
||||||
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
const st = await checkDebridLinkKey(debridLinkKey(), undefined, NOW);
|
||||||
expect(st.valid).toBe(true);
|
expect(st.valid).toBe(true);
|
||||||
@ -118,7 +118,6 @@ describe("checkAllDebridAccounts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("checks every configured mega account + debrid-link key", async () => {
|
it("checks every configured mega account + debrid-link key", async () => {
|
||||||
// All requests succeed as valid premium
|
|
||||||
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
const futureSec = Math.floor(Date.now() / 1000) + 1000;
|
||||||
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
vi.stubGlobal("fetch", vi.fn(async (url: string) => {
|
||||||
if (String(url).includes("mega-debrid")) {
|
if (String(url).includes("mega-debrid")) {
|
||||||
@ -134,7 +133,7 @@ describe("checkAllDebridAccounts", () => {
|
|||||||
} as unknown as AppSettings;
|
} as unknown as AppSettings;
|
||||||
|
|
||||||
const result = await checkAllDebridAccounts(settings);
|
const result = await checkAllDebridAccounts(settings);
|
||||||
expect(result).toHaveLength(5); // 2 mega + 3 debrid-link
|
expect(result).toHaveLength(5);
|
||||||
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
expect(result.filter((r) => r.provider === "megadebrid")).toHaveLength(2);
|
||||||
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
expect(result.filter((r) => r.provider === "debridlink")).toHaveLength(3);
|
||||||
expect(result.every((r) => r.valid)).toBe(true);
|
expect(result.every((r) => r.valid)).toBe(true);
|
||||||
|
|||||||
@ -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("WARN", "Mega-Debrid Web", "Account 1/3 (ab**xy)", "FAILED", { reason: "Timeout", cooldownSec: 30, next: "Account 2/3 (cd**zw)" });
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "TEST", { link: "x" });
|
||||||
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
logAccountRotation("INFO", "Mega-Debrid Web", "Account 2/3 (cd**zw)", "OK", { fileName: "f.mkv" });
|
||||||
// simulate an await boundary — ALS must survive it
|
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -23,7 +22,6 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
|||||||
|
|
||||||
it("does not leak events to the sink outside the run() scope", () => {
|
it("does not leak events to the sink outside the run() scope", () => {
|
||||||
const captured: RotationEvent[] = [];
|
const captured: RotationEvent[] = [];
|
||||||
// No active sink here
|
|
||||||
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
logAccountRotation("INFO", "Debrid-Link", "Key 1/2 (k1)", "OK");
|
||||||
expect(captured).toHaveLength(0);
|
expect(captured).toHaveLength(0);
|
||||||
});
|
});
|
||||||
@ -43,7 +41,6 @@ describe("rotation item-sink (AsyncLocalStorage)", () => {
|
|||||||
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
logAccountRotation("WARN", "Debrid-Link", "Key 1 (b)", "FAILED", { reason: "badToken" });
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
// Each sink only saw its own provider's events
|
|
||||||
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
expect(a.every((e) => e.provider === "Mega-Debrid Web")).toBe(true);
|
||||||
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
expect(b.every((e) => e.provider === "Debrid-Link")).toBe(true);
|
||||||
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
expect(a.map((e) => e.event)).toEqual(["TEST", "OK"]);
|
||||||
@ -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)", "TEST");
|
||||||
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
logAccountRotation("INFO", "Mega-Debrid API", "Account 9 (zz)", "OK", { fileName: "ring.mkv" });
|
||||||
const ring = getRecentRotationEvents(10);
|
const ring = getRecentRotationEvents(10);
|
||||||
// OK is in the ring; the TEST marker is filtered out of the panel
|
|
||||||
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
expect(ring.some((e) => e.event === "OK" && e.accountLabel === "Account 9 (zz)")).toBe(true);
|
||||||
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
|
expect(ring.some((e) => e.event === "TEST" && e.accountLabel === "Account 9 (zz)")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -14,10 +14,6 @@ import {
|
|||||||
} from "../src/main/download-manager";
|
} from "../src/main/download-manager";
|
||||||
|
|
||||||
describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => {
|
describe("decideAutoRenameBaseName (shared naming decision — used by auto-rename AND mkv-collect)", () => {
|
||||||
// Characterization corpus: pins the EXACT decision for the real failures from
|
|
||||||
// rename-session_2026-06-02 (17 raw files that mkv-move moved un-renamed) plus
|
|
||||||
// the guard cases. mkv-collect now routes through this same function so a file
|
|
||||||
// auto-rename missed still lands clean in the library.
|
|
||||||
|
|
||||||
it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => {
|
it("derives the clean name for a Herzflimmern episode from the per-episode folder (S07E12 — the reported failure)", () => {
|
||||||
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
|
const source = "tvarchiv.herzflimmern.die.klinik.am.see.s07e12-720.mkv";
|
||||||
@ -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)", () => {
|
it("GUARD: lets the parent folder token override an OBFUSCATED source filename (anti-piracy scramble)", () => {
|
||||||
// Obfuscated file (E16) inside an explicitly-named E01 folder → trust the folder.
|
|
||||||
const decision = decideAutoRenameBaseName(
|
const decision = decideAutoRenameBaseName(
|
||||||
["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"],
|
["Die.Thundermans.S02E01.Der.Thunder.Van.GERMAN.x264-aWake"],
|
||||||
"awa-diethundermans02e16hd.mkv",
|
"awa-diethundermans02e16hd.mkv",
|
||||||
@ -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)", () => {
|
it("GUARD: a CLEAN scene source is NEVER overridden by a mismatching folder token (folder is wrong, not the file)", () => {
|
||||||
// Clean source S01E09 in a folder that says E08 → must NOT rename to E08.
|
|
||||||
const decision = decideAutoRenameBaseName(
|
const decision = decideAutoRenameBaseName(
|
||||||
["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"],
|
["The.Royals.2015.S01E08.German.DL.720p.BluRay.x264-iNTENTiON"],
|
||||||
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv",
|
"the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv",
|
||||||
@ -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", () => {
|
it("uses the CLEAN per-episode folder (scene group WITH underscore, e.g. -idTV_iNT) — not the obfuscated package folder", () => {
|
||||||
// User-Report v1.7.178: castle.s08e02....mkv im sauberen Ordner "Castle.S08E02...H264-idTV_iNT"
|
|
||||||
// (Paket: "scn2-cstl7") wurde zu "scn2-cstl7.S08E02" verschlimmbessert, weil hasSceneGroupSuffix
|
|
||||||
// die Unterstrich-Gruppe "-idTV_iNT" nicht erkannte und auf den Paketordner zurueckfiel.
|
|
||||||
const epFolder = "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT";
|
const epFolder = "Castle.S08E02.GERMAN.DL.720p.WEB.H264-idTV_iNT";
|
||||||
const decision = decideAutoRenameBaseName(
|
const decision = decideAutoRenameBaseName(
|
||||||
[epFolder, "scn2-cstl7"],
|
[epFolder, "scn2-cstl7"],
|
||||||
@ -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)", () => {
|
it("uses the complete per-episode folder when the SOURCE has no SxxExx token (bare 'Folge 01' format)", () => {
|
||||||
// User-Report: "Kreuzfahrt ins Glück" — Datei "bet_kig_01_hdt.mkv" (kein SxxExx-Token),
|
|
||||||
// Episoden-Ordner nummeriert mit "01" statt S01E01. Frueher "kein Zielname" -> roh in die
|
|
||||||
// Library. Jetzt: der vollstaendige Release-Ordnername wird direkt verwendet.
|
|
||||||
const folders = [
|
const folders = [
|
||||||
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET",
|
"Kreuzfahrt.ins.Glueck.01.Hochzeitsreise.nach.Burma.2007.German.720p.HDTV.x264-BET",
|
||||||
"kig.hdtv.7p-001",
|
"kig.hdtv.7p-001",
|
||||||
@ -189,14 +177,12 @@ describe("hasMeaningfulSeriesPrefix", () => {
|
|||||||
|
|
||||||
describe("looksLikeObfuscatedSceneFileName", () => {
|
describe("looksLikeObfuscatedSceneFileName", () => {
|
||||||
it("flags hoster-obfuscated names with no scene markers as obfuscated", () => {
|
it("flags hoster-obfuscated names with no scene markers as obfuscated", () => {
|
||||||
// No 720p / german / x264 / bluray, no dot-separated structure
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("awa-diethundermans02e16hd.mkv")).toBe(true);
|
expect(looksLikeObfuscatedSceneFileName("awa-diethundermans02e16hd.mkv")).toBe(true);
|
||||||
expect(looksLikeObfuscatedSceneFileName("scn-dthund7-S02E06.mkv")).toBe(true);
|
expect(looksLikeObfuscatedSceneFileName("scn-dthund7-S02E06.mkv")).toBe(true);
|
||||||
expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true);
|
expect(looksLikeObfuscatedSceneFileName("4sj-blue-bloods-s08e21-720p.mkv")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats clean scene releases with multiple markers as NOT obfuscated", () => {
|
it("treats clean scene releases with multiple markers as NOT obfuscated", () => {
|
||||||
// Has 720p + german + bluray + x264 — clearly a clean scene file
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv")).toBe(false);
|
expect(looksLikeObfuscatedSceneFileName("the.royals.2015.s01e09.german.dl.720p.bluray.x264-j4f.mkv")).toBe(false);
|
||||||
expect(looksLikeObfuscatedSceneFileName("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake.mkv")).toBe(false);
|
expect(looksLikeObfuscatedSceneFileName("Die.Thundermans.S02E06.Tickets.und.Shreddy.GERMAN.WS.720p.HDTV.x264-aWake.mkv")).toBe(false);
|
||||||
expect(looksLikeObfuscatedSceneFileName("Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv")).toBe(false);
|
expect(looksLikeObfuscatedSceneFileName("Desperate.Housewives.S01E01.German.Synced.DL.720p.WEB-DL.AC3.h264.mkv")).toBe(false);
|
||||||
@ -208,7 +194,6 @@ describe("looksLikeObfuscatedSceneFileName", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("treats long dotted names as scene-style even with few markers", () => {
|
it("treats long dotted names as scene-style even with few markers", () => {
|
||||||
// 6+ dots → looks like scene structure even without quality/codec markers
|
|
||||||
expect(looksLikeObfuscatedSceneFileName("Some.Show.With.Many.Tokens.S01E01.mkv")).toBe(false);
|
expect(looksLikeObfuscatedSceneFileName("Some.Show.With.Many.Tokens.S01E01.mkv")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -218,31 +203,22 @@ describe("extractEpisodeToken (extended formats)", () => {
|
|||||||
expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01");
|
expect(extractEpisodeToken("show.1x01.720p.mkv")).toBe("S01E01");
|
||||||
expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05");
|
expect(extractEpisodeToken("show-2x05-hdtv.mkv")).toBe("S02E05");
|
||||||
expect(extractEpisodeToken("Show.Name.10x99.mkv")).toBe("S10E99");
|
expect(extractEpisodeToken("Show.Name.10x99.mkv")).toBe("S10E99");
|
||||||
// 3-digit episode in xX format is intentionally NOT supported — would
|
|
||||||
// collide with codec tokens (x264/x265/x266). 3-digit episodes still
|
|
||||||
// work in the modern SxxEnnn format which has explicit S/E delimiters.
|
|
||||||
expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBeNull();
|
expect(extractEpisodeToken("Show.Name.10x100.mkv")).toBeNull();
|
||||||
expect(extractEpisodeToken("Show.Name.S10E100.mkv")).toBe("S10E100");
|
expect(extractEpisodeToken("Show.Name.S10E100.mkv")).toBe("S10E100");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not falsely match resolution tokens like 1080x720", () => {
|
it("does not falsely match resolution tokens like 1080x720", () => {
|
||||||
// The xX regex is bounded; 1080p shouldn't match as "1080x???" because
|
|
||||||
// there's no second number group in 1080p / 720p / etc.
|
|
||||||
expect(extractEpisodeToken("show.1080p.mkv")).toBeNull();
|
expect(extractEpisodeToken("show.1080p.mkv")).toBeNull();
|
||||||
expect(extractEpisodeToken("show.S01E01.1080p.mkv")).toBe("S01E01");
|
expect(extractEpisodeToken("show.S01E01.1080p.mkv")).toBe("S01E01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not falsely match codec tokens like x264 / x265 (caps episode digits)", () => {
|
it("does not falsely match codec tokens like x264 / x265 (caps episode digits)", () => {
|
||||||
// First number 5, second number capped to 2 digits → "5x265" CANNOT
|
|
||||||
// match because 265 has 3 digits. Same for x264, x266, h264, h265.
|
|
||||||
expect(extractEpisodeToken("Movie.x264-GROUP.mkv")).toBeNull();
|
expect(extractEpisodeToken("Movie.x264-GROUP.mkv")).toBeNull();
|
||||||
expect(extractEpisodeToken("Movie.5x265.x265.mkv")).toBeNull();
|
expect(extractEpisodeToken("Movie.5x265.x265.mkv")).toBeNull();
|
||||||
// SxxExx still wins ahead of phantom xX matches.
|
|
||||||
expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01");
|
expect(extractEpisodeToken("Show.S01E01.x265-GROUP.mkv")).toBe("S01E01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not falsely match common aspect ratios like 1920x1080", () => {
|
it("does not falsely match common aspect ratios like 1920x1080", () => {
|
||||||
// 1920 has 4 digits, first group capped at 2 → no match.
|
|
||||||
expect(extractEpisodeToken("Movie.1920x1080.mkv")).toBeNull();
|
expect(extractEpisodeToken("Movie.1920x1080.mkv")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -491,7 +467,6 @@ describe("buildAutoRenameBaseName", () => {
|
|||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edge cases
|
|
||||||
it("handles 2160p quality token", () => {
|
it("handles 2160p quality token", () => {
|
||||||
const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv");
|
const result = buildAutoRenameBaseName("Show.S01.2160p-4sf", "show.s01e01.rp.2160p.mkv");
|
||||||
expect(result).toBe("Show.S01E01.REPACK.2160p-4sf");
|
expect(result).toBe("Show.S01E01.REPACK.2160p-4sf");
|
||||||
@ -509,12 +484,10 @@ describe("buildAutoRenameBaseName", () => {
|
|||||||
|
|
||||||
it("handles high season and episode numbers", () => {
|
it("handles high season and episode numbers", () => {
|
||||||
const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv");
|
const result = buildAutoRenameBaseName("Show.S99.720p-4sf", "show.s99e999.720p.mkv");
|
||||||
// SCENE_EPISODE_RE allows up to 3-digit episodes and 2-digit seasons
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!).toContain("S99E999");
|
expect(result!).toContain("S99E999");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Real-world scene release patterns
|
|
||||||
it("real-world: German series with dots", () => {
|
it("real-world: German series with dots", () => {
|
||||||
const result = buildAutoRenameBaseName(
|
const result = buildAutoRenameBaseName(
|
||||||
"Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ",
|
"Der.Bergdoktor.S18.German.720p.WEB.x264-4SJ",
|
||||||
@ -579,18 +552,13 @@ describe("buildAutoRenameBaseName", () => {
|
|||||||
expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF");
|
expect(result).toBe("Cobra.Kai.S06E14.720p.NF.WEB-DL.DDP5.1.x264-4SF");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bug-hunting edge cases
|
|
||||||
it("source filename extension is not included in episode detection", () => {
|
it("source filename extension is not included in episode detection", () => {
|
||||||
// The sourceFileName passed to buildAutoRenameBaseName is the basename without extension
|
|
||||||
// so .mkv should not interfere, but let's verify with an actual extension
|
|
||||||
const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv");
|
const result = buildAutoRenameBaseName("Show.S01-4sf", "show.s01e01.mkv");
|
||||||
// "mkv" should not be treated as part of the filename match
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!).toContain("S01E01");
|
expect(result!).toContain("S01E01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not match episode-like patterns in codec strings", () => {
|
it("does not match episode-like patterns in codec strings", () => {
|
||||||
// h.265 has digits but should not be confused with episode tokens
|
|
||||||
const token = extractEpisodeToken("show.s01e01.h.265");
|
const token = extractEpisodeToken("show.s01e01.h.265");
|
||||||
expect(token).toBe("S01E01");
|
expect(token).toBe("S01E01");
|
||||||
});
|
});
|
||||||
@ -608,23 +576,19 @@ describe("buildAutoRenameBaseName", () => {
|
|||||||
"Show.S01E05.720p-4sf",
|
"Show.S01E05.720p-4sf",
|
||||||
"show.s01e05.720p"
|
"show.s01e05.720p"
|
||||||
);
|
);
|
||||||
// Must NOT produce "Show.S01E05.720p.S01E05-4sf" (double episode bug)
|
|
||||||
expect(result).toBe("Show.S01E05.720p-4sf");
|
expect(result).toBe("Show.S01E05.720p-4sf");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles folder with only -4sf suffix (edge case)", () => {
|
it("handles folder with only -4sf suffix (edge case)", () => {
|
||||||
const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv");
|
const result = buildAutoRenameBaseName("-4sf", "show.s01e01.mkv");
|
||||||
// Extreme edge case - sanitizeFilename trims leading dots
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!).toContain("S01E01");
|
expect(result!).toContain("S01E01");
|
||||||
expect(result!).toContain("-4sf");
|
expect(result!).toContain("-4sf");
|
||||||
expect(result!).not.toContain(".S01E01.S01E01"); // no duplication
|
expect(result!).not.toContain(".S01E01.S01E01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sanitizes special characters from result", () => {
|
it("sanitizes special characters from result", () => {
|
||||||
// sanitizeFilename should strip dangerous chars
|
|
||||||
const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv");
|
const result = buildAutoRenameBaseName("Show:Name.S01-4sf", "show.s01e01.mkv");
|
||||||
// The colon should be sanitized away
|
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!).not.toContain(":");
|
expect(result!).not.toContain(":");
|
||||||
});
|
});
|
||||||
@ -888,7 +852,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD");
|
expect(result).toBe("Mammon.S01E05E06.German.1080P.Bluray.x264-SMAHD");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Last-resort fallback: folder has season but no scene group suffix (user-renamed packages)
|
|
||||||
it("renames when folder has season but no scene group suffix (Mystery Road case)", () => {
|
it("renames when folder has season but no scene group suffix (Mystery Road case)", () => {
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||||
["Mystery Road S02"],
|
["Mystery Road S02"],
|
||||||
@ -916,7 +879,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
"myst.road.de.dl.hdtv.7p-s02e05",
|
"myst.road.de.dl.hdtv.7p-s02e05",
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
{ forceEpisodeForSeasonFolder: true }
|
||||||
);
|
);
|
||||||
// Should use the scene-group folder (hrs), not the custom one
|
|
||||||
expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs");
|
expect(result).toBe("Mystery.Road.S02E05.GERMAN.DL.AC3.720p.HDTV.x264-hrs");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1002,11 +964,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("documents malformed package name (S01GERMAN) limitation", () => {
|
it("documents malformed package name (S01GERMAN) limitation", () => {
|
||||||
// Real-world: "Drei.Meter.ueber.dem.Himmel.S01GERMAN.DL.720P.WEB.X264-WAYNE"
|
|
||||||
// is malformed (no separator between S01 and GERMAN). SCENE_SEASON_ONLY_RE
|
|
||||||
// doesn't match this, so the helper falls back to the package name as-is.
|
|
||||||
// The download-manager autoRenameExtractedVideoFiles safety net repairs
|
|
||||||
// this at runtime by inserting the source's episode token.
|
|
||||||
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
const result = buildAutoRenameBaseNameFromFoldersWithOptions(
|
||||||
[
|
[
|
||||||
"3MH.web.7p-101",
|
"3MH.web.7p-101",
|
||||||
@ -1015,8 +972,6 @@ describe("buildAutoRenameBaseNameFromFolders", () => {
|
|||||||
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
|
"Drei.Meter.ueber.dem.Himmel.S01E01.GERMAN.DL.720P.WEB.X264-WAYNE",
|
||||||
{ forceEpisodeForSeasonFolder: true }
|
{ forceEpisodeForSeasonFolder: true }
|
||||||
);
|
);
|
||||||
// Helper limitation: returns the malformed folder name unchanged.
|
|
||||||
// The download-manager safety net catches this at runtime.
|
|
||||||
if (result !== null) {
|
if (result !== null) {
|
||||||
expect(typeof result).toBe("string");
|
expect(typeof result).toBe("string");
|
||||||
}
|
}
|
||||||
@ -1027,7 +982,6 @@ describe("isBonusContent (numbered episodes are never bonus)", () => {
|
|||||||
const pkgDir = "/pkg/Show.S04.GERMAN.DL.720p.WEB.x264-GRP";
|
const pkgDir = "/pkg/Show.S04.GERMAN.DL.720p.WEB.x264-GRP";
|
||||||
|
|
||||||
it("does NOT treat a numbered episode as bonus even when its TITLE is a bonus word", () => {
|
it("does NOT treat a numbered episode as bonus even when its TITLE is a bonus word", () => {
|
||||||
// Der gemeldete Bug: Revenge.2011.S04E19.Interview wurde als Bonus verworfen.
|
|
||||||
const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
|
const name = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
|
||||||
const fp = `${pkgDir}/${name}/${name}.mkv`;
|
const fp = `${pkgDir}/${name}/${name}.mkv`;
|
||||||
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
|
expect(isBonusContent(fp, pkgDir, name)).toBe(false);
|
||||||
@ -1066,9 +1020,6 @@ describe("complete episode folder WITHOUT group suffix (codec/resolution only)",
|
|||||||
const hash = "c284d9d9072eaf3ac314d05f951dd115";
|
const hash = "c284d9d9072eaf3ac314d05f951dd115";
|
||||||
|
|
||||||
it("uses the clean folder name when it has an episode token + codec but no -GROUP (safari S04E08a)", () => {
|
it("uses the clean folder name when it has an episode token + codec but no -GROUP (safari S04E08a)", () => {
|
||||||
// Echter Bug (rename-session 2026-06-04): alte deutsche Doku ohne Gruppen-Suffix.
|
|
||||||
// Ordner endet auf ".XviD" (kein "-GROUP") -> buildAutoRenameBaseName lieferte null ->
|
|
||||||
// "kein Zielname" -> Datei landete roh als "safari-fm-s04e08a.avi" in der Library.
|
|
||||||
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
|
const folder = "Fluss-Monster.S04E08a.Am.Essequibo.Teil.1.German.DOKU.SATRiP.XviD";
|
||||||
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
|
const decision = decideAutoRenameBaseName([folder, hash], "safari-fm-s04e08a.avi", "safari-fm-s04e08a", hash, hash);
|
||||||
expect(decision).toEqual({ kind: "rename", baseName: folder });
|
expect(decision).toEqual({ kind: "rename", baseName: folder });
|
||||||
@ -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);
|
const db = decideAutoRenameBaseName([fb, hash], "safari-fm-s04e08b.avi", "safari-fm-s04e08b", hash, hash);
|
||||||
expect(da).toEqual({ kind: "rename", baseName: fa });
|
expect(da).toEqual({ kind: "rename", baseName: fa });
|
||||||
expect(db).toEqual({ kind: "rename", baseName: fb });
|
expect(db).toEqual({ kind: "rename", baseName: fb });
|
||||||
// Verschiedene Zielnamen -> keine "(2)"-Kollision beim Sammeln.
|
|
||||||
expect((da as any).baseName).not.toBe((db as any).baseName);
|
expect((da as any).baseName).not.toBe((db as any).baseName);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -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)", () => {
|
it("does NOT use a bare episode folder WITHOUT any codec/resolution marker (stays conservative)", () => {
|
||||||
// "Show.S01E01" allein (keine Qualitaets-/Codec-Info, kein -GROUP) ist mehrdeutig ->
|
|
||||||
// weiterhin kein Ableiten (kein Over-Firing auf generische Ordner).
|
|
||||||
const decision = decideAutoRenameBaseName(["Show.S01E01", hash], "abc-s01e01.avi", "abc-s01e01", hash, hash);
|
const decision = decideAutoRenameBaseName(["Show.S01E01", hash], "abc-s01e01.avi", "abc-s01e01", hash, hash);
|
||||||
expect(decision.kind).toBe("skip");
|
expect(decision.kind).toBe("skip");
|
||||||
});
|
});
|
||||||
@ -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", () => {
|
describe("collect must not mangle an already-clean SxxExx name via an episode-title folder", () => {
|
||||||
const hash = "c284d9d9072eaf3ac314d05f951dd115";
|
const hash = "c284d9d9072eaf3ac314d05f951dd115";
|
||||||
// Echter Bug (rename-session 2026-06-05): Miniserie "Steven Spielbergs Taken". Auto-Rename
|
|
||||||
// benannte korrekt zu "...S01E01...-GTVG". Der per-Episode-Ordner traegt aber nur einen
|
|
||||||
// Episode-only-Token + Titel ("...E01.Hinter.dem.Himmel...-GTVG", KEIN S01). Der Collect leitete
|
|
||||||
// daraus neu ab und HAENGTE den Quell-Token verkrueppelt an: "...-GTVG.S01E01" → in der Library
|
|
||||||
// stand dann "E01.Titel...S01E01" statt sauber "S01E01".
|
|
||||||
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
|
const epFolder = "Steven.Spielbergs.Taken.E01.Hinter.dem.Himmel.German.720p.HDTV.x264-GTVG";
|
||||||
const pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
|
const pkgFolder = "Steven.Spielbergs.Taken.S01.German.720p.HDTV.x264-GTVG";
|
||||||
const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG";
|
const cleanSource = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG";
|
||||||
@ -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", () => {
|
it("keeps the clean source (skip) instead of appending the token to the episode-title folder", () => {
|
||||||
const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder);
|
const decision = decideAutoRenameBaseName([epFolder, pkgFolder], cleanSource + ".mkv", cleanSource, epFolder, pkgFolder);
|
||||||
expect(decision.kind).toBe("skip");
|
expect(decision.kind).toBe("skip");
|
||||||
// NICHT der verkrueppelte "...-GTVG.S01E01"-Name.
|
|
||||||
expect(JSON.stringify(decision)).not.toContain("GTVG.S01E01");
|
expect(JSON.stringify(decision)).not.toContain("GTVG.S01E01");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still cleans a JUNK/obfuscated source via an episode-title folder (append path intact, no skip)", () => {
|
it("still cleans a JUNK/obfuscated source via an episode-title folder (append path intact, no skip)", () => {
|
||||||
// Obfuskierter Hoster-Name (obf=true) → meine Klausel greift NICHT (sie behaelt nur saubere
|
|
||||||
// Quellen). Mit Season-Ordner als Kontext (Root-Guard ok) wird der Token weiter angewandt:
|
|
||||||
// die Quelle wird NICHT roh behalten. Pruegt, dass der Fix nicht zu breit ist.
|
|
||||||
const epFolder = "Show.E05.Die.Sache.German.720p.HDTV.x264-GRP";
|
const epFolder = "Show.E05.Die.Sache.German.720p.HDTV.x264-GRP";
|
||||||
const seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP";
|
const seasonFolder = "Show.S01.German.720p.HDTV.x264-GRP";
|
||||||
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder);
|
const decision = decideAutoRenameBaseName([epFolder, seasonFolder], "scn-show7-S01E05.mkv", "scn-show7-S01E05", epFolder, seasonFolder);
|
||||||
@ -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", () => {
|
it("keeps a clean SHORT-prefix series source (ER) instead of the crippled token append", () => {
|
||||||
// Adversarial-Befund: die Praefix-Laenge darf KEIN Kriterium sein. Kurze Serien (ER, V, 24, Yu)
|
|
||||||
// sind genauso autoritativ; mit dem alten `hasMeaningfulSeriesPrefix`-Konjunkt (>=3 Alpha vor S0x)
|
|
||||||
// waere ER durchgefallen -> selber verkrueppelter Name wie der gemeldete Bug.
|
|
||||||
const epFolder = "ER.E01.Tag.und.Nacht.German.720p.HDTV.x264-GROUP";
|
const epFolder = "ER.E01.Tag.und.Nacht.German.720p.HDTV.x264-GROUP";
|
||||||
const seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP";
|
const seasonFolder = "ER.S01.German.720p.HDTV.x264-GROUP";
|
||||||
const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP";
|
const cleanSource = "ER.S01E01.German.720p.HDTV.x264-GROUP";
|
||||||
|
|||||||
@ -1,86 +1,82 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
|
import { encryptBackup, decryptBackup } from "../src/main/backup-crypto";
|
||||||
|
|
||||||
describe("backup-crypto", () => {
|
describe("backup-crypto", () => {
|
||||||
it("encrypts and decrypts a round-trip correctly", () => {
|
it("encrypts and decrypts a round-trip correctly", () => {
|
||||||
const original = JSON.stringify({
|
const original = JSON.stringify({
|
||||||
version: 2,
|
version: 2,
|
||||||
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
|
settings: { token: "my-secret-api-key", outputDir: "C:\\Downloads" },
|
||||||
session: { packages: {}, items: {} },
|
session: { packages: {}, items: {} },
|
||||||
history: [{ id: "h1", name: "Test" }]
|
history: [{ id: "h1", name: "Test" }]
|
||||||
});
|
});
|
||||||
|
|
||||||
const encrypted = encryptBackup(original);
|
const encrypted = encryptBackup(original);
|
||||||
const decrypted = decryptBackup(encrypted);
|
const decrypted = decryptBackup(encrypted);
|
||||||
expect(decrypted).toBe(original);
|
expect(decrypted).toBe(original);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("produces binary output that is not plaintext readable", () => {
|
it("produces binary output that is not plaintext readable", () => {
|
||||||
const secret = "super-secret-token-12345";
|
const secret = "super-secret-token-12345";
|
||||||
const plaintext = JSON.stringify({ settings: { token: secret } });
|
const plaintext = JSON.stringify({ settings: { token: secret } });
|
||||||
const encrypted = encryptBackup(plaintext);
|
const encrypted = encryptBackup(plaintext);
|
||||||
|
|
||||||
// The encrypted buffer should NOT contain the secret in plaintext
|
expect(encrypted.toString("utf8")).not.toContain(secret);
|
||||||
expect(encrypted.toString("utf8")).not.toContain(secret);
|
expect(encrypted.toString("latin1")).not.toContain(secret);
|
||||||
expect(encrypted.toString("latin1")).not.toContain(secret);
|
});
|
||||||
});
|
|
||||||
|
it("starts with the MDD1 magic bytes", () => {
|
||||||
it("starts with the MDD1 magic bytes", () => {
|
const encrypted = encryptBackup("test");
|
||||||
const encrypted = encryptBackup("test");
|
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
|
||||||
expect(encrypted.subarray(0, 4).toString("utf8")).toBe("MDD1");
|
});
|
||||||
});
|
|
||||||
|
it("produces different ciphertext for the same input (random IV)", () => {
|
||||||
it("produces different ciphertext for the same input (random IV)", () => {
|
const plaintext = "same input data";
|
||||||
const plaintext = "same input data";
|
const a = encryptBackup(plaintext);
|
||||||
const a = encryptBackup(plaintext);
|
const b = encryptBackup(plaintext);
|
||||||
const b = encryptBackup(plaintext);
|
expect(a.equals(b)).toBe(false);
|
||||||
// IVs are different, so full buffers must differ
|
expect(decryptBackup(a)).toBe(plaintext);
|
||||||
expect(a.equals(b)).toBe(false);
|
expect(decryptBackup(b)).toBe(plaintext);
|
||||||
// But both decrypt to the same plaintext
|
});
|
||||||
expect(decryptBackup(a)).toBe(plaintext);
|
|
||||||
expect(decryptBackup(b)).toBe(plaintext);
|
it("throws on truncated data", () => {
|
||||||
});
|
const encrypted = encryptBackup("test data");
|
||||||
|
const truncated = encrypted.subarray(0, 10);
|
||||||
it("throws on truncated data", () => {
|
expect(() => decryptBackup(truncated)).toThrow();
|
||||||
const encrypted = encryptBackup("test data");
|
});
|
||||||
const truncated = encrypted.subarray(0, 10);
|
|
||||||
expect(() => decryptBackup(truncated)).toThrow();
|
it("throws on corrupted ciphertext", () => {
|
||||||
});
|
const encrypted = encryptBackup("test data");
|
||||||
|
const corrupted = Buffer.from(encrypted);
|
||||||
it("throws on corrupted ciphertext", () => {
|
corrupted[corrupted.length - 1] ^= 0xff;
|
||||||
const encrypted = encryptBackup("test data");
|
expect(() => decryptBackup(corrupted)).toThrow();
|
||||||
// Flip a byte in the ciphertext area
|
});
|
||||||
const corrupted = Buffer.from(encrypted);
|
|
||||||
corrupted[corrupted.length - 1] ^= 0xff;
|
it("throws on wrong magic bytes", () => {
|
||||||
expect(() => decryptBackup(corrupted)).toThrow();
|
const encrypted = encryptBackup("test data");
|
||||||
});
|
const wrongMagic = Buffer.from(encrypted);
|
||||||
|
wrongMagic[0] = 0x00;
|
||||||
it("throws on wrong magic bytes", () => {
|
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
|
||||||
const encrypted = encryptBackup("test data");
|
});
|
||||||
const wrongMagic = Buffer.from(encrypted);
|
|
||||||
wrongMagic[0] = 0x00;
|
it("throws on empty buffer", () => {
|
||||||
expect(() => decryptBackup(wrongMagic)).toThrow(/Signatur/);
|
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws on empty buffer", () => {
|
it("handles large payloads", () => {
|
||||||
expect(() => decryptBackup(Buffer.alloc(0))).toThrow();
|
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
|
||||||
});
|
const encrypted = encryptBackup(large);
|
||||||
|
const decrypted = decryptBackup(encrypted);
|
||||||
it("handles large payloads", () => {
|
expect(decrypted).toBe(large);
|
||||||
const large = JSON.stringify({ data: "x".repeat(1_000_000) });
|
});
|
||||||
const encrypted = encryptBackup(large);
|
|
||||||
const decrypted = decryptBackup(encrypted);
|
it("handles unicode content", () => {
|
||||||
expect(decrypted).toBe(large);
|
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
|
||||||
});
|
const encrypted = encryptBackup(unicode);
|
||||||
|
expect(decryptBackup(encrypted)).toBe(unicode);
|
||||||
it("handles unicode content", () => {
|
});
|
||||||
const unicode = JSON.stringify({ name: "Ünïcödé 日本語 🎉", path: "C:\\Benutzer\\Ö" });
|
|
||||||
const encrypted = encryptBackup(unicode);
|
it("handles empty string round-trip", () => {
|
||||||
expect(decryptBackup(encrypted)).toBe(unicode);
|
const encrypted = encryptBackup("");
|
||||||
});
|
expect(decryptBackup(encrypted)).toBe("");
|
||||||
|
});
|
||||||
it("handles empty string round-trip", () => {
|
});
|
||||||
const encrypted = encryptBackup("");
|
|
||||||
expect(decryptBackup(encrypted)).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -73,7 +73,6 @@ describe("bestdebrid-web", () => {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(filePath, { force: true });
|
fs.rmSync(filePath, { force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore temp cleanup failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,109 +1,100 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
|
import { cleanupCancelledPackageArtifacts, removeDownloadLinkArtifacts, removeSampleArtifacts } from "../src/main/cleanup";
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("cleanup", () => {
|
describe("cleanup", () => {
|
||||||
it("removes archive artifacts but keeps media", () => {
|
it("removes archive artifacts but keeps media", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
|
fs.writeFileSync(path.join(dir, "release.part1.rar"), "x");
|
||||||
fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
|
fs.writeFileSync(path.join(dir, "movie.mkv"), "x");
|
||||||
|
|
||||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
const removed = cleanupCancelledPackageArtifacts(dir);
|
||||||
expect(removed).toBeGreaterThan(0);
|
expect(removed).toBeGreaterThan(0);
|
||||||
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
|
expect(fs.existsSync(path.join(dir, "release.part1.rar"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(dir, "movie.mkv"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes sample artifacts and link files", async () => {
|
it("removes sample artifacts and link files", async () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
|
fs.mkdirSync(path.join(dir, "Samples"), { recursive: true });
|
||||||
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
|
fs.writeFileSync(path.join(dir, "Samples", "demo-sample.mkv"), "x");
|
||||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
|
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://example.com/a\n");
|
||||||
|
|
||||||
const links = await removeDownloadLinkArtifacts(dir);
|
const links = await removeDownloadLinkArtifacts(dir);
|
||||||
const samples = await removeSampleArtifacts(dir);
|
const samples = await removeSampleArtifacts(dir);
|
||||||
expect(links).toBeGreaterThan(0);
|
expect(links).toBeGreaterThan(0);
|
||||||
expect(samples.files + samples.dirs).toBeGreaterThan(0);
|
expect(samples.files + samples.dirs).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up archive files in nested directories", () => {
|
it("cleans up archive files in nested directories", () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|
||||||
// Create nested directory structure with archive files
|
const sub1 = path.join(dir, "season1");
|
||||||
const sub1 = path.join(dir, "season1");
|
const sub2 = path.join(dir, "season1", "extras");
|
||||||
const sub2 = path.join(dir, "season1", "extras");
|
fs.mkdirSync(sub2, { recursive: true });
|
||||||
fs.mkdirSync(sub2, { recursive: true });
|
|
||||||
|
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
|
||||||
fs.writeFileSync(path.join(sub1, "episode.part1.rar"), "x");
|
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
|
||||||
fs.writeFileSync(path.join(sub1, "episode.part2.rar"), "x");
|
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
|
||||||
fs.writeFileSync(path.join(sub2, "bonus.zip"), "x");
|
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
|
||||||
fs.writeFileSync(path.join(sub2, "bonus.7z"), "x");
|
fs.writeFileSync(path.join(sub1, "video.mkv"), "real content");
|
||||||
// Non-archive files should be kept
|
fs.writeFileSync(path.join(sub2, "subtitle.srt"), "subtitle content");
|
||||||
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);
|
||||||
const removed = cleanupCancelledPackageArtifacts(dir);
|
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
|
||||||
expect(removed).toBe(4); // 2 rar parts + zip + 7z
|
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(sub1, "episode.part1.rar"))).toBe(false);
|
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(sub1, "episode.part2.rar"))).toBe(false);
|
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(sub2, "bonus.zip"))).toBe(false);
|
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(sub2, "bonus.7z"))).toBe(false);
|
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
|
||||||
// Non-archives kept
|
});
|
||||||
expect(fs.existsSync(path.join(sub1, "video.mkv"))).toBe(true);
|
|
||||||
expect(fs.existsSync(path.join(sub2, "subtitle.srt"))).toBe(true);
|
it("detects link artifacts by URL content in text files", async () => {
|
||||||
});
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
|
tempDirs.push(dir);
|
||||||
it("detects link artifacts by URL content in text files", async () => {
|
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
|
||||||
tempDirs.push(dir);
|
fs.writeFileSync(path.join(dir, "my_downloads.txt"), "Just some random text without URLs");
|
||||||
|
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
||||||
// File with link-like name containing URLs should be removed
|
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
||||||
fs.writeFileSync(path.join(dir, "download_links.txt"), "https://rapidgator.net/file/abc123\nhttps://uploaded.net/file/def456\n");
|
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
|
||||||
// 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");
|
const removed = await removeDownloadLinkArtifacts(dir);
|
||||||
// Regular text file that doesn't match the link pattern should be kept
|
expect(removed).toBeGreaterThanOrEqual(3);
|
||||||
fs.writeFileSync(path.join(dir, "readme.txt"), "https://example.com");
|
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
|
||||||
// .url files should always be removed
|
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
|
||||||
fs.writeFileSync(path.join(dir, "bookmark.url"), "[InternetShortcut]\nURL=https://example.com");
|
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
|
||||||
// .dlc files should always be removed
|
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
||||||
fs.writeFileSync(path.join(dir, "container.dlc"), "encrypted-data");
|
});
|
||||||
|
|
||||||
const removed = await removeDownloadLinkArtifacts(dir);
|
it("does not recurse into sample symlink or junction targets", async () => {
|
||||||
expect(removed).toBeGreaterThanOrEqual(3); // download_links.txt + bookmark.url + container.dlc
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
||||||
expect(fs.existsSync(path.join(dir, "download_links.txt"))).toBe(false);
|
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
|
||||||
expect(fs.existsSync(path.join(dir, "bookmark.url"))).toBe(false);
|
tempDirs.push(dir, external);
|
||||||
expect(fs.existsSync(path.join(dir, "container.dlc"))).toBe(false);
|
|
||||||
// Non-matching files should be kept
|
const outsideFile = path.join(external, "outside-sample.mkv");
|
||||||
expect(fs.existsSync(path.join(dir, "readme.txt"))).toBe(true);
|
fs.writeFileSync(outsideFile, "keep", "utf8");
|
||||||
});
|
|
||||||
|
const linkedSampleDir = path.join(dir, "sample");
|
||||||
it("does not recurse into sample symlink or junction targets", async () => {
|
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-"));
|
fs.symlinkSync(external, linkedSampleDir, linkType);
|
||||||
const external = fs.mkdtempSync(path.join(os.tmpdir(), "rd-clean-ext-"));
|
|
||||||
tempDirs.push(dir, external);
|
const result = await removeSampleArtifacts(dir);
|
||||||
|
expect(result.files).toBe(0);
|
||||||
const outsideFile = path.join(external, "outside-sample.mkv");
|
expect(fs.existsSync(outsideFile)).toBe(true);
|
||||||
fs.writeFileSync(outsideFile, "keep", "utf8");
|
});
|
||||||
|
});
|
||||||
const linkedSampleDir = path.join(dir, "sample");
|
|
||||||
const linkType: fs.symlink.Type = process.platform === "win32" ? "junction" : "dir";
|
|
||||||
fs.symlinkSync(external, linkedSampleDir, linkType);
|
|
||||||
|
|
||||||
const result = await removeSampleArtifacts(dir);
|
|
||||||
expect(result.files).toBe(0);
|
|
||||||
expect(fs.existsSync(outsideFile)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@ -22,9 +22,7 @@ describe("container", () => {
|
|||||||
const oversizedFilePath = path.join(dir, "oversized.dlc");
|
const oversizedFilePath = path.join(dir, "oversized.dlc");
|
||||||
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
|
fs.writeFileSync(oversizedFilePath, Buffer.alloc((8 * 1024 * 1024) + 1, 1));
|
||||||
|
|
||||||
// Create a valid mockup DLC that would be skipped if an error was thrown
|
|
||||||
const validFilePath = path.join(dir, "valid.dlc");
|
const validFilePath = path.join(dir, "valid.dlc");
|
||||||
// Just needs to be short enough to pass file limits but fail parsing, triggering dcrypt fallback
|
|
||||||
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
|
fs.writeFileSync(validFilePath, Buffer.from("Valid but not real DLC content..."));
|
||||||
|
|
||||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||||
@ -38,7 +36,6 @@ describe("container", () => {
|
|||||||
|
|
||||||
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
|
const result = await importDlcContainers([oversizedFilePath, validFilePath]);
|
||||||
|
|
||||||
// Expect the oversized to be silently skipped, and valid to be parsed into 1 package with DLC filename
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].name).toBe("valid");
|
expect(result[0].name).toBe("valid");
|
||||||
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
|
expect(result[0].links).toEqual(["http://example.com/file1.rar", "http://example.com/file2.rar"]);
|
||||||
@ -60,17 +57,14 @@ describe("container", () => {
|
|||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const filePath = path.join(dir, "fallback.dlc");
|
const filePath = path.join(dir, "fallback.dlc");
|
||||||
|
|
||||||
// A file large enough to trigger local decryption attempt (needs > 89 bytes to pass the slice check)
|
|
||||||
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
fs.writeFileSync(filePath, Buffer.alloc(100, 1).toString("base64"));
|
||||||
|
|
||||||
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
const fetchSpy = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("service.jdownloader.org")) {
|
if (urlStr.includes("service.jdownloader.org")) {
|
||||||
// Mock local RC service failure (returning 404)
|
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
if (urlStr.includes("dcrypt.it/decrypt/upload")) {
|
||||||
// Mock dcrypt fallback success
|
|
||||||
return new Response("http://fallback.com/1", { status: 200 });
|
return new Response("http://fallback.com/1", { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("", { status: 404 });
|
return new Response("", { status: 404 });
|
||||||
@ -81,7 +75,6 @@ describe("container", () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].name).toBe("fallback");
|
expect(result[0].name).toBe("fallback");
|
||||||
expect(result[0].links).toEqual(["http://fallback.com/1"]);
|
expect(result[0].links).toEqual(["http://fallback.com/1"]);
|
||||||
// Should have tried both!
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -135,7 +128,6 @@ describe("container", () => {
|
|||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].name).toBe("big-dlc");
|
expect(result[0].name).toBe("big-dlc");
|
||||||
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
|
expect(result[0].links).toEqual(["http://paste-fallback.com/file1.rar", "http://paste-fallback.com/file2.rar"]);
|
||||||
// local RC + upload + paste = 3 calls
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
4284
tests/debrid.test.ts
4284
tests/debrid.test.ts
File diff suppressed because it is too large
Load Diff
@ -78,7 +78,6 @@ async function waitForReady(url: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// retry
|
|
||||||
}
|
}
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
}
|
}
|
||||||
@ -314,7 +313,6 @@ afterEach(() => {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cleanup failures
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -24,7 +24,6 @@ afterEach(() => {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createdTmpDirs.length = 0;
|
createdTmpDirs.length = 0;
|
||||||
@ -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", () => {
|
it("self-heals: recreates the whole Downloader-Log FOLDER and file if it is deleted mid-session", () => {
|
||||||
// Genau die User-Anforderung: Ordner zur Laufzeit geloescht -> beim naechsten
|
|
||||||
// Rename automatisch wieder da, selbst wenn das Programm offen ist.
|
|
||||||
const desktop = tmpDesktop();
|
const desktop = tmpDesktop();
|
||||||
initDesktopRenameLog(desktop);
|
initDesktopRenameLog(desktop);
|
||||||
const logPath = getDesktopRenameLogPath() as string;
|
const logPath = getDesktopRenameLogPath() as string;
|
||||||
@ -60,7 +57,6 @@ describe("desktop-rename-log", () => {
|
|||||||
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
|
fs.rmSync(path.join(desktop, "Downloader-Log"), { recursive: true, force: true });
|
||||||
expect(fs.existsSync(logPath)).toBe(false);
|
expect(fs.existsSync(logPath)).toBe(false);
|
||||||
|
|
||||||
// Naechster Vorgang muss Ordner UND Datei (mit Header) selbstheilend neu anlegen.
|
|
||||||
logDesktopRename("INFO", "ZeileB");
|
logDesktopRename("INFO", "ZeileB");
|
||||||
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
|
expect(fs.existsSync(path.join(desktop, "Downloader-Log"))).toBe(true);
|
||||||
expect(fs.existsSync(logPath)).toBe(true);
|
expect(fs.existsSync(logPath)).toBe(true);
|
||||||
@ -80,7 +76,7 @@ describe("desktop-rename-log", () => {
|
|||||||
const dir = tmpDesktop();
|
const dir = tmpDesktop();
|
||||||
const source = path.join(dir, "scn-xyz.part1.rar");
|
const source = path.join(dir, "scn-xyz.part1.rar");
|
||||||
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
|
const target = path.join(dir, "Movie.2024.German.1080p.part1.rar");
|
||||||
fs.writeFileSync(target, "data"); // Post-Rename-Zustand: Ziel da, Quelle weg.
|
fs.writeFileSync(target, "data");
|
||||||
|
|
||||||
const v = verifyRename(source, target);
|
const v = verifyRename(source, target);
|
||||||
expect(v.ok).toBe(true);
|
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", () => {
|
it("verifyRename: FAILS (half-done move) when the source still exists next to the target", () => {
|
||||||
// EXDEV-Copy gelang, aber rm(source) schlug fehl -> Ziel da, Quelle auch noch.
|
|
||||||
const dir = tmpDesktop();
|
const dir = tmpDesktop();
|
||||||
const source = path.join(dir, "src.rar");
|
const source = path.join(dir, "src.rar");
|
||||||
const target = path.join(dir, "dst.rar");
|
const target = path.join(dir, "dst.rar");
|
||||||
|
|||||||
@ -26,8 +26,6 @@ describe("download-completion", () => {
|
|||||||
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
|
const contentLength = (n: number) => ({ expectedTotal: n, source: "content-length" as const, canFinishEarly: true });
|
||||||
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
|
const providerMeta = (n: number) => ({ expectedTotal: n, source: "provider-metadata" as const, canFinishEarly: false });
|
||||||
|
|
||||||
// H3 regression: a stream-end download (no Content-Length, no provider size)
|
|
||||||
// that yielded 0 bytes is a FAILED download, not a valid completion.
|
|
||||||
it("rejects a 0-byte stream-end download (H3)", () => {
|
it("rejects a 0-byte stream-end download (H3)", () => {
|
||||||
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
|
const result = validateDownloadedFileCompletion({ actualBytes: 0, plan: streamEnd });
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
@ -57,7 +55,6 @@ describe("download-completion", () => {
|
|||||||
|
|
||||||
it("accepts provider-metadata download and flags size mismatch", () => {
|
it("accepts provider-metadata download and flags size mismatch", () => {
|
||||||
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
|
const result = validateDownloadedFileCompletion({ actualBytes: 1900, plan: providerMeta(2000), toleranceBytes: 0 });
|
||||||
// provider-metadata: shorter than expected -> underflow rejected
|
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2453,8 +2453,6 @@ describe("download manager", () => {
|
|||||||
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
await waitFor(() => !manager.getSnapshot().session.running, 25000);
|
||||||
|
|
||||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
// Content-Length matches actual bytes sent, so completion validation passes
|
|
||||||
// (HTTP-level truth takes priority over provider-metadata filesize).
|
|
||||||
expect(item?.status).toBe("completed");
|
expect(item?.status).toBe("completed");
|
||||||
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(actual.length);
|
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(actual.length);
|
||||||
} finally {
|
} finally {
|
||||||
@ -3292,7 +3290,6 @@ describe("download manager", () => {
|
|||||||
expect(item?.status).toBe("completed");
|
expect(item?.status).toBe("completed");
|
||||||
expect(item?.targetPath).toBe(existingTargetPath);
|
expect(item?.targetPath).toBe(existingTargetPath);
|
||||||
expect(sawResumeRange).toBe(true);
|
expect(sawResumeRange).toBe(true);
|
||||||
// Allow ALLOCATION_UNIT_SIZE (4096) tolerance for write-flush timing on Windows
|
|
||||||
const fileSize = fs.statSync(existingTargetPath).size;
|
const fileSize = fs.statSync(existingTargetPath).size;
|
||||||
expect(fileSize).toBeGreaterThanOrEqual(binary.length - 4096);
|
expect(fileSize).toBeGreaterThanOrEqual(binary.length - 4096);
|
||||||
expect(fileSize).toBeLessThanOrEqual(binary.length);
|
expect(fileSize).toBeLessThanOrEqual(binary.length);
|
||||||
@ -3578,9 +3575,6 @@ describe("download manager", () => {
|
|||||||
const item = manager.getSnapshot().session.items[itemId];
|
const item = manager.getSnapshot().session.items[itemId];
|
||||||
expect(item?.status).toBe("completed");
|
expect(item?.status).toBe("completed");
|
||||||
expect(item?.provider).toBe("debridlink");
|
expect(item?.provider).toBe("debridlink");
|
||||||
// downloadedBytes may reflect stat.size which can be within ALLOCATION_UNIT_SIZE
|
|
||||||
// tolerance of the expected total, or the original partial size if the settle
|
|
||||||
// recovery finalized the item from disk before retry completed.
|
|
||||||
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(partialSize);
|
expect(item?.downloadedBytes).toBeGreaterThanOrEqual(partialSize);
|
||||||
expect(item?.downloadedBytes).toBeLessThanOrEqual(binary.length);
|
expect(item?.downloadedBytes).toBeLessThanOrEqual(binary.length);
|
||||||
expect(unrestrictCalls).toBeGreaterThanOrEqual(1);
|
expect(unrestrictCalls).toBeGreaterThanOrEqual(1);
|
||||||
@ -4401,7 +4395,6 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
for (const [index, archiveName] of archiveNames.entries()) {
|
for (const [index, archiveName] of archiveNames.entries()) {
|
||||||
const targetPath = path.join(outputDir, archiveName);
|
const targetPath = path.join(outputDir, archiveName);
|
||||||
// Write garbage content (no valid archive signature) — simulates corrupt download
|
|
||||||
fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA));
|
fs.writeFileSync(targetPath, Buffer.alloc(archiveSize, 0xAA));
|
||||||
session.items[itemIds[index]!] = {
|
session.items[itemIds[index]!] = {
|
||||||
id: itemIds[index]!,
|
id: itemIds[index]!,
|
||||||
@ -4450,7 +4443,6 @@ describe("download manager", () => {
|
|||||||
"hybrid"
|
"hybrid"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Invalid archive signature = genuine corruption → force re-download
|
|
||||||
expect(changed).toBe(2);
|
expect(changed).toBe(2);
|
||||||
for (const itemId of itemIds) {
|
for (const itemId of itemIds) {
|
||||||
const item = session.items[itemId]!;
|
const item = session.items[itemId]!;
|
||||||
@ -4494,7 +4486,6 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
for (const [index, archiveName] of archiveNames.entries()) {
|
for (const [index, archiveName] of archiveNames.entries()) {
|
||||||
const targetPath = path.join(outputDir, archiveName);
|
const targetPath = path.join(outputDir, archiveName);
|
||||||
// Write file with valid RAR5 signature — simulates wrong password, not corruption
|
|
||||||
const content = Buffer.alloc(archiveSize, 0);
|
const content = Buffer.alloc(archiveSize, 0);
|
||||||
Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content);
|
Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00]).copy(content);
|
||||||
fs.writeFileSync(targetPath, content);
|
fs.writeFileSync(targetPath, content);
|
||||||
@ -4545,7 +4536,6 @@ describe("download manager", () => {
|
|||||||
"hybrid"
|
"hybrid"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Valid RAR signature = file is structurally intact → wrong password, don't re-download
|
|
||||||
expect(changed).toBe(0);
|
expect(changed).toBe(0);
|
||||||
for (const itemId of itemIds) {
|
for (const itemId of itemIds) {
|
||||||
const item = session.items[itemId]!;
|
const item = session.items[itemId]!;
|
||||||
@ -6129,9 +6119,6 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
const item = Object.values(manager.getSnapshot().session.items)[0];
|
const item = Object.values(manager.getSnapshot().session.items)[0];
|
||||||
expect(item?.status).toBe("completed");
|
expect(item?.status).toBe("completed");
|
||||||
// On Windows with pre-allocation, the tiny error-page detection is masked
|
|
||||||
// (stat reports pre-allocated size, not actual written bytes), so the first
|
|
||||||
// download may be accepted without a retry.
|
|
||||||
if (process.platform !== "win32") {
|
if (process.platform !== "win32") {
|
||||||
expect(directCalls).toBeGreaterThan(1);
|
expect(directCalls).toBeGreaterThan(1);
|
||||||
}
|
}
|
||||||
@ -6145,7 +6132,6 @@ describe("download manager", () => {
|
|||||||
it("accepts small .sfv metadata files without rejecting them as suspicious", async () => {
|
it("accepts small .sfv metadata files without rejecting them as suspicious", async () => {
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
// SFV content is just CRC32 checksums — legitimately tiny
|
|
||||||
const sfvContent = Buffer.from("archive.part1.rar 1A2B3C4D\narchive.part2.rar 5E6F7A8B\n", "utf8");
|
const sfvContent = Buffer.from("archive.part1.rar 1A2B3C4D\narchive.part2.rar 5E6F7A8B\n", "utf8");
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
@ -9359,12 +9345,6 @@ describe("download manager", () => {
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("hybrid collect defers fresh files instead of moving them unrenamed; final pass collects them", async () => {
|
it("hybrid collect defers fresh files instead of moving them unrenamed; final pass collects them", async () => {
|
||||||
// Regression: User-Report — bei Hybrid-Extraktion blieben 1-2 Dateien pro
|
|
||||||
// Staffel unbenannt (mit Original-Scene-Namen in der Library). Ursache: eine
|
|
||||||
// frisch extrahierte Datei wird vom Auto-Rename absichtlich deferred (noch nicht
|
|
||||||
// stabil), aber der Collect moved sie vorher mit Original-Namen. Fix: der
|
|
||||||
// Hybrid-Collect (deferFreshFiles=true) ueberspringt frische Dateien; der finale
|
|
||||||
// Deferred-Pass (deferFreshFiles=false) sammelt sie nach Stabilisierung ein.
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
@ -9375,7 +9355,7 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
const mkvName = "grp-freshshow.s01e07-720p.mkv";
|
const mkvName = "grp-freshshow.s01e07-720p.mkv";
|
||||||
const mkvPath = path.join(extractDir, mkvName);
|
const mkvPath = path.join(extractDir, mkvName);
|
||||||
fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7)); // mtime = jetzt → "frisch"
|
fs.writeFileSync(mkvPath, Buffer.alloc(4096, 7));
|
||||||
|
|
||||||
const session = emptySession();
|
const session = emptySession();
|
||||||
const packageId = `${packageName}-pkg`;
|
const packageId = `${packageName}-pkg`;
|
||||||
@ -9410,18 +9390,14 @@ describe("download manager", () => {
|
|||||||
session,
|
session,
|
||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
);
|
);
|
||||||
// In Tests ist fileStabilizeMinAgeMs=0 (Frische-Erkennung aus) — fuer diesen
|
|
||||||
// Test aktivieren, damit die gerade erstellte Datei als "frisch" gilt.
|
|
||||||
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
||||||
|
|
||||||
const libPath = path.join(mkvLibraryDir, mkvName);
|
const libPath = path.join(mkvLibraryDir, mkvName);
|
||||||
|
|
||||||
// Hybrid-Collect (deferFreshFiles=true): frische Datei darf NICHT gemoved werden.
|
|
||||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, true);
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, true);
|
||||||
expect(fs.existsSync(libPath)).toBe(false);
|
expect(fs.existsSync(libPath)).toBe(false);
|
||||||
expect(fs.existsSync(mkvPath)).toBe(true);
|
expect(fs.existsSync(mkvPath)).toBe(true);
|
||||||
|
|
||||||
// Finaler Deferred-Pass (deferFreshFiles=false): sammelt die Datei trotzdem ein.
|
|
||||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||||
expect(fs.existsSync(libPath)).toBe(true);
|
expect(fs.existsSync(libPath)).toBe(true);
|
||||||
expect(fs.existsSync(mkvPath)).toBe(false);
|
expect(fs.existsSync(mkvPath)).toBe(false);
|
||||||
@ -9430,19 +9406,12 @@ describe("download manager", () => {
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("collect CLEANS a raw scene file that auto-rename never processed (the 17-file library bug)", async () => {
|
it("collect CLEANS a raw scene file that auto-rename never processed (the 17-file library bug)", async () => {
|
||||||
// Echter Bug aus rename-session_2026-06-02: Auto-Rename verpasste einzelne Dateien
|
|
||||||
// (verpasster Scan / lag ausserhalb der extractDir), der Collect schob sie dann ROH in
|
|
||||||
// die Library ("tvarchiv...s07e12-720.mkv"). Fix: Collect leitet den sauberen Namen
|
|
||||||
// selbst ab (gleiche Logik wie Auto-Rename) — die Library-Datei heisst garantiert sauber,
|
|
||||||
// auch wenn KEIN Auto-Rename-Pass die Datei je angefasst hat (hier: Collect direkt
|
|
||||||
// aufgerufen, ohne vorherigen Auto-Rename-Lauf).
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
const packageName = "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV";
|
const packageName = "Herzflimmern.Die.Klinik.am.See.S07.German.720p.Webrip.x264-TVARCHiV";
|
||||||
const outputDir = path.join(root, "downloads", packageName);
|
const outputDir = path.join(root, "downloads", packageName);
|
||||||
const extractDir = path.join(root, "extract", packageName);
|
const extractDir = path.join(root, "extract", packageName);
|
||||||
// Per-Episoden-Ordner (vom Release-Group sauber benannt) mit ROHER MKV darin.
|
|
||||||
const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV";
|
const episodeFolder = "Herzflimmern.Die.Klinik.am.See.S07E12.German.720p.Webrip.x264-TVARCHiV";
|
||||||
const epDir = path.join(extractDir, episodeFolder);
|
const epDir = path.join(extractDir, episodeFolder);
|
||||||
fs.mkdirSync(epDir, { recursive: true });
|
fs.mkdirSync(epDir, { recursive: true });
|
||||||
@ -9474,7 +9443,7 @@ describe("download manager", () => {
|
|||||||
outputDir: path.join(root, "downloads"),
|
outputDir: path.join(root, "downloads"),
|
||||||
extractDir: path.join(root, "extract"),
|
extractDir: path.join(root, "extract"),
|
||||||
autoExtract: true,
|
autoExtract: true,
|
||||||
autoRename4sf4sj: true, // Umbenennen AN — wie in der echten User-Config
|
autoRename4sf4sj: true,
|
||||||
collectMkvToLibrary: true,
|
collectMkvToLibrary: true,
|
||||||
mkvLibraryDir,
|
mkvLibraryDir,
|
||||||
enableIntegrityCheck: false,
|
enableIntegrityCheck: false,
|
||||||
@ -9484,13 +9453,10 @@ describe("download manager", () => {
|
|||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Collect DIREKT aufrufen, OHNE vorherigen Auto-Rename-Lauf — simuliert genau die
|
|
||||||
// verpasste Datei. deferFreshFiles=false (finaler Pass).
|
|
||||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||||
|
|
||||||
const cleanLibPath = path.join(mkvLibraryDir, `${episodeFolder}.mkv`);
|
const cleanLibPath = path.join(mkvLibraryDir, `${episodeFolder}.mkv`);
|
||||||
const rawLibPath = path.join(mkvLibraryDir, rawName);
|
const rawLibPath = path.join(mkvLibraryDir, rawName);
|
||||||
// Library-Datei heisst SAUBER, nicht roh; Quelle ist weg.
|
|
||||||
expect(fs.existsSync(cleanLibPath)).toBe(true);
|
expect(fs.existsSync(cleanLibPath)).toBe(true);
|
||||||
expect(fs.existsSync(rawLibPath)).toBe(false);
|
expect(fs.existsSync(rawLibPath)).toBe(false);
|
||||||
expect(fs.existsSync(rawPath)).toBe(false);
|
expect(fs.existsSync(rawPath)).toBe(false);
|
||||||
@ -9499,16 +9465,12 @@ describe("download manager", () => {
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("collect cleans a raw file sitting OUTSIDE extractDir (Downloader-Unfertig case) AND its .srt follows the rename", async () => {
|
it("collect cleans a raw file sitting OUTSIDE extractDir (Downloader-Unfertig case) AND its .srt follows the rename", async () => {
|
||||||
// Die 5 Fritzie-S04-Dateien lagen in "Downloader Unfertig" (= outputDir-Seite, NICHT
|
|
||||||
// extractDir) — Auto-Rename scannt nur extractDir, sah sie also nie. Collect muss sie
|
|
||||||
// trotzdem aus dem Staffel-Ordner heraus sauber benennen, und der Untertitel muss
|
|
||||||
// mit dem Video mitwandern (auf den GEAENDERTEN Namen).
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
const packageName = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
|
const packageName = "Fritzie.-.Der.Himmel.muss.warten.S04.GERMAN.720p.WEB.AVC-4SF";
|
||||||
const outputDir = path.join(root, "downloads", packageName); // = "Unfertig"-Aequivalent
|
const outputDir = path.join(root, "downloads", packageName);
|
||||||
const extractDir = path.join(root, "extract", packageName); // bleibt leer/fehlt
|
const extractDir = path.join(root, "extract", packageName);
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
const rawName = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
|
const rawName = "4sf-fritzie.himmel.muss.warten.web.7p-s04e01.mkv";
|
||||||
@ -9555,27 +9517,18 @@ describe("download manager", () => {
|
|||||||
const cleanBase = "Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF";
|
const cleanBase = "Fritzie.-.Der.Himmel.muss.warten.S04E01.GERMAN.720p.WEB.AVC-4SF";
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.mkv`))).toBe(true);
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.mkv`))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, rawName))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, rawName))).toBe(false);
|
||||||
// Untertitel folgt dem Video auf den sauberen Namen (Sprach-Suffix .de erhalten).
|
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.de.srt`))).toBe(true);
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${cleanBase}.de.srt`))).toBe(true);
|
||||||
|
|
||||||
void manager;
|
void manager;
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("collect MOVES a numbered episode whose TITLE is a bonus keyword (Revenge S04E19 'Interview')", async () => {
|
it("collect MOVES a numbered episode whose TITLE is a bonus keyword (Revenge S04E19 'Interview')", async () => {
|
||||||
// Echter Bug aus rd-support-bundle 2026-06-04: Revenge.2011.S04E19.Interview blieb roh
|
|
||||||
// in "Downloader Fertig" haengen — nie in die Library verschoben, KEIN Fehler. Ursache:
|
|
||||||
// der Episodentitel "Interview" (UND der Episoden-Ordnername) matcht BONUS_FILENAME_RE /
|
|
||||||
// isInsideBonusDir -> der Collect stufte die Folge als Bonus/Extras ein und skippte sie
|
|
||||||
// (nur logger.info, im Paket-Log unsichtbar). Eine Folge MIT gueltigem SxxExx-Token ist
|
|
||||||
// aber eine echte Episode, niemals Bonus. Betrifft Interview/Outtakes/Special/Featurette-
|
|
||||||
// Titel -> "selten, aber 4-5 Folgen pro grossem Download".
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC";
|
const packageName = "Revenge.2011.S04.GERMAN.DL.720p.WEB.x264-TSCC";
|
||||||
const outputDir = path.join(root, "downloads", packageName);
|
const outputDir = path.join(root, "downloads", packageName);
|
||||||
const extractDir = path.join(root, "extract", packageName);
|
const extractDir = path.join(root, "extract", packageName);
|
||||||
// Per-Episoden-Ordner UND Datei tragen beide das Bonus-Wort "Interview" — exakt der Fall.
|
|
||||||
const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
|
const episodeFolder = "Revenge.2011.S04E19.Interview.GERMAN.DL.720p.WEB.x264-TSCC";
|
||||||
const epDir = path.join(extractDir, episodeFolder);
|
const epDir = path.join(extractDir, episodeFolder);
|
||||||
fs.mkdirSync(epDir, { recursive: true });
|
fs.mkdirSync(epDir, { recursive: true });
|
||||||
@ -9618,7 +9571,6 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||||
|
|
||||||
// Die Folge MUSS in der Library liegen (nicht als Bonus verworfen) und die Quelle weg sein.
|
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
|
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(epDir, epName))).toBe(false);
|
expect(fs.existsSync(path.join(epDir, epName))).toBe(false);
|
||||||
|
|
||||||
@ -9626,9 +9578,6 @@ describe("download manager", () => {
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("collect STILL skips genuine bonus/extras with NO episode token (Making.Of) — proves the filter isn't disabled", async () => {
|
it("collect STILL skips genuine bonus/extras with NO episode token (Making.Of) — proves the filter isn't disabled", async () => {
|
||||||
// Guard zum Fix oben: eine echte Bonus-Datei OHNE SxxExx-Token (Making.Of) bleibt Bonus
|
|
||||||
// und darf NICHT in die Library wandern. Sonst haetten wir den Bonus-Filter nur kaputt
|
|
||||||
// gemacht statt praezisiert.
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
@ -9636,7 +9585,6 @@ describe("download manager", () => {
|
|||||||
const outputDir = path.join(root, "downloads", packageName);
|
const outputDir = path.join(root, "downloads", packageName);
|
||||||
const extractDir = path.join(root, "extract", packageName);
|
const extractDir = path.join(root, "extract", packageName);
|
||||||
fs.mkdirSync(extractDir, { recursive: true });
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
// Echte Episode (mit Token) + echtes Extra (ohne Token) im selben Paket.
|
|
||||||
const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv";
|
const epName = "Some.Show.S01E01.GERMAN.720p.WEB.x264-GRP.mkv";
|
||||||
const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv";
|
const bonusName = "Some.Show.Making.Of.GERMAN.720p.WEB.x264-GRP.mkv";
|
||||||
fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1));
|
fs.writeFileSync(path.join(extractDir, epName), Buffer.alloc(4096, 1));
|
||||||
@ -9678,7 +9626,6 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||||
|
|
||||||
// Echte Episode wandert in die Library; das Making-Of bleibt liegen (Bonus).
|
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
|
expect(fs.existsSync(path.join(mkvLibraryDir, epName))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, bonusName))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, bonusName))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(extractDir, bonusName))).toBe(true);
|
expect(fs.existsSync(path.join(extractDir, bonusName))).toBe(true);
|
||||||
@ -9687,10 +9634,6 @@ describe("download manager", () => {
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("collect CLEANS a raw .avi whose folder is a complete episode name WITHOUT a -GROUP suffix (safari S04E08a)", async () => {
|
it("collect CLEANS a raw .avi whose folder is a complete episode name WITHOUT a -GROUP suffix (safari S04E08a)", async () => {
|
||||||
// Echter Bug (rename-session 2026-06-04): alte deutsche Doku ohne Gruppen-Suffix
|
|
||||||
// (Ordner endet ".XviD", kein "-GROUP"). buildAutoRenameBaseName lieferte null -> die
|
|
||||||
// Folge landete ROH als "safari-fm-s04e08a.avi" in der Library. Der Part-Buchstabe a/b
|
|
||||||
// muss erhalten bleiben (Teil 1 vs Teil 2 duerfen nicht kollidieren).
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
@ -9743,7 +9686,6 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||||
|
|
||||||
// Beide Folgen sauber benannt in der Library, Part a/b distinkt, KEINE rohen Namen.
|
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderA}.avi`))).toBe(true);
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderA}.avi`))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderB}.avi`))).toBe(true);
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${folderB}.avi`))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08a.avi"))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, "safari-fm-s04e08a.avi"))).toBe(false);
|
||||||
@ -9753,10 +9695,6 @@ describe("download manager", () => {
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("collect KEEPS the clean SxxExx name and does NOT mangle it via an episode-title folder (Taken S01E01)", async () => {
|
it("collect KEEPS the clean SxxExx name and does NOT mangle it via an episode-title folder (Taken S01E01)", async () => {
|
||||||
// Echter Bug (rename-session 2026-06-05): Auto-Rename hatte die Datei bereits korrekt zu
|
|
||||||
// "...S01E01...-GTVG.mkv" benannt. Der per-Episode-Ordner traegt nur "E01" + Titel (kein S01).
|
|
||||||
// Der Collect leitete daraus neu ab und haengte den Token verkrueppelt an
|
|
||||||
// ("...-GTVG.S01E01"). Erwartung: der saubere S01E01-Name bleibt unangetastet.
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
@ -9767,7 +9705,6 @@ describe("download manager", () => {
|
|||||||
const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv";
|
const cleanName = "Steven.Spielbergs.Taken.S01E01.German.720p.HDTV.x264-GTVG.mkv";
|
||||||
const epDir = path.join(extractDir, epFolder);
|
const epDir = path.join(extractDir, epFolder);
|
||||||
fs.mkdirSync(epDir, { recursive: true });
|
fs.mkdirSync(epDir, { recursive: true });
|
||||||
// Datei liegt bereits SAUBER benannt vor (so wie Auto-Rename sie hinterlassen hat).
|
|
||||||
fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5));
|
fs.writeFileSync(path.join(epDir, cleanName), Buffer.alloc(4096, 5));
|
||||||
|
|
||||||
const session = emptySession();
|
const session = emptySession();
|
||||||
@ -9806,11 +9743,9 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||||
|
|
||||||
// Sauberer S01E01-Name bleibt; KEIN verkrueppelter "...E01.Titel...S01E01"-Name.
|
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, cleanName))).toBe(true);
|
expect(fs.existsSync(path.join(mkvLibraryDir, cleanName))).toBe(true);
|
||||||
const mangled = `${epFolder}.S01E01.mkv`;
|
const mangled = `${epFolder}.S01E01.mkv`;
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, mangled))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, mangled))).toBe(false);
|
||||||
// Nichts mit dem Episoden-Titel im Library-Ordner.
|
|
||||||
const inLib = fs.readdirSync(mkvLibraryDir);
|
const inLib = fs.readdirSync(mkvLibraryDir);
|
||||||
expect(inLib.some((n) => /Hinter\.dem\.Himmel/i.test(n))).toBe(false);
|
expect(inLib.some((n) => /Hinter\.dem\.Himmel/i.test(n))).toBe(false);
|
||||||
|
|
||||||
@ -9818,30 +9753,18 @@ describe("download manager", () => {
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => {
|
it("deferred final pass renames fresh files before collecting them (no scene names in library)", async () => {
|
||||||
// Folge-Fund zu 18eada9 (verifiziert via Advisor-Gate): 18eada9 schloss den
|
|
||||||
// "frische Datei landet unbenannt"-Bug nur fuer den HYBRID-Pfad (deferFreshFiles=true
|
|
||||||
// + Mehrfach-Pässe). Der finale Deferred-Pass (runDeferredPostExtraction) macht
|
|
||||||
// Rename (treatFilesAsStable? nein) -> Collect (deferFreshFiles=false). Ist eine
|
|
||||||
// Datei beim Deferred-Rename noch "frisch" (< fileStabilizeMinAgeMs) — z.B. eine
|
|
||||||
// gerade per Nested-Extraction (12045) geschriebene Datei — ueberspringt der
|
|
||||||
// Frische-Gate sie, und der Collect moved sie mit Original-Scene-Namen in die
|
|
||||||
// Library. Im Deferred-FINAL-Pass laeuft aber KEIN concurrent Extractor mehr
|
|
||||||
// (Extraktion abgeschlossen/awaited), der Frische-Gate ist dort ein False
|
|
||||||
// Positive. Fix: der Final-Pass-Rename behandelt alle Dateien als stabil
|
|
||||||
// (treatFilesAsStable=true) → benennt um, bevor der Collect sie sammelt.
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake";
|
const packageName = "Test.Show.S02.GERMAN.WS.720p.HDTV.x264-aWake";
|
||||||
const outputDir = path.join(root, "downloads", packageName);
|
const outputDir = path.join(root, "downloads", packageName);
|
||||||
const extractDir = path.join(root, "extract", packageName);
|
const extractDir = path.join(root, "extract", packageName);
|
||||||
// Episoden-Ordner liefert den kanonischen Zielnamen (enthaelt SxxExx).
|
|
||||||
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
|
const epFolder = path.join(extractDir, "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake");
|
||||||
fs.mkdirSync(epFolder, { recursive: true });
|
fs.mkdirSync(epFolder, { recursive: true });
|
||||||
|
|
||||||
const sceneName = "awa-testshow02e05hd.mkv";
|
const sceneName = "awa-testshow02e05hd.mkv";
|
||||||
const scenePath = path.join(epFolder, sceneName);
|
const scenePath = path.join(epFolder, sceneName);
|
||||||
fs.writeFileSync(scenePath, Buffer.alloc(4096, 5)); // mtime = jetzt → "frisch"
|
fs.writeFileSync(scenePath, Buffer.alloc(4096, 5));
|
||||||
|
|
||||||
const session = emptySession();
|
const session = emptySession();
|
||||||
const packageId = `${packageName}-pkg`;
|
const packageId = `${packageName}-pkg`;
|
||||||
@ -9876,22 +9799,15 @@ describe("download manager", () => {
|
|||||||
session,
|
session,
|
||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
);
|
);
|
||||||
// Produktion: fileStabilizeMinAgeMs=2000. Hier 30s, damit die gerade erstellte
|
|
||||||
// Datei garantiert als "frisch" gilt — wie eine eben extrahierte Datei, die der
|
|
||||||
// Deferred-Pass sofort danach verarbeitet.
|
|
||||||
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
||||||
|
|
||||||
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
||||||
const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`);
|
const renamedLibPath = path.join(mkvLibraryDir, `${expectedBase}.mkv`);
|
||||||
const sceneLibPath = path.join(mkvLibraryDir, sceneName);
|
const sceneLibPath = path.join(mkvLibraryDir, sceneName);
|
||||||
|
|
||||||
// Deferred-FINAL-Pass-Sequenz, exakt wie runDeferredPostExtraction:
|
|
||||||
// 1) Rename — treatFilesAsStable=true (Extraktion abgeschlossen, kein Frische-Skip)
|
|
||||||
// 2) Collect — deferFreshFiles=false
|
|
||||||
await (manager as any).autoRenameExtractedVideoFiles(extractDir, session.packages[packageId], undefined, true);
|
await (manager as any).autoRenameExtractedVideoFiles(extractDir, session.packages[packageId], undefined, true);
|
||||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId], undefined, false);
|
||||||
|
|
||||||
// Die Datei landet UMBENANNT in der Library — nicht mit dem Scene-Namen.
|
|
||||||
expect(fs.existsSync(renamedLibPath)).toBe(true);
|
expect(fs.existsSync(renamedLibPath)).toBe(true);
|
||||||
expect(fs.existsSync(sceneLibPath)).toBe(false);
|
expect(fs.existsSync(sceneLibPath)).toBe(false);
|
||||||
|
|
||||||
@ -9899,11 +9815,6 @@ describe("download manager", () => {
|
|||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("deferred post-extraction wiring renames fresh files end-to-end (treatFilesAsStable reaches the rename)", async () => {
|
it("deferred post-extraction wiring renames fresh files end-to-end (treatFilesAsStable reaches the rename)", async () => {
|
||||||
// Wiring-Lock zum vorherigen Test: stellt sicher, dass runDeferredPostExtraction
|
|
||||||
// den Rename TATSAECHLICH mit treatFilesAsStable=true aufruft. Wuerde jemand das
|
|
||||||
// `true` an der Call-Site (autoRenameExtractedVideoFiles(..., true)) entfernen,
|
|
||||||
// faellt dieser Test (frische Datei landet wieder unbenannt) — der reine
|
|
||||||
// Mechanism-Test wuerde das NICHT bemerken (er ruft den Rename selbst mit true).
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
@ -9915,7 +9826,7 @@ describe("download manager", () => {
|
|||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
const sceneName = "awa-testshow02e05hd.mkv";
|
const sceneName = "awa-testshow02e05hd.mkv";
|
||||||
fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5)); // mtime = jetzt → "frisch"
|
fs.writeFileSync(path.join(epFolder, sceneName), Buffer.alloc(4096, 5));
|
||||||
|
|
||||||
const session = emptySession();
|
const session = emptySession();
|
||||||
const packageId = `${packageName}-pkg`;
|
const packageId = `${packageName}-pkg`;
|
||||||
@ -9953,9 +9864,6 @@ describe("download manager", () => {
|
|||||||
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
(manager as any).fileStabilizeMinAgeMs = 30_000;
|
||||||
|
|
||||||
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
||||||
// Treibt den ECHTEN Produktionspfad: runDeferredPostExtraction → Rename
|
|
||||||
// (Call-Site mit treatFilesAsStable=true) → Collect (deferFreshFiles=false).
|
|
||||||
// success=1 (Collect-Gate), alreadyMarkedExtracted=true (Rename-Gate), failed=0.
|
|
||||||
await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
|
await (manager as any).runDeferredPostExtraction(packageId, session.packages[packageId], 1, 0, true, 1);
|
||||||
|
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true);
|
expect(fs.existsSync(path.join(mkvLibraryDir, `${expectedBase}.mkv`))).toBe(true);
|
||||||
@ -9973,7 +9881,6 @@ describe("download manager", () => {
|
|||||||
const extractDir = path.join(root, "extract", packageName);
|
const extractDir = path.join(root, "extract", packageName);
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
// Direct .mkv download (no archive) — wie es Mega-Debrid bei mega.nz liefert.
|
|
||||||
const directMkvName = "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv";
|
const directMkvName = "Direct.Show.S01E01.German.1080p.WEB.x264-DIRECT.mkv";
|
||||||
const directMkvPath = path.join(outputDir, directMkvName);
|
const directMkvPath = path.join(outputDir, directMkvName);
|
||||||
fs.writeFileSync(directMkvPath, Buffer.alloc(2048, 1));
|
fs.writeFileSync(directMkvPath, Buffer.alloc(2048, 1));
|
||||||
@ -10039,20 +9946,13 @@ describe("download manager", () => {
|
|||||||
await waitFor(() => fs.existsSync(libraryPath), 12000);
|
await waitFor(() => fs.existsSync(libraryPath), 12000);
|
||||||
|
|
||||||
expect(fs.existsSync(libraryPath)).toBe(true);
|
expect(fs.existsSync(libraryPath)).toBe(true);
|
||||||
// Filename darf NICHT umbenannt werden (Mega-Files sind oft schon korrekt benannt).
|
|
||||||
expect(fs.readFileSync(libraryPath).length).toBe(directMkvSize);
|
expect(fs.readFileSync(libraryPath).length).toBe(directMkvSize);
|
||||||
// Quelle ist weg (verschoben).
|
|
||||||
expect(fs.existsSync(directMkvPath)).toBe(false);
|
expect(fs.existsSync(directMkvPath)).toBe(false);
|
||||||
|
|
||||||
void manager;
|
void manager;
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
it("does NOT delete pending RAR archive sets in outputDir when collecting MKVs from extractDir", async () => {
|
it("does NOT delete pending RAR archive sets in outputDir when collecting MKVs from extractDir", async () => {
|
||||||
// Regression v1.7.156: bei einem Multi-Archive-Set-Paket (z.B. S01 + S02 RARs
|
|
||||||
// im selben outputDir) wurde nach dem Extrahieren von S01 die MKV-Collection
|
|
||||||
// getriggert. Diese loeschte als "Restdateien" ALLE Nicht-Video-Files im
|
|
||||||
// outputDir — also auch die noch nicht entpackten S02-RAR-Parts. Folge:
|
|
||||||
// S02 ging verloren ("missing_file" beim spaeteren Extract).
|
|
||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
|
|
||||||
@ -10062,7 +9962,6 @@ describe("download manager", () => {
|
|||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
fs.mkdirSync(extractDir, { recursive: true });
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
|
||||||
// outputDir: S02-RAR-Set noch NICHT entpackt (pending). Muss erhalten bleiben.
|
|
||||||
const s02Parts = [
|
const s02Parts = [
|
||||||
"Ugly.Americans.S02.COMPLETE.German.part1.rar",
|
"Ugly.Americans.S02.COMPLETE.German.part1.rar",
|
||||||
"Ugly.Americans.S02.COMPLETE.German.part2.rar",
|
"Ugly.Americans.S02.COMPLETE.German.part2.rar",
|
||||||
@ -10071,10 +9970,8 @@ describe("download manager", () => {
|
|||||||
for (const part of s02Parts) {
|
for (const part of s02Parts) {
|
||||||
fs.writeFileSync(path.join(outputDir, part), Buffer.alloc(1024, 7));
|
fs.writeFileSync(path.join(outputDir, part), Buffer.alloc(1024, 7));
|
||||||
}
|
}
|
||||||
// Auch eine harmlose Nicht-Video-Restdatei im outputDir (z.B. .nfo).
|
|
||||||
fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo"));
|
fs.writeFileSync(path.join(outputDir, "info.nfo"), Buffer.from("nfo"));
|
||||||
|
|
||||||
// extractDir: S01 wurde bereits entpackt → MKVs liegen hier.
|
|
||||||
const s01Mkvs = [
|
const s01Mkvs = [
|
||||||
"Ugly.Americans.S01E01.German.mkv",
|
"Ugly.Americans.S01E01.German.mkv",
|
||||||
"Ugly.Americans.S01E02.German.mkv"
|
"Ugly.Americans.S01E02.German.mkv"
|
||||||
@ -10118,14 +10015,11 @@ describe("download manager", () => {
|
|||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Direkt aufrufen (umgeht die volle Download/Extract-Pipeline).
|
|
||||||
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]);
|
await (manager as any).collectMkvFilesToLibrary(packageId, session.packages[packageId]);
|
||||||
|
|
||||||
// S01-MKVs sind in der Library angekommen.
|
|
||||||
for (const mkv of s01Mkvs) {
|
for (const mkv of s01Mkvs) {
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true);
|
expect(fs.existsSync(path.join(mkvLibraryDir, mkv))).toBe(true);
|
||||||
}
|
}
|
||||||
// KRITISCH: S02-RAR-Parts im outputDir wurden NICHT geloescht.
|
|
||||||
for (const part of s02Parts) {
|
for (const part of s02Parts) {
|
||||||
expect(fs.existsSync(path.join(outputDir, part))).toBe(true);
|
expect(fs.existsSync(path.join(outputDir, part))).toBe(true);
|
||||||
}
|
}
|
||||||
@ -10142,7 +10036,6 @@ describe("download manager", () => {
|
|||||||
const extractDir = path.join(root, "extract", packageName);
|
const extractDir = path.join(root, "extract", packageName);
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
// Build archive containing one real episode + several bonus files in an Extras subdirectory
|
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
zip.addFile("Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv", Buffer.from("episode-data"));
|
zip.addFile("Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv", Buffer.from("episode-data"));
|
||||||
zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/Schrotflinte.mkv", Buffer.from("bonus-1"));
|
zip.addFile("Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC/Schrotflinte.mkv", Buffer.from("bonus-1"));
|
||||||
@ -10209,22 +10102,18 @@ describe("download manager", () => {
|
|||||||
createStoragePaths(path.join(root, "state"))
|
createStoragePaths(path.join(root, "state"))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Wait until the real episode landed in the library
|
|
||||||
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv");
|
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S04E01.GERMAN.5.1.DL.BluRay.720p.x264-TSCC.mkv");
|
||||||
await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
|
await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
|
||||||
|
|
||||||
// Bonus files MUST NOT be in the flat library
|
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, "Schrotflinte.mkv"))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, "Schrotflinte.mkv"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, "Die.Autoexplosion.mkv"))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, "Die.Autoexplosion.mkv"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, "White.House.mkv"))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, "White.House.mkv"))).toBe(false);
|
||||||
|
|
||||||
// Bonus files MUST still exist in the extract dir Extras subfolder
|
|
||||||
const extrasDir = path.join(extractDir, "Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC");
|
const extrasDir = path.join(extractDir, "Breaking.Bad.S04Extras.720p.BluRay.x264-TSCC");
|
||||||
expect(fs.existsSync(path.join(extrasDir, "Schrotflinte.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(extrasDir, "Schrotflinte.mkv"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(extrasDir, "Die.Autoexplosion.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(extrasDir, "Die.Autoexplosion.mkv"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(extrasDir, "White.House.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(extrasDir, "White.House.mkv"))).toBe(true);
|
||||||
|
|
||||||
// The real episode must be in the library and removed from extract
|
|
||||||
expect(fs.existsSync(flattenedEpisode)).toBe(true);
|
expect(fs.existsSync(flattenedEpisode)).toBe(true);
|
||||||
}, 20000);
|
}, 20000);
|
||||||
|
|
||||||
@ -10237,7 +10126,6 @@ describe("download manager", () => {
|
|||||||
const extractDir = path.join(root, "extract", packageName);
|
const extractDir = path.join(root, "extract", packageName);
|
||||||
fs.mkdirSync(outputDir, { recursive: true });
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
|
||||||
// Mix of dot-separated bonus subdirs - must all be detected as bonus
|
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
zip.addFile("Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv", Buffer.from("real-episode"));
|
zip.addFile("Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv", Buffer.from("real-episode"));
|
||||||
zip.addFile("Breaking.Bad.S05.Making.Of/SomeBonusClip.mkv", Buffer.from("bonus-1"));
|
zip.addFile("Breaking.Bad.S05.Making.Of/SomeBonusClip.mkv", Buffer.from("bonus-1"));
|
||||||
@ -10308,13 +10196,11 @@ describe("download manager", () => {
|
|||||||
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv");
|
const flattenedEpisode = path.join(mkvLibraryDir, "Breaking.Bad.S05E01.GERMAN.DL.720p.BluRay.x264-TSCC.mkv");
|
||||||
await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
|
await waitFor(() => fs.existsSync(flattenedEpisode), 12000);
|
||||||
|
|
||||||
// None of the bonus files should have landed in the flat library
|
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, "SomeBonusClip.mkv"))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, "SomeBonusClip.mkv"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, "AnotherClip.mkv"))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, "AnotherClip.mkv"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, "DeletedClip.mkv"))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, "DeletedClip.mkv"))).toBe(false);
|
||||||
expect(fs.existsSync(path.join(mkvLibraryDir, "GagClip.mkv"))).toBe(false);
|
expect(fs.existsSync(path.join(mkvLibraryDir, "GagClip.mkv"))).toBe(false);
|
||||||
|
|
||||||
// All bonus files must still exist in their respective subfolders
|
|
||||||
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Making.Of", "SomeBonusClip.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Making.Of", "SomeBonusClip.mkv"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Behind.The.Scenes", "AnotherClip.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Behind.The.Scenes", "AnotherClip.mkv"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Deleted.Scenes", "DeletedClip.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(extractDir, "Breaking.Bad.S05.Deleted.Scenes", "DeletedClip.mkv"))).toBe(true);
|
||||||
@ -10998,7 +10884,6 @@ describe("download manager", () => {
|
|||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const binary = Buffer.alloc(256 * 1024, 7);
|
const binary = Buffer.alloc(256 * 1024, 7);
|
||||||
|
|
||||||
// Slow server: delivers data in chunks with delay
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
if ((req.url || "") !== "/slow-dl") {
|
if ((req.url || "") !== "/slow-dl") {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
@ -11008,7 +10893,6 @@ describe("download manager", () => {
|
|||||||
res.statusCode = 200;
|
res.statusCode = 200;
|
||||||
res.setHeader("Accept-Ranges", "bytes");
|
res.setHeader("Accept-Ranges", "bytes");
|
||||||
res.setHeader("Content-Length", String(binary.length));
|
res.setHeader("Content-Length", String(binary.length));
|
||||||
// Send first half, then delay
|
|
||||||
res.write(binary.subarray(0, Math.floor(binary.length / 4)));
|
res.write(binary.subarray(0, Math.floor(binary.length / 4)));
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (!res.writableEnded && !res.destroyed) {
|
if (!res.writableEnded && !res.destroyed) {
|
||||||
@ -11065,23 +10949,19 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
manager.addPackages([{ name: "hang-test", links: ["https://dummy/hang-test"] }]);
|
manager.addPackages([{ name: "hang-test", links: ["https://dummy/hang-test"] }]);
|
||||||
|
|
||||||
// Step 1: Start and wait for download to begin
|
|
||||||
await manager.start();
|
await manager.start();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const items = Object.values(manager.getSnapshot().session.items);
|
const items = Object.values(manager.getSnapshot().session.items);
|
||||||
return items.some((item) => item.status === "downloading");
|
return items.some((item) => item.status === "downloading");
|
||||||
}, 12000);
|
}, 12000);
|
||||||
|
|
||||||
// Step 2: Stop — do NOT wait for running=false
|
|
||||||
manager.stop();
|
manager.stop();
|
||||||
|
|
||||||
// Step 3: Immediately disable the active provider
|
|
||||||
manager.setSettings({
|
manager.setSettings({
|
||||||
...settings,
|
...settings,
|
||||||
disabledProviders: ["realdebrid"]
|
disabledProviders: ["realdebrid"]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 4: Start again immediately — must resolve (not hang)
|
|
||||||
const startPromise = manager.start();
|
const startPromise = manager.start();
|
||||||
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
|
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 8000));
|
||||||
const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]);
|
const result = await Promise.race([startPromise.then(() => "ok" as const), timeout]);
|
||||||
@ -11112,9 +10992,6 @@ describe("download manager", () => {
|
|||||||
createStoragePaths(stateDir)
|
createStoragePaths(stateDir)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 60 packages with 25 links each = 1500 items. This was freezing the UI
|
|
||||||
// for 1-2 min on slower filesystems because every item triggered
|
|
||||||
// ensurePackageLog + ensureItemLog + multiple sync appendFileSync calls.
|
|
||||||
const packages = Array.from({ length: 60 }, (_, pkgIdx) => ({
|
const packages = Array.from({ length: 60 }, (_, pkgIdx) => ({
|
||||||
name: `bulk-pkg-${pkgIdx}`,
|
name: `bulk-pkg-${pkgIdx}`,
|
||||||
links: Array.from({ length: 25 }, (_, linkIdx) => `https://dummy/bulk-${pkgIdx}-${linkIdx}.rar`)
|
links: Array.from({ length: 25 }, (_, linkIdx) => `https://dummy/bulk-${pkgIdx}-${linkIdx}.rar`)
|
||||||
@ -11127,25 +11004,14 @@ describe("download manager", () => {
|
|||||||
expect(result.addedPackages).toBe(60);
|
expect(result.addedPackages).toBe(60);
|
||||||
expect(result.addedLinks).toBe(1500);
|
expect(result.addedLinks).toBe(1500);
|
||||||
|
|
||||||
// Hard cap: on any reasonable CI box this should complete well under 5 s.
|
|
||||||
// Before the fix, the same workload produced thousands of sync-FS writes
|
|
||||||
// and took 60-120 s even on fast local disks.
|
|
||||||
expect(elapsedMs).toBeLessThan(5000);
|
expect(elapsedMs).toBeLessThan(5000);
|
||||||
|
|
||||||
// No per-item log files should have been created — they're only
|
|
||||||
// initialized lazily when an item gets a real lifecycle event later.
|
|
||||||
// Item log files are named item_<id>.txt.
|
|
||||||
const itemLogsDir = path.join(stateDir, "item-logs");
|
const itemLogsDir = path.join(stateDir, "item-logs");
|
||||||
const itemLogFiles = fs.existsSync(itemLogsDir)
|
const itemLogFiles = fs.existsSync(itemLogsDir)
|
||||||
? fs.readdirSync(itemLogsDir).filter((f) => f.startsWith("item_") && f.endsWith(".txt"))
|
? fs.readdirSync(itemLogsDir).filter((f) => f.startsWith("item_") && f.endsWith(".txt"))
|
||||||
: [];
|
: [];
|
||||||
expect(itemLogFiles.length).toBe(0);
|
expect(itemLogFiles.length).toBe(0);
|
||||||
|
|
||||||
// One package log per package. Package log file names are package_<id>.txt.
|
|
||||||
// The "Links registriert" entry is appended async (batched flush every
|
|
||||||
// ~250ms), so we don't assert content here — just that each package has
|
|
||||||
// been initialized with the startup block (ensurePackageLog wrote the
|
|
||||||
// "Paket-Log Start" header synchronously).
|
|
||||||
const packageLogsDir = path.join(stateDir, "package-logs");
|
const packageLogsDir = path.join(stateDir, "package-logs");
|
||||||
const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt"));
|
const pkgLogFiles = fs.readdirSync(packageLogsDir).filter((f) => f.startsWith("package_") && f.endsWith(".txt"));
|
||||||
expect(pkgLogFiles.length).toBe(60);
|
expect(pkgLogFiles.length).toBe(60);
|
||||||
@ -11160,8 +11026,6 @@ describe("download manager", () => {
|
|||||||
initItemLogs(stateDir);
|
initItemLogs(stateDir);
|
||||||
initRenameLog(stateDir);
|
initRenameLog(stateDir);
|
||||||
|
|
||||||
// Build extract tree with 3 episode-folders, each containing 1 obfuscated MKV
|
|
||||||
// mirroring the scene release pattern from the production log.
|
|
||||||
const extractDir = path.join(root, "extracted");
|
const extractDir = path.join(root, "extracted");
|
||||||
const episodes = [
|
const episodes = [
|
||||||
{ folder: "Test.Show.S02E01.Pilot.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e01hd.mkv" },
|
{ folder: "Test.Show.S02E01.Pilot.GERMAN.WS.720p.HDTV.x264-aWake", file: "awa-testshow02e01hd.mkv" },
|
||||||
@ -11204,24 +11068,15 @@ describe("download manager", () => {
|
|||||||
downloadCompletedAt: 0
|
downloadCompletedAt: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fire two scans simultaneously for the SAME package — without
|
|
||||||
// serialization, both would race on the same fileset.
|
|
||||||
const [n1, n2] = await Promise.all([
|
const [n1, n2] = await Promise.all([
|
||||||
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg),
|
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg),
|
||||||
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg)
|
(manager as any).autoRenameExtractedVideoFiles(extractDir, pkg)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// First scan should rename all 3 files. Second scan, having waited for
|
|
||||||
// the first via the in-flight promise, should find them already
|
|
||||||
// renamed (== 0 fresh renames). What matters is that BOTH calls
|
|
||||||
// resolved cleanly (no thrown ENOENT) and the disk state is correct.
|
|
||||||
expect(typeof n1).toBe("number");
|
expect(typeof n1).toBe("number");
|
||||||
expect(typeof n2).toBe("number");
|
expect(typeof n2).toBe("number");
|
||||||
expect(n1 + n2).toBe(3);
|
expect(n1 + n2).toBe(3);
|
||||||
|
|
||||||
// All three episodes should now have the folder-derived name (the
|
|
||||||
// obfuscated source name was overridden via the v1.7.148 logic AND
|
|
||||||
// the rename actually succeeded for ALL of them, not just some).
|
|
||||||
for (const ep of episodes) {
|
for (const ep of episodes) {
|
||||||
const dir = path.join(extractDir, ep.folder);
|
const dir = path.join(extractDir, ep.folder);
|
||||||
const files = fs.readdirSync(dir);
|
const files = fs.readdirSync(dir);
|
||||||
@ -11243,9 +11098,6 @@ describe("download manager", () => {
|
|||||||
createStoragePaths(stateDir)
|
createStoragePaths(stateDir)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Both rename and mkvMove route through the SAME chain, so any pair of
|
|
||||||
// invocations for the same package must run strictly sequentially —
|
|
||||||
// even when they come from different call sites (hybrid + deferred).
|
|
||||||
const pkgId = "crosspipe-pkg-1";
|
const pkgId = "crosspipe-pkg-1";
|
||||||
let concurrent = 0;
|
let concurrent = 0;
|
||||||
let maxConcurrent = 0;
|
let maxConcurrent = 0;
|
||||||
@ -11268,9 +11120,7 @@ describe("download manager", () => {
|
|||||||
expect(r2).toBe("done-20");
|
expect(r2).toBe("done-20");
|
||||||
expect(r3).toBe("done-30");
|
expect(r3).toBe("done-30");
|
||||||
expect(r4).toBe("done-10");
|
expect(r4).toBe("done-10");
|
||||||
// Crucial: never more than 1 operation in flight at a time.
|
|
||||||
expect(maxConcurrent).toBe(1);
|
expect(maxConcurrent).toBe(1);
|
||||||
// Chain slot cleared after the last op completed.
|
|
||||||
expect((manager as any).packageFileOpChain.has(pkgId)).toBe(false);
|
expect((manager as any).packageFileOpChain.has(pkgId)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -11311,7 +11161,6 @@ describe("download manager", () => {
|
|||||||
|
|
||||||
const renamed = await (manager as any).autoRenameExtractedVideoFiles(sharedDir, pkg);
|
const renamed = await (manager as any).autoRenameExtractedVideoFiles(sharedDir, pkg);
|
||||||
expect(renamed).toBe(0);
|
expect(renamed).toBe(0);
|
||||||
// File must remain untouched — no rename performed.
|
|
||||||
expect(fs.existsSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"))).toBe(true);
|
expect(fs.existsSync(path.join(sharedDir, "EpisodeFolder", "obfus.mkv"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -11353,13 +11202,8 @@ describe("download manager", () => {
|
|||||||
await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg);
|
await (manager as any).collectMkvFilesToLibrary("movecomp-pkg", pkg);
|
||||||
|
|
||||||
const libFiles = fs.readdirSync(libDir);
|
const libFiles = fs.readdirSync(libDir);
|
||||||
// Video AND subtitle moved to library.
|
|
||||||
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv");
|
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.mkv");
|
||||||
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.srt");
|
expect(libFiles).toContain("Show.S01E01.GERMAN.x264-GROUP.srt");
|
||||||
// .nfo MUST NOT end up in the library — that's the user-visible bug
|
|
||||||
// we are fixing. (It gets cleaned up with other residual files in
|
|
||||||
// cleanupNonMkvResidualFiles after the move; we don't care whether it
|
|
||||||
// survives in the extract dir, only that the library stays clean.)
|
|
||||||
expect(libFiles).not.toContain("Show.S01E01.GERMAN.x264-GROUP.nfo");
|
expect(libFiles).not.toContain("Show.S01E01.GERMAN.x264-GROUP.nfo");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -11392,10 +11236,8 @@ describe("download manager", () => {
|
|||||||
expect(renamed).toBe(1);
|
expect(renamed).toBe(1);
|
||||||
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
||||||
const files = fs.readdirSync(epFolder);
|
const files = fs.readdirSync(epFolder);
|
||||||
// Video renamed.
|
|
||||||
expect(files).toContain(`${expectedBase}.mkv`);
|
expect(files).toContain(`${expectedBase}.mkv`);
|
||||||
expect(files).not.toContain("awa-testshow02e05hd.mkv");
|
expect(files).not.toContain("awa-testshow02e05hd.mkv");
|
||||||
// Companions renamed alongside.
|
|
||||||
expect(files).toContain(`${expectedBase}.srt`);
|
expect(files).toContain(`${expectedBase}.srt`);
|
||||||
expect(files).toContain(`${expectedBase}.de.srt`);
|
expect(files).toContain(`${expectedBase}.de.srt`);
|
||||||
expect(files).toContain(`${expectedBase}.nfo`);
|
expect(files).toContain(`${expectedBase}.nfo`);
|
||||||
@ -11431,7 +11273,6 @@ describe("download manager", () => {
|
|||||||
expect(renamed).toBe(2);
|
expect(renamed).toBe(2);
|
||||||
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
const expectedBase = "Test.Show.S02E05.Title.GERMAN.WS.720p.HDTV.x264-aWake";
|
||||||
const files = fs.readdirSync(epFolder).sort();
|
const files = fs.readdirSync(epFolder).sort();
|
||||||
// First file got the canonical name; second got a numeric suffix.
|
|
||||||
expect(files).toContain(`${expectedBase}.mkv`);
|
expect(files).toContain(`${expectedBase}.mkv`);
|
||||||
expect(files).toContain(`${expectedBase}.2.mkv`);
|
expect(files).toContain(`${expectedBase}.2.mkv`);
|
||||||
expect(files).not.toContain("awa-testshow02e05hd.mkv");
|
expect(files).not.toContain("awa-testshow02e05hd.mkv");
|
||||||
|
|||||||
@ -74,7 +74,6 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
const targetDir = path.join(root, "out");
|
const targetDir = path.join(root, "out");
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
// Create a ZIP with some content to trigger progress
|
|
||||||
const zipPath = path.join(packageDir, "progress-test.zip");
|
const zipPath = path.join(packageDir, "progress-test.zip");
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
|
zip.addFile("file1.txt", Buffer.from("Hello World ".repeat(100)));
|
||||||
@ -108,20 +107,16 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
expect(result.extracted).toBe(1);
|
expect(result.extracted).toBe(1);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
|
|
||||||
// Should have at least preparing, extracting, and done phases
|
|
||||||
const phases = new Set(progressUpdates.map((u) => u.phase));
|
const phases = new Set(progressUpdates.map((u) => u.phase));
|
||||||
expect(phases.has("preparing")).toBe(true);
|
expect(phases.has("preparing")).toBe(true);
|
||||||
expect(phases.has("extracting")).toBe(true);
|
expect(phases.has("extracting")).toBe(true);
|
||||||
|
|
||||||
// Extracting phase should include the archive name
|
|
||||||
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
|
const extracting = progressUpdates.filter((u) => u.phase === "extracting" && u.archiveName === "progress-test.zip");
|
||||||
expect(extracting.length).toBeGreaterThan(0);
|
expect(extracting.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
// Should end at 100%
|
|
||||||
const lastExtracting = extracting[extracting.length - 1];
|
const lastExtracting = extracting[extracting.length - 1];
|
||||||
expect(lastExtracting.archivePercent).toBe(100);
|
expect(lastExtracting.archivePercent).toBe(100);
|
||||||
|
|
||||||
// Files should exist
|
|
||||||
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(targetDir, "file1.txt"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(targetDir, "file2.txt"))).toBe(true);
|
||||||
});
|
});
|
||||||
@ -135,7 +130,6 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
const targetDir = path.join(root, "out");
|
const targetDir = path.join(root, "out");
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
// Create two separate ZIP archives
|
|
||||||
const zip1 = new AdmZip();
|
const zip1 = new AdmZip();
|
||||||
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
|
zip1.addFile("episode01.txt", Buffer.from("ep1 content"));
|
||||||
zip1.writeZip(path.join(packageDir, "archive1.zip"));
|
zip1.writeZip(path.join(packageDir, "archive1.zip"));
|
||||||
@ -162,10 +156,8 @@ describe.skipIf(!hasJavaRuntime() || !hasJvmExtractorRuntime())("extractor jvm b
|
|||||||
|
|
||||||
expect(result.extracted).toBe(2);
|
expect(result.extracted).toBe(2);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
// Both archive names should have appeared in progress
|
|
||||||
expect(archiveNames.has("archive1.zip")).toBe(true);
|
expect(archiveNames.has("archive1.zip")).toBe(true);
|
||||||
expect(archiveNames.has("archive2.zip")).toBe(true);
|
expect(archiveNames.has("archive2.zip")).toBe(true);
|
||||||
// Both files extracted
|
|
||||||
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(targetDir, "episode01.txt"))).toBe(true);
|
||||||
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
|
expect(fs.existsSync(path.join(targetDir, "episode02.txt"))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -865,7 +865,6 @@ describe("extractor", () => {
|
|||||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-"));
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-sig-"));
|
||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const filePath = path.join(root, "test.rar");
|
const filePath = path.join(root, "test.rar");
|
||||||
// RAR5 signature: 52 61 72 21 1A 07
|
|
||||||
fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex"));
|
fs.writeFileSync(filePath, Buffer.from("526172211a0700", "hex"));
|
||||||
const sig = await detectArchiveSignature(filePath);
|
const sig = await detectArchiveSignature(filePath);
|
||||||
expect(sig).toBe("rar");
|
expect(sig).toBe("rar");
|
||||||
@ -942,7 +941,6 @@ describe("extractor", () => {
|
|||||||
const candidates = await findArchiveCandidates(packageDir);
|
const candidates = await findArchiveCandidates(packageDir);
|
||||||
const names = candidates.map((c) => path.basename(c));
|
const names = candidates.map((c) => path.basename(c));
|
||||||
expect(names).toContain("movie.001");
|
expect(names).toContain("movie.001");
|
||||||
// .002 should NOT be in candidates (only .001 is the entry point)
|
|
||||||
expect(names).not.toContain("movie.002");
|
expect(names).not.toContain("movie.002");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -957,7 +955,6 @@ describe("extractor", () => {
|
|||||||
|
|
||||||
const candidates = await findArchiveCandidates(packageDir);
|
const candidates = await findArchiveCandidates(packageDir);
|
||||||
const names = candidates.map((c) => path.basename(c));
|
const names = candidates.map((c) => path.basename(c));
|
||||||
// .zip.001 should appear once from zipSplit detection, not duplicated by genericSplit
|
|
||||||
expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1);
|
expect(names.filter((n) => n === "movie.zip.001")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1100,7 +1097,6 @@ describe("extractor", () => {
|
|||||||
const targetDir = path.join(root, "out");
|
const targetDir = path.join(root, "out");
|
||||||
fs.mkdirSync(packageDir, { recursive: true });
|
fs.mkdirSync(packageDir, { recursive: true });
|
||||||
|
|
||||||
// Create 3 zip archives
|
|
||||||
for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) {
|
for (const name of ["ep01.zip", "ep02.zip", "ep03.zip"]) {
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
zip.addFile(`${name}.txt`, Buffer.from(name));
|
zip.addFile(`${name}.txt`, Buffer.from(name));
|
||||||
@ -1127,7 +1123,6 @@ describe("extractor", () => {
|
|||||||
|
|
||||||
expect(result.extracted).toBe(3);
|
expect(result.extracted).toBe(3);
|
||||||
expect(result.failed).toBe(0);
|
expect(result.failed).toBe(0);
|
||||||
// First archive should be ep01 (natural order, extracted serially for discovery)
|
|
||||||
expect(seenOrder[0]).toBe("ep01.zip");
|
expect(seenOrder[0]).toBe("ep01.zip");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1144,7 +1139,6 @@ describe("extractor", () => {
|
|||||||
zip.writeZip(path.join(packageDir, name));
|
zip.writeZip(path.join(packageDir, name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// No passwordList → only empty string → length=1 → no discovery phase
|
|
||||||
const result = await extractPackageArchives({
|
const result = await extractPackageArchives({
|
||||||
packageDir,
|
packageDir,
|
||||||
targetDir,
|
targetDir,
|
||||||
|
|||||||
@ -34,25 +34,20 @@ describe("integrity", () => {
|
|||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-int-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
|
|
||||||
// Create a .md5 manifest that exceeds the 5MB limit
|
|
||||||
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
|
const largeContent = "d41d8cd98f00b204e9800998ecf8427e sample.bin\n".repeat(200000);
|
||||||
const manifestPath = path.join(dir, "hashes.md5");
|
const manifestPath = path.join(dir, "hashes.md5");
|
||||||
fs.writeFileSync(manifestPath, largeContent, "utf8");
|
fs.writeFileSync(manifestPath, largeContent, "utf8");
|
||||||
|
|
||||||
// Verify the file is actually > 5MB
|
|
||||||
const stat = fs.statSync(manifestPath);
|
const stat = fs.statSync(manifestPath);
|
||||||
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
|
expect(stat.size).toBeGreaterThan(5 * 1024 * 1024);
|
||||||
|
|
||||||
// readHashManifest should skip the oversized file
|
|
||||||
const manifest = readHashManifest(dir);
|
const manifest = readHashManifest(dir);
|
||||||
expect(manifest.size).toBe(0);
|
expect(manifest.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not parse SHA256 (64-char hex) as valid hash", () => {
|
it("does not parse SHA256 (64-char hex) as valid hash", () => {
|
||||||
// SHA256 is 64 chars - parseHashLine only supports 32 (MD5) and 40 (SHA1)
|
|
||||||
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
|
const sha256Line = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 emptyfile.bin";
|
||||||
const result = parseHashLine(sha256Line);
|
const result = parseHashLine(sha256Line);
|
||||||
// 64-char hex should not match the MD5 (32) or SHA1 (40) pattern
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,16 +8,16 @@ describe("link-parser", () => {
|
|||||||
{ name: "Package A", links: ["http://link1", "http://link2"] },
|
{ name: "Package A", links: ["http://link1", "http://link2"] },
|
||||||
{ name: "Package B", links: ["http://link3"] },
|
{ name: "Package B", links: ["http://link3"] },
|
||||||
{ name: "Package A", links: ["http://link4", "http://link1"] },
|
{ name: "Package A", links: ["http://link4", "http://link1"] },
|
||||||
{ name: "", links: ["http://link5"] } // empty name will be inferred
|
{ name: "", links: ["http://link5"] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = mergePackageInputs(input);
|
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");
|
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");
|
const pkgB = result.find(p => p.name === "Package B");
|
||||||
expect(pkgB?.links).toEqual(["http://link3"]);
|
expect(pkgB?.links).toEqual(["http://link3"]);
|
||||||
});
|
});
|
||||||
@ -29,8 +29,7 @@ describe("link-parser", () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const result = mergePackageInputs(input);
|
const result = mergePackageInputs(input);
|
||||||
|
|
||||||
// "Valid?Name*" becomes "Valid Name " -> trimmed to "Valid Name"
|
|
||||||
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
|
expect(result.map(p => p.name).sort()).toEqual(["Valid Name", "Valid_Name"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -59,24 +58,23 @@ describe("link-parser", () => {
|
|||||||
Here are some links:
|
Here are some links:
|
||||||
http://example.com/part1.rar
|
http://example.com/part1.rar
|
||||||
http://example.com/part2.rar
|
http://example.com/part2.rar
|
||||||
|
|
||||||
# package: Custom_Name
|
# package: Custom_Name
|
||||||
http://other.com/file1
|
http://other.com/file1
|
||||||
http://other.com/file2
|
http://other.com/file2
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = parseCollectorInput(rawText, "DefaultFallback");
|
const result = parseCollectorInput(rawText, "DefaultFallback");
|
||||||
|
|
||||||
// Should have 2 packages: "DefaultFallback" and "Custom_Name"
|
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
|
|
||||||
const defaultPkg = result.find(p => p.name === "DefaultFallback");
|
const defaultPkg = result.find(p => p.name === "DefaultFallback");
|
||||||
expect(defaultPkg?.links).toEqual([
|
expect(defaultPkg?.links).toEqual([
|
||||||
"http://example.com/part1.rar",
|
"http://example.com/part1.rar",
|
||||||
"http://example.com/part2.rar"
|
"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([
|
expect(customPkg?.links).toEqual([
|
||||||
"http://other.com/file1",
|
"http://other.com/file1",
|
||||||
"http://other.com/file2"
|
"http://other.com/file2"
|
||||||
|
|||||||
@ -6,23 +6,18 @@ describe("logTimestamp", () => {
|
|||||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
const instant = new Date("2026-05-31T17:29:43.605Z");
|
||||||
const formatted = logTimestamp(instant);
|
const formatted = logTimestamp(instant);
|
||||||
|
|
||||||
// Shape: YYYY-MM-DDTHH:MM:SS.mmm±HH:MM
|
|
||||||
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
|
expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/);
|
||||||
// The whole point: NOT the old UTC "...Z" format that showed 17:29 instead of 19:29.
|
|
||||||
expect(formatted.endsWith("Z")).toBe(false);
|
expect(formatted.endsWith("Z")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
|
it("is parseable back to the exact same instant (offset keeps it unambiguous)", () => {
|
||||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
const instant = new Date("2026-05-31T17:29:43.605Z");
|
||||||
// Date.parse must still recover the identical instant (trace-log autoDisableAt etc.).
|
|
||||||
expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime());
|
expect(new Date(logTimestamp(instant)).getTime()).toBe(instant.getTime());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
|
it("shows the LOCAL wall-clock hour (machine-timezone-independent assertion)", () => {
|
||||||
const instant = new Date("2026-05-31T17:29:43.605Z");
|
const instant = new Date("2026-05-31T17:29:43.605Z");
|
||||||
const formatted = logTimestamp(instant);
|
const formatted = logTimestamp(instant);
|
||||||
// Hour segment must equal the local getHours() of the same instant — i.e. the
|
|
||||||
// user's wall clock, whatever the server timezone is.
|
|
||||||
expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0"));
|
expect(formatted.slice(11, 13)).toBe(String(instant.getHours()).padStart(2, "0"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,7 +17,6 @@ function makeRandomFileKey(): Buffer {
|
|||||||
|
|
||||||
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
|
function encryptAttributes(jsonAttrs: Record<string, unknown>, aesKey: Buffer): string {
|
||||||
const plain = "MEGA" + JSON.stringify(jsonAttrs);
|
const plain = "MEGA" + JSON.stringify(jsonAttrs);
|
||||||
// Pad to 16-byte boundary with \0 (Mega convention).
|
|
||||||
const padded = Buffer.from(plain, "utf8");
|
const padded = Buffer.from(plain, "utf8");
|
||||||
const padLen = (16 - (padded.length % 16)) % 16;
|
const padLen = (16 - (padded.length % 16)) % 16;
|
||||||
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
|
const buf = Buffer.concat([padded, Buffer.alloc(padLen, 0)]);
|
||||||
@ -59,7 +58,6 @@ describe("mega-public-api", () => {
|
|||||||
expect(parsed?.rawKey.length).toBe(32);
|
expect(parsed?.rawKey.length).toBe(32);
|
||||||
});
|
});
|
||||||
it("parses legacy-format URL", () => {
|
it("parses legacy-format URL", () => {
|
||||||
// Make a valid legacy URL with a 32-byte key.
|
|
||||||
const id = "abcDEF12";
|
const id = "abcDEF12";
|
||||||
const key = makeRandomFileKey();
|
const key = makeRandomFileKey();
|
||||||
const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
|
const url = `https://mega.nz/#!${id}!${base64Url(key)}`;
|
||||||
@ -143,7 +141,7 @@ describe("mega-public-api", () => {
|
|||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
async json() {
|
async json() {
|
||||||
return -9; // ENOENT — file not found
|
return -9;
|
||||||
}
|
}
|
||||||
} as unknown as Response);
|
} as unknown as Response);
|
||||||
|
|
||||||
@ -156,7 +154,7 @@ describe("mega-public-api", () => {
|
|||||||
global.fetch = vi.fn().mockResolvedValue({
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
async json() {
|
async json() {
|
||||||
return [-16]; // EBLOCKED
|
return [-16];
|
||||||
}
|
}
|
||||||
} as unknown as Response);
|
} as unknown as Response);
|
||||||
|
|
||||||
|
|||||||
@ -21,19 +21,18 @@ describe("mega-web-fallback", () => {
|
|||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
fetchCallCount += 1;
|
fetchCallCount += 1;
|
||||||
|
|
||||||
if (urlStr.includes("form=login")) {
|
if (urlStr.includes("form=login")) {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.append("set-cookie", "session=goodcookie; path=/");
|
headers.append("set-cookie", "session=goodcookie; path=/");
|
||||||
return new Response("", { headers, status: 200 });
|
return new Response("", { headers, status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes("page=debrideur")) {
|
if (urlStr.includes("page=debrideur")) {
|
||||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes("form=debrid")) {
|
if (urlStr.includes("form=debrid")) {
|
||||||
// The POST to generate the code
|
|
||||||
return new Response(`
|
return new Response(`
|
||||||
<div class="acp-box">
|
<div class="acp-box">
|
||||||
<h3>Link: https://mega.debrid/link1</h3>
|
<h3>Link: https://mega.debrid/link1</h3>
|
||||||
@ -41,22 +40,20 @@ describe("mega-web-fallback", () => {
|
|||||||
</div>
|
</div>
|
||||||
`, { status: 200 });
|
`, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes("ajax=debrid")) {
|
if (urlStr.includes("ajax=debrid")) {
|
||||||
// Polling endpoint
|
|
||||||
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
|
return new Response(JSON.stringify({ link: "https://mega.direct/123" }), { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||||
|
|
||||||
const result = await fallback.unrestrict("https://mega.debrid/link1");
|
const result = await fallback.unrestrict("https://mega.debrid/link1");
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result?.directUrl).toBe("https://mega.direct/123");
|
expect(result?.directUrl).toBe("https://mega.direct/123");
|
||||||
expect(result?.fileName).toBe("link1");
|
expect(result?.fileName).toBe("link1");
|
||||||
// Calls: 1. Login POST, 2. Verify GET, 3. Generate POST, 4. Polling POST
|
|
||||||
expect(fetchCallCount).toBe(4);
|
expect(fetchCallCount).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -83,17 +80,11 @@ describe("mega-web-fallback", () => {
|
|||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||||
// Muss schnell mit der ECHTEN Meldung scheitern — NICHT null zurückgeben (was
|
|
||||||
// re-Login + erneutes Pollen auslösen würde und das Rotations-Budget frisst).
|
|
||||||
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
||||||
expect(ajaxCalls).toBe(1);
|
expect(ajaxCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
|
it("surfaces 'Kein Server für diesen Hoster' from the debrid PAGE (daily limit, no debrid code) instead of empty", async () => {
|
||||||
// Tageslimit dieses Accounts: die DEBRID-Seite enthält KEINEN processDebrid-Code,
|
|
||||||
// sondern die Limit-Meldung als Page-Error. Früher -> kein Code -> null -> "Antwort
|
|
||||||
// leer" (auf Message-Ebene nicht als Tageslimit erkennbar). Jetzt muss die Meldung
|
|
||||||
// als Fehler hochkommen, damit die Rotation den Account als limitiert behandelt.
|
|
||||||
let ajaxCalls = 0;
|
let ajaxCalls = 0;
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
@ -106,7 +97,6 @@ describe("mega-web-fallback", () => {
|
|||||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("form=debrid")) {
|
if (urlStr.includes("form=debrid")) {
|
||||||
// Keine processDebrid(...)-Codes — nur die Tageslimit-Meldung als Page-Error.
|
|
||||||
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
|
return new Response('<div class="error">Erreur : Kein Server für diesen Hoster verfügbar. Bitte versuchen Sie es später noch einmal.</div>', { status: 200 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("ajax=debrid")) {
|
if (urlStr.includes("ajax=debrid")) {
|
||||||
@ -118,7 +108,6 @@ describe("mega-web-fallback", () => {
|
|||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
const fallback = new MegaWebFallback(() => ({ login: "user", password: "pwd" }));
|
||||||
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
await expect(fallback.unrestrict("https://mega.debrid/l1")).rejects.toThrow(/kein server für diesen hoster/i);
|
||||||
// Ohne Code wird gar nicht erst gepollt — die Meldung kommt direkt von der Seite.
|
|
||||||
expect(ajaxCalls).toBe(0);
|
expect(ajaxCalls).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -145,9 +134,7 @@ describe("mega-web-fallback", () => {
|
|||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
// getCredentials liefert den DEFAULT/Legacy-Account ...
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
|
const fallback = new MegaWebFallback(() => ({ login: "defaultacc", password: "defpw" }));
|
||||||
// ... aber die Rotation übergibt explizit Account 2 — DESSEN Login MUSS verwendet werden.
|
|
||||||
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
|
const result = await fallback.unrestrict("https://mega.debrid/l1", undefined, { login: "account2", password: "pw2" });
|
||||||
expect(result?.directUrl).toBe("https://mega.direct/ok");
|
expect(result?.directUrl).toBe("https://mega.direct/ok");
|
||||||
expect(loginsUsed).toContain("account2");
|
expect(loginsUsed).toContain("account2");
|
||||||
@ -158,14 +145,14 @@ describe("mega-web-fallback", () => {
|
|||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||||
const urlStr = String(url);
|
const urlStr = String(url);
|
||||||
if (urlStr.includes("form=login")) {
|
if (urlStr.includes("form=login")) {
|
||||||
const headers = new Headers(); // No cookie
|
const headers = new Headers();
|
||||||
return new Response("", { headers, status: 200 });
|
return new Response("", { headers, status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" }));
|
const fallback = new MegaWebFallback(() => ({ login: "bad", password: "bad" }));
|
||||||
|
|
||||||
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
||||||
.rejects.toThrow("Mega-Web Login liefert kein Session-Cookie");
|
.rejects.toThrow("Mega-Web Login liefert kein Session-Cookie");
|
||||||
});
|
});
|
||||||
@ -179,18 +166,17 @@ describe("mega-web-fallback", () => {
|
|||||||
return new Response("", { headers, status: 200 });
|
return new Response("", { headers, status: 200 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("page=debrideur")) {
|
if (urlStr.includes("page=debrideur")) {
|
||||||
// Missing form!
|
|
||||||
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
|
return new Response('<html><body>Nothing here</body></html>', { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
||||||
|
|
||||||
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
await expect(fallback.unrestrict("http://mega.debrid/file"))
|
||||||
.rejects.toThrow("Mega-Web Login ungültig oder Session blockiert");
|
.rejects.toThrow("Mega-Web Login ungültig oder Session blockiert");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null if generation fails to find a code", async () => {
|
it("returns null if generation fails to find a code", async () => {
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
globalThis.fetch = vi.fn(async (url: string | URL | Request) => {
|
||||||
@ -205,7 +191,6 @@ describe("mega-web-fallback", () => {
|
|||||||
return new Response('<form id="debridForm"></form>', { status: 200 });
|
return new Response('<form id="debridForm"></form>', { status: 200 });
|
||||||
}
|
}
|
||||||
if (urlStr.includes("form=debrid")) {
|
if (urlStr.includes("form=debrid")) {
|
||||||
// The generate POST returns HTML without any codes
|
|
||||||
return new Response(`<div>No links here</div>`, { status: 200 });
|
return new Response(`<div>No links here</div>`, { status: 200 });
|
||||||
}
|
}
|
||||||
return new Response("Not found", { status: 404 });
|
return new Response("Not found", { status: 404 });
|
||||||
@ -213,8 +198,7 @@ describe("mega-web-fallback", () => {
|
|||||||
|
|
||||||
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
const fallback = new MegaWebFallback(() => ({ login: "a", password: "b" }));
|
||||||
const result = await fallback.unrestrict("http://mega.debrid/file");
|
const result = await fallback.unrestrict("http://mega.debrid/file");
|
||||||
|
|
||||||
// Generation fails -> resets cookie -> tries again -> fails again -> returns null
|
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,6 @@ function makeItems(names: string[]): MinimalItem[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("resolveArchiveItemsFromList", () => {
|
describe("resolveArchiveItemsFromList", () => {
|
||||||
// ── Multipart RAR (.partN.rar) ──
|
|
||||||
|
|
||||||
it("matches multipart .part1.rar archives", () => {
|
it("matches multipart .part1.rar archives", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
@ -46,8 +45,6 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(3);
|
expect(result).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Old-style RAR (.rar + .r00, .r01, etc.) ──
|
|
||||||
|
|
||||||
it("matches old-style .rar + .rNN volumes", () => {
|
it("matches old-style .rar + .rNN volumes", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Archive.rar",
|
"Archive.rar",
|
||||||
@ -60,8 +57,6 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(4);
|
expect(result).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Single RAR ──
|
|
||||||
|
|
||||||
it("matches a single .rar file", () => {
|
it("matches a single .rar file", () => {
|
||||||
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
|
const items = makeItems(["SingleFile.rar", "Other.mkv"]);
|
||||||
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
|
const result = resolveArchiveItemsFromList("SingleFile.rar", items as any);
|
||||||
@ -69,8 +64,6 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect((result[0] as any).fileName).toBe("SingleFile.rar");
|
expect((result[0] as any).fileName).toBe("SingleFile.rar");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Split ZIP ──
|
|
||||||
|
|
||||||
it("matches split .zip.NNN files", () => {
|
it("matches split .zip.NNN files", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Data.zip",
|
"Data.zip",
|
||||||
@ -82,8 +75,6 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(4);
|
expect(result).toHaveLength(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Split 7z ──
|
|
||||||
|
|
||||||
it("matches split .7z.NNN files", () => {
|
it("matches split .7z.NNN files", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Backup.7z.001",
|
"Backup.7z.001",
|
||||||
@ -93,8 +84,6 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Generic .NNN splits ──
|
|
||||||
|
|
||||||
it("matches generic .NNN split files", () => {
|
it("matches generic .NNN split files", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"video.001",
|
"video.001",
|
||||||
@ -105,8 +94,6 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(3);
|
expect(result).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Exact filename match ──
|
|
||||||
|
|
||||||
it("matches a single .zip by exact name", () => {
|
it("matches a single .zip by exact name", () => {
|
||||||
const items = makeItems(["myarchive.zip", "other.rar"]);
|
const items = makeItems(["myarchive.zip", "other.rar"]);
|
||||||
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
|
const result = resolveArchiveItemsFromList("myarchive.zip", items as any);
|
||||||
@ -114,8 +101,6 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect((result[0] as any).fileName).toBe("myarchive.zip");
|
expect((result[0] as any).fileName).toBe("myarchive.zip");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Case insensitivity ──
|
|
||||||
|
|
||||||
it("matches case-insensitively", () => {
|
it("matches case-insensitively", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"MOVIE.PART1.RAR",
|
"MOVIE.PART1.RAR",
|
||||||
@ -125,40 +110,26 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Stem-based fallback ──
|
|
||||||
|
|
||||||
it("uses stem-based fallback when exact patterns fail", () => {
|
it("uses stem-based fallback when exact patterns fail", () => {
|
||||||
// Simulate a debrid service that renames "Movie.part1.rar" to "Movie.part1_dl.rar"
|
|
||||||
// but the disk file is "Movie.part1.rar"
|
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Movie.rar",
|
"Movie.rar",
|
||||||
]);
|
]);
|
||||||
// The archive on disk is "Movie.part1.rar" but there's no item matching the
|
|
||||||
// .partN pattern. The stem "movie" should match "Movie.rar" via fallback.
|
|
||||||
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
const result = resolveArchiveItemsFromList("Movie.part1.rar", items as any);
|
||||||
// stem fallback: "movie" starts with "movie" and ends with .rar
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Single item fallback ──
|
|
||||||
|
|
||||||
it("returns single archive item when no pattern matches", () => {
|
it("returns single archive item when no pattern matches", () => {
|
||||||
const items = makeItems(["totally-different-name.rar"]);
|
const items = makeItems(["totally-different-name.rar"]);
|
||||||
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
const result = resolveArchiveItemsFromList("Original.rar", items as any);
|
||||||
// Single item in list with archive extension → return it
|
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Empty when no match ──
|
|
||||||
|
|
||||||
it("returns empty when items have no archive extensions", () => {
|
it("returns empty when items have no archive extensions", () => {
|
||||||
const items = makeItems(["video.mkv", "subtitle.srt"]);
|
const items = makeItems(["video.mkv", "subtitle.srt"]);
|
||||||
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
|
const result = resolveArchiveItemsFromList("Archive.rar", items as any);
|
||||||
expect(result).toHaveLength(0);
|
expect(result).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Items without targetPath ──
|
|
||||||
|
|
||||||
it("falls back to fileName when targetPath is missing", () => {
|
it("falls back to fileName when targetPath is missing", () => {
|
||||||
const items = [
|
const items = [
|
||||||
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
|
{ fileName: "Movie.part1.rar", id: "1", status: "completed" },
|
||||||
@ -168,8 +139,6 @@ describe("resolveArchiveItemsFromList", () => {
|
|||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Multiple archives, should not cross-match ──
|
|
||||||
|
|
||||||
it("does not cross-match different archive groups", () => {
|
it("does not cross-match different archive groups", () => {
|
||||||
const items = makeItems([
|
const items = makeItems([
|
||||||
"Episode.S01E01.part1.rar",
|
"Episode.S01E01.part1.rar",
|
||||||
|
|||||||
@ -8,9 +8,7 @@ import { setLogListener } from "../src/main/logger";
|
|||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Ensure session log is shut down between tests
|
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
// Ensure listener is cleared between tests
|
|
||||||
setLogListener(null);
|
setLogListener(null);
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
@ -42,11 +40,9 @@ describe("session-log", () => {
|
|||||||
initSessionLog(baseDir);
|
initSessionLog(baseDir);
|
||||||
const logPath = getSessionLogPath()!;
|
const logPath = getSessionLogPath()!;
|
||||||
|
|
||||||
// Simulate a log line via the listener
|
|
||||||
const { logger } = await import("../src/main/logger");
|
const { logger } = await import("../src/main/logger");
|
||||||
logger.info("Test-Nachricht für Session-Log");
|
logger.info("Test-Nachricht für Session-Log");
|
||||||
|
|
||||||
// Wait for flush (200ms interval + margin)
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
const content = fs.readFileSync(logPath, "utf8");
|
const content = fs.readFileSync(logPath, "utf8");
|
||||||
@ -77,7 +73,6 @@ describe("session-log", () => {
|
|||||||
|
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
|
|
||||||
// Log after shutdown - should NOT appear in session log
|
|
||||||
const { logger } = await import("../src/main/logger");
|
const { logger } = await import("../src/main/logger");
|
||||||
logger.info("Nach-Shutdown-Nachricht");
|
logger.info("Nach-Shutdown-Nachricht");
|
||||||
|
|
||||||
@ -94,21 +89,16 @@ describe("session-log", () => {
|
|||||||
const logsDir = path.join(baseDir, "session-logs");
|
const logsDir = path.join(baseDir, "session-logs");
|
||||||
fs.mkdirSync(logsDir, { recursive: true });
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
// Create a fake old session log
|
|
||||||
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
|
const oldFile = path.join(logsDir, "session_2020-01-01_00-00-00.txt");
|
||||||
fs.writeFileSync(oldFile, "old session");
|
fs.writeFileSync(oldFile, "old session");
|
||||||
// Set mtime to 30 days ago
|
|
||||||
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
const oldTime = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
fs.utimesSync(oldFile, oldTime, oldTime);
|
fs.utimesSync(oldFile, oldTime, oldTime);
|
||||||
|
|
||||||
// Create a recent file
|
|
||||||
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
|
const newFile = path.join(logsDir, "session_2099-01-01_00-00-00.txt");
|
||||||
fs.writeFileSync(newFile, "new session");
|
fs.writeFileSync(newFile, "new session");
|
||||||
|
|
||||||
// initSessionLog triggers cleanup
|
|
||||||
initSessionLog(baseDir);
|
initSessionLog(baseDir);
|
||||||
|
|
||||||
// Wait for async cleanup
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
expect(fs.existsSync(oldFile)).toBe(false);
|
expect(fs.existsSync(oldFile)).toBe(false);
|
||||||
@ -124,7 +114,6 @@ describe("session-log", () => {
|
|||||||
const logsDir = path.join(baseDir, "session-logs");
|
const logsDir = path.join(baseDir, "session-logs");
|
||||||
fs.mkdirSync(logsDir, { recursive: true });
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
|
||||||
// Create a file from 2 days ago (should be kept)
|
|
||||||
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
|
const recentFile = path.join(logsDir, "session_2025-12-01_00-00-00.txt");
|
||||||
fs.writeFileSync(recentFile, "recent session");
|
fs.writeFileSync(recentFile, "recent session");
|
||||||
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
const recentTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||||
@ -147,7 +136,6 @@ describe("session-log", () => {
|
|||||||
const path1 = getSessionLogPath();
|
const path1 = getSessionLogPath();
|
||||||
shutdownSessionLog();
|
shutdownSessionLog();
|
||||||
|
|
||||||
// Small delay to ensure different timestamp
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||||
|
|
||||||
initSessionLog(baseDir);
|
initSessionLog(baseDir);
|
||||||
|
|||||||
@ -12,12 +12,6 @@ import {
|
|||||||
saveSessionAsync
|
saveSessionAsync
|
||||||
} from "../src/main/storage";
|
} from "../src/main/storage";
|
||||||
|
|
||||||
// Regression tests for queue loss across an app-update restart.
|
|
||||||
// Both scenarios were observed empirically to drop packages before the fix:
|
|
||||||
// - a queued stale async save clobbering a newer synchronous save
|
|
||||||
// (persistNowSync / prepareForShutdown), and
|
|
||||||
// - loadSession ignoring a good .bak when the primary file is momentarily absent.
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -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 {
|
function sessionWith(ids: string[]): SessionState {
|
||||||
const s = emptySession();
|
const s = emptySession();
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
@ -91,8 +84,6 @@ describe("session restart loss", () => {
|
|||||||
|
|
||||||
saveSession(paths, sessionWith(["A", "B"]));
|
saveSession(paths, sessionWith(["A", "B"]));
|
||||||
|
|
||||||
// An async save goes in-flight, a second async save (stale snapshot) gets
|
|
||||||
// queued, then a synchronous save persists the live state with package C.
|
|
||||||
const inflight = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
const inflight = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
||||||
const queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
const queued = saveSessionAsync(paths, sessionWith(["A", "B"]));
|
||||||
saveSession(paths, sessionWith(["A", "B", "C"]));
|
saveSession(paths, sessionWith(["A", "B", "C"]));
|
||||||
|
|||||||
@ -13,7 +13,6 @@ afterEach(() => {
|
|||||||
try {
|
try {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -88,7 +87,6 @@ describe("runStartupHealthCheck", () => {
|
|||||||
it("flags large state files", () => {
|
it("flags large state files", () => {
|
||||||
const { outputDir, paths } = makeTempBase();
|
const { outputDir, paths } = makeTempBase();
|
||||||
fs.mkdirSync(paths.baseDir, { recursive: true });
|
fs.mkdirSync(paths.baseDir, { recursive: true });
|
||||||
// 60 MB dummy state file, threshold is 50 MB
|
|
||||||
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
|
fs.writeFileSync(paths.sessionFile, Buffer.alloc(60 * 1024 * 1024, 0));
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
@ -103,7 +101,6 @@ describe("runStartupHealthCheck", () => {
|
|||||||
|
|
||||||
it("flags missing base dir as ERROR", () => {
|
it("flags missing base dir as ERROR", () => {
|
||||||
const { outputDir, paths } = makeTempBase();
|
const { outputDir, paths } = makeTempBase();
|
||||||
// Intentionally DON'T create baseDir.
|
|
||||||
|
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -10,14 +10,6 @@ import { createStoragePaths, emptySession, loadSession } from "../src/main/stora
|
|||||||
import { shutdownItemLogs } from "../src/main/item-log";
|
import { shutdownItemLogs } from "../src/main/item-log";
|
||||||
import { shutdownPackageLogs } from "../src/main/package-log";
|
import { shutdownPackageLogs } from "../src/main/package-log";
|
||||||
|
|
||||||
// Regression for the reported symptom: after an app update while downloading,
|
|
||||||
// packages that were in flight do not continue after the restart.
|
|
||||||
//
|
|
||||||
// Root cause: installUpdate() called manager.stop(), whose abort continuation
|
|
||||||
// marks the in-flight item "cancelled"/"Gestoppt". autoResumeOnStart only
|
|
||||||
// resumes "queued"/"reconnect_wait" items, so after the silent-install relaunch
|
|
||||||
// the download silently stays parked instead of continuing.
|
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
@ -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> }> {
|
async function startTricklingServer(): Promise<{ directUrl: string; stop: () => Promise<void> }> {
|
||||||
const openTimers = new Set<NodeJS.Timeout>();
|
const openTimers = new Set<NodeJS.Timeout>();
|
||||||
const openResponses = new Set<http.ServerResponse>();
|
const openResponses = new Set<http.ServerResponse>();
|
||||||
@ -68,7 +57,6 @@ async function startTricklingServer(): Promise<{ directUrl: string; stop: () =>
|
|||||||
try {
|
try {
|
||||||
res.write(Buffer.alloc(16 * 1024, 9));
|
res.write(Buffer.alloc(16 * 1024, 9));
|
||||||
} catch {
|
} catch {
|
||||||
// socket gone
|
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
openTimers.add(timer);
|
openTimers.add(timer);
|
||||||
@ -94,7 +82,6 @@ async function startTricklingServer(): Promise<{ directUrl: string; stop: () =>
|
|||||||
try {
|
try {
|
||||||
res.destroy();
|
res.destroy();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
openResponses.clear();
|
openResponses.clear();
|
||||||
@ -157,7 +144,6 @@ describe("update restart resume", () => {
|
|||||||
const reloaded = loadSession(paths);
|
const reloaded = loadSession(paths);
|
||||||
const item = Object.values(reloaded.items)[0];
|
const item = Object.values(reloaded.items)[0];
|
||||||
expect(item).toBeTruthy();
|
expect(item).toBeTruthy();
|
||||||
// Documents the loss of resumability: cancelled items are not auto-resumed.
|
|
||||||
expect(item.status).toBe("cancelled");
|
expect(item.status).toBe("cancelled");
|
||||||
} finally {
|
} finally {
|
||||||
await serverStop();
|
await serverStop();
|
||||||
@ -169,7 +155,6 @@ describe("update restart resume", () => {
|
|||||||
tempDirs.push(root);
|
tempDirs.push(root);
|
||||||
const { manager, paths, serverStop } = await driveActiveDownload(root);
|
const { manager, paths, serverStop } = await driveActiveDownload(root);
|
||||||
try {
|
try {
|
||||||
// Mirrors AppController.installUpdate(): park downloads, then sync-persist.
|
|
||||||
manager.stop({ parkForRestart: true });
|
manager.stop({ parkForRestart: true });
|
||||||
manager.persistNowSync();
|
manager.persistNowSync();
|
||||||
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
|
await waitFor(() => (manager as unknown as { activeTasks: Map<string, unknown> }).activeTasks.size === 0);
|
||||||
@ -178,7 +163,6 @@ describe("update restart resume", () => {
|
|||||||
const reloaded = loadSession(paths);
|
const reloaded = loadSession(paths);
|
||||||
const item = Object.values(reloaded.items)[0];
|
const item = Object.values(reloaded.items)[0];
|
||||||
expect(item).toBeTruthy();
|
expect(item).toBeTruthy();
|
||||||
// The package/item must survive AND be resumable so auto-resume continues it.
|
|
||||||
expect(Object.keys(reloaded.packages).length).toBe(1);
|
expect(Object.keys(reloaded.packages).length).toBe(1);
|
||||||
expect(item.status).toBe("queued");
|
expect(item.status).toBe("queued");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
1079
tests/update.test.ts
1079
tests/update.test.ts
File diff suppressed because it is too large
Load Diff
@ -92,7 +92,6 @@ describe("utils", () => {
|
|||||||
const result = sanitizeFilename(longName);
|
const result = sanitizeFilename(longName);
|
||||||
expect(typeof result).toBe("string");
|
expect(typeof result).toBe("string");
|
||||||
expect(result.length).toBeGreaterThan(0);
|
expect(result.length).toBeGreaterThan(0);
|
||||||
// The function should return a non-empty string and not crash
|
|
||||||
expect(result).toBe(longName);
|
expect(result).toBe(longName);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -100,7 +99,6 @@ describe("utils", () => {
|
|||||||
const result = formatEta(999999);
|
const result = formatEta(999999);
|
||||||
expect(typeof result).toBe("string");
|
expect(typeof result).toBe("string");
|
||||||
expect(result.length).toBeGreaterThan(0);
|
expect(result.length).toBeGreaterThan(0);
|
||||||
// 999999 seconds = 277h 46m 39s
|
|
||||||
expect(result).toBe("277:46:39");
|
expect(result).toBe("277:46:39");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -113,28 +111,22 @@ describe("utils", () => {
|
|||||||
|
|
||||||
it("extracts filenames from URLs with encoded characters", () => {
|
it("extracts filenames from URLs with encoded characters", () => {
|
||||||
expect(filenameFromUrl("https://example.com/file%20with%20spaces.rar")).toBe("file with spaces.rar");
|
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/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");
|
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");
|
const result = filenameFromUrl("https://example.com/%ZZ%invalid");
|
||||||
expect(typeof result).toBe("string");
|
expect(typeof result).toBe("string");
|
||||||
expect(result.length).toBeGreaterThan(0);
|
expect(result.length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles looksLikeOpaqueFilename edge cases", () => {
|
it("handles looksLikeOpaqueFilename edge cases", () => {
|
||||||
// Empty string -> sanitizeFilename returns "Paket" which is not opaque
|
|
||||||
expect(looksLikeOpaqueFilename("")).toBe(false);
|
expect(looksLikeOpaqueFilename("")).toBe(false);
|
||||||
expect(looksLikeOpaqueFilename("a")).toBe(false);
|
expect(looksLikeOpaqueFilename("a")).toBe(false);
|
||||||
expect(looksLikeOpaqueFilename("ab")).toBe(false);
|
expect(looksLikeOpaqueFilename("ab")).toBe(false);
|
||||||
expect(looksLikeOpaqueFilename("abc")).toBe(false);
|
expect(looksLikeOpaqueFilename("abc")).toBe(false);
|
||||||
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
|
expect(looksLikeOpaqueFilename("download.bin")).toBe(true);
|
||||||
// 24-char hex string is opaque (matches /^[a-f0-9]{24,}$/)
|
|
||||||
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
|
expect(looksLikeOpaqueFilename("abcdef123456789012345678")).toBe(true);
|
||||||
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
|
expect(looksLikeOpaqueFilename("abcdef1234567890abcdef12")).toBe(true);
|
||||||
// Short hex strings (< 24 chars) are NOT considered opaque
|
|
||||||
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
|
expect(looksLikeOpaqueFilename("abcdef12345")).toBe(false);
|
||||||
// Real filename with extension
|
|
||||||
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
|
expect(looksLikeOpaqueFilename("Show.S01E01.720p.mkv")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user