Initial commit: NAUT torrent web UI with stubbed server
Advanced torrent client web UI aimed at power users, with a zero-dependency Node stub server (built-in http + SSE) serving live mock data. - Dense sortable/multi-select torrent grid with live updates - Detail panel: General/Trackers/Peers/Content/Pieces (resizable) - Sidebar filters: status, categories, tags, trackers - Create/delete categories and tags (sidebar + right-click) - Add via magnet or client-side-parsed .torrent upload - RSS, integrated search, and read-only engine views - Hand-drawn SVG icon set, dark theme, keyboard shortcuts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
bc1be49a37
12 changed files with 2677 additions and 0 deletions
333
server/index.js
Normal file
333
server/index.js
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
// Zero-dependency stub server for the torrent web UI.
|
||||
// Built-in `http` only: serves the static frontend, a JSON API, and a
|
||||
// Server-Sent Events stream that pushes a live snapshot every second.
|
||||
|
||||
import http from 'node:http';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { extname, join, normalize } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { db } from './data.js';
|
||||
import { tick, globalStats } from './simulator.js';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const PUBLIC_DIR = join(__dirname, '..', 'public');
|
||||
const PORT = process.env.PORT || 8088;
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
// ---- run the simulator on a fixed cadence ----
|
||||
setInterval(tick, 1000);
|
||||
|
||||
// ---- SSE clients ----
|
||||
const sseClients = new Set();
|
||||
function broadcast() {
|
||||
if (sseClients.size === 0) return;
|
||||
const payload = JSON.stringify(snapshot());
|
||||
const frame = `event: snapshot\ndata: ${payload}\n\n`;
|
||||
for (const res of sseClients) res.write(frame);
|
||||
}
|
||||
setInterval(broadcast, 1000);
|
||||
|
||||
// A compact snapshot for the live grid (omits heavy per-torrent detail).
|
||||
function snapshot() {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
server: globalStats(),
|
||||
torrents: db.torrents.map((t) => ({
|
||||
hash: t.hash,
|
||||
name: t.name,
|
||||
size: t.size,
|
||||
progress: t.progress,
|
||||
dlspeed: t.dlspeed,
|
||||
upspeed: t.upspeed,
|
||||
state: t.state,
|
||||
eta: t.eta,
|
||||
seeds: t.seeds, seedsTotal: t.seedsTotal,
|
||||
peers: t.peers, peersTotal: t.peersTotal,
|
||||
ratio: t.ratio,
|
||||
category: t.category,
|
||||
tags: t.tags,
|
||||
savePath: t.savePath,
|
||||
addedOn: t.addedOn,
|
||||
completionOn: t.completionOn,
|
||||
lastActivity: t.lastActivity,
|
||||
downloaded: t.downloaded,
|
||||
uploaded: t.uploaded,
|
||||
availability: t.availability,
|
||||
priority: t.priority,
|
||||
seqDl: t.seqDl,
|
||||
superSeeding: t.superSeeding,
|
||||
forceStart: t.forceStart,
|
||||
timeActive: t.timeActive,
|
||||
private: t.private,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function json(res, data, code = 200) {
|
||||
const body = JSON.stringify(data);
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
async function readBody(req) {
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
if (!chunks.length) return {};
|
||||
try { return JSON.parse(Buffer.concat(chunks).toString()); } catch { return {}; }
|
||||
}
|
||||
|
||||
function byHash(hash) { return db.torrents.find((t) => t.hash === hash); }
|
||||
function findMany(hashes) {
|
||||
const set = new Set(hashes);
|
||||
return db.torrents.filter((t) => set.has(t.hash));
|
||||
}
|
||||
|
||||
// ---- action handlers (mutate the stub state) ----
|
||||
const ACTIONS = {
|
||||
pause: (t) => { if (!t.state.startsWith('paused')) { t.state = t.progress >= 1 ? 'pausedUP' : 'pausedDL'; t.dlspeed = 0; t.upspeed = 0; t.forceStart = false; } },
|
||||
resume: (t) => { t.state = t.progress >= 1 ? 'uploading' : 'downloading'; },
|
||||
forceStart: (t) => { t.forceStart = true; t.state = t.progress >= 1 ? 'forcedUP' : 'forcedDL'; },
|
||||
recheck: (t) => { t.state = t.progress >= 1 ? 'checkingUP' : 'checkingDL'; setTimeout(() => { t.state = t.progress >= 1 ? 'uploading' : 'downloading'; }, 3000); },
|
||||
reannounce: (t) => { t.lastActivity = Date.now(); },
|
||||
toggleSeqDl: (t) => { t.seqDl = !t.seqDl; },
|
||||
toggleSuperSeeding: (t) => { t.superSeeding = !t.superSeeding; },
|
||||
setCategory: (t, p) => { t.category = p.category ?? ''; },
|
||||
setSavePath: (t, p) => { if (p.savePath) { t.savePath = p.savePath; t.contentPath = `${p.savePath}/${t.name}`; } },
|
||||
addTags: (t, p) => { for (const tag of p.tags || []) if (!t.tags.includes(tag)) t.tags.push(tag); },
|
||||
removeTags: (t, p) => { t.tags = t.tags.filter((x) => !(p.tags || []).includes(x)); },
|
||||
setDownLimit: (t, p) => { t.downLimit = p.limit ?? 0; },
|
||||
setUpLimit: (t, p) => { t.upLimit = p.limit ?? 0; },
|
||||
setRatioLimit: (t, p) => { t.ratioLimit = p.limit ?? -1; },
|
||||
topPriority: (t) => { t.priority = 1; },
|
||||
bottomPriority: (t) => { t.priority = 99; },
|
||||
increasePriority: (t) => { t.priority = Math.max(1, (t.priority || 1) - 1); },
|
||||
decreasePriority: (t) => { t.priority = (t.priority || 1) + 1; },
|
||||
};
|
||||
|
||||
async function handleApi(req, res, url) {
|
||||
const parts = url.pathname.split('/').filter(Boolean); // ['api', ...]
|
||||
const seg = parts.slice(1); // drop 'api'
|
||||
|
||||
// GET /api/stream (SSE)
|
||||
if (seg[0] === 'stream') {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
});
|
||||
res.write('retry: 2000\n\n');
|
||||
res.write(`event: snapshot\ndata: ${JSON.stringify(snapshot())}\n\n`);
|
||||
sseClients.add(res);
|
||||
req.on('close', () => sseClients.delete(res));
|
||||
return;
|
||||
}
|
||||
|
||||
// GET /api/snapshot
|
||||
if (seg[0] === 'snapshot' && req.method === 'GET') return json(res, snapshot());
|
||||
|
||||
// GET /api/meta (sidebar data)
|
||||
if (seg[0] === 'meta' && req.method === 'GET') {
|
||||
return json(res, {
|
||||
categories: db.categories,
|
||||
tags: db.tags,
|
||||
trackers: trackerSummary(),
|
||||
preferences: db.preferences,
|
||||
searchPlugins: db.searchPlugins,
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/preferences / POST to update
|
||||
if (seg[0] === 'preferences') {
|
||||
if (req.method === 'GET') return json(res, db.preferences);
|
||||
if (req.method === 'POST') {
|
||||
Object.assign(db.preferences, await readBody(req));
|
||||
return json(res, db.preferences);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/altspeed (toggle alt speed)
|
||||
if (seg[0] === 'altspeed' && req.method === 'POST') {
|
||||
db.preferences.alt_speed_enabled = !db.preferences.alt_speed_enabled;
|
||||
return json(res, { alt_speed_enabled: db.preferences.alt_speed_enabled });
|
||||
}
|
||||
|
||||
// Categories: create (POST /api/categories) / delete (POST /api/categories/delete)
|
||||
if (seg[0] === 'categories' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
if (seg[1] === 'delete') {
|
||||
const name = (body.name || '').trim();
|
||||
if (name) {
|
||||
db.categories = db.categories.filter((c) => c.name !== name);
|
||||
for (const t of db.torrents) if (t.category === name) t.category = '';
|
||||
}
|
||||
return json(res, db.categories);
|
||||
}
|
||||
const name = (body.name || '').trim();
|
||||
if (name && !db.categories.some((c) => c.name === name)) {
|
||||
db.categories.push({ name, savePath: (body.savePath || `${db.preferences.save_path}/${name}`).trim() });
|
||||
} else if (name && body.savePath) {
|
||||
db.categories.find((c) => c.name === name).savePath = body.savePath.trim(); // edit existing
|
||||
}
|
||||
return json(res, db.categories);
|
||||
}
|
||||
|
||||
// Tags: create (POST /api/tags) / delete (POST /api/tags/delete)
|
||||
if (seg[0] === 'tags' && req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
if (seg[1] === 'delete') {
|
||||
const name = (body.name || '').trim();
|
||||
if (name) {
|
||||
db.tags = db.tags.filter((x) => x !== name);
|
||||
for (const t of db.torrents) t.tags = t.tags.filter((x) => x !== name);
|
||||
}
|
||||
return json(res, db.tags);
|
||||
}
|
||||
const name = (body.name || '').trim();
|
||||
if (name && !db.tags.includes(name)) db.tags.push(name);
|
||||
return json(res, db.tags);
|
||||
}
|
||||
|
||||
// /api/torrents ...
|
||||
if (seg[0] === 'torrents') {
|
||||
// GET /api/torrents/:hash/:tab
|
||||
if (req.method === 'GET' && seg[1]) {
|
||||
const t = byHash(seg[1]);
|
||||
if (!t) return json(res, { error: 'not found' }, 404);
|
||||
const tab = seg[2];
|
||||
if (tab === 'trackers') return json(res, t.trackers);
|
||||
if (tab === 'peers') return json(res, t.peersList);
|
||||
if (tab === 'files') return json(res, t.files);
|
||||
if (tab === 'pieces') return json(res, { pieceSize: t.pieceSize, pieceCount: t.pieceCount, pieces: t.pieces });
|
||||
// default: full general properties
|
||||
return json(res, t);
|
||||
}
|
||||
// GET /api/torrents -> full list (rarely needed; stream is primary)
|
||||
if (req.method === 'GET') return json(res, snapshot().torrents);
|
||||
}
|
||||
|
||||
// POST /api/action { action, hashes:[], params:{} }
|
||||
if (seg[0] === 'action' && req.method === 'POST') {
|
||||
const { action, hashes, params } = await readBody(req);
|
||||
const fn = ACTIONS[action];
|
||||
if (!fn) return json(res, { error: `unknown action: ${action}` }, 400);
|
||||
const targets = findMany(hashes || []);
|
||||
for (const t of targets) fn(t, params || {});
|
||||
return json(res, { ok: true, affected: targets.length });
|
||||
}
|
||||
|
||||
// DELETE via POST /api/delete { hashes:[], deleteFiles:bool }
|
||||
if (seg[0] === 'delete' && req.method === 'POST') {
|
||||
const { hashes } = await readBody(req);
|
||||
const set = new Set(hashes || []);
|
||||
const before = db.torrents.length;
|
||||
db.torrents = db.torrents.filter((t) => !set.has(t.hash));
|
||||
return json(res, { ok: true, removed: before - db.torrents.length });
|
||||
}
|
||||
|
||||
// POST /api/add { magnet | name+size+pieceCount+pieceSize+files, category, savePath, paused, skipCheck, seqDl }
|
||||
if (seg[0] === 'add' && req.method === 'POST') {
|
||||
const p = await readBody(req);
|
||||
const name = (p.magnet && decodeURIComponent((p.magnet.match(/dn=([^&]+)/) || [])[1] || '')) || p.name || 'new-torrent.iso';
|
||||
const GiB = 1024 * 1024 * 1024;
|
||||
const size = p.size || GiB;
|
||||
const pieceSize = p.pieceSize || 1024 * 1024;
|
||||
const pieceCount = Math.min(p.pieceCount || Math.max(1, Math.ceil(size / pieceSize)), 4000);
|
||||
const files = (Array.isArray(p.files) && p.files.length)
|
||||
? p.files.map((fl) => ({ name: fl.name, size: fl.size, progress: 0, priority: 1, availability: 1 }))
|
||||
: [{ name, size, progress: 0, priority: 1, availability: 1 }];
|
||||
const savePath = p.savePath || db.preferences.save_path;
|
||||
const hash = Math.random().toString(16).slice(2).padEnd(40, '0').slice(0, 40);
|
||||
db.torrents.unshift({
|
||||
hash, name, size, progress: 0,
|
||||
dlspeed: p.paused ? 0 : 256 * 1024, upspeed: 0, state: p.paused ? 'pausedDL' : (p.magnet ? 'metaDL' : 'downloading'),
|
||||
eta: 8640000, seeds: 4, seedsTotal: 40, peers: 2, peersTotal: 20, ratio: 0, ratioLimit: -1,
|
||||
category: p.category || '', tags: [], savePath,
|
||||
contentPath: `${savePath}/${name}`,
|
||||
addedOn: Date.now(), completionOn: -1, lastActivity: Date.now(), seenComplete: -1,
|
||||
downloaded: 0, uploaded: 0, downloadedSession: 0, uploadedSession: 0,
|
||||
availability: 1, priority: 1, seqDl: !!p.seqDl, superSeeding: false, autoTMM: true,
|
||||
forceStart: false, pieceSize, pieceCount, pieces: new Array(pieceCount).fill(0),
|
||||
downLimit: 0, upLimit: 0, timeActive: 0, comment: '', createdBy: 'uploaded .torrent',
|
||||
creationDate: Date.now(), private: false, magnetUri: p.magnet || '',
|
||||
files,
|
||||
trackers: [
|
||||
{ url: '** [DHT] **', tier: -1, status: 'working', seeds: 4, peers: 2, leeches: -1, downloaded: -1, message: '' },
|
||||
{ url: 'udp://tracker.opentrackr.org:1337/announce', tier: 0, status: 'working', seeds: 10, peers: 4, leeches: 4, downloaded: 0, message: '' },
|
||||
],
|
||||
peersList: [],
|
||||
});
|
||||
return json(res, { ok: true, hash });
|
||||
}
|
||||
|
||||
// RSS
|
||||
if (seg[0] === 'rss') {
|
||||
if (seg[1] === 'rules') return json(res, db.rssRules);
|
||||
return json(res, db.rssFeeds);
|
||||
}
|
||||
|
||||
// Search: GET /api/search?q=...
|
||||
if (seg[0] === 'search' && req.method === 'GET') {
|
||||
return json(res, db.runSearch(url.searchParams.get('q') || ''));
|
||||
}
|
||||
|
||||
return json(res, { error: 'not found' }, 404);
|
||||
}
|
||||
|
||||
function trackerSummary() {
|
||||
const map = new Map();
|
||||
for (const t of db.torrents) {
|
||||
for (const tr of t.trackers) {
|
||||
if (tr.tier === -1) continue; // skip DHT/PeX/LSD pseudo
|
||||
let host;
|
||||
try { host = new URL(tr.url).host; } catch { host = tr.url; }
|
||||
map.set(host, (map.get(host) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return [...map.entries()].map(([host, count]) => ({ host, count })).sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, url) {
|
||||
let pathname = decodeURIComponent(url.pathname);
|
||||
if (pathname === '/') pathname = '/index.html';
|
||||
const filePath = normalize(join(PUBLIC_DIR, pathname));
|
||||
if (!filePath.startsWith(PUBLIC_DIR)) { res.writeHead(403).end('forbidden'); return; }
|
||||
try {
|
||||
const data = await readFile(filePath);
|
||||
const type = MIME[extname(filePath)] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-cache' });
|
||||
res.end(data);
|
||||
} catch {
|
||||
// SPA fallback
|
||||
try {
|
||||
const data = await readFile(join(PUBLIC_DIR, 'index.html'));
|
||||
res.writeHead(200, { 'Content-Type': MIME['.html'] });
|
||||
res.end(data);
|
||||
} catch { res.writeHead(404).end('not found'); }
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
try {
|
||||
if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url);
|
||||
return await serveStatic(req, res, url);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
json(res, { error: 'internal error', detail: String(err) }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`\n torrent-ui stub server running`);
|
||||
console.log(` → http://localhost:${PORT}\n`);
|
||||
console.log(` ${db.torrents.length} mock torrents, live simulator ticking every 1s\n`);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue