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>
342 lines
13 KiB
JavaScript
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`);
|
|
});
|