Initial commit: NAUT torrent web UI with stubbed server
Advanced torrent client web UI aimed at power users, with a zero-dependency Node stub server (built-in http + SSE) serving live mock data. - Dense sortable/multi-select torrent grid with live updates - Detail panel: General/Trackers/Peers/Content/Pieces (resizable) - Sidebar filters: status, categories, tags, trackers - Create/delete categories and tags (sidebar + right-click) - Add via magnet or client-side-parsed .torrent upload - RSS, integrated search, and read-only engine views - Hand-drawn SVG icon set, dark theme, keyboard shortcuts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
bc1be49a37
12 changed files with 2677 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
.env
|
||||
113
README.md
Normal file
113
README.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# NAUT — Torrent Console
|
||||
|
||||
*Naut* — short for nautical; a nod to the sea (and "psychonaut"-style explorer of the swarm).
|
||||
|
||||
|
||||
An advanced, power-user-focused **web UI for a torrent client**, with a fully
|
||||
**stubbed server** so you can run it and click around immediately. No build step,
|
||||
no npm dependencies — just Node.
|
||||
|
||||
```bash
|
||||
node server/index.js # → http://localhost:8088
|
||||
# or
|
||||
npm start
|
||||
PORT=9000 npm start # custom port
|
||||
```
|
||||
|
||||
The server ships 24 mock torrents in varied states and runs a 1-second
|
||||
**simulator** that fluctuates speeds, advances downloads, fills the piece map,
|
||||
drifts the swarm, and accumulates session/ratio stats — so the UI is genuinely
|
||||
*live*, not static.
|
||||
|
||||
---
|
||||
|
||||
## Design rationale (researched against qBittorrent, Flood, Deluge, Tixati)
|
||||
|
||||
Advanced users want **information density + fast bulk control**. The layout is the
|
||||
classic three-pane "fleet console":
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Toolbar: add · resume/pause/recheck/delete · queue · filter · ▼▲ rates · 🐢 │
|
||||
├────────────┬─────────────────────────────────────────────────┤
|
||||
│ Sidebar │ View tabs: Torrents · RSS · Search · Engine │
|
||||
│ • Status │ ┌─────────────────────────────────────────────┐ │
|
||||
│ • Categories│ │ Sortable, multi-select torrent grid │ │
|
||||
│ • Tags │ │ (progress bars, state dots, tags, columns) │ │
|
||||
│ • Trackers │ ├─────────────────────────────────────────────┤ │
|
||||
│ │ │ Detail: General·Trackers·Peers·Content·Pieces│ │
|
||||
├────────────┴─┴─────────────────────────────────────────────┴─┤
|
||||
│ Status bar: DHT · port · active · session ▼▲ · ratio · cache · disk │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Feature set built for advanced users
|
||||
- **Dense sortable grid** — click any header to sort; columns include seeds/peers
|
||||
(connected vs swarm total), availability, ratio, ETA, category, tags, queue #.
|
||||
- **Multi-select + bulk ops** — click / Ctrl-click / Shift-click; toolbar and
|
||||
right-click context menu act on the whole selection.
|
||||
- **Detail panel** with the five tabs power users live in:
|
||||
- *General* — transfer + torrent metadata (pieces, hash, privacy, save/content
|
||||
paths, sequential/super-seed/auto-TMM flags, session totals, time active).
|
||||
- *Trackers* — tier, status, seeds/peers/leeches, announce message, plus the
|
||||
DHT/PeX/LSD pseudo-trackers.
|
||||
- *Peers* — IP:port, country, client, connection type, **BT flag string**,
|
||||
progress, per-peer up/down, relevance.
|
||||
- *Content* — file tree with per-file **priority selector** (Skip/Normal/High/Max)
|
||||
and per-file progress/availability.
|
||||
- *Pieces* — live **piece map** (have / downloading / missing).
|
||||
- **Sidebar filters** — status (downloading, seeding, completed, active, stalled,
|
||||
paused, errored…), categories, tags, and tracker hosts, each with live counts.
|
||||
- **RSS & automation** — feeds, unread articles, and auto-download rules
|
||||
(must/must-not contain, regex, target category + save path, add-paused).
|
||||
- **Integrated search** — multi-indexer search with seeds/leeches, one-click add.
|
||||
- **Engine view** — bandwidth, connection, queueing, and privacy/BitTorrent
|
||||
(DHT/PeX/LSD, encryption mode, µTP) preferences.
|
||||
- **Alt-speed (🐢) toggle**, global rate display vs. limits, resizable detail pane.
|
||||
- **Keyboard shortcuts**: `/` filter · `N` add · `Space` pause/resume ·
|
||||
`Enter` properties · `Del` remove · `Ctrl/⌘-A` select all · `Esc` close.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
server/
|
||||
index.js Zero-dependency http server: static + JSON API + SSE stream
|
||||
data.js Mock fleet generator (torrents, trackers, peers, files, pieces,
|
||||
categories, tags, RSS feeds/rules, search, preferences)
|
||||
simulator.js 1 Hz mutation of the fleet + derived global stats
|
||||
public/
|
||||
index.html App shell
|
||||
css/styles.css Dark, dense theme
|
||||
js/
|
||||
app.js Controller: state, sidebar, grid, selection, context menu,
|
||||
views (RSS/Search/Engine), modals, hotkeys, status bar
|
||||
detail.js Detail panel tabs + live refresh + resize
|
||||
api.js fetch wrappers + EventSource live stream
|
||||
format.js bytes/rate/eta/ratio/date/state formatters
|
||||
```
|
||||
|
||||
### API (stub)
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| GET | `/api/stream` | **SSE** — pushes a compact snapshot (~1/s) |
|
||||
| GET | `/api/snapshot` | One-shot snapshot (grid + server stats) |
|
||||
| GET | `/api/meta` | Categories, tags, tracker hosts, prefs, search plugins |
|
||||
| GET | `/api/torrents/:hash` | Full general properties |
|
||||
| GET | `/api/torrents/:hash/{trackers,peers,files,pieces}` | Tab data |
|
||||
| POST | `/api/action` | `{action, hashes, params}` — pause/resume/recheck/queue/category/limits… |
|
||||
| POST | `/api/delete` | `{hashes, deleteFiles}` |
|
||||
| POST | `/api/add` | `{magnet, name, category, savePath, paused, seqDl, skipCheck}` |
|
||||
| POST | `/api/altspeed` | Toggle alternative speed limits |
|
||||
| GET | `/api/rss`, `/api/rss/rules` | Feeds + auto-download rules |
|
||||
| GET | `/api/search?q=` | Indexer search |
|
||||
|
||||
### Wiring to a real client
|
||||
The stub mirrors common client semantics. To go live, replace the handlers in
|
||||
`server/index.js` with adapters to a real backend — the shapes map closely to:
|
||||
- **qBittorrent** Web API v2 (`/api/v2/torrents/info`, `/sync/maindata`, …)
|
||||
- **Transmission** RPC (`torrent-get`/`torrent-set`)
|
||||
- **Deluge** JSON-RPC
|
||||
|
||||
Keep the SSE `snapshot` contract and the frontend needs no changes.
|
||||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "torrent-ui",
|
||||
"version": "0.1.0",
|
||||
"description": "Advanced web UI for a torrent client (stubbed server with live mock data)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
"dev": "node --watch server/index.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
348
public/css/styles.css
Normal file
348
public/css/styles.css
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
/* ============================================================
|
||||
NAUT — advanced torrent console
|
||||
Dense, keyboard-friendly, dark-first UI.
|
||||
============================================================ */
|
||||
:root {
|
||||
--bg: #0c0f14;
|
||||
--bg-1: #11151c;
|
||||
--bg-2: #161b24;
|
||||
--bg-3: #1d2430;
|
||||
--bg-hover: #232c3a;
|
||||
--bg-sel: #1b3b53;
|
||||
--bg-sel-strong: #1f5174;
|
||||
--line: #232b38;
|
||||
--line-soft: #1a212c;
|
||||
--txt: #d7dde6;
|
||||
--txt-dim: #8b97a8;
|
||||
--txt-faint: #5c6779;
|
||||
--accent: #4ea1ff;
|
||||
--accent-2: #7c5cff;
|
||||
--dl: #4ea1ff;
|
||||
--up: #36d399;
|
||||
--warn: #f4bf4f;
|
||||
--err: #f0616d;
|
||||
--ok: #36d399;
|
||||
--pause: #8b97a8;
|
||||
--check: #b58cff;
|
||||
--queue: #f4bf4f;
|
||||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
|
||||
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--r: 6px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; }
|
||||
body {
|
||||
font-family: var(--sans);
|
||||
font-size: 13px;
|
||||
color: var(--txt);
|
||||
background: var(--bg);
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, select { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #2a3342; border-radius: 6px; border: 2px solid var(--bg-1); }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-rows: 44px 1fr 26px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ---------------- Toolbar ---------------- */
|
||||
.toolbar {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 0 12px;
|
||||
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.brand { display: flex; align-items: baseline; gap: 6px; user-select: none; }
|
||||
.brand-mark { color: var(--accent); font-size: 18px; }
|
||||
.brand-name { font-weight: 700; letter-spacing: 2px; }
|
||||
.brand-sub { color: var(--txt-faint); font-size: 11px; letter-spacing: .5px; }
|
||||
.tb-actions { display: flex; align-items: center; gap: 3px; }
|
||||
.tb-sep { width: 1px; height: 22px; background: var(--line); margin: 0 5px; }
|
||||
.tb-spacer { flex: 1; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r);
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--txt);
|
||||
white-space: nowrap;
|
||||
transition: background .12s, border-color .12s, color .12s;
|
||||
}
|
||||
.btn:hover { background: var(--bg-hover); border-color: #344155; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.icon { padding: 5px 7px; min-width: 32px; }
|
||||
.btn.danger { color: var(--txt-dim); }
|
||||
.btn.danger:hover { border-color: var(--err); color: var(--err); }
|
||||
.btn.alt-toggle.on { background: var(--warn); color: #1a1300; border-color: var(--warn); }
|
||||
|
||||
/* inline SVG icon system: monochrome, inherits button color */
|
||||
.ic-svg {
|
||||
width: 16px; height: 16px; flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.ic-svg.fill { fill: currentColor; stroke: none; }
|
||||
|
||||
.quick-filter {
|
||||
width: 240px; background: var(--bg); border: 1px solid var(--line);
|
||||
border-radius: 14px; padding: 5px 12px;
|
||||
}
|
||||
.quick-filter:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
.tb-rates { display: flex; gap: 12px; font-variant-numeric: tabular-nums; }
|
||||
.rate { color: var(--txt-dim); font-size: 12px; }
|
||||
.rate.dl b { color: var(--dl); }
|
||||
.rate.up b { color: var(--up); }
|
||||
|
||||
/* ---------------- Main layout ---------------- */
|
||||
.main { display: grid; grid-template-columns: 232px 1fr; grid-template-rows: minmax(0, 1fr); min-height: 0; }
|
||||
|
||||
/* ---------------- Sidebar ---------------- */
|
||||
.sidebar {
|
||||
background: var(--bg-1);
|
||||
border-right: 1px solid var(--line);
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.side-group { margin-bottom: 6px; }
|
||||
.side-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 6px 12px 4px; color: var(--txt-faint);
|
||||
font-size: 10.5px; text-transform: uppercase; letter-spacing: 1px; user-select: none;
|
||||
}
|
||||
.side-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 4px 12px; cursor: pointer; color: var(--txt-dim);
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
.side-item:hover { background: var(--bg-2); color: var(--txt); }
|
||||
.side-item.active { background: var(--bg-sel); color: #fff; border-left-color: var(--accent); }
|
||||
.side-item .ic { width: 16px; text-align: center; opacity: .9; }
|
||||
.side-item .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.side-item .cnt {
|
||||
font-size: 11px; color: var(--txt-faint);
|
||||
background: var(--bg-3); border-radius: 9px; padding: 0 7px; min-width: 18px; text-align: center;
|
||||
}
|
||||
.side-item.active .cnt { background: var(--bg-sel-strong); color: #cfe8ff; }
|
||||
.side-add {
|
||||
background: transparent; border: none; color: var(--txt-faint); cursor: pointer;
|
||||
font-size: 16px; line-height: 1; padding: 0 4px; border-radius: 4px;
|
||||
}
|
||||
.side-add:hover { color: var(--accent); background: var(--bg-3); }
|
||||
|
||||
/* tag editor */
|
||||
.taglist { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; margin-top: 6px;
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 8px; background: var(--bg); }
|
||||
.check-row { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 5px; cursor: pointer; color: var(--txt); }
|
||||
.check-row:hover { background: var(--bg-2); }
|
||||
|
||||
/* ---------------- Content / view tabs ---------------- */
|
||||
.content { display: grid; grid-template-rows: 34px minmax(0, 1fr); min-width: 0; min-height: 0; }
|
||||
.viewtabs { display: flex; gap: 2px; padding: 4px 8px 0; background: var(--bg-1); border-bottom: 1px solid var(--line); }
|
||||
.vtab {
|
||||
background: transparent; border: 1px solid transparent; border-bottom: none;
|
||||
padding: 6px 14px; cursor: pointer; color: var(--txt-dim);
|
||||
border-radius: 6px 6px 0 0;
|
||||
}
|
||||
.vtab:hover { color: var(--txt); background: var(--bg-2); }
|
||||
.vtab.active { background: var(--bg); color: #fff; border-color: var(--line); position: relative; top: 1px; }
|
||||
.view-host { min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
|
||||
|
||||
/* ---------------- Torrents view (grid + detail) ---------------- */
|
||||
.torrents-view { display: grid; grid-template-rows: minmax(0, 1fr) var(--detail-h, 0px); min-height: 0; height: 100%; }
|
||||
.grid-wrap { overflow: auto; min-height: 0; }
|
||||
#detailHost { min-height: 0; } /* must NOT clip — the resize handle overhangs the top edge */
|
||||
|
||||
table.grid { width: 100%; min-width: 1660px; table-layout: fixed; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||||
table.grid thead th {
|
||||
position: sticky; top: 0; z-index: 2;
|
||||
background: var(--bg-2); text-align: left; font-weight: 600; color: var(--txt-dim);
|
||||
padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap;
|
||||
cursor: pointer; user-select: none; font-size: 11.5px;
|
||||
}
|
||||
table.grid thead th:hover { color: var(--txt); }
|
||||
table.grid thead th .sort { color: var(--accent); margin-left: 3px; }
|
||||
table.grid td {
|
||||
padding: 4px 8px; border-bottom: 1px solid var(--line-soft); white-space: nowrap;
|
||||
overflow: hidden; text-overflow: ellipsis; max-width: 360px;
|
||||
}
|
||||
table.grid tbody tr { cursor: default; }
|
||||
table.grid tbody tr:hover { background: var(--bg-2); }
|
||||
table.grid tbody tr.sel { background: var(--bg-sel); }
|
||||
table.grid tbody tr.sel:hover { background: var(--bg-sel-strong); }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.dim { color: var(--txt-dim); }
|
||||
.faint { color: var(--txt-faint); }
|
||||
|
||||
.name-cell { display: flex; align-items: center; gap: 7px; }
|
||||
.state-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; box-shadow: 0 0 6px currentColor; }
|
||||
.priv-badge { font-size: 9px; color: var(--warn); border: 1px solid var(--warn); border-radius: 3px; padding: 0 3px; opacity: .8; }
|
||||
|
||||
/* progress bar in-cell */
|
||||
.pbar { position: relative; width: 100%; height: 14px; background: var(--bg-3); border-radius: 3px; overflow: hidden; min-width: 90px; }
|
||||
.pbar > i { position: absolute; left: 0; top: 0; bottom: 0; background: linear-gradient(90deg, #2f6db0, var(--dl)); }
|
||||
.pbar.done > i { background: linear-gradient(90deg, #1f9e72, var(--up)); }
|
||||
.pbar.paused > i { background: #3a4452; }
|
||||
.pbar.error > i { background: linear-gradient(90deg, #8a2b33, var(--err)); }
|
||||
.pbar.check > i { background: repeating-linear-gradient(45deg, #5b3f99, #5b3f99 6px, #7c5cff 6px, #7c5cff 12px); }
|
||||
.pbar > span { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 10.5px; color: #fff; text-shadow: 0 1px 2px rgba(0,0,0,.7); }
|
||||
|
||||
.tags-cell { display: flex; gap: 4px; flex-wrap: nowrap; overflow: hidden; }
|
||||
.tag { background: var(--bg-3); border: 1px solid var(--line); border-radius: 10px; padding: 0 7px; font-size: 11px; color: var(--txt-dim); }
|
||||
.cat-chip { color: var(--accent); }
|
||||
|
||||
/* state text colors */
|
||||
.s-downloading, .s-forcedDL, .s-metaDL { color: var(--dl); }
|
||||
.s-uploading, .s-forcedUP { color: var(--up); }
|
||||
.s-stalledDL, .s-stalledUP { color: var(--txt-dim); }
|
||||
.s-pausedDL, .s-pausedUP { color: var(--pause); }
|
||||
.s-checkingDL, .s-checkingUP, .s-moving { color: var(--check); }
|
||||
.s-queuedDL, .s-queuedUP { color: var(--queue); }
|
||||
.s-error, .s-missingFiles { color: var(--err); }
|
||||
|
||||
/* ---------------- Detail panel ---------------- */
|
||||
.detail {
|
||||
border-top: 1px solid var(--line); background: var(--bg-1);
|
||||
display: grid; grid-template-rows: 30px 1fr; height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.detail-resize { position: absolute; top: -6px; left: 0; right: 0; height: 12px; cursor: ns-resize; z-index: 10; touch-action: none; }
|
||||
.detail-resize::after {
|
||||
content: ""; position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
|
||||
width: 56px; height: 4px; border-radius: 2px; background: #3a4452;
|
||||
}
|
||||
.detail-resize:hover::after { background: var(--accent); }
|
||||
.detail-tabs { display: flex; gap: 2px; padding: 3px 8px 0; border-bottom: 1px solid var(--line); align-items: center; }
|
||||
.dtab { background: transparent; border: none; padding: 5px 12px; cursor: pointer; color: var(--txt-dim); border-radius: 5px 5px 0 0; }
|
||||
.dtab:hover { color: var(--txt); background: var(--bg-2); }
|
||||
.dtab.active { color: #fff; background: var(--bg-3); }
|
||||
.detail-close { margin-left: auto; color: var(--txt-faint); }
|
||||
.detail-body { overflow: auto; padding: 10px 14px; min-height: 0; }
|
||||
.detail-empty { display: grid; place-items: center; color: var(--txt-faint); height: 100%; }
|
||||
|
||||
/* general grid of props */
|
||||
.props { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 2px 24px; }
|
||||
.prop { display: flex; justify-content: space-between; gap: 12px; padding: 3px 0; border-bottom: 1px solid var(--line-soft); }
|
||||
.prop .k { color: var(--txt-faint); }
|
||||
.prop .v { color: var(--txt); font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.prop .v.mono { font-family: var(--mono); font-size: 12px; }
|
||||
.section-h { color: var(--accent); font-size: 11px; text-transform: uppercase; letter-spacing: 1px; margin: 14px 0 6px; grid-column: 1/-1; }
|
||||
|
||||
/* generic data tables inside detail */
|
||||
table.dtbl { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
|
||||
table.dtbl th { text-align: left; color: var(--txt-faint); font-weight: 600; padding: 4px 8px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--bg-1); font-size: 11px; }
|
||||
table.dtbl td { padding: 3px 8px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
|
||||
.flagchip { font-family: var(--mono); letter-spacing: 1px; }
|
||||
.status-working { color: var(--ok); }
|
||||
.status-error { color: var(--err); }
|
||||
.status-updating, .status-not.contacted, .status-not { color: var(--warn); }
|
||||
|
||||
/* files tree */
|
||||
.file-row td:first-child { font-family: var(--mono); font-size: 12px; }
|
||||
.prio-sel { background: var(--bg-3); border: 1px solid var(--line); border-radius: 4px; padding: 1px 4px; }
|
||||
.mini-bar { width: 80px; height: 9px; background: var(--bg-3); border-radius: 2px; overflow: hidden; display: inline-block; vertical-align: middle; }
|
||||
.mini-bar > i { display: block; height: 100%; background: var(--up); }
|
||||
|
||||
/* piece map */
|
||||
.piecemap { display: flex; flex-wrap: wrap; gap: 1px; align-content: flex-start; }
|
||||
.piece { width: 9px; height: 9px; border-radius: 1px; background: var(--bg-3); }
|
||||
.piece.dl { background: var(--warn); }
|
||||
.piece.done { background: var(--dl); }
|
||||
.piece-legend { display: flex; gap: 16px; margin-bottom: 10px; color: var(--txt-dim); font-size: 12px; }
|
||||
.piece-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.swatch { width: 11px; height: 11px; border-radius: 2px; display: inline-block; }
|
||||
|
||||
/* ---------------- RSS view ---------------- */
|
||||
.pane { padding: 14px 18px; overflow: auto; height: 100%; }
|
||||
.split2 { display: grid; grid-template-columns: 280px 1fr; gap: 16px; height: 100%; }
|
||||
.card { background: var(--bg-1); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; margin-bottom: 12px; }
|
||||
.card h3 { margin: 0 0 10px; font-size: 13px; color: var(--txt); }
|
||||
.rule { border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; background: var(--bg-1); }
|
||||
.rule.off { opacity: .55; }
|
||||
.rule-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
|
||||
.rule-name { font-weight: 600; }
|
||||
.pill { font-size: 11px; border-radius: 10px; padding: 1px 8px; border: 1px solid var(--line); color: var(--txt-dim); }
|
||||
.pill.on { color: var(--ok); border-color: var(--ok); }
|
||||
.pill.paused { color: var(--warn); border-color: var(--warn); }
|
||||
.kvrow { display: flex; gap: 8px; font-size: 12px; color: var(--txt-dim); margin: 2px 0; }
|
||||
.kvrow b { color: var(--txt); font-weight: 500; }
|
||||
code.inline { background: var(--bg-3); border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px; font-family: var(--mono); font-size: 12px; color: var(--accent); }
|
||||
|
||||
/* ---------------- Search view ---------------- */
|
||||
.search-bar { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.search-bar input { flex: 1; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; }
|
||||
.search-bar input:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* ---------------- Settings ---------------- */
|
||||
.settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
|
||||
/* ---------------- Status bar ---------------- */
|
||||
.statusbar {
|
||||
display: flex; align-items: center; gap: 16px; padding: 0 12px;
|
||||
background: var(--bg-2); border-top: 1px solid var(--line);
|
||||
color: var(--txt-dim); font-size: 11.5px; font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.statusbar .sb { display: inline-flex; align-items: center; gap: 5px; }
|
||||
.statusbar .sb b { color: var(--txt); font-weight: 600; }
|
||||
.statusbar .conn-ok { color: var(--ok); }
|
||||
.statusbar .spacer { flex: 1; }
|
||||
.led { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); box-shadow: 0 0 6px var(--ok); }
|
||||
|
||||
/* ---------------- Modal ---------------- */
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: grid; place-items: center; z-index: 100; }
|
||||
.modal-backdrop[hidden] { display: none; }
|
||||
.modal { background: var(--bg-1); border: 1px solid var(--line); border-radius: 10px; width: min(560px, 92vw); max-height: 86vh; overflow: auto; box-shadow: 0 20px 60px rgba(0,0,0,.5); }
|
||||
.modal h2 { margin: 0; padding: 14px 18px; border-bottom: 1px solid var(--line); font-size: 15px; }
|
||||
.modal .mbody { padding: 16px 18px; }
|
||||
.modal .mfoot { padding: 12px 18px; border-top: 1px solid var(--line); display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.field { margin-bottom: 12px; }
|
||||
.field label { display: block; color: var(--txt-dim); margin-bottom: 4px; font-size: 12px; }
|
||||
.field input[type=text], .field textarea, .field select {
|
||||
width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 6px; padding: 7px 10px;
|
||||
}
|
||||
.field input[type=text]:focus, .field textarea:focus, .field select:focus,
|
||||
.field input[type=file]:focus { outline: none; border-color: var(--accent); }
|
||||
.field textarea { min-height: 70px; font-family: var(--mono); font-size: 12px; resize: vertical; }
|
||||
|
||||
/* themed file picker */
|
||||
.field input[type=file] {
|
||||
width: 100%; background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
|
||||
padding: 6px 8px; color: var(--txt-dim); cursor: pointer; font-size: 12px;
|
||||
}
|
||||
.field input[type=file]::file-selector-button {
|
||||
background: var(--bg-3); border: 1px solid var(--line); border-radius: 5px;
|
||||
color: var(--txt); padding: 5px 12px; margin-right: 12px; cursor: pointer;
|
||||
font-family: inherit; font-size: 12px;
|
||||
transition: background .12s, border-color .12s;
|
||||
}
|
||||
.field input[type=file]::file-selector-button:hover { background: var(--bg-hover); border-color: #344155; }
|
||||
.checks { display: flex; gap: 16px; flex-wrap: wrap; }
|
||||
.checks label { color: var(--txt-dim); display: inline-flex; gap: 6px; align-items: center; }
|
||||
.btn.primary { background: var(--accent); color: #052238; border-color: var(--accent); font-weight: 600; }
|
||||
.btn.primary:hover { background: #6cb3ff; }
|
||||
|
||||
/* context menu */
|
||||
.ctxmenu { position: fixed; z-index: 200; background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; padding: 5px; min-width: 200px; box-shadow: 0 12px 40px rgba(0,0,0,.5); }
|
||||
.ctxmenu .mi { padding: 6px 12px; border-radius: 5px; cursor: pointer; display: flex; justify-content: space-between; gap: 16px; color: var(--txt); }
|
||||
.ctxmenu .mi:hover { background: var(--bg-sel-strong); }
|
||||
.ctxmenu .mi.danger:hover { background: #5a2228; }
|
||||
.ctxmenu .mi .sc { color: var(--txt-faint); font-size: 11px; }
|
||||
.ctxmenu .sep { height: 1px; background: var(--line); margin: 5px 4px; }
|
||||
.ctxmenu .sub { color: var(--txt-faint); padding: 4px 12px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px; }
|
||||
|
||||
.empty { display: grid; place-items: center; height: 100%; color: var(--txt-faint); }
|
||||
83
public/index.html
Normal file
83
public/index.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Naut · Torrent Console</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='14' font-size='14'>🛰️</text></svg>" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Top toolbar -->
|
||||
<header class="toolbar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">⬡</span>
|
||||
<span class="brand-name">NAUT</span>
|
||||
<span class="brand-sub">torrent console</span>
|
||||
</div>
|
||||
|
||||
<div class="tb-actions">
|
||||
<button class="btn" data-act="add" title="Add torrent / magnet (N)">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M12 5v14M5 12h14"/></svg> Add</button>
|
||||
<div class="tb-sep"></div>
|
||||
<button class="btn icon" data-act="resume" title="Resume (selection)">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg fill"><path d="M8 5.5v13l11-6.5z"/></svg></button>
|
||||
<button class="btn icon" data-act="pause" title="Pause (selection)">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg fill"><rect x="6.5" y="5" width="3.5" height="14" rx="1"/><rect x="14" y="5" width="3.5" height="14" rx="1"/></svg></button>
|
||||
<button class="btn icon" data-act="recheck" title="Force recheck">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 4v5h-5"/></svg></button>
|
||||
<button class="btn icon danger" data-act="delete" title="Delete (Del)">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M4 6.5h16M9.5 6.5V4.5h5v2M17.5 6.5l-1 13.5a1.8 1.8 0 0 1-1.8 1.5H9.3a1.8 1.8 0 0 1-1.8-1.5l-1-13.5M10 10.5v7M14 10.5v7"/></svg></button>
|
||||
<div class="tb-sep"></div>
|
||||
<button class="btn icon" data-act="topPriority" title="Move to top of queue">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M5 4.5h14M12 20.5V9M7 14l5-5 5 5"/></svg></button>
|
||||
<button class="btn icon" data-act="increasePriority" title="Queue up one">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M12 20V6M6.5 11.5L12 6l5.5 5.5"/></svg></button>
|
||||
<button class="btn icon" data-act="decreasePriority" title="Queue down one">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M12 4v14M6.5 12.5L12 18l5.5-5.5"/></svg></button>
|
||||
<button class="btn icon" data-act="bottomPriority" title="Move to bottom of queue">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M5 19.5h14M12 3.5V15M7 10l5 5 5-5"/></svg></button>
|
||||
</div>
|
||||
|
||||
<div class="tb-spacer"></div>
|
||||
|
||||
<input id="quickFilter" class="quick-filter" type="search" placeholder="Filter torrents… (/)" />
|
||||
|
||||
<div class="tb-rates">
|
||||
<span class="rate dl" title="Global download rate">▼ <b id="globalDl">–</b></span>
|
||||
<span class="rate up" title="Global upload rate">▲ <b id="globalUp">–</b></span>
|
||||
</div>
|
||||
<button class="btn alt-toggle" data-act="altspeed" title="Toggle alternative speed limits">
|
||||
<svg viewBox="0 0 24 24" class="ic-svg"><path d="M4 18a8 8 0 0 1 16 0M12 18l3.5-5"/><circle cx="12" cy="18" r="1.3" fill="currentColor" stroke="none"/></svg> Alt</button>
|
||||
</header>
|
||||
|
||||
<div class="main">
|
||||
<!-- Sidebar: status / categories / tags / trackers -->
|
||||
<aside class="sidebar" id="sidebar"></aside>
|
||||
|
||||
<!-- Center: nav tabs + content -->
|
||||
<section class="content">
|
||||
<nav class="viewtabs" id="viewtabs">
|
||||
<button class="vtab active" data-view="torrents">Torrents</button>
|
||||
<button class="vtab" data-view="rss">RSS & Automation</button>
|
||||
<button class="vtab" data-view="search">Search</button>
|
||||
<button class="vtab" data-view="settings">Engine</button>
|
||||
</nav>
|
||||
|
||||
<div class="view-host" id="viewHost"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<footer class="statusbar" id="statusbar"></footer>
|
||||
</div>
|
||||
|
||||
<!-- Modal host -->
|
||||
<div class="modal-backdrop" id="modalBackdrop" hidden>
|
||||
<div class="modal" id="modal"></div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
48
public/js/api.js
Normal file
48
public/js/api.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Thin API client + SSE live stream.
|
||||
|
||||
async function jget(url) {
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`${url} → ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
async function jpost(url, body) {
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
meta: () => jget('/api/meta'),
|
||||
snapshot: () => jget('/api/snapshot'),
|
||||
torrent: (hash) => jget(`/api/torrents/${hash}`),
|
||||
trackers: (hash) => jget(`/api/torrents/${hash}/trackers`),
|
||||
peers: (hash) => jget(`/api/torrents/${hash}/peers`),
|
||||
files: (hash) => jget(`/api/torrents/${hash}/files`),
|
||||
pieces: (hash) => jget(`/api/torrents/${hash}/pieces`),
|
||||
|
||||
action: (action, hashes, params) => jpost('/api/action', { action, hashes, params }),
|
||||
remove: (hashes, deleteFiles) => jpost('/api/delete', { hashes, deleteFiles }),
|
||||
add: (opts) => jpost('/api/add', opts),
|
||||
toggleAltSpeed: () => jpost('/api/altspeed', {}),
|
||||
|
||||
createCategory: (name, savePath) => jpost('/api/categories', { name, savePath }),
|
||||
deleteCategory: (name) => jpost('/api/categories/delete', { name }),
|
||||
createTag: (name) => jpost('/api/tags', { name }),
|
||||
deleteTag: (name) => jpost('/api/tags/delete', { name }),
|
||||
|
||||
rss: () => jget('/api/rss'),
|
||||
rssRules: () => jget('/api/rss/rules'),
|
||||
search: (q) => jget(`/api/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
// Live snapshot stream. onSnapshot(snapshot) called ~1/s.
|
||||
stream(onSnapshot, onStatus) {
|
||||
const es = new EventSource('/api/stream');
|
||||
es.addEventListener('snapshot', (e) => onSnapshot(JSON.parse(e.data)));
|
||||
es.onopen = () => onStatus && onStatus('connected');
|
||||
es.onerror = () => onStatus && onStatus('reconnecting');
|
||||
return es;
|
||||
},
|
||||
};
|
||||
826
public/js/app.js
Normal file
826
public/js/app.js
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
// NAUT — main application controller.
|
||||
// Wires the live stream into the sidebar, torrent grid, detail panel,
|
||||
// status bar, RSS/Search/Engine views, selection, context menu, hotkeys.
|
||||
|
||||
import { api } from './api.js';
|
||||
import * as f from './format.js';
|
||||
import { renderDetailShell, closeDetail, detailTab } from './detail.js';
|
||||
|
||||
/* ===================== state ===================== */
|
||||
const state = {
|
||||
view: 'torrents',
|
||||
snapshot: { torrents: [], server: {} },
|
||||
meta: { categories: [], tags: [], trackers: [], preferences: {}, searchPlugins: [] },
|
||||
selected: new Set(),
|
||||
lastClicked: null,
|
||||
detailHash: null,
|
||||
filter: { type: 'status', value: 'all' }, // type: status|category|tag|tracker
|
||||
quick: '',
|
||||
sort: { key: 'addedOn', dir: -1 },
|
||||
columns: ['name', 'size', 'progress', 'state', 'seeds', 'peers', 'dlspeed', 'upspeed', 'eta', 'ratio', 'category', 'tags', 'addedOn'],
|
||||
searchResults: null,
|
||||
searchQuery: '',
|
||||
};
|
||||
|
||||
const COLUMNS = {
|
||||
name: { label: 'Name' }, // flexible: absorbs remaining width
|
||||
size: { label: 'Size', num: true, w: '90px' },
|
||||
progress: { label: 'Done', w: '120px' },
|
||||
state: { label: 'Status', w: '120px' },
|
||||
seeds: { label: 'Seeds', num: true, w: '92px' },
|
||||
peers: { label: 'Peers', num: true, w: '92px' },
|
||||
dlspeed: { label: 'Down', num: true, w: '100px' },
|
||||
upspeed: { label: 'Up', num: true, w: '100px' },
|
||||
eta: { label: 'ETA', num: true, w: '90px' },
|
||||
ratio: { label: 'Ratio', num: true, w: '70px' },
|
||||
availability: { label: 'Avail.', num: true, w: '72px' },
|
||||
category: { label: 'Category', w: '130px' },
|
||||
tags: { label: 'Tags', w: '170px' },
|
||||
addedOn: { label: 'Added', num: true, w: '128px' },
|
||||
completionOn: { label: 'Completed', num: true, w: '128px' },
|
||||
savePath: { label: 'Save path', w: '220px' },
|
||||
priority: { label: '#', num: true, w: '56px' },
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
['all', 'All', '◎'],
|
||||
['downloading', 'Downloading', '▼'],
|
||||
['seeding', 'Seeding', '▲'],
|
||||
['completed', 'Completed', '✓'],
|
||||
['active', 'Active', '⚡'],
|
||||
['inactive', 'Inactive', '○'],
|
||||
['stalled', 'Stalled', '◍'],
|
||||
['paused', 'Paused', '⏸'],
|
||||
['errored', 'Errored', '⚠'],
|
||||
];
|
||||
|
||||
/* ===================== bootstrap ===================== */
|
||||
async function boot() {
|
||||
state.meta = await api.meta();
|
||||
syncAltToggle();
|
||||
renderSidebar();
|
||||
renderView();
|
||||
api.stream(onSnapshot, onStatus);
|
||||
bindGlobal();
|
||||
}
|
||||
|
||||
function onSnapshot(snap) {
|
||||
state.snapshot = snap;
|
||||
updateRates();
|
||||
renderStatusbar();
|
||||
// keep sidebar counts fresh + grid live
|
||||
renderSidebar();
|
||||
if (state.view === 'torrents') renderGrid();
|
||||
}
|
||||
|
||||
function onStatus(s) {
|
||||
const led = document.querySelector('.statusbar .led');
|
||||
if (led) led.style.background = s === 'connected' ? 'var(--ok)' : 'var(--warn)';
|
||||
}
|
||||
|
||||
/* ===================== derived ===================== */
|
||||
function visibleTorrents() {
|
||||
let list = state.snapshot.torrents;
|
||||
const { type, value } = state.filter;
|
||||
list = list.filter((t) => matchFilter(t, type, value));
|
||||
if (state.quick) {
|
||||
const q = state.quick.toLowerCase();
|
||||
list = list.filter((t) => t.name.toLowerCase().includes(q) || (t.category || '').toLowerCase().includes(q) || t.tags.some((x) => x.includes(q)));
|
||||
}
|
||||
const { key, dir } = state.sort;
|
||||
list = [...list].sort((a, b) => cmp(a, b, key) * dir);
|
||||
return list;
|
||||
}
|
||||
|
||||
function matchFilter(t, type, value) {
|
||||
if (type === 'category') return (t.category || '') === value;
|
||||
if (type === 'tag') return t.tags.includes(value);
|
||||
if (type === 'tracker') return true; // tracker host not in compact snapshot; show all (stub)
|
||||
// status
|
||||
const s = t.state;
|
||||
switch (value) {
|
||||
case 'all': return true;
|
||||
case 'downloading': return ['downloading', 'forcedDL', 'metaDL', 'stalledDL', 'queuedDL'].includes(s);
|
||||
case 'seeding': return ['uploading', 'forcedUP', 'stalledUP', 'queuedUP'].includes(s);
|
||||
case 'completed': return t.progress >= 1;
|
||||
case 'active': return t.dlspeed > 0 || t.upspeed > 0;
|
||||
case 'inactive': return t.dlspeed === 0 && t.upspeed === 0;
|
||||
case 'stalled': return s === 'stalledDL' || s === 'stalledUP';
|
||||
case 'paused': return s.startsWith('paused');
|
||||
case 'errored': return s === 'error' || s === 'missingFiles';
|
||||
default: return true;
|
||||
}
|
||||
}
|
||||
|
||||
function cmp(a, b, key) {
|
||||
if (key === 'name' || key === 'state' || key === 'category' || key === 'savePath') {
|
||||
return String(a[key] || '').localeCompare(String(b[key] || ''));
|
||||
}
|
||||
if (key === 'tags') return a.tags.join().localeCompare(b.tags.join());
|
||||
return (a[key] ?? 0) - (b[key] ?? 0);
|
||||
}
|
||||
|
||||
/* ===================== sidebar ===================== */
|
||||
function renderSidebar() {
|
||||
const all = state.snapshot.torrents;
|
||||
const count = (type, value) => all.filter((t) => matchFilter(t, type, value)).length;
|
||||
const el = document.getElementById('sidebar');
|
||||
|
||||
const statusItems = STATUS_FILTERS.map(([v, label, ic]) =>
|
||||
sideItem('status', v, ic, label, count('status', v))).join('');
|
||||
|
||||
// Categories/tags: source names from server meta (so empty ones still show) merged with live counts.
|
||||
const catCounts = new Map();
|
||||
for (const t of all) catCounts.set(t.category || '', (catCounts.get(t.category || '') || 0) + 1);
|
||||
const catNames = new Set(state.meta.categories.map((c) => c.name));
|
||||
for (const k of catCounts.keys()) catNames.add(k);
|
||||
const cats = [...catNames].sort((a, b) => a.localeCompare(b))
|
||||
.map((c) => sideItem('category', c, '🗂', c || 'Uncategorized', catCounts.get(c) || 0, c !== '')).join('');
|
||||
|
||||
const tagCounts = new Map();
|
||||
for (const t of all) for (const tag of t.tags) tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
const tagNames = new Set(state.meta.tags);
|
||||
for (const k of tagCounts.keys()) tagNames.add(k);
|
||||
const tags = [...tagNames].sort((a, b) => (tagCounts.get(b) || 0) - (tagCounts.get(a) || 0) || a.localeCompare(b))
|
||||
.map((tg) => sideItem('tag', tg, '#', tg, tagCounts.get(tg) || 0, true)).join('');
|
||||
|
||||
const trackers = state.meta.trackers.slice(0, 10)
|
||||
.map((tr) => sideItem('tracker', tr.host, '🛰', tr.host, tr.count)).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="side-group"><div class="side-head">Status</div>${statusItems}</div>
|
||||
<div class="side-group"><div class="side-head">Categories<button class="side-add" data-add="category" title="New category">+</button></div>${cats}</div>
|
||||
<div class="side-group"><div class="side-head">Tags<button class="side-add" data-add="tag" title="New tag">+</button></div>${tags || '<div class="side-item dim"><span class="lbl">none</span></div>'}</div>
|
||||
<div class="side-group"><div class="side-head">Trackers</div>${trackers}</div>`;
|
||||
|
||||
el.querySelectorAll('.side-item[data-type]').forEach((it) => {
|
||||
it.addEventListener('click', () => {
|
||||
state.filter = { type: it.dataset.type, value: it.dataset.value };
|
||||
if (state.view !== 'torrents') { state.view = 'torrents'; switchViewTab('torrents'); }
|
||||
renderSidebar(); renderView();
|
||||
});
|
||||
if (it.dataset.type === 'category' || it.dataset.type === 'tag') {
|
||||
it.addEventListener('contextmenu', (e) =>
|
||||
onSidebarContext(e, it.dataset.type, it.dataset.value, it.dataset.removable === '1'));
|
||||
}
|
||||
});
|
||||
|
||||
el.querySelectorAll('.side-add[data-add]').forEach((b) =>
|
||||
b.addEventListener('click', (e) => { e.stopPropagation(); b.dataset.add === 'category' ? openCreateCategory() : openCreateTag(); }));
|
||||
}
|
||||
|
||||
function onSidebarContext(e, type, value, removable) {
|
||||
e.preventDefault();
|
||||
const items = [
|
||||
{ label: type === 'category' ? 'New category…' : 'New tag…', act: () => (type === 'category' ? openCreateCategory() : openCreateTag()) },
|
||||
];
|
||||
if (removable) {
|
||||
items.push({ sep: true });
|
||||
items.push({ label: `Delete ${type}`, danger: true, act: () => confirmDeleteMeta(type, value) });
|
||||
}
|
||||
showContextMenu(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
function sideItem(type, value, ic, label, cnt, removable) {
|
||||
const active = state.filter.type === type && state.filter.value === value;
|
||||
return `<div class="side-item ${active ? 'active' : ''}" data-type="${type}" data-value="${f.esc(value)}" data-removable="${removable ? 1 : 0}">
|
||||
<span class="ic">${ic}</span><span class="lbl">${f.esc(label)}</span><span class="cnt">${cnt}</span></div>`;
|
||||
}
|
||||
|
||||
async function refreshMeta() {
|
||||
state.meta = await api.meta();
|
||||
renderSidebar();
|
||||
}
|
||||
|
||||
/* ===================== views ===================== */
|
||||
function renderView() {
|
||||
const host = document.getElementById('viewHost');
|
||||
if (state.view === 'torrents') return renderTorrentsView(host);
|
||||
if (state.view === 'rss') return renderRssView(host);
|
||||
if (state.view === 'search') return renderSearchView(host);
|
||||
if (state.view === 'settings') return renderSettingsView(host);
|
||||
}
|
||||
|
||||
/* ---------- torrents view ---------- */
|
||||
function renderTorrentsView(host) {
|
||||
host.innerHTML = `
|
||||
<div class="torrents-view">
|
||||
<div class="grid-wrap" id="gridWrap"></div>
|
||||
<div id="detailHost"></div>
|
||||
</div>`;
|
||||
renderGrid();
|
||||
if (state.detailHash) renderDetailShell(state.detailHash, document.getElementById('detailHost'));
|
||||
}
|
||||
|
||||
function renderGrid() {
|
||||
const wrap = document.getElementById('gridWrap');
|
||||
if (!wrap) return;
|
||||
const list = visibleTorrents();
|
||||
const cols = state.columns;
|
||||
|
||||
const head = cols.map((k) => {
|
||||
const c = COLUMNS[k];
|
||||
const arrow = state.sort.key === k ? `<span class="sort">${state.sort.dir < 0 ? '▾' : '▴'}</span>` : '';
|
||||
return `<th data-col="${k}" class="${c.num ? 'num' : ''}" ${c.w ? `style="width:${c.w}"` : ''}>${c.label}${arrow}</th>`;
|
||||
}).join('');
|
||||
|
||||
const rows = list.map((t) => `<tr data-hash="${t.hash}" class="${state.selected.has(t.hash) ? 'sel' : ''}">
|
||||
${cols.map((k) => cell(t, k)).join('')}
|
||||
</tr>`).join('');
|
||||
|
||||
wrap.innerHTML = `<table class="grid"><thead><tr>${head}</tr></thead><tbody>${rows || emptyRow(cols.length)}</tbody></table>`;
|
||||
|
||||
wrap.querySelectorAll('th[data-col]').forEach((th) =>
|
||||
th.addEventListener('click', () => {
|
||||
const k = th.dataset.col;
|
||||
if (state.sort.key === k) state.sort.dir *= -1; else state.sort = { key: k, dir: k === 'name' ? 1 : -1 };
|
||||
renderGrid();
|
||||
}));
|
||||
|
||||
wrap.querySelectorAll('tr[data-hash]').forEach((tr) => {
|
||||
tr.addEventListener('click', (e) => onRowClick(e, tr.dataset.hash, list));
|
||||
tr.addEventListener('dblclick', () => openDetail(tr.dataset.hash));
|
||||
tr.addEventListener('contextmenu', (e) => onRowContext(e, tr.dataset.hash));
|
||||
});
|
||||
}
|
||||
|
||||
function emptyRow(span) {
|
||||
return `<tr><td colspan="${span}"><div class="empty" style="height:200px">No torrents match this filter</div></td></tr>`;
|
||||
}
|
||||
|
||||
function cell(t, k) {
|
||||
switch (k) {
|
||||
case 'name': return `<td><div class="name-cell">
|
||||
<span class="state-dot" style="color:${f.stateColor(t.state)};background:${f.stateColor(t.state)}"></span>
|
||||
${t.private ? '<span class="priv-badge">PRIV</span>' : ''}
|
||||
<span title="${f.esc(t.name)}">${f.esc(t.name)}</span></div></td>`;
|
||||
case 'size': return `<td class="num">${f.bytes(t.size)}</td>`;
|
||||
case 'progress': return `<td>${progressBar(t)}</td>`;
|
||||
case 'state': return `<td class="s-${t.state}">${f.stateLabel(t.state)}</td>`;
|
||||
case 'seeds': return `<td class="num">${t.seeds}<span class="faint"> (${t.seedsTotal})</span></td>`;
|
||||
case 'peers': return `<td class="num">${t.peers}<span class="faint"> (${t.peersTotal})</span></td>`;
|
||||
case 'dlspeed': return `<td class="num" style="color:${t.dlspeed ? 'var(--dl)' : 'var(--txt-faint)'}">${t.dlspeed ? f.rate(t.dlspeed) : '–'}</td>`;
|
||||
case 'upspeed': return `<td class="num" style="color:${t.upspeed ? 'var(--up)' : 'var(--txt-faint)'}">${t.upspeed ? f.rate(t.upspeed) : '–'}</td>`;
|
||||
case 'eta': return `<td class="num dim">${f.eta(t.eta)}</td>`;
|
||||
case 'ratio': return `<td class="num">${f.ratio(t.ratio)}</td>`;
|
||||
case 'availability': return `<td class="num dim">${t.availability.toFixed(2)}</td>`;
|
||||
case 'category': return `<td>${t.category ? `<span class="cat-chip">${f.esc(t.category)}</span>` : '<span class="faint">—</span>'}</td>`;
|
||||
case 'tags': return `<td><div class="tags-cell">${t.tags.map((x) => `<span class="tag">${f.esc(x)}</span>`).join('') || '<span class="faint">—</span>'}</div></td>`;
|
||||
case 'addedOn': return `<td class="num dim">${f.date(t.addedOn)}</td>`;
|
||||
case 'completionOn': return `<td class="num dim">${f.date(t.completionOn)}</td>`;
|
||||
case 'savePath': return `<td class="dim">${f.esc(t.savePath)}</td>`;
|
||||
case 'priority': return `<td class="num dim">${t.priority || '—'}</td>`;
|
||||
default: return '<td></td>';
|
||||
}
|
||||
}
|
||||
|
||||
function progressBar(t) {
|
||||
const cls = t.progress >= 1 ? 'done' : t.state.startsWith('paused') ? 'paused' : t.state.startsWith('checking') ? 'check' : (t.state === 'error' || t.state === 'missingFiles') ? 'error' : '';
|
||||
return `<div class="pbar ${cls}"><i style="width:${(t.progress * 100).toFixed(1)}%"></i><span>${f.pct(t.progress)}</span></div>`;
|
||||
}
|
||||
|
||||
/* ---------- selection ---------- */
|
||||
function onRowClick(e, hash, list) {
|
||||
if (e.shiftKey && state.lastClicked) {
|
||||
const order = list.map((t) => t.hash);
|
||||
const a = order.indexOf(state.lastClicked), b = order.indexOf(hash);
|
||||
if (a > -1 && b > -1) {
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
if (!(e.ctrlKey || e.metaKey)) state.selected.clear();
|
||||
for (let i = lo; i <= hi; i++) state.selected.add(order[i]);
|
||||
}
|
||||
} else if (e.ctrlKey || e.metaKey) {
|
||||
state.selected.has(hash) ? state.selected.delete(hash) : state.selected.add(hash);
|
||||
state.lastClicked = hash;
|
||||
} else {
|
||||
state.selected.clear();
|
||||
state.selected.add(hash);
|
||||
state.lastClicked = hash;
|
||||
}
|
||||
renderGrid();
|
||||
// a single-selection click opens/updates the detail panel; multi-select leaves it alone
|
||||
if (state.selected.size === 1) showDetail([...state.selected][0]);
|
||||
}
|
||||
|
||||
function showDetail(hash) {
|
||||
state.detailHash = hash;
|
||||
const host = document.getElementById('detailHost');
|
||||
if (host) renderDetailShell(hash, host);
|
||||
}
|
||||
|
||||
function openDetail(hash) {
|
||||
state.selected.clear(); state.selected.add(hash); state.lastClicked = hash;
|
||||
renderGrid();
|
||||
showDetail(hash);
|
||||
}
|
||||
|
||||
function closeDetailPanel() {
|
||||
state.detailHash = null;
|
||||
closeDetail();
|
||||
const host = document.getElementById('detailHost');
|
||||
if (host) {
|
||||
host.closest('.torrents-view')?.style.setProperty('--detail-h', '0px');
|
||||
host.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== actions ===================== */
|
||||
async function doAction(action, hashes) {
|
||||
hashes = hashes || [...state.selected];
|
||||
if (!hashes.length) return;
|
||||
if (action === 'delete') return confirmDelete(hashes);
|
||||
if (action === 'altspeed') { await api.toggleAltSpeed(); syncAltToggle(); return; }
|
||||
await api.action(action, hashes);
|
||||
}
|
||||
|
||||
function syncAltToggle() {
|
||||
const btn = document.querySelector('[data-act="altspeed"]');
|
||||
if (btn) btn.classList.toggle('on', !!state.snapshot.server?.alt_speed_enabled);
|
||||
}
|
||||
|
||||
async function confirmDelete(hashes) {
|
||||
openModal(`Remove ${hashes.length} torrent(s)?`, `
|
||||
<p class="dim">Choose whether to also delete the downloaded data from disk.</p>
|
||||
<div class="checks"><label><input type="checkbox" id="delFiles"> Also delete files on disk</label></div>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Remove', cls: 'danger', primary: true, act: async () => {
|
||||
const delFiles = document.getElementById('delFiles').checked;
|
||||
await api.remove(hashes, delFiles);
|
||||
hashes.forEach((h) => state.selected.delete(h));
|
||||
if (hashes.includes(state.detailHash)) closeDetailPanel();
|
||||
closeModal();
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
/* ---------- add torrent modal ---------- */
|
||||
function openAddModal() {
|
||||
const cats = state.meta.categories.map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name || 'Uncategorized')}</option>`).join('');
|
||||
openModal('Add torrent', `
|
||||
<div class="field"><label>Magnet link / URL</label>
|
||||
<input type="text" id="addMagnet" placeholder="magnet:?xt=urn:btih:…" /></div>
|
||||
<div class="field"><label>Or upload .torrent file(s)</label>
|
||||
<input type="file" id="addFile" accept=".torrent,application/x-bittorrent" multiple />
|
||||
<div id="addFileInfo" class="dim" style="margin-top:6px;font-size:12px"></div></div>
|
||||
<div class="field"><label>Category</label><select id="addCat">${cats}</select></div>
|
||||
<div class="field"><label>Save path</label>
|
||||
<input type="text" id="addPath" value="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>
|
||||
<div class="checks">
|
||||
<label><input type="checkbox" id="addPaused"> Add paused</label>
|
||||
<label><input type="checkbox" id="addSeq"> Sequential download</label>
|
||||
<label><input type="checkbox" id="addSkip"> Skip hash check</label>
|
||||
</div>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Add', primary: true, act: async () => {
|
||||
const magnet = document.getElementById('addMagnet').value.trim();
|
||||
const common = {
|
||||
category: document.getElementById('addCat').value,
|
||||
savePath: document.getElementById('addPath').value.trim(),
|
||||
paused: document.getElementById('addPaused').checked,
|
||||
seqDl: document.getElementById('addSeq').checked,
|
||||
skipCheck: document.getElementById('addSkip').checked,
|
||||
};
|
||||
const files = [...document.getElementById('addFile').files];
|
||||
if (files.length) {
|
||||
for (const file of files) await api.add({ ...common, ...(await readTorrentFile(file)) });
|
||||
} else if (magnet) {
|
||||
await api.add({ ...common, magnet });
|
||||
}
|
||||
closeModal();
|
||||
},
|
||||
}]);
|
||||
// live readout of the chosen file(s)
|
||||
document.getElementById('addFile').addEventListener('change', async (e) => {
|
||||
const info = document.getElementById('addFileInfo');
|
||||
const files = [...e.target.files];
|
||||
if (!files.length) { info.textContent = ''; return; }
|
||||
const metas = await Promise.all(files.map(readTorrentFile));
|
||||
info.innerHTML = metas.map((m) =>
|
||||
`📄 <b>${f.esc(m.name)}</b> — ${f.bytes(m.size)} · ${m.pieceCount} pieces · ${(m.files || []).length || 1} file(s)`).join('<br>');
|
||||
});
|
||||
}
|
||||
|
||||
// Minimal bencode decoder (operates on a Uint8Array) — enough to read a .torrent's
|
||||
// info dict client-side so an uploaded file shows realistic name/size/pieces.
|
||||
function bdecode(buf) {
|
||||
let i = 0;
|
||||
const td = new TextDecoder('utf-8');
|
||||
const readUntil = (term) => { let s = ''; while (buf[i] !== term) s += String.fromCharCode(buf[i++]); i++; return s; };
|
||||
const parseStr = () => { const len = parseInt(readUntil(0x3a), 10); const out = buf.subarray(i, i + len); i += len; return out; };
|
||||
const parse = () => {
|
||||
const c = buf[i];
|
||||
if (c === 0x69) { i++; return parseInt(readUntil(0x65), 10); } // i…e
|
||||
if (c === 0x6c) { i++; const a = []; while (buf[i] !== 0x65) a.push(parse()); i++; return a; } // l…e
|
||||
if (c === 0x64) { i++; const o = {}; while (buf[i] !== 0x65) { const k = td.decode(parseStr()); o[k] = parse(); } i++; return o; } // d…e
|
||||
return parseStr();
|
||||
};
|
||||
return parse();
|
||||
}
|
||||
|
||||
async function readTorrentFile(file) {
|
||||
const td = new TextDecoder('utf-8');
|
||||
try {
|
||||
const meta = bdecode(new Uint8Array(await file.arrayBuffer()));
|
||||
const info = meta.info;
|
||||
const name = td.decode(info.name);
|
||||
const pieceSize = info['piece length'];
|
||||
const pieceCount = Math.round((info.pieces?.length || 0) / 20) || undefined;
|
||||
let size, files;
|
||||
if (info.length != null) { size = info.length; files = [{ name, size }]; }
|
||||
else {
|
||||
files = (info.files || []).map((fl) => ({ name: `${name}/${fl.path.map((p) => td.decode(p)).join('/')}`, size: fl.length }));
|
||||
size = files.reduce((a, fl) => a + fl.size, 0);
|
||||
}
|
||||
return { name, size, pieceSize, pieceCount, files };
|
||||
} catch {
|
||||
// Not parseable as bencode — fall back to the filename.
|
||||
return { name: file.name.replace(/\.torrent$/i, '') };
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== context menu ===================== */
|
||||
function onRowContext(e, hash) {
|
||||
e.preventDefault();
|
||||
if (!state.selected.has(hash)) { state.selected.clear(); state.selected.add(hash); renderGrid(); }
|
||||
const n = state.selected.size;
|
||||
const items = [
|
||||
{ label: 'Resume', sc: '', act: () => doAction('resume') },
|
||||
{ label: 'Pause', sc: '', act: () => doAction('pause') },
|
||||
{ label: 'Force start', act: () => doAction('forceStart') },
|
||||
{ label: 'Force recheck', act: () => doAction('recheck') },
|
||||
{ label: 'Reannounce', act: () => doAction('reannounce') },
|
||||
{ sep: true },
|
||||
{ sub: 'Queue' },
|
||||
{ label: 'Move to top', act: () => doAction('topPriority') },
|
||||
{ label: 'Move up', act: () => doAction('increasePriority') },
|
||||
{ label: 'Move down', act: () => doAction('decreasePriority') },
|
||||
{ label: 'Move to bottom', act: () => doAction('bottomPriority') },
|
||||
{ sep: true },
|
||||
{ label: 'Toggle sequential', act: () => doAction('toggleSeqDl') },
|
||||
{ label: 'Toggle super seeding', act: () => doAction('toggleSuperSeeding') },
|
||||
{ label: 'Set category…', act: () => promptCategory() },
|
||||
{ label: 'Edit tags…', act: () => promptTags() },
|
||||
{ label: 'Properties', sc: '⏎', act: () => openDetail(hash) },
|
||||
{ sep: true },
|
||||
{ label: `Remove ${n > 1 ? `(${n})` : ''}`, danger: true, sc: 'Del', act: () => doAction('delete') },
|
||||
];
|
||||
showContextMenu(e.clientX, e.clientY, items);
|
||||
}
|
||||
|
||||
function promptCategory() {
|
||||
const opts = state.meta.categories.map((c) => `<option value="${f.esc(c.name)}">${f.esc(c.name || 'Uncategorized')}</option>`).join('');
|
||||
openModal('Set category', `
|
||||
<div class="field"><label>Category</label><select id="catSel">${opts}</select></div>
|
||||
<p class="dim" style="font-size:12px">Need a new one? Use <b>+</b> next to “Categories” in the sidebar.</p>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Apply', primary: true, act: async () => {
|
||||
await api.action('setCategory', [...state.selected], { category: document.getElementById('catSel').value });
|
||||
closeModal();
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
/* ---------- create / delete categories & tags ---------- */
|
||||
function openCreateCategory() {
|
||||
openModal('New category', `
|
||||
<div class="field"><label>Name</label><input type="text" id="catName" placeholder="e.g. Documentaries" /></div>
|
||||
<div class="field"><label>Save path</label>
|
||||
<input type="text" id="catPath" placeholder="${f.esc(state.meta.preferences.save_path || '/data/downloads')}" /></div>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Create', primary: true, act: async () => {
|
||||
const name = document.getElementById('catName').value.trim();
|
||||
if (!name) return closeModal();
|
||||
await api.createCategory(name, document.getElementById('catPath').value.trim());
|
||||
await refreshMeta();
|
||||
closeModal();
|
||||
},
|
||||
}]);
|
||||
setTimeout(() => document.getElementById('catName')?.focus(), 0);
|
||||
}
|
||||
|
||||
function openCreateTag() {
|
||||
openModal('New tag', `<div class="field"><label>Name</label><input type="text" id="tagName" placeholder="e.g. seed-2weeks" /></div>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Create', primary: true, act: async () => {
|
||||
const name = document.getElementById('tagName').value.trim();
|
||||
if (!name) return closeModal();
|
||||
await api.createTag(name);
|
||||
await refreshMeta();
|
||||
closeModal();
|
||||
},
|
||||
}]);
|
||||
setTimeout(() => document.getElementById('tagName')?.focus(), 0);
|
||||
}
|
||||
|
||||
function confirmDeleteMeta(type, value) {
|
||||
const label = type === 'category' ? `category “${value || 'Uncategorized'}”` : `tag “${value}”`;
|
||||
openModal(`Delete ${type}`, `<p class="dim">Remove the ${label}? It will be unassigned from all torrents. Downloaded files are not affected.</p>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Delete', cls: 'danger', primary: true, act: async () => {
|
||||
if (type === 'category') await api.deleteCategory(value); else await api.deleteTag(value);
|
||||
await refreshMeta();
|
||||
if (state.filter.type === type && state.filter.value === value) state.filter = { type: 'status', value: 'all' };
|
||||
renderView();
|
||||
closeModal();
|
||||
},
|
||||
}]);
|
||||
}
|
||||
|
||||
// Add/remove tags on the current selection (with inline create).
|
||||
function promptTags() {
|
||||
const hashes = [...state.selected];
|
||||
if (!hashes.length) return;
|
||||
const torrents = state.snapshot.torrents.filter((t) => hashes.includes(t.hash));
|
||||
const tagRow = (tag) => {
|
||||
const have = torrents.filter((t) => t.tags.includes(tag)).length;
|
||||
const some = have > 0 && have < torrents.length;
|
||||
return `<label class="check-row"><input type="checkbox" data-tag="${f.esc(tag)}" ${have === torrents.length ? 'checked' : ''}>
|
||||
<span>${f.esc(tag)}</span>${some ? '<span class="dim"> · on some</span>' : ''}</label>`;
|
||||
};
|
||||
openModal(`Edit tags · ${hashes.length} torrent(s)`, `
|
||||
<div class="field"><label>Add new tag</label>
|
||||
<div style="display:flex;gap:8px">
|
||||
<input type="text" id="newTag" placeholder="tag name" style="flex:1" />
|
||||
<button class="btn" id="addTagBtn">Add</button></div></div>
|
||||
<div class="taglist" id="tagList">${state.meta.tags.map(tagRow).join('') || '<span class="dim">No tags yet — add one above.</span>'}</div>`,
|
||||
[{ label: 'Cancel', act: closeModal }, {
|
||||
label: 'Apply', primary: true, act: async () => {
|
||||
const boxes = [...document.querySelectorAll('#tagList [data-tag]')];
|
||||
const toAdd = boxes.filter((b) => b.checked).map((b) => b.dataset.tag);
|
||||
const toRemove = boxes.filter((b) => !b.checked).map((b) => b.dataset.tag);
|
||||
if (toAdd.length) await api.action('addTags', hashes, { tags: toAdd });
|
||||
if (toRemove.length) await api.action('removeTags', hashes, { tags: toRemove });
|
||||
closeModal();
|
||||
},
|
||||
}]);
|
||||
const addNew = async () => {
|
||||
const inp = document.getElementById('newTag');
|
||||
const v = inp.value.trim();
|
||||
if (!v) return;
|
||||
await api.createTag(v);
|
||||
await refreshMeta();
|
||||
const list = document.getElementById('tagList');
|
||||
if (!list.querySelector(`[data-tag="${CSS.escape(v)}"]`)) {
|
||||
if (list.querySelector('span.dim') && !list.querySelector('[data-tag]')) list.innerHTML = '';
|
||||
const lbl = document.createElement('label');
|
||||
lbl.className = 'check-row';
|
||||
lbl.innerHTML = `<input type="checkbox" data-tag="${f.esc(v)}" checked><span>${f.esc(v)}</span>`;
|
||||
list.appendChild(lbl);
|
||||
}
|
||||
inp.value = '';
|
||||
inp.focus();
|
||||
};
|
||||
document.getElementById('addTagBtn').addEventListener('click', addNew);
|
||||
document.getElementById('newTag').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addNew(); } });
|
||||
setTimeout(() => document.getElementById('newTag')?.focus(), 0);
|
||||
}
|
||||
|
||||
function showContextMenu(x, y, items) {
|
||||
closeContextMenu();
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'ctxmenu';
|
||||
menu.id = 'ctxmenu';
|
||||
menu.innerHTML = items.map((it) => {
|
||||
if (it.sep) return '<div class="sep"></div>';
|
||||
if (it.sub) return `<div class="sub">${it.sub}</div>`;
|
||||
return `<div class="mi ${it.danger ? 'danger' : ''}" data-idx="${items.indexOf(it)}"><span>${it.label}</span><span class="sc">${it.sc || ''}</span></div>`;
|
||||
}).join('');
|
||||
document.body.appendChild(menu);
|
||||
const r = menu.getBoundingClientRect();
|
||||
menu.style.left = `${Math.min(x, window.innerWidth - r.width - 6)}px`;
|
||||
menu.style.top = `${Math.min(y, window.innerHeight - r.height - 6)}px`;
|
||||
menu.querySelectorAll('.mi').forEach((mi) =>
|
||||
mi.addEventListener('click', () => { closeContextMenu(); items[+mi.dataset.idx].act(); }));
|
||||
}
|
||||
function closeContextMenu() { document.getElementById('ctxmenu')?.remove(); }
|
||||
|
||||
/* ===================== RSS view ===================== */
|
||||
async function renderRssView(host) {
|
||||
host.innerHTML = '<div class="pane"><div class="empty">Loading feeds…</div></div>';
|
||||
const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]);
|
||||
host.innerHTML = `<div class="pane"><div class="split2">
|
||||
<div>
|
||||
<div class="card"><h3>Feeds</h3>
|
||||
${feeds.map((fd) => `<div class="kvrow"><span style="flex:1">📡 <b>${f.esc(fd.name)}</b></span><span class="dim">${f.ago(fd.lastUpdate)}</span></div>
|
||||
<div class="kvrow dim"><code class="inline">${f.esc(fd.url)}</code></div>`).join('<div style="height:8px"></div>')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card"><h3>Unread articles</h3>
|
||||
<table class="dtbl"><thead><tr><th>Feed</th><th>Title</th><th class="num">Size</th><th class="num">Published</th><th></th></tr></thead><tbody>
|
||||
${feeds.flatMap((fd) => fd.articles.map((a) => `<tr style="${a.isRead ? 'opacity:.5' : ''}">
|
||||
<td class="dim">${f.esc(fd.name)}</td><td>${f.esc(a.title)}</td>
|
||||
<td class="num">${f.bytes(a.size)}</td><td class="num dim">${f.ago(a.date)}</td>
|
||||
<td>${a.isRead ? '<span class="faint">read</span>' : '<span style="color:var(--accent)">● new</span>'}</td></tr>`)).join('')}
|
||||
</tbody></table>
|
||||
</div>
|
||||
<h3 style="margin:16px 0 8px">Auto-download rules</h3>
|
||||
${rules.map((r) => renderRule(r)).join('')}
|
||||
</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function renderRule(r) {
|
||||
return `<div class="rule ${r.enabled ? '' : 'off'}">
|
||||
<div class="rule-head">
|
||||
<span class="rule-name">${f.esc(r.name)}</span>
|
||||
<span class="pill ${r.enabled ? 'on' : ''}">${r.enabled ? 'enabled' : 'disabled'}</span>
|
||||
${r.useRegex ? '<span class="pill">regex</span>' : ''}
|
||||
${r.addPaused ? '<span class="pill paused">add paused</span>' : ''}
|
||||
</div>
|
||||
<div class="kvrow">must contain <code class="inline">${f.esc(r.mustContain)}</code></div>
|
||||
${r.mustNotContain ? `<div class="kvrow">must not contain <code class="inline">${f.esc(r.mustNotContain)}</code></div>` : ''}
|
||||
<div class="kvrow">→ category <b>${f.esc(r.assignedCategory)}</b> · save to <b>${f.esc(r.savePath)}</b></div>
|
||||
<div class="kvrow dim">feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ===================== Search view ===================== */
|
||||
function renderSearchView(host) {
|
||||
const plugins = state.meta.searchPlugins.map((p) =>
|
||||
`<span class="pill ${p.enabled ? 'on' : ''}">${f.esc(p.name)}</span>`).join(' ');
|
||||
host.innerHTML = `<div class="pane">
|
||||
<div class="search-bar">
|
||||
<input type="text" id="searchInput" placeholder="Search indexers… e.g. debian, sintel, dataset" value="${f.esc(state.searchQuery)}" />
|
||||
<button class="btn primary" id="searchBtn">Search</button>
|
||||
</div>
|
||||
<div class="kvrow" style="margin-bottom:12px">Indexers: ${plugins}</div>
|
||||
<div id="searchResults">${state.searchResults ? searchTable(state.searchResults) : '<div class="empty" style="height:200px">Enter a query to search configured indexers</div>'}</div>
|
||||
</div>`;
|
||||
const input = document.getElementById('searchInput');
|
||||
const run = async () => {
|
||||
state.searchQuery = input.value.trim();
|
||||
document.getElementById('searchResults').innerHTML = '<div class="empty" style="height:120px">Searching…</div>';
|
||||
state.searchResults = await api.search(state.searchQuery);
|
||||
document.getElementById('searchResults').innerHTML = searchTable(state.searchResults);
|
||||
bindSearchRows();
|
||||
};
|
||||
document.getElementById('searchBtn').addEventListener('click', run);
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') run(); });
|
||||
input.focus();
|
||||
bindSearchRows();
|
||||
}
|
||||
|
||||
function searchTable(rows) {
|
||||
if (!rows.length) return '<div class="empty" style="height:160px">No results</div>';
|
||||
return `<table class="dtbl"><thead><tr>
|
||||
<th>Name</th><th class="num">Size</th><th class="num">Seeds</th><th class="num">Leeches</th>
|
||||
<th>Engine</th><th class="num">Published</th><th></th></tr></thead><tbody>
|
||||
${rows.map((r, i) => `<tr>
|
||||
<td>${f.esc(r.name)}</td><td class="num">${f.bytes(r.size)}</td>
|
||||
<td class="num" style="color:var(--up)">${r.seeds}</td><td class="num dim">${r.leeches}</td>
|
||||
<td class="dim">${f.esc(r.engine)}</td><td class="num dim">${f.ago(r.pubDate)}</td>
|
||||
<td><button class="btn" data-sr="${i}">+ Add</button></td></tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function bindSearchRows() {
|
||||
document.querySelectorAll('[data-sr]').forEach((b) =>
|
||||
b.addEventListener('click', async () => {
|
||||
const r = state.searchResults[+b.dataset.sr];
|
||||
await api.add({ name: r.name });
|
||||
b.textContent = '✓ Added'; b.disabled = true;
|
||||
}));
|
||||
}
|
||||
|
||||
/* ===================== Engine/settings view ===================== */
|
||||
function renderSettingsView(host) {
|
||||
const p = state.meta.preferences;
|
||||
const card = (title, rows) => `<div class="card"><h3>${title}</h3>${rows.map(([k, v]) =>
|
||||
`<div class="prop"><span class="k">${k}</span><span class="v mono">${v}</span></div>`).join('')}</div>`;
|
||||
host.innerHTML = `<div class="pane"><div class="settings-grid">
|
||||
${card('Bandwidth', [
|
||||
['Global download limit', p.dl_limit ? f.rate(p.dl_limit) : '∞'],
|
||||
['Global upload limit', p.up_limit ? f.rate(p.up_limit) : '∞'],
|
||||
['Alt download limit', f.rate(p.alt_dl_limit)],
|
||||
['Alt upload limit', f.rate(p.alt_up_limit)],
|
||||
['Alt limits active', p.alt_speed_enabled ? 'yes' : 'no'],
|
||||
])}
|
||||
${card('Connections', [
|
||||
['Global max connections', p.max_connec],
|
||||
['Max per torrent', p.max_connec_per_torrent],
|
||||
['Max upload slots', p.max_uploads],
|
||||
['Listen port', p.listen_port],
|
||||
['UPnP / NAT-PMP', p.upnp ? 'on' : 'off'],
|
||||
['µTP enabled', p.utp ? 'on' : 'off'],
|
||||
])}
|
||||
${card('Queueing', [
|
||||
['Queueing enabled', p.queueing_enabled ? 'on' : 'off'],
|
||||
['Max active downloads', p.max_active_downloads],
|
||||
['Max active uploads', p.max_active_uploads],
|
||||
['Max active torrents', p.max_active_torrents],
|
||||
])}
|
||||
${card('Privacy / BitTorrent', [
|
||||
['DHT', p.dht ? 'on' : 'off'],
|
||||
['Peer Exchange (PeX)', p.pex ? 'on' : 'off'],
|
||||
['Local Peer Discovery', p.lsd ? 'on' : 'off'],
|
||||
['Encryption', ['Prefer encryption', 'Require encryption', 'Disable encryption'][p.encryption]],
|
||||
])}
|
||||
${card('Paths', [
|
||||
['Default save path', p.save_path],
|
||||
['RSS scan interval', `${p.scan_interval} min`],
|
||||
])}
|
||||
</div>
|
||||
<p class="dim" style="margin-top:14px">This is a stubbed engine view — values are read from the mock server. Wire these to a real client's API (qBittorrent WebAPI, Transmission RPC, Deluge JSON-RPC) to make them editable.</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ===================== status bar ===================== */
|
||||
function renderStatusbar() {
|
||||
const s = state.snapshot.server || {};
|
||||
const el = document.getElementById('statusbar');
|
||||
el.innerHTML = `
|
||||
<span class="sb"><span class="led"></span><span class="conn-ok">${s.connection_status || 'connecting'}</span></span>
|
||||
<span class="sb">DHT: <b>${s.dht_nodes ?? '–'}</b> nodes</span>
|
||||
<span class="sb">Port: <b>${s.listen_port ?? '–'}</b></span>
|
||||
<span class="sb">Active: <b>${s.active_torrents ?? 0}</b>/${s.total_torrents ?? 0}</span>
|
||||
<span class="sb">Session ▼ <b>${f.bytes(s.dl_info_data)}</b> ▲ <b>${f.bytes(s.up_info_data)}</b></span>
|
||||
<span class="sb">Global ratio: <b>${f.ratio(s.global_ratio)}</b></span>
|
||||
<span class="spacer"></span>
|
||||
<span class="sb">Cache hit: <b>${s.read_cache_hits ?? '–'}%</b></span>
|
||||
<span class="sb">Queued I/O: <b>${s.queued_io_jobs ?? 0}</b></span>
|
||||
<span class="sb">Free space: <b>${f.bytes(s.free_space)}</b></span>`;
|
||||
}
|
||||
|
||||
function updateRates() {
|
||||
const s = state.snapshot.server || {};
|
||||
const dl = document.getElementById('globalDl');
|
||||
const up = document.getElementById('globalUp');
|
||||
if (dl) dl.textContent = f.rate(s.dl_info_speed) + (s.dl_rate_limit ? ` /${f.rate(s.dl_rate_limit)}` : '');
|
||||
if (up) up.textContent = f.rate(s.up_info_speed) + (s.up_rate_limit ? ` /${f.rate(s.up_rate_limit)}` : '');
|
||||
}
|
||||
|
||||
/* ===================== modal ===================== */
|
||||
function openModal(title, bodyHtml, buttons) {
|
||||
const back = document.getElementById('modalBackdrop');
|
||||
const modal = document.getElementById('modal');
|
||||
modal.innerHTML = `<h2>${f.esc(title)}</h2><div class="mbody">${bodyHtml}</div>
|
||||
<div class="mfoot">${buttons.map((b, i) => `<button class="btn ${b.primary ? 'primary' : ''} ${b.cls || ''}" data-mb="${i}">${b.label}</button>`).join('')}</div>`;
|
||||
back.hidden = false;
|
||||
modal.querySelectorAll('[data-mb]').forEach((b) => b.addEventListener('click', () => buttons[+b.dataset.mb].act()));
|
||||
}
|
||||
function closeModal() { document.getElementById('modalBackdrop').hidden = true; }
|
||||
|
||||
/* ===================== view tabs ===================== */
|
||||
function switchViewTab(view) {
|
||||
state.view = view;
|
||||
document.querySelectorAll('.vtab').forEach((b) => b.classList.toggle('active', b.dataset.view === view));
|
||||
renderView();
|
||||
}
|
||||
|
||||
/* ===================== global bindings ===================== */
|
||||
function bindGlobal() {
|
||||
document.querySelectorAll('.toolbar [data-act]').forEach((b) =>
|
||||
b.addEventListener('click', () => {
|
||||
const a = b.dataset.act;
|
||||
if (a === 'add') return openAddModal();
|
||||
doAction(a);
|
||||
}));
|
||||
|
||||
document.querySelectorAll('.vtab').forEach((b) =>
|
||||
b.addEventListener('click', () => switchViewTab(b.dataset.view)));
|
||||
|
||||
const qf = document.getElementById('quickFilter');
|
||||
qf.addEventListener('input', () => { state.quick = qf.value; if (state.view === 'torrents') renderGrid(); });
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('#ctxmenu')) closeContextMenu();
|
||||
});
|
||||
|
||||
document.getElementById('modalBackdrop').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'modalBackdrop') closeModal();
|
||||
});
|
||||
|
||||
// detail close + esc handling delegated
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.closest('[data-act="closeDetail"]')) closeDetailPanel();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const typing = /input|textarea|select/i.test(document.activeElement.tagName);
|
||||
if (e.key === 'Escape') {
|
||||
closeContextMenu();
|
||||
if (!document.getElementById('modalBackdrop').hidden) return closeModal();
|
||||
if (state.detailHash) return closeDetailPanel();
|
||||
}
|
||||
if (typing) return;
|
||||
if (e.key === '/') { e.preventDefault(); qf.focus(); }
|
||||
else if (e.key.toLowerCase() === 'n') openAddModal();
|
||||
else if (e.key === 'Delete' || e.key === 'Backspace') doAction('delete');
|
||||
else if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
// toggle pause/resume on selection
|
||||
const sel = state.snapshot.torrents.filter((t) => state.selected.has(t.hash));
|
||||
const anyRunning = sel.some((t) => !t.state.startsWith('paused'));
|
||||
doAction(anyRunning ? 'pause' : 'resume');
|
||||
} else if (e.key === 'Enter' && state.selected.size === 1) {
|
||||
openDetail([...state.selected][0]);
|
||||
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a' && state.view === 'torrents') {
|
||||
e.preventDefault();
|
||||
visibleTorrents().forEach((t) => state.selected.add(t.hash));
|
||||
renderGrid();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
boot();
|
||||
227
public/js/detail.js
Normal file
227
public/js/detail.js
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
// Detail panel: General / Trackers / Peers / Content (files) / Pieces.
|
||||
|
||||
import { api } from './api.js';
|
||||
import * as f from './format.js';
|
||||
|
||||
const TABS = ['general', 'trackers', 'peers', 'content', 'pieces'];
|
||||
let activeTab = 'general';
|
||||
let currentHash = null;
|
||||
let refreshTimer = null;
|
||||
let detailHeight = 300; // px, persisted across re-renders so resizing sticks
|
||||
|
||||
export function detailTab() { return activeTab; }
|
||||
|
||||
export function renderDetailShell(hash, host) {
|
||||
currentHash = hash;
|
||||
// Drive the container's grid track (persists across row clicks / re-renders),
|
||||
// clamped so the panel always fits inside the available area.
|
||||
const view = host.closest('.torrents-view');
|
||||
if (view) {
|
||||
detailHeight = clampDetailHeight(view, detailHeight);
|
||||
view.style.setProperty('--detail-h', `${detailHeight}px`);
|
||||
}
|
||||
host.innerHTML = `
|
||||
<div class="detail" id="detailPanel">
|
||||
<div class="detail-resize" id="detailResize" title="Drag to resize"></div>
|
||||
<div class="detail-tabs">
|
||||
${TABS.map((t) => `<button class="dtab ${t === activeTab ? 'active' : ''}" data-dtab="${t}">${tabLabel(t)}</button>`).join('')}
|
||||
<button class="dtab detail-close" data-act="closeDetail" title="Close detail (Esc)">✕</button>
|
||||
</div>
|
||||
<div class="detail-body" id="detailBody"></div>
|
||||
</div>`;
|
||||
|
||||
host.querySelectorAll('[data-dtab]').forEach((b) =>
|
||||
b.addEventListener('click', () => { activeTab = b.dataset.dtab; renderDetailShell(currentHash, host); }));
|
||||
|
||||
setupResize(host);
|
||||
loadTab();
|
||||
}
|
||||
|
||||
function tabLabel(t) {
|
||||
return { general: 'General', trackers: 'Trackers', peers: 'Peers', content: 'Content', pieces: 'Pieces' }[t];
|
||||
}
|
||||
|
||||
export function closeDetail() {
|
||||
currentHash = null;
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
refreshTimer = null;
|
||||
}
|
||||
|
||||
async function loadTab() {
|
||||
const body = document.getElementById('detailBody');
|
||||
if (!body || !currentHash) return;
|
||||
if (refreshTimer) clearInterval(refreshTimer);
|
||||
|
||||
const render = async () => {
|
||||
if (!currentHash) return;
|
||||
try {
|
||||
if (activeTab === 'general') body.innerHTML = renderGeneral(await api.torrent(currentHash));
|
||||
else if (activeTab === 'trackers') body.innerHTML = renderTrackers(await api.trackers(currentHash));
|
||||
else if (activeTab === 'peers') body.innerHTML = renderPeers(await api.peers(currentHash));
|
||||
else if (activeTab === 'content') body.innerHTML = renderFiles(await api.files(currentHash));
|
||||
else if (activeTab === 'pieces') body.innerHTML = renderPieces(await api.pieces(currentHash));
|
||||
} catch { /* ignore transient */ }
|
||||
};
|
||||
await render();
|
||||
// live-refresh the dynamic tabs
|
||||
if (['peers', 'pieces', 'general'].includes(activeTab)) refreshTimer = setInterval(render, 1500);
|
||||
}
|
||||
|
||||
/* ---------- General ---------- */
|
||||
function renderGeneral(t) {
|
||||
const row = (k, v, mono) => `<div class="prop"><span class="k">${k}</span><span class="v ${mono ? 'mono' : ''}">${v}</span></div>`;
|
||||
return `<div class="props">
|
||||
<div class="section-h">Transfer</div>
|
||||
${row('Status', `<span class="s-${t.state}">${f.stateLabel(t.state)}</span>`)}
|
||||
${row('Progress', f.pct(t.progress))}
|
||||
${row('Downloaded', f.bytes(t.downloaded))}
|
||||
${row('Uploaded', f.bytes(t.uploaded))}
|
||||
${row('Down speed', f.rate(t.dlspeed))}
|
||||
${row('Up speed', f.rate(t.upspeed))}
|
||||
${row('Share ratio', f.ratio(t.ratio))}
|
||||
${row('ETA', f.eta(t.eta))}
|
||||
${row('Availability', t.availability.toFixed(3))}
|
||||
${row('Seeds', `${t.seeds} (${t.seedsTotal})`)}
|
||||
${row('Peers', `${t.peers} (${t.peersTotal})`)}
|
||||
${row('Down limit', t.downLimit ? f.rate(t.downLimit) : '∞')}
|
||||
${row('Up limit', t.upLimit ? f.rate(t.upLimit) : '∞')}
|
||||
${row('Ratio limit', t.ratioLimit > 0 ? t.ratioLimit.toFixed(2) : 'global')}
|
||||
${row('Session DL / UL', `${f.bytes(t.downloadedSession)} / ${f.bytes(t.uploadedSession)}`)}
|
||||
${row('Time active', f.duration(t.timeActive))}
|
||||
|
||||
<div class="section-h">Information</div>
|
||||
${row('Name', f.esc(t.name), true)}
|
||||
${row('Total size', f.bytes(t.size))}
|
||||
${row('Pieces', `${t.pieceCount} × ${f.bytes(t.pieceSize)}`)}
|
||||
${row('Save path', f.esc(t.savePath), true)}
|
||||
${row('Content path', f.esc(t.contentPath), true)}
|
||||
${row('Category', t.category ? `<span class="cat-chip">${f.esc(t.category)}</span>` : '—')}
|
||||
${row('Tags', t.tags.length ? t.tags.map((x) => `<span class="tag">${f.esc(x)}</span>`).join(' ') : '—')}
|
||||
${row('Added on', f.date(t.addedOn))}
|
||||
${row('Completed on', f.date(t.completionOn))}
|
||||
${row('Last activity', f.ago(t.lastActivity))}
|
||||
${row('Hash (v1)', t.hash, true)}
|
||||
${row('Privacy', t.private ? '<span class="priv-badge">PRIVATE</span>' : 'Public (DHT/PeX/LSD)')}
|
||||
${row('Created by', f.esc(t.createdBy), true)}
|
||||
${row('Creation date', f.date(t.creationDate))}
|
||||
${row('Sequential', t.seqDl ? 'On' : 'Off')}
|
||||
${row('Super seeding', t.superSeeding ? 'On' : 'Off')}
|
||||
${row('Auto TMM', t.autoTMM ? 'On' : 'Off')}
|
||||
${row('Force start', t.forceStart ? 'On' : 'Off')}
|
||||
${t.comment ? row('Comment', f.esc(t.comment)) : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------- Trackers ---------- */
|
||||
function renderTrackers(trackers) {
|
||||
return `<table class="dtbl"><thead><tr>
|
||||
<th>Tier</th><th>URL</th><th>Status</th><th class="num">Seeds</th><th class="num">Peers</th>
|
||||
<th class="num">Leeches</th><th class="num">Downloaded</th><th>Message</th>
|
||||
</tr></thead><tbody>
|
||||
${trackers.map((tr) => `<tr>
|
||||
<td class="dim">${tr.tier < 0 ? '—' : tr.tier}</td>
|
||||
<td>${f.esc(tr.url)}</td>
|
||||
<td class="status-${tr.status.replace(/\s/g, '.')}">${tr.status}</td>
|
||||
<td class="num">${tr.seeds < 0 ? '—' : tr.seeds}</td>
|
||||
<td class="num">${tr.peers < 0 ? '—' : tr.peers}</td>
|
||||
<td class="num">${tr.leeches < 0 ? '—' : tr.leeches}</td>
|
||||
<td class="num">${tr.downloaded < 0 ? '—' : tr.downloaded}</td>
|
||||
<td class="dim">${f.esc(tr.message)}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
/* ---------- Peers ---------- */
|
||||
function renderPeers(peers) {
|
||||
if (!peers.length) return '<div class="empty">No peers connected</div>';
|
||||
const sorted = [...peers].sort((a, b) => (b.dlspeed + b.upspeed) - (a.dlspeed + a.upspeed));
|
||||
return `<table class="dtbl"><thead><tr>
|
||||
<th>Country</th><th>IP : Port</th><th>Client</th><th>Conn</th><th>Flags</th>
|
||||
<th class="num">Progress</th><th class="num">Down</th><th class="num">Up</th>
|
||||
<th class="num">Downloaded</th><th class="num">Uploaded</th><th class="num">Rel.</th>
|
||||
</tr></thead><tbody>
|
||||
${sorted.map((p) => `<tr>
|
||||
<td class="dim">${p.country}</td>
|
||||
<td class="mono">${p.ip}:${p.port}</td>
|
||||
<td>${f.esc(p.client)}</td>
|
||||
<td class="dim">${p.connection}</td>
|
||||
<td class="flagchip" title="D down U up O optimistic I incoming E encrypted X PEX H DHT">${p.flags || '·'}</td>
|
||||
<td class="num">${f.pct(p.progress)}</td>
|
||||
<td class="num" style="color:var(--dl)">${p.dlspeed ? f.rate(p.dlspeed) : '–'}</td>
|
||||
<td class="num" style="color:var(--up)">${p.upspeed ? f.rate(p.upspeed) : '–'}</td>
|
||||
<td class="num dim">${f.bytes(p.downloaded)}</td>
|
||||
<td class="num dim">${f.bytes(p.uploaded)}</td>
|
||||
<td class="num">${f.pct(p.relevance)}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
/* ---------- Content / files ---------- */
|
||||
function renderFiles(files) {
|
||||
return `<table class="dtbl"><thead><tr>
|
||||
<th>Name</th><th class="num">Size</th><th>Progress</th><th class="num">%</th>
|
||||
<th>Priority</th><th class="num">Availability</th>
|
||||
</tr></thead><tbody>
|
||||
${files.map((file, i) => `<tr class="file-row">
|
||||
<td>${f.esc(file.name)}</td>
|
||||
<td class="num">${f.bytes(file.size)}</td>
|
||||
<td><span class="mini-bar"><i style="width:${(file.progress * 100).toFixed(0)}%"></i></span></td>
|
||||
<td class="num">${f.pct(file.progress)}</td>
|
||||
<td><select class="prio-sel" data-file="${i}">
|
||||
${[[0, 'Skip'], [1, 'Normal'], [6, 'High'], [7, 'Max']].map(([v, l]) =>
|
||||
`<option value="${v}" ${file.priority === v ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select></td>
|
||||
<td class="num">${file.availability.toFixed(2)}</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
/* ---------- Pieces ---------- */
|
||||
function renderPieces(data) {
|
||||
const { pieces, pieceSize, pieceCount } = data;
|
||||
let done = 0, dl = 0;
|
||||
for (const p of pieces) { if (p === 2) done++; else if (p === 1) dl++; }
|
||||
const cells = pieces.map((p) => `<i class="piece ${p === 2 ? 'done' : p === 1 ? 'dl' : ''}"></i>`).join('');
|
||||
return `
|
||||
<div class="piece-legend">
|
||||
<span><i class="swatch" style="background:var(--dl)"></i> Have (${done})</span>
|
||||
<span><i class="swatch" style="background:var(--warn)"></i> Downloading (${dl})</span>
|
||||
<span><i class="swatch" style="background:var(--bg-3)"></i> Missing (${pieceCount - done - dl})</span>
|
||||
<span class="dim">${pieceCount} pieces · ${f.bytes(pieceSize)} each</span>
|
||||
</div>
|
||||
<div class="piecemap">${cells}</div>`;
|
||||
}
|
||||
|
||||
/* ---------- resize ---------- */
|
||||
// Keep the panel between 120px and (container height − 80px) so the list above
|
||||
// always keeps a usable strip and the panel never overflows/clips.
|
||||
function clampDetailHeight(view, h) {
|
||||
const maxH = Math.max(120, view.clientHeight - 80);
|
||||
return Math.round(Math.max(120, Math.min(maxH, h)));
|
||||
}
|
||||
|
||||
function setupResize(host) {
|
||||
const handle = host.querySelector('#detailResize');
|
||||
const view = host.closest('.torrents-view');
|
||||
if (!handle || !view) return;
|
||||
handle.addEventListener('pointerdown', (e) => {
|
||||
e.preventDefault();
|
||||
handle.setPointerCapture(e.pointerId);
|
||||
const startY = e.clientY;
|
||||
const startH = detailHeight;
|
||||
document.body.style.userSelect = 'none';
|
||||
document.body.style.cursor = 'ns-resize';
|
||||
const move = (ev) => {
|
||||
detailHeight = clampDetailHeight(view, startH + (startY - ev.clientY));
|
||||
view.style.setProperty('--detail-h', `${detailHeight}px`);
|
||||
};
|
||||
const up = () => {
|
||||
handle.removeEventListener('pointermove', move);
|
||||
handle.removeEventListener('pointerup', up);
|
||||
document.body.style.userSelect = '';
|
||||
document.body.style.cursor = '';
|
||||
};
|
||||
handle.addEventListener('pointermove', move);
|
||||
handle.addEventListener('pointerup', up);
|
||||
});
|
||||
}
|
||||
87
public/js/format.js
Normal file
87
public/js/format.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// Display formatters — units, durations, dates, states.
|
||||
|
||||
export function bytes(n, dp = 1) {
|
||||
if (n == null || isNaN(n)) return '–';
|
||||
if (n === 0) return '0 B';
|
||||
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
|
||||
const i = Math.min(u.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
|
||||
return `${(n / 1024 ** i).toFixed(i === 0 ? 0 : dp)} ${u[i]}`;
|
||||
}
|
||||
|
||||
export function rate(n) {
|
||||
if (!n) return '–';
|
||||
return `${bytes(n)}/s`;
|
||||
}
|
||||
|
||||
export function eta(sec) {
|
||||
if (sec == null || sec >= 8640000 || sec < 0) return '∞';
|
||||
if (sec < 1) return '0s';
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
if (d) return `${d}d ${h}h`;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
if (m) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function duration(sec) {
|
||||
if (sec == null || sec < 0) return '–';
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
if (d) return `${d}d ${h}h ${m}m`;
|
||||
if (h) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export function pct(p) { return `${(p * 100).toFixed(1)}%`; }
|
||||
|
||||
export function ratio(r) {
|
||||
if (r == null) return '–';
|
||||
if (r >= 9999) return '∞';
|
||||
return r.toFixed(2);
|
||||
}
|
||||
|
||||
export function date(ms) {
|
||||
if (!ms || ms < 0) return '–';
|
||||
const d = new Date(ms);
|
||||
return d.toLocaleString(undefined, { year: '2-digit', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function ago(ms) {
|
||||
if (!ms || ms < 0) return '–';
|
||||
const s = (Date.now() - ms) / 1000;
|
||||
if (s < 60) return 'just now';
|
||||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||||
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
||||
return `${Math.floor(s / 86400)}d ago`;
|
||||
}
|
||||
|
||||
const STATE_LABEL = {
|
||||
downloading: 'Downloading', forcedDL: '[F] Downloading', metaDL: 'Fetching metadata',
|
||||
stalledDL: 'Stalled (DL)', uploading: 'Seeding', forcedUP: '[F] Seeding',
|
||||
stalledUP: 'Seeding (idle)', pausedDL: 'Paused', pausedUP: 'Completed',
|
||||
checkingDL: 'Checking', checkingUP: 'Checking', moving: 'Moving',
|
||||
queuedDL: 'Queued', queuedUP: 'Queued', error: 'Error', missingFiles: 'Missing files',
|
||||
};
|
||||
export function stateLabel(s) { return STATE_LABEL[s] || s; }
|
||||
|
||||
const STATE_COLOR = {
|
||||
downloading: 'var(--dl)', forcedDL: 'var(--dl)', metaDL: 'var(--dl)',
|
||||
uploading: 'var(--up)', forcedUP: 'var(--up)',
|
||||
stalledDL: 'var(--txt-dim)', stalledUP: 'var(--txt-dim)',
|
||||
pausedDL: 'var(--pause)', pausedUP: 'var(--pause)',
|
||||
checkingDL: 'var(--check)', checkingUP: 'var(--check)', moving: 'var(--check)',
|
||||
queuedDL: 'var(--queue)', queuedUP: 'var(--queue)',
|
||||
error: 'var(--err)', missingFiles: 'var(--err)',
|
||||
};
|
||||
export function stateColor(s) { return STATE_COLOR[s] || 'var(--txt-dim)'; }
|
||||
|
||||
const PRIO_LABEL = { 0: 'Do not download', 1: 'Normal', 6: 'High', 7: 'Maximum' };
|
||||
export function filePrio(p) { return PRIO_LABEL[p] ?? 'Normal'; }
|
||||
|
||||
export function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
459
server/data.js
Normal file
459
server/data.js
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
// Mock data store for the torrent UI.
|
||||
// Generates a realistic "fleet" of torrents with full per-torrent detail
|
||||
// (trackers, peers, files, piece map) plus categories, tags, RSS and search stubs.
|
||||
|
||||
const KiB = 1024;
|
||||
const MiB = 1024 * KiB;
|
||||
const GiB = 1024 * MiB;
|
||||
|
||||
// ---- deterministic-ish PRNG so reloads look stable but varied ----
|
||||
let _seed = 1337;
|
||||
function rng() {
|
||||
_seed = (_seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return _seed / 0x7fffffff;
|
||||
}
|
||||
function pick(arr) { return arr[Math.floor(rng() * arr.length)]; }
|
||||
function between(min, max) { return min + rng() * (max - min); }
|
||||
function intBetween(min, max) { return Math.floor(between(min, max + 1)); }
|
||||
function chance(p) { return rng() < p; }
|
||||
|
||||
function fakeHash() {
|
||||
const hex = '0123456789abcdef';
|
||||
let s = '';
|
||||
for (let i = 0; i < 40; i++) s += hex[Math.floor(rng() * 16)];
|
||||
return s;
|
||||
}
|
||||
|
||||
const COUNTRIES = ['US', 'DE', 'NL', 'FR', 'GB', 'SE', 'CA', 'JP', 'AU', 'RU', 'BR', 'CN', 'IN', 'PL', 'UA', 'CH'];
|
||||
const CLIENTS = [
|
||||
'qBittorrent 4.6.5', 'qBittorrent 5.0.0', 'Transmission 4.0.5', 'Deluge 2.1.1',
|
||||
'libtorrent 2.0.10', 'Vuze 5.7.7', 'rTorrent 0.9.8', 'BiglyBT 3.6.0',
|
||||
'µTorrent 3.6.0', 'WebTorrent 2.1.0', 'Tixati 3.27',
|
||||
];
|
||||
const PEER_FLAGS = ['D', 'U', 'O', 'S', 'I', 'E', 'X', 'H', 'P', 'K', '?'];
|
||||
|
||||
const CATEGORIES = [
|
||||
{ name: 'Linux ISOs', savePath: '/data/iso' },
|
||||
{ name: 'Movies', savePath: '/data/media/movies' },
|
||||
{ name: 'TV', savePath: '/data/media/tv' },
|
||||
{ name: 'Music', savePath: '/data/media/music' },
|
||||
{ name: 'Books', savePath: '/data/books' },
|
||||
{ name: 'Datasets', savePath: '/data/datasets' },
|
||||
{ name: 'Games', savePath: '/data/games' },
|
||||
{ name: '', savePath: '/data/downloads' }, // uncategorized
|
||||
];
|
||||
|
||||
const TAGS = ['archive', 'public', 'private', 'seed-forever', 'hit-and-run', 'verified', '4k', 'remux', 'incomplete', 'priority'];
|
||||
|
||||
const TRACKER_HOSTS = [
|
||||
'tracker.opentrackr.org:1337', 'open.demonii.com:1337', 'tracker.torrent.eu.org:451',
|
||||
'exodus.desync.com:6969', 'tracker.openbittorrent.com:6969', 'private.tracker.lan:2710',
|
||||
'tracker.dler.org:6969', 'open.stealth.si:80', '** [DHT] **', '** [PeX] **', '** [LSD] **',
|
||||
];
|
||||
|
||||
const NAME_TEMPLATES = [
|
||||
'debian-12.5.0-amd64-netinst.iso',
|
||||
'ubuntu-24.04-desktop-amd64.iso',
|
||||
'archlinux-2024.06.01-x86_64.iso',
|
||||
'Fedora-Workstation-Live-x86_64-40.iso',
|
||||
'NixOS-24.05-x86_64-linux.iso',
|
||||
'The.Expanse.S01.2160p.UHD.BluRay.REMUX.HDR.DV',
|
||||
'Cosmos.A.Spacetime.Odyssey.S01.1080p.BluRay',
|
||||
'Blender.Open.Movies.Collection.2006-2023',
|
||||
'Big.Buck.Bunny.4K.60fps.HDR',
|
||||
'Sintel.2010.2160p.BluRay.x265',
|
||||
'MIT.OCW.6.006.Introduction.to.Algorithms.2020',
|
||||
'Stanford.CS231n.2017.Lectures',
|
||||
'Wikipedia.en.all.maxi.2024-05.zim',
|
||||
'OpenStreetMap.planet-240603.osm.pbf',
|
||||
'Common.Voice.Corpus.17.0.en',
|
||||
'ImageNet.ILSVRC2012.tar',
|
||||
'GPL.Source.Mirror.linux-6.9.4',
|
||||
'Project.Gutenberg.2024.Snapshot',
|
||||
'FreeBSD-14.0-RELEASE-amd64-dvd1.iso',
|
||||
'TempleOS.Distro.5.03',
|
||||
'Public.Domain.Jazz.Collection.FLAC',
|
||||
'NASA.Apollo.Archive.4K.Scans',
|
||||
'OpenStax.Textbook.Bundle.2024',
|
||||
'Rocky.Linux.9.4.x86_64.dvd.iso',
|
||||
];
|
||||
|
||||
const STATES = {
|
||||
downloading: 'downloading',
|
||||
stalledDL: 'stalledDL',
|
||||
uploading: 'uploading',
|
||||
stalledUP: 'stalledUP',
|
||||
pausedDL: 'pausedDL',
|
||||
pausedUP: 'pausedUP',
|
||||
checkingDL: 'checkingDL',
|
||||
queuedDL: 'queuedDL',
|
||||
forcedDL: 'forcedDL',
|
||||
forcedUP: 'forcedUP',
|
||||
metaDL: 'metaDL',
|
||||
error: 'error',
|
||||
missingFiles: 'missingFiles',
|
||||
moving: 'moving',
|
||||
};
|
||||
|
||||
function makeFiles(name, totalSize) {
|
||||
// multi-file torrents get a tree; single-file otherwise.
|
||||
const isMulti = chance(0.55);
|
||||
if (!isMulti) {
|
||||
return [{
|
||||
name,
|
||||
size: totalSize,
|
||||
progress: 1,
|
||||
priority: 1,
|
||||
availability: 1,
|
||||
}];
|
||||
}
|
||||
const folder = name.replace(/\.[a-z0-9]+$/i, '');
|
||||
const count = intBetween(2, 9);
|
||||
const files = [];
|
||||
let remaining = totalSize;
|
||||
const subdirs = ['', '', 'extras/', 'subs/', 'sample/'];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const portion = i === count - 1 ? remaining : Math.floor(remaining * between(0.1, 0.5));
|
||||
remaining -= portion;
|
||||
const ext = pick(['.mkv', '.mp4', '.flac', '.iso', '.pdf', '.tar', '.zim', '.nfo', '.srt']);
|
||||
const prio = pick([0, 1, 1, 1, 6, 7]); // 0=do not download,1=normal,6=high,7=max
|
||||
files.push({
|
||||
name: `${folder}/${pick(subdirs)}part${i + 1}${ext}`,
|
||||
size: Math.max(portion, MiB),
|
||||
progress: prio === 0 ? 0 : between(0.2, 1),
|
||||
priority: prio,
|
||||
availability: between(0.8, 2.5),
|
||||
});
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function makePieces(count, progress) {
|
||||
// 0 = missing, 1 = downloading, 2 = done
|
||||
const arr = new Array(count).fill(0);
|
||||
const done = Math.floor(count * progress);
|
||||
// make completed pieces somewhat scattered to look realistic
|
||||
let filled = 0;
|
||||
for (let i = 0; i < count && filled < done; i++) {
|
||||
if (chance(0.85)) { arr[i] = 2; filled++; }
|
||||
}
|
||||
// remaining done pieces fill from the front
|
||||
for (let i = 0; i < count && filled < done; i++) {
|
||||
if (arr[i] === 0) { arr[i] = 2; filled++; }
|
||||
}
|
||||
// a few in-flight
|
||||
if (progress < 1) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (arr[i] === 0 && chance(0.02)) arr[i] = 1;
|
||||
}
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function makeTrackers(seeds, peers) {
|
||||
const n = intBetween(2, 4);
|
||||
const hosts = [...TRACKER_HOSTS];
|
||||
const trackers = [];
|
||||
// always include DHT/PeX/LSD pseudo-trackers
|
||||
for (const pseudo of ['** [DHT] **', '** [PeX] **', '** [LSD] **']) {
|
||||
trackers.push({
|
||||
url: pseudo,
|
||||
tier: -1,
|
||||
status: 'working',
|
||||
seeds: pseudo.includes('DHT') ? intBetween(0, seeds) : -1,
|
||||
peers: pseudo.includes('DHT') ? intBetween(0, peers) : -1,
|
||||
leeches: -1,
|
||||
downloaded: -1,
|
||||
message: '',
|
||||
});
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const url = `udp://${pick(hosts)}/announce`;
|
||||
const working = chance(0.78);
|
||||
trackers.push({
|
||||
url,
|
||||
tier: i,
|
||||
status: working ? 'working' : pick(['not contacted', 'updating', 'error']),
|
||||
seeds: working ? intBetween(0, seeds + 50) : 0,
|
||||
peers: working ? intBetween(0, peers + 30) : 0,
|
||||
leeches: working ? intBetween(0, peers + 30) : 0,
|
||||
downloaded: working ? intBetween(100, 50000) : 0,
|
||||
message: working ? '' : pick(['Connection timed out', 'Host not found', 'Not working', 'unregistered torrent']),
|
||||
});
|
||||
}
|
||||
return trackers;
|
||||
}
|
||||
|
||||
function makePeers(count, dlActive) {
|
||||
const peers = [];
|
||||
const realCount = Math.min(count, intBetween(0, 40));
|
||||
for (let i = 0; i < realCount; i++) {
|
||||
const flags = [];
|
||||
if (dlActive && chance(0.5)) flags.push('D');
|
||||
if (chance(0.4)) flags.push('U');
|
||||
if (chance(0.3)) flags.push('O'); // optimistic unchoke
|
||||
if (chance(0.2)) flags.push('I'); // incoming
|
||||
if (chance(0.5)) flags.push('E'); // encrypted
|
||||
if (chance(0.1)) flags.push('X'); // PEX
|
||||
if (chance(0.1)) flags.push('H'); // DHT
|
||||
const prog = between(0, 1);
|
||||
peers.push({
|
||||
ip: `${intBetween(1, 254)}.${intBetween(0, 254)}.${intBetween(0, 254)}.${intBetween(1, 254)}`,
|
||||
port: intBetween(1024, 65535),
|
||||
country: pick(COUNTRIES),
|
||||
client: pick(CLIENTS),
|
||||
flags: flags.join(' '),
|
||||
progress: prog,
|
||||
dlspeed: flags.includes('D') ? intBetween(10 * KiB, 4 * MiB) : 0,
|
||||
upspeed: flags.includes('U') ? intBetween(1 * KiB, 800 * KiB) : 0,
|
||||
downloaded: intBetween(0, 500 * MiB),
|
||||
uploaded: intBetween(0, 500 * MiB),
|
||||
relevance: prog,
|
||||
connection: pick(['µTP', 'BT', 'BT', 'WEB']),
|
||||
});
|
||||
}
|
||||
return peers;
|
||||
}
|
||||
|
||||
function makeTorrent(name, idx) {
|
||||
const size = Math.floor(between(150 * MiB, 60 * GiB));
|
||||
const pieceSizeOptions = [256 * KiB, 512 * KiB, 1 * MiB, 2 * MiB, 4 * MiB, 8 * MiB, 16 * MiB];
|
||||
const pieceSize = pick(pieceSizeOptions);
|
||||
const pieceCount = Math.max(8, Math.min(2400, Math.ceil(size / pieceSize)));
|
||||
|
||||
// pick a plausible state
|
||||
const stateRoll = rng();
|
||||
let state, progress;
|
||||
if (stateRoll < 0.30) { state = STATES.downloading; progress = between(0.05, 0.95); }
|
||||
else if (stateRoll < 0.40) { state = STATES.stalledDL; progress = between(0.0, 0.6); }
|
||||
else if (stateRoll < 0.62) { state = STATES.uploading; progress = 1; }
|
||||
else if (stateRoll < 0.74) { state = STATES.stalledUP; progress = 1; }
|
||||
else if (stateRoll < 0.82) { state = STATES.pausedUP; progress = 1; }
|
||||
else if (stateRoll < 0.87) { state = STATES.pausedDL; progress = between(0.1, 0.8); }
|
||||
else if (stateRoll < 0.90) { state = STATES.queuedDL; progress = between(0, 0.3); }
|
||||
else if (stateRoll < 0.93) { state = STATES.checkingDL; progress = between(0.3, 0.99); }
|
||||
else if (stateRoll < 0.95) { state = STATES.forcedUP; progress = 1; }
|
||||
else if (stateRoll < 0.97) { state = STATES.metaDL; progress = 0; }
|
||||
else if (stateRoll < 0.99) { state = STATES.error; progress = between(0, 0.9); }
|
||||
else { state = STATES.missingFiles; progress = between(0.5, 1); }
|
||||
|
||||
const isDL = ['downloading', 'forcedDL', 'metaDL'].includes(state);
|
||||
const isUP = ['uploading', 'forcedUP'].includes(state);
|
||||
|
||||
const seedsTotal = intBetween(0, 800);
|
||||
const peersTotal = intBetween(0, 600);
|
||||
const seeds = Math.min(seedsTotal, intBetween(0, 50));
|
||||
const peers = Math.min(peersTotal, intBetween(0, 40));
|
||||
|
||||
const dlspeed = isDL ? intBetween(50 * KiB, 18 * MiB) : 0;
|
||||
const upspeed = (isUP || isDL) ? intBetween(0, 6 * MiB) : 0;
|
||||
|
||||
const downloaded = Math.floor(size * progress + intBetween(0, 200 * MiB));
|
||||
const ratio = between(0, 8.5);
|
||||
const uploaded = Math.floor(downloaded * ratio);
|
||||
|
||||
const now = Date.now();
|
||||
const addedOn = now - intBetween(60, 60 * 60 * 24 * 90) * 1000;
|
||||
const completionOn = progress >= 1 ? addedOn + intBetween(60, 60 * 60 * 24) * 1000 : -1;
|
||||
|
||||
const cat = pick(CATEGORIES);
|
||||
const tagSet = new Set();
|
||||
const tagCount = intBetween(0, 3);
|
||||
for (let i = 0; i < tagCount; i++) tagSet.add(pick(TAGS));
|
||||
|
||||
const eta = isDL && dlspeed > 0
|
||||
? Math.floor((size - downloaded) / dlspeed)
|
||||
: 8640000; // ∞ sentinel (100 days)
|
||||
|
||||
const files = makeFiles(name, size);
|
||||
const isPrivate = chance(0.35);
|
||||
|
||||
return {
|
||||
hash: fakeHash(),
|
||||
name,
|
||||
size,
|
||||
progress: Math.min(1, progress),
|
||||
dlspeed,
|
||||
upspeed,
|
||||
state,
|
||||
eta,
|
||||
seeds, seedsTotal,
|
||||
peers, peersTotal,
|
||||
ratio,
|
||||
ratioLimit: chance(0.3) ? between(1, 4) : -1,
|
||||
category: cat.name,
|
||||
tags: [...tagSet],
|
||||
savePath: cat.savePath,
|
||||
contentPath: `${cat.savePath}/${name}`,
|
||||
addedOn,
|
||||
completionOn,
|
||||
lastActivity: now - intBetween(0, 60 * 60 * 12) * 1000,
|
||||
seenComplete: completionOn,
|
||||
downloaded,
|
||||
uploaded,
|
||||
downloadedSession: Math.floor(downloaded * between(0.01, 0.3)),
|
||||
uploadedSession: Math.floor(uploaded * between(0.01, 0.3)),
|
||||
availability: state.includes('paused') ? 0 : between(0.5, 4),
|
||||
priority: ['queuedDL', 'queuedUP'].includes(state) ? intBetween(1, 12) : 0,
|
||||
seqDl: chance(0.15),
|
||||
superSeeding: progress >= 1 && chance(0.1),
|
||||
autoTMM: chance(0.5),
|
||||
forceStart: state.startsWith('forced'),
|
||||
pieceSize,
|
||||
pieceCount,
|
||||
pieces: makePieces(pieceCount, Math.min(1, progress)),
|
||||
downLimit: chance(0.2) ? intBetween(50 * KiB, 5 * MiB) : 0,
|
||||
upLimit: chance(0.2) ? intBetween(20 * KiB, 2 * MiB) : 0,
|
||||
timeActive: intBetween(60, 60 * 60 * 24 * 60),
|
||||
comment: chance(0.4) ? pick(['Verified release', 'Please seed!', 'Official mirror', 'See README for checksums']) : '',
|
||||
createdBy: pick(['mktorrent 1.1', 'qBittorrent v5.0.0', 'Transmission/4.0.5', 'libtorrent']),
|
||||
creationDate: addedOn - intBetween(60 * 60 * 24, 60 * 60 * 24 * 365) * 1000,
|
||||
private: isPrivate,
|
||||
magnetUri: `magnet:?xt=urn:btih:${''}`,
|
||||
files,
|
||||
trackers: makeTrackers(seeds, peers),
|
||||
peersList: makePeers(peers, isDL),
|
||||
};
|
||||
}
|
||||
|
||||
function buildFleet() {
|
||||
_seed = 1337;
|
||||
const torrents = NAME_TEMPLATES.map((n, i) => makeTorrent(n, i));
|
||||
for (const t of torrents) t.magnetUri = `magnet:?xt=urn:btih:${t.hash}&dn=${encodeURIComponent(t.name)}`;
|
||||
return torrents;
|
||||
}
|
||||
|
||||
const torrents = buildFleet();
|
||||
|
||||
const rssFeeds = [
|
||||
{
|
||||
uid: 'feed-1',
|
||||
name: 'Linux ISO Tracker',
|
||||
url: 'https://tracker.example.org/rss/linux',
|
||||
lastUpdate: Date.now() - 8 * 60 * 1000,
|
||||
articles: [
|
||||
{ title: 'debian-12.6.0-amd64-netinst.iso', date: Date.now() - 60 * 60 * 1000, size: 660 * MiB, isRead: false },
|
||||
{ title: 'ubuntu-24.04.1-desktop-amd64.iso', date: Date.now() - 5 * 60 * 60 * 1000, size: 5.9 * GiB, isRead: true },
|
||||
{ title: 'archlinux-2024.07.01-x86_64.iso', date: Date.now() - 26 * 60 * 60 * 1000, size: 1.1 * GiB, isRead: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
uid: 'feed-2',
|
||||
name: 'Public Domain Media',
|
||||
url: 'https://media.example.org/rss',
|
||||
lastUpdate: Date.now() - 22 * 60 * 1000,
|
||||
articles: [
|
||||
{ title: 'Sintel.2010.2160p.BluRay.x265', date: Date.now() - 2 * 60 * 60 * 1000, size: 4.2 * GiB, isRead: false },
|
||||
{ title: 'Big.Buck.Bunny.4K.60fps.HDR', date: Date.now() - 9 * 60 * 60 * 1000, size: 2.8 * GiB, isRead: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const rssRules = [
|
||||
{
|
||||
name: 'New Debian stable',
|
||||
enabled: true,
|
||||
mustContain: 'debian amd64 netinst',
|
||||
mustNotContain: 'rc beta alpha',
|
||||
useRegex: false,
|
||||
episodeFilter: '',
|
||||
affectedFeeds: ['feed-1'],
|
||||
assignedCategory: 'Linux ISOs',
|
||||
savePath: '/data/iso',
|
||||
addPaused: false,
|
||||
lastMatch: Date.now() - 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
name: '4K Public Domain',
|
||||
enabled: true,
|
||||
mustContain: '2160p',
|
||||
mustNotContain: 'cam ts',
|
||||
useRegex: false,
|
||||
episodeFilter: '',
|
||||
affectedFeeds: ['feed-2'],
|
||||
assignedCategory: 'Movies',
|
||||
savePath: '/data/media/movies',
|
||||
addPaused: true,
|
||||
lastMatch: Date.now() - 2 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
name: 'Ubuntu LTS only',
|
||||
enabled: false,
|
||||
mustContain: 'ubuntu.*desktop.*amd64',
|
||||
mustNotContain: 'daily beta',
|
||||
useRegex: true,
|
||||
episodeFilter: '',
|
||||
affectedFeeds: ['feed-1'],
|
||||
assignedCategory: 'Linux ISOs',
|
||||
savePath: '/data/iso',
|
||||
addPaused: false,
|
||||
lastMatch: -1,
|
||||
},
|
||||
];
|
||||
|
||||
const searchPlugins = [
|
||||
{ name: 'LinuxTracker', enabled: true, url: 'https://linuxtracker.org' },
|
||||
{ name: 'PublicMediaDB', enabled: true, url: 'https://media.example.org' },
|
||||
{ name: 'AcademicTorrents', enabled: true, url: 'https://academictorrents.com' },
|
||||
{ name: 'LegacyIndexer', enabled: false, url: 'https://legacy.example.net' },
|
||||
];
|
||||
|
||||
function runSearch(query) {
|
||||
const q = (query || '').toLowerCase().trim();
|
||||
const pool = [
|
||||
...NAME_TEMPLATES,
|
||||
'OpenWRT-23.05.3-x86-64-generic.img',
|
||||
'KDE.neon.User.Edition.2024.iso',
|
||||
'Manjaro.KDE.24.0.iso',
|
||||
'Public.Domain.Films.1920s.Collection',
|
||||
'LibreOffice.24.2.SDK.docs',
|
||||
];
|
||||
return pool
|
||||
.filter((n) => !q || n.toLowerCase().includes(q))
|
||||
.map((n) => ({
|
||||
name: n,
|
||||
size: Math.floor(between(100 * MiB, 50 * GiB)),
|
||||
seeds: intBetween(0, 1200),
|
||||
leeches: intBetween(0, 600),
|
||||
engine: pick(searchPlugins.filter((p) => p.enabled).map((p) => p.name)),
|
||||
pubDate: Date.now() - intBetween(60, 60 * 60 * 24 * 400) * 1000,
|
||||
descrLink: 'https://example.org/details',
|
||||
}))
|
||||
.sort((a, b) => b.seeds - a.seeds);
|
||||
}
|
||||
|
||||
export const db = {
|
||||
torrents,
|
||||
categories: CATEGORIES,
|
||||
tags: TAGS,
|
||||
rssFeeds,
|
||||
rssRules,
|
||||
searchPlugins,
|
||||
runSearch,
|
||||
// server preferences (subset, advanced)
|
||||
preferences: {
|
||||
dl_limit: 0,
|
||||
up_limit: 0,
|
||||
alt_dl_limit: 1 * MiB,
|
||||
alt_up_limit: 256 * KiB,
|
||||
alt_speed_enabled: false,
|
||||
max_connec: 500,
|
||||
max_connec_per_torrent: 100,
|
||||
max_uploads: 20,
|
||||
max_active_downloads: 5,
|
||||
max_active_uploads: 10,
|
||||
max_active_torrents: 12,
|
||||
dht: true,
|
||||
pex: true,
|
||||
lsd: true,
|
||||
encryption: 1, // 0=prefer,1=force on,2=force off
|
||||
utp: true,
|
||||
listen_port: 6881,
|
||||
upnp: true,
|
||||
queueing_enabled: true,
|
||||
save_path: '/data/downloads',
|
||||
scan_interval: 15,
|
||||
},
|
||||
};
|
||||
|
||||
export const constants = { KiB, MiB, GiB, STATES };
|
||||
333
server/index.js
Normal file
333
server/index.js
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
// Zero-dependency stub server for the torrent web UI.
|
||||
// Built-in `http` only: serves the static frontend, a JSON API, and a
|
||||
// Server-Sent Events stream that pushes a live snapshot every second.
|
||||
|
||||
import http from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { db } from './data.js';
|
||||
import { tick, globalStats } from './simulator.js';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
||||
const PORT = process.env.PORT || 8088;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
// ---- run the simulator on a fixed cadence ----
|
||||
setInterval(tick, 1000);
|
||||
|
||||
// ---- SSE clients ----
|
||||
const sseClients = new Set();
|
||||
function broadcast() {
|
||||
if (sseClients.size === 0) return;
|
||||
const payload = JSON.stringify(snapshot());
|
||||
const frame = `event: snapshot\ndata: ${payload}\n\n`;
|
||||
for (const res of sseClients) res.write(frame);
|
||||
}
|
||||
setInterval(broadcast, 1000);
|
||||
|
||||
// A compact snapshot for the live grid (omits heavy per-torrent detail).
|
||||
function snapshot() {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
server: globalStats(),
|
||||
torrents: db.torrents.map((t) => ({
|
||||
hash: t.hash,
|
||||
name: t.name,
|
||||
size: t.size,
|
||||
progress: t.progress,
|
||||
dlspeed: t.dlspeed,
|
||||
upspeed: t.upspeed,
|
||||
state: t.state,
|
||||
eta: t.eta,
|
||||
seeds: t.seeds, seedsTotal: t.seedsTotal,
|
||||
peers: t.peers, peersTotal: t.peersTotal,
|
||||
ratio: t.ratio,
|
||||
category: t.category,
|
||||
tags: t.tags,
|
||||
savePath: t.savePath,
|
||||
addedOn: t.addedOn,
|
||||
completionOn: t.completionOn,
|
||||
lastActivity: t.lastActivity,
|
||||
downloaded: t.downloaded,
|
||||
uploaded: t.uploaded,
|
||||
availability: t.availability,
|
||||
priority: t.priority,
|
||||
seqDl: t.seqDl,
|
||||
superSeeding: t.superSeeding,
|
||||
forceStart: t.forceStart,
|
||||
timeActive: t.timeActive,
|
||||
private: t.private,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function json(res, data, code = 200) {
|
||||
const body = JSON.stringify(data);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
if (!chunks.length) return {};
|
||||
try { return JSON.parse(Buffer.concat(chunks).toString()); } catch { return {}; }
|
||||
}
|
||||
|
||||
function byHash(hash) { return db.torrents.find((t) => t.hash === hash); }
|
||||
function findMany(hashes) {
|
||||
const set = new Set(hashes);
|
||||
return db.torrents.filter((t) => set.has(t.hash));
|
||||
}
|
||||
|
||||
// ---- action handlers (mutate the stub state) ----
|
||||
const ACTIONS = {
|
||||
pause: (t) => { if (!t.state.startsWith('paused')) { t.state = t.progress >= 1 ? 'pausedUP' : 'pausedDL'; t.dlspeed = 0; t.upspeed = 0; t.forceStart = false; } },
|
||||
resume: (t) => { t.state = t.progress >= 1 ? 'uploading' : 'downloading'; },
|
||||
forceStart: (t) => { t.forceStart = true; t.state = t.progress >= 1 ? 'forcedUP' : 'forcedDL'; },
|
||||
recheck: (t) => { t.state = t.progress >= 1 ? 'checkingUP' : 'checkingDL'; setTimeout(() => { t.state = t.progress >= 1 ? 'uploading' : 'downloading'; }, 3000); },
|
||||
reannounce: (t) => { t.lastActivity = Date.now(); },
|
||||
toggleSeqDl: (t) => { t.seqDl = !t.seqDl; },
|
||||
toggleSuperSeeding: (t) => { t.superSeeding = !t.superSeeding; },
|
||||
setCategory: (t, p) => { t.category = p.category ?? ''; },
|
||||
setSavePath: (t, p) => { if (p.savePath) { t.savePath = p.savePath; t.contentPath = `${p.savePath}/${t.name}`; } },
|
||||
addTags: (t, p) => { for (const tag of p.tags || []) if (!t.tags.includes(tag)) t.tags.push(tag); },
|
||||
removeTags: (t, p) => { t.tags = t.tags.filter((x) => !(p.tags || []).includes(x)); },
|
||||
setDownLimit: (t, p) => { t.downLimit = p.limit ?? 0; },
|
||||
setUpLimit: (t, p) => { t.upLimit = p.limit ?? 0; },
|
||||
setRatioLimit: (t, p) => { t.ratioLimit = p.limit ?? -1; },
|
||||
topPriority: (t) => { t.priority = 1; },
|
||||
bottomPriority: (t) => { t.priority = 99; },
|
||||
increasePriority: (t) => { t.priority = Math.max(1, (t.priority || 1) - 1); },
|
||||
decreasePriority: (t) => { t.priority = (t.priority || 1) + 1; },
|
||||
};
|
||||
|
||||
async function handleApi(req, res, url) {
|
||||
const parts = url.pathname.split('/').filter(Boolean); // ['api', ...]
|
||||
const seg = parts.slice(1); // drop 'api'
|
||||
|
||||
// GET /api/stream (SSE)
|
||||
if (seg[0] === 'stream') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 2000\n\n');
|
||||
res.write(`event: snapshot\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
||||
sseClients.add(res);
|
||||
req.on('close', () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/snapshot
|
||||
if (seg[0] === 'snapshot' && req.method === 'GET') return json(res, snapshot());
|
||||
|
||||
// GET /api/meta (sidebar data)
|
||||
if (seg[0] === 'meta' && req.method === 'GET') {
|
||||
return json(res, {
|
||||
categories: db.categories,
|
||||
tags: db.tags,
|
||||
trackers: trackerSummary(),
|
||||
preferences: db.preferences,
|
||||
searchPlugins: db.searchPlugins,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/preferences / POST to update
|
||||
if (seg[0] === 'preferences') {
|
||||
if (req.method === 'GET') return json(res, db.preferences);
|
||||
if (req.method === 'POST') {
|
||||
Object.assign(db.preferences, await readBody(req));
|
||||
return json(res, db.preferences);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/altspeed (toggle alt speed)
|
||||
if (seg[0] === 'altspeed' && req.method === 'POST') {
|
||||
db.preferences.alt_speed_enabled = !db.preferences.alt_speed_enabled;
|
||||
return json(res, { alt_speed_enabled: db.preferences.alt_speed_enabled });
|
||||
}
|
||||
|
||||
// Categories: create (POST /api/categories) / delete (POST /api/categories/delete)
|
||||
if (seg[0] === 'categories' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
if (seg[1] === 'delete') {
|
||||
const name = (body.name || '').trim();
|
||||
if (name) {
|
||||
db.categories = db.categories.filter((c) => c.name !== name);
|
||||
for (const t of db.torrents) if (t.category === name) t.category = '';
|
||||
}
|
||||
return json(res, db.categories);
|
||||
}
|
||||
const name = (body.name || '').trim();
|
||||
if (name && !db.categories.some((c) => c.name === name)) {
|
||||
db.categories.push({ name, savePath: (body.savePath || `${db.preferences.save_path}/${name}`).trim() });
|
||||
} else if (name && body.savePath) {
|
||||
db.categories.find((c) => c.name === name).savePath = body.savePath.trim(); // edit existing
|
||||
}
|
||||
return json(res, db.categories);
|
||||
}
|
||||
|
||||
// Tags: create (POST /api/tags) / delete (POST /api/tags/delete)
|
||||
if (seg[0] === 'tags' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
if (seg[1] === 'delete') {
|
||||
const name = (body.name || '').trim();
|
||||
if (name) {
|
||||
db.tags = db.tags.filter((x) => x !== name);
|
||||
for (const t of db.torrents) t.tags = t.tags.filter((x) => x !== name);
|
||||
}
|
||||
return json(res, db.tags);
|
||||
}
|
||||
const name = (body.name || '').trim();
|
||||
if (name && !db.tags.includes(name)) db.tags.push(name);
|
||||
return json(res, db.tags);
|
||||
}
|
||||
|
||||
// /api/torrents ...
|
||||
if (seg[0] === 'torrents') {
|
||||
// GET /api/torrents/:hash/:tab
|
||||
if (req.method === 'GET' && seg[1]) {
|
||||
const t = byHash(seg[1]);
|
||||
if (!t) return json(res, { error: 'not found' }, 404);
|
||||
const tab = seg[2];
|
||||
if (tab === 'trackers') return json(res, t.trackers);
|
||||
if (tab === 'peers') return json(res, t.peersList);
|
||||
if (tab === 'files') return json(res, t.files);
|
||||
if (tab === 'pieces') return json(res, { pieceSize: t.pieceSize, pieceCount: t.pieceCount, pieces: t.pieces });
|
||||
// default: full general properties
|
||||
return json(res, t);
|
||||
}
|
||||
// GET /api/torrents -> full list (rarely needed; stream is primary)
|
||||
if (req.method === 'GET') return json(res, snapshot().torrents);
|
||||
}
|
||||
|
||||
// POST /api/action { action, hashes:[], params:{} }
|
||||
if (seg[0] === 'action' && req.method === 'POST') {
|
||||
const { action, hashes, params } = await readBody(req);
|
||||
const fn = ACTIONS[action];
|
||||
if (!fn) return json(res, { error: `unknown action: ${action}` }, 400);
|
||||
const targets = findMany(hashes || []);
|
||||
for (const t of targets) fn(t, params || {});
|
||||
return json(res, { ok: true, affected: targets.length });
|
||||
}
|
||||
|
||||
// DELETE via POST /api/delete { hashes:[], deleteFiles:bool }
|
||||
if (seg[0] === 'delete' && req.method === 'POST') {
|
||||
const { hashes } = await readBody(req);
|
||||
const set = new Set(hashes || []);
|
||||
const before = db.torrents.length;
|
||||
db.torrents = db.torrents.filter((t) => !set.has(t.hash));
|
||||
return json(res, { ok: true, removed: before - db.torrents.length });
|
||||
}
|
||||
|
||||
// POST /api/add { magnet | name+size+pieceCount+pieceSize+files, category, savePath, paused, skipCheck, seqDl }
|
||||
if (seg[0] === 'add' && req.method === 'POST') {
|
||||
const p = await readBody(req);
|
||||
const name = (p.magnet && decodeURIComponent((p.magnet.match(/dn=([^&]+)/) || [])[1] || '')) || p.name || 'new-torrent.iso';
|
||||
const GiB = 1024 * 1024 * 1024;
|
||||
const size = p.size || GiB;
|
||||
const pieceSize = p.pieceSize || 1024 * 1024;
|
||||
const pieceCount = Math.min(p.pieceCount || Math.max(1, Math.ceil(size / pieceSize)), 4000);
|
||||
const files = (Array.isArray(p.files) && p.files.length)
|
||||
? p.files.map((fl) => ({ name: fl.name, size: fl.size, progress: 0, priority: 1, availability: 1 }))
|
||||
: [{ name, size, progress: 0, priority: 1, availability: 1 }];
|
||||
const savePath = p.savePath || db.preferences.save_path;
|
||||
const hash = Math.random().toString(16).slice(2).padEnd(40, '0').slice(0, 40);
|
||||
db.torrents.unshift({
|
||||
hash, name, size, progress: 0,
|
||||
dlspeed: p.paused ? 0 : 256 * 1024, upspeed: 0, state: p.paused ? 'pausedDL' : (p.magnet ? 'metaDL' : 'downloading'),
|
||||
eta: 8640000, seeds: 4, seedsTotal: 40, peers: 2, peersTotal: 20, ratio: 0, ratioLimit: -1,
|
||||
category: p.category || '', tags: [], savePath,
|
||||
contentPath: `${savePath}/${name}`,
|
||||
addedOn: Date.now(), completionOn: -1, lastActivity: Date.now(), seenComplete: -1,
|
||||
downloaded: 0, uploaded: 0, downloadedSession: 0, uploadedSession: 0,
|
||||
availability: 1, priority: 1, seqDl: !!p.seqDl, superSeeding: false, autoTMM: true,
|
||||
forceStart: false, pieceSize, pieceCount, pieces: new Array(pieceCount).fill(0),
|
||||
downLimit: 0, upLimit: 0, timeActive: 0, comment: '', createdBy: 'uploaded .torrent',
|
||||
creationDate: Date.now(), private: false, magnetUri: p.magnet || '',
|
||||
files,
|
||||
trackers: [
|
||||
{ url: '** [DHT] **', tier: -1, status: 'working', seeds: 4, peers: 2, leeches: -1, downloaded: -1, message: '' },
|
||||
{ url: 'udp://tracker.opentrackr.org:1337/announce', tier: 0, status: 'working', seeds: 10, peers: 4, leeches: 4, downloaded: 0, message: '' },
|
||||
],
|
||||
peersList: [],
|
||||
});
|
||||
return json(res, { ok: true, hash });
|
||||
}
|
||||
|
||||
// RSS
|
||||
if (seg[0] === 'rss') {
|
||||
if (seg[1] === 'rules') return json(res, db.rssRules);
|
||||
return json(res, db.rssFeeds);
|
||||
}
|
||||
|
||||
// Search: GET /api/search?q=...
|
||||
if (seg[0] === 'search' && req.method === 'GET') {
|
||||
return json(res, db.runSearch(url.searchParams.get('q') || ''));
|
||||
}
|
||||
|
||||
return json(res, { error: 'not found' }, 404);
|
||||
}
|
||||
|
||||
function trackerSummary() {
|
||||
const map = new Map();
|
||||
for (const t of db.torrents) {
|
||||
for (const tr of t.trackers) {
|
||||
if (tr.tier === -1) continue; // skip DHT/PeX/LSD pseudo
|
||||
let host;
|
||||
try { host = new URL(tr.url).host; } catch { host = tr.url; }
|
||||
map.set(host, (map.get(host) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...map.entries()].map(([host, count]) => ({ host, count })).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, url) {
|
||||
let pathname = decodeURIComponent(url.pathname);
|
||||
if (pathname === '/') pathname = '/index.html';
|
||||
const filePath = normalize(join(PUBLIC_DIR, pathname));
|
||||
if (!filePath.startsWith(PUBLIC_DIR)) { res.writeHead(403).end('forbidden'); return; }
|
||||
try {
|
||||
const data = await readFile(filePath);
|
||||
const type = MIME[extname(filePath)] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache' });
|
||||
res.end(data);
|
||||
} catch {
|
||||
// SPA fallback
|
||||
try {
|
||||
const data = await readFile(join(PUBLIC_DIR, 'index.html'));
|
||||
res.writeHead(200, { 'Content-Type': MIME['.html'] });
|
||||
res.end(data);
|
||||
} catch { res.writeHead(404).end('not found'); }
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
try {
|
||||
if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url);
|
||||
return await serveStatic(req, res, url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
json(res, { error: 'internal error', detail: String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`\n torrent-ui stub server running`);
|
||||
console.log(` → http://localhost:${PORT}\n`);
|
||||
console.log(` ${db.torrents.length} mock torrents, live simulator ticking every 1s\n`);
|
||||
});
|
||||
135
server/simulator.js
Normal file
135
server/simulator.js
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Mutates the in-memory fleet over time so the UI shows live activity:
|
||||
// speeds fluctuate, downloads progress, pieces fill in, ETA/ratio drift,
|
||||
// session totals accumulate, and the occasional torrent completes.
|
||||
|
||||
import { db, constants } from './data.js';
|
||||
|
||||
const { KiB, MiB } = constants;
|
||||
|
||||
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
||||
function jitter(v, frac) { return v * (1 + (Math.random() * 2 - 1) * frac); }
|
||||
|
||||
const DL_STATES = new Set(['downloading', 'forcedDL', 'metaDL']);
|
||||
const UP_STATES = new Set(['uploading', 'forcedUP']);
|
||||
|
||||
export function tick() {
|
||||
const now = Date.now();
|
||||
for (const t of db.torrents) {
|
||||
const isDL = DL_STATES.has(t.state);
|
||||
const isUP = UP_STATES.has(t.state) || isDL;
|
||||
|
||||
// --- speeds ---
|
||||
if (isDL) {
|
||||
const ceiling = t.downLimit > 0 ? t.downLimit : 18 * MiB;
|
||||
t.dlspeed = clamp(Math.round(jitter(t.dlspeed || 500 * KiB, 0.4)), 20 * KiB, ceiling);
|
||||
// occasionally stall
|
||||
if (Math.random() < 0.03) { t.state = 'stalledDL'; t.dlspeed = 0; }
|
||||
} else if (t.state === 'stalledDL') {
|
||||
t.dlspeed = 0;
|
||||
if (Math.random() < 0.08) { t.state = 'downloading'; t.dlspeed = 800 * KiB; }
|
||||
} else {
|
||||
t.dlspeed = 0;
|
||||
}
|
||||
|
||||
if (isUP) {
|
||||
const ceiling = t.upLimit > 0 ? t.upLimit : 6 * MiB;
|
||||
t.upspeed = clamp(Math.round(jitter(t.upspeed || 100 * KiB, 0.5)), 0, ceiling);
|
||||
} else {
|
||||
t.upspeed = 0;
|
||||
}
|
||||
|
||||
// --- progress ---
|
||||
if (isDL && t.progress < 1 && t.dlspeed > 0) {
|
||||
const gained = t.dlspeed; // ~1s of bytes
|
||||
t.downloaded += gained;
|
||||
t.downloadedSession += gained;
|
||||
t.progress = clamp(t.progress + gained / t.size, 0, 1);
|
||||
|
||||
// fill some pieces proportionally
|
||||
const targetDone = Math.floor(t.pieceCount * t.progress);
|
||||
let done = 0;
|
||||
for (const p of t.pieces) if (p === 2) done++;
|
||||
let toFill = targetDone - done;
|
||||
for (let i = 0; i < t.pieces.length && toFill > 0; i++) {
|
||||
if (t.pieces[i] !== 2) {
|
||||
if (t.pieces[i] === 1 || Math.random() < 0.5) { t.pieces[i] = 2; toFill--; }
|
||||
else t.pieces[i] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (t.state === 'metaDL' && t.progress > 0.01) t.state = 'downloading';
|
||||
|
||||
// completion
|
||||
if (t.progress >= 1) {
|
||||
t.progress = 1;
|
||||
t.state = 'uploading';
|
||||
t.completionOn = now;
|
||||
t.dlspeed = 0;
|
||||
for (let i = 0; i < t.pieces.length; i++) t.pieces[i] = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// --- uploaded / ratio ---
|
||||
if (t.upspeed > 0) {
|
||||
t.uploaded += t.upspeed;
|
||||
t.uploadedSession += t.upspeed;
|
||||
}
|
||||
t.ratio = t.downloaded > 0 ? t.uploaded / t.downloaded : (t.uploaded > 0 ? 9999 : 0);
|
||||
|
||||
// --- eta ---
|
||||
t.eta = isDL && t.dlspeed > 0
|
||||
? Math.floor((t.size - t.downloaded) / t.dlspeed)
|
||||
: 8640000;
|
||||
|
||||
// --- swarm drift ---
|
||||
if (!t.state.startsWith('paused')) {
|
||||
t.seeds = clamp(t.seeds + (Math.random() < 0.5 ? -1 : 1) * (Math.random() < 0.3 ? 1 : 0), 0, t.seedsTotal);
|
||||
t.peers = clamp(t.peers + (Math.random() < 0.5 ? -1 : 1) * (Math.random() < 0.3 ? 1 : 0), 0, t.peersTotal);
|
||||
t.lastActivity = now;
|
||||
t.timeActive += 1;
|
||||
}
|
||||
|
||||
// --- peer speeds drift ---
|
||||
for (const p of t.peersList) {
|
||||
if (p.dlspeed) p.dlspeed = clamp(Math.round(jitter(p.dlspeed, 0.5)), 0, 4 * MiB);
|
||||
if (p.upspeed) p.upspeed = clamp(Math.round(jitter(p.upspeed, 0.5)), 0, 800 * KiB);
|
||||
if (p.progress < 1) p.progress = clamp(p.progress + Math.random() * 0.01, 0, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global session/server stats derived from the fleet.
|
||||
export function globalStats() {
|
||||
let dl = 0, up = 0, dlSession = 0, upSession = 0, totalDown = 0, totalUp = 0;
|
||||
let active = 0;
|
||||
for (const t of db.torrents) {
|
||||
dl += t.dlspeed;
|
||||
up += t.upspeed;
|
||||
dlSession += t.downloadedSession;
|
||||
upSession += t.uploadedSession;
|
||||
totalDown += t.downloaded;
|
||||
totalUp += t.uploaded;
|
||||
if (t.dlspeed > 0 || t.upspeed > 0) active++;
|
||||
}
|
||||
const p = db.preferences;
|
||||
return {
|
||||
dl_info_speed: dl,
|
||||
up_info_speed: up,
|
||||
dl_info_data: dlSession,
|
||||
up_info_data: upSession,
|
||||
dl_rate_limit: p.alt_speed_enabled ? p.alt_dl_limit : p.dl_limit,
|
||||
up_rate_limit: p.alt_speed_enabled ? p.alt_up_limit : p.up_limit,
|
||||
alt_speed_enabled: p.alt_speed_enabled,
|
||||
global_ratio: totalDown > 0 ? totalUp / totalDown : 0,
|
||||
dht_nodes: 312 + Math.floor(Math.random() * 40),
|
||||
connection_status: 'connected',
|
||||
listen_port: p.listen_port,
|
||||
free_space: 812 * 1024 * 1024 * 1024 + Math.floor(Math.random() * 1e9),
|
||||
active_torrents: active,
|
||||
total_torrents: db.torrents.length,
|
||||
queued_io_jobs: Math.floor(Math.random() * 4),
|
||||
read_cache_hits: (88 + Math.random() * 8).toFixed(1),
|
||||
total_buffer_size: 16 * MiB,
|
||||
average_time_queue: Math.floor(Math.random() * 12),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue