commit bc1be49a37a1f72f209c3d511d366cdfed200433 Author: ookami125 Date: Tue Jun 16 21:15:31 2026 -0400 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3f00ede --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.log +.DS_Store +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe064f1 --- /dev/null +++ b/README.md @@ -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. diff --git a/package.json b/package.json new file mode 100644 index 0000000..f578c14 --- /dev/null +++ b/package.json @@ -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" +} diff --git a/public/css/styles.css b/public/css/styles.css new file mode 100644 index 0000000..ff38293 --- /dev/null +++ b/public/css/styles.css @@ -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); } diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..07177c3 --- /dev/null +++ b/public/index.html @@ -0,0 +1,83 @@ + + + + + + Naut · Torrent Console + + + + +
+ +
+
+ + NAUT + torrent console +
+ +
+ +
+ + + + +
+ + + + +
+ +
+ + + +
+ + +
+ +
+ +
+ + + + +
+ + +
+
+
+ + +
+
+ + + + + + + diff --git a/public/js/api.js b/public/js/api.js new file mode 100644 index 0000000..67790f4 --- /dev/null +++ b/public/js/api.js @@ -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; + }, +}; diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..087d982 --- /dev/null +++ b/public/js/app.js @@ -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 = ` +
Status
${statusItems}
+
Categories
${cats}
+
Tags
${tags || '
none
'}
+
Trackers
${trackers}
`; + + 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 `
+ ${ic}${f.esc(label)}${cnt}
`; +} + +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 = ` +
+
+
+
`; + 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 ? `${state.sort.dir < 0 ? '▾' : '▴'}` : ''; + return `${c.label}${arrow}`; + }).join(''); + + const rows = list.map((t) => ` + ${cols.map((k) => cell(t, k)).join('')} + `).join(''); + + wrap.innerHTML = `${head}${rows || emptyRow(cols.length)}
`; + + 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 `
No torrents match this filter
`; +} + +function cell(t, k) { + switch (k) { + case 'name': return `
+ + ${t.private ? 'PRIV' : ''} + ${f.esc(t.name)}
`; + case 'size': return `${f.bytes(t.size)}`; + case 'progress': return `${progressBar(t)}`; + case 'state': return `${f.stateLabel(t.state)}`; + case 'seeds': return `${t.seeds} (${t.seedsTotal})`; + case 'peers': return `${t.peers} (${t.peersTotal})`; + case 'dlspeed': return `${t.dlspeed ? f.rate(t.dlspeed) : '–'}`; + case 'upspeed': return `${t.upspeed ? f.rate(t.upspeed) : '–'}`; + case 'eta': return `${f.eta(t.eta)}`; + case 'ratio': return `${f.ratio(t.ratio)}`; + case 'availability': return `${t.availability.toFixed(2)}`; + case 'category': return `${t.category ? `${f.esc(t.category)}` : ''}`; + case 'tags': return `
${t.tags.map((x) => `${f.esc(x)}`).join('') || ''}
`; + case 'addedOn': return `${f.date(t.addedOn)}`; + case 'completionOn': return `${f.date(t.completionOn)}`; + case 'savePath': return `${f.esc(t.savePath)}`; + case 'priority': return `${t.priority || '—'}`; + default: return ''; + } +} + +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 `
${f.pct(t.progress)}
`; +} + +/* ---------- 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)?`, ` +

Choose whether to also delete the downloaded data from disk.

+
`, + [{ 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) => ``).join(''); + openModal('Add torrent', ` +
+
+
+ +
+
+
+
+
+ + + +
`, + [{ 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) => + `📄 ${f.esc(m.name)} — ${f.bytes(m.size)} · ${m.pieceCount} pieces · ${(m.files || []).length || 1} file(s)`).join('
'); + }); +} + +// 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) => ``).join(''); + openModal('Set category', ` +
+

Need a new one? Use + next to “Categories” in the sidebar.

`, + [{ 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', ` +
+
+
`, + [{ 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', `
`, + [{ 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}`, `

Remove the ${label}? It will be unassigned from all torrents. Downloaded files are not affected.

`, + [{ 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 ``; + }; + openModal(`Edit tags · ${hashes.length} torrent(s)`, ` +
+
+ +
+
${state.meta.tags.map(tagRow).join('') || 'No tags yet — add one above.'}
`, + [{ 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 = `${f.esc(v)}`; + 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 '
'; + if (it.sub) return `
${it.sub}
`; + return `
${it.label}${it.sc || ''}
`; + }).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 = '
Loading feeds…
'; + const [feeds, rules] = await Promise.all([api.rss(), api.rssRules()]); + host.innerHTML = `
+
+

Feeds

+ ${feeds.map((fd) => `
📡 ${f.esc(fd.name)}${f.ago(fd.lastUpdate)}
+
${f.esc(fd.url)}
`).join('
')} +
+
+
+

Unread articles

+ + ${feeds.flatMap((fd) => fd.articles.map((a) => ` + + + `)).join('')} +
FeedTitleSizePublished
${f.esc(fd.name)}${f.esc(a.title)}${f.bytes(a.size)}${f.ago(a.date)}${a.isRead ? 'read' : '● new'}
+
+

Auto-download rules

+ ${rules.map((r) => renderRule(r)).join('')} +
+
`; +} + +function renderRule(r) { + return `
+
+ ${f.esc(r.name)} + ${r.enabled ? 'enabled' : 'disabled'} + ${r.useRegex ? 'regex' : ''} + ${r.addPaused ? 'add paused' : ''} +
+
must contain ${f.esc(r.mustContain)}
+ ${r.mustNotContain ? `
must not contain ${f.esc(r.mustNotContain)}
` : ''} +
→ category ${f.esc(r.assignedCategory)} · save to ${f.esc(r.savePath)}
+
feeds: ${r.affectedFeeds.join(', ')} · last match ${f.ago(r.lastMatch)}
+
`; +} + +/* ===================== Search view ===================== */ +function renderSearchView(host) { + const plugins = state.meta.searchPlugins.map((p) => + `${f.esc(p.name)}`).join(' '); + host.innerHTML = `
+ +
Indexers: ${plugins}
+
${state.searchResults ? searchTable(state.searchResults) : '
Enter a query to search configured indexers
'}
+
`; + const input = document.getElementById('searchInput'); + const run = async () => { + state.searchQuery = input.value.trim(); + document.getElementById('searchResults').innerHTML = '
Searching…
'; + 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 '
No results
'; + return ` + + + ${rows.map((r, i) => ` + + + + `).join('')} +
NameSizeSeedsLeechesEnginePublished
${f.esc(r.name)}${f.bytes(r.size)}${r.seeds}${r.leeches}${f.esc(r.engine)}${f.ago(r.pubDate)}
`; +} + +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) => `

${title}

${rows.map(([k, v]) => + `
${k}${v}
`).join('')}
`; + host.innerHTML = `
+ ${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`], + ])} +
+

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.

+
`; +} + +/* ===================== status bar ===================== */ +function renderStatusbar() { + const s = state.snapshot.server || {}; + const el = document.getElementById('statusbar'); + el.innerHTML = ` + ${s.connection_status || 'connecting'} + DHT: ${s.dht_nodes ?? '–'} nodes + Port: ${s.listen_port ?? '–'} + Active: ${s.active_torrents ?? 0}/${s.total_torrents ?? 0} + Session ▼ ${f.bytes(s.dl_info_data)}${f.bytes(s.up_info_data)} + Global ratio: ${f.ratio(s.global_ratio)} + + Cache hit: ${s.read_cache_hits ?? '–'}% + Queued I/O: ${s.queued_io_jobs ?? 0} + Free space: ${f.bytes(s.free_space)}`; +} + +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 = `

${f.esc(title)}

${bodyHtml}
+
${buttons.map((b, i) => ``).join('')}
`; + 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(); diff --git a/public/js/detail.js b/public/js/detail.js new file mode 100644 index 0000000..a0b67e4 --- /dev/null +++ b/public/js/detail.js @@ -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 = ` +
+
+
+ ${TABS.map((t) => ``).join('')} + +
+
+
`; + + 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) => `
${k}${v}
`; + return `
+
Transfer
+ ${row('Status', `${f.stateLabel(t.state)}`)} + ${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))} + +
Information
+ ${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 ? `${f.esc(t.category)}` : '—')} + ${row('Tags', t.tags.length ? t.tags.map((x) => `${f.esc(x)}`).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 ? 'PRIVATE' : '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)) : ''} +
`; +} + +/* ---------- Trackers ---------- */ +function renderTrackers(trackers) { + return ` + + + + ${trackers.map((tr) => ` + + + + + + + + + `).join('')} +
TierURLStatusSeedsPeersLeechesDownloadedMessage
${tr.tier < 0 ? '—' : tr.tier}${f.esc(tr.url)}${tr.status}${tr.seeds < 0 ? '—' : tr.seeds}${tr.peers < 0 ? '—' : tr.peers}${tr.leeches < 0 ? '—' : tr.leeches}${tr.downloaded < 0 ? '—' : tr.downloaded}${f.esc(tr.message)}
`; +} + +/* ---------- Peers ---------- */ +function renderPeers(peers) { + if (!peers.length) return '
No peers connected
'; + const sorted = [...peers].sort((a, b) => (b.dlspeed + b.upspeed) - (a.dlspeed + a.upspeed)); + return ` + + + + + ${sorted.map((p) => ` + + + + + + + + + + + + `).join('')} +
CountryIP : PortClientConnFlagsProgressDownUpDownloadedUploadedRel.
${p.country}${p.ip}:${p.port}${f.esc(p.client)}${p.connection}${p.flags || '·'}${f.pct(p.progress)}${p.dlspeed ? f.rate(p.dlspeed) : '–'}${p.upspeed ? f.rate(p.upspeed) : '–'}${f.bytes(p.downloaded)}${f.bytes(p.uploaded)}${f.pct(p.relevance)}
`; +} + +/* ---------- Content / files ---------- */ +function renderFiles(files) { + return ` + + + + ${files.map((file, i) => ` + + + + + + + `).join('')} +
NameSizeProgress%PriorityAvailability
${f.esc(file.name)}${f.bytes(file.size)}${f.pct(file.progress)}${file.availability.toFixed(2)}
`; +} + +/* ---------- 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) => ``).join(''); + return ` +
+ Have (${done}) + Downloading (${dl}) + Missing (${pieceCount - done - dl}) + ${pieceCount} pieces · ${f.bytes(pieceSize)} each +
+
${cells}
`; +} + +/* ---------- 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); + }); +} diff --git a/public/js/format.js b/public/js/format.js new file mode 100644 index 0000000..db2575c --- /dev/null +++ b/public/js/format.js @@ -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])); +} diff --git a/server/data.js b/server/data.js new file mode 100644 index 0000000..2833807 --- /dev/null +++ b/server/data.js @@ -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 }; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..683b583 --- /dev/null +++ b/server/index.js @@ -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`); +}); diff --git a/server/simulator.js b/server/simulator.js new file mode 100644 index 0000000..e9cfa43 --- /dev/null +++ b/server/simulator.js @@ -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), + }; +}