Naut-Plugin-WebUI/server/index.js
ookami125 0b35571565 Make existing controls real + add robustness pass
Wire up controls that were rendered but inert, expose unused server
actions, and harden the live update path.

Controls now functional:
- File priority dropdowns (Content tab) now persist via new
  setFilePriority server action
- Context menu: Set location, Limit download/upload rate, Set share
  limit (wire setSavePath/setDownLimit/setUpLimit/setRatioLimit)
- Trackers sidebar filter actually filters (snapshot now carries
  per-torrent trackerHosts)

Robustness:
- Grid updates cells in place when row order/columns are unchanged
  (preserves text selection/scroll, no per-second listener re-bind)
- Boot retries until the server responds; SSE drops show a
  "Reconnecting" banner; failed actions/add/delete surface a toast
- Prune selection/detail for torrents that disappear
- Drop Backspace-as-delete (kept Delete)

Cleanup:
- Remove pseudo DHT/PeX/LSD names from the real-tracker host pool in
  mock data (they leaked into announce URLs and the sidebar)
- Remove dead detailTab import/export

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 21:24:20 -04:00

342 lines
13 KiB
JavaScript

// 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,
trackerHosts: trackerHostsOf(t),
})),
};
}
function hostOf(url) {
try { return new URL(url).host; } catch { return url; }
}
function trackerHostsOf(t) {
return t.trackers.filter((tr) => tr.tier >= 0).map((tr) => hostOf(tr.url));
}
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; },
setFilePriority: (t, p) => { if (t.files[p.index]) t.files[p.index].priority = p.priority; },
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`);
});