add web UI: search, download queue with progress, cancel

- ui/index.html, ui/styles.css, ui/scripts.js — minimal two-tab UI
- Search tab: big search bar, results with size/votes, add-to-queue button
- Queue tab: progress bar, speed, downloaded/total, status badges, cancel
- Switches to queue tab on load if downloads are active
- Backend: GET /api/search, POST /api/queue/add, GET /api/queue, POST /api/queue/cancel
- Speed tracking in streamToFile, abort tracking for immediate cancel
- No rounded corners, dark theme
This commit is contained in:
2026-08-07 10:44:41 +02:00
parent a840fee91a
commit 7b739607ff
7 changed files with 595 additions and 4 deletions

View File

@@ -113,6 +113,46 @@ app.get('/resolve/:ident', async (req, res) => {
}
});
// ── UI static files ────────────────────────────────────────────────────────
app.use(express.static('ui'));
// ── UI API: JSON search ───────────────────────────────────────────────────
app.get('/api/search', async (req, res) => {
const q = (req.query.q || '').trim();
if (!q) return res.json([]);
try {
const results = await client.search(q, {
limit: Math.min(parseInt(req.query.limit) || 30, 100),
offset: parseInt(req.query.offset) || 0,
});
res.json(results);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── UI API: add download to queue ─────────────────────────────────────────
app.post('/api/queue/add', (req, res) => {
const { ident, name, size } = req.body || {};
if (!ident) return res.status(400).json({ error: 'ident required' });
const ok = qbt.enqueueDirect(ident, name || ident, parseInt(size) || 0);
if (!ok) return res.status(409).json({ error: 'already in queue' });
res.json({ ok: true });
});
// ── UI API: queue status ──────────────────────────────────────────────────
app.get('/api/queue', (req, res) => {
res.json(qbt.getQueue());
});
// ── UI API: cancel download ───────────────────────────────────────────────
app.post('/api/queue/cancel', (req, res) => {
const { hash } = req.body || {};
if (!hash) return res.status(400).json({ error: 'hash required' });
qbt.cancelDownload(hash);
res.json({ ok: true });
});
// ── Fake qBittorrent Web API ──────────────────────────────────────────────
qbt.register(app);

View File

@@ -10,11 +10,19 @@ const SAVE_PATH = process.env.DOWNLOAD_PATH || '/downloads/webshare';
const MEDIA_ROOT = process.env.MEDIA_ROOT || '/data';
const upload = multer({ storage: multer.memoryStorage() });
function formatSpeed(bps) {
if (bps >= 1e9) return (bps / 1e9).toFixed(1) + ' GB/s';
if (bps >= 1e6) return (bps / 1e6).toFixed(1) + ' MB/s';
if (bps >= 1e3) return (bps / 1e3).toFixed(0) + ' KB/s';
return bps.toFixed(0) + ' B/s';
}
const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT_DOWNLOADS || '2', 10);
const STATE_FILE = path.join(SAVE_PATH, '.queue-state.json');
// hash → download entry (persisted to disk)
const downloads = new Map();
const downloads = new Map(); // hash → entry
const activeRequests = new Map(); // hash → request (destroy to abort)
let activeDownloads = 0;
const waitingQueue = []; // { entry, webseedUrl }
@@ -148,7 +156,7 @@ function streamToFile(url, entry) {
const follow = (u, hops) => {
if (hops > 10) return reject(new Error('Too many redirects'));
const mod = u.startsWith('https') ? https : http;
mod.get(u, res => {
const req = mod.get(u, res => {
if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
res.resume();
return follow(res.headers.location, hops + 1);
@@ -158,6 +166,8 @@ function streamToFile(url, entry) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
activeRequests.set(entry.hash, req);
entry.size = parseInt(res.headers['content-length'] || '0', 10);
// try to grab filename from Content-Disposition
@@ -179,13 +189,23 @@ function streamToFile(url, entry) {
res.destroy(new Error('Download stalled (60s no data)'));
}, 10_000);
let speedAccum = 0;
let speedSampleAt = Date.now();
res.on('data', chunk => {
received += chunk.length;
lastDataAt = Date.now();
if (entry.size > 0) entry.progress = received / entry.size;
speedAccum += chunk.length;
const elapsed = Date.now() - speedSampleAt;
if (elapsed >= 2000) {
const bps = speedAccum / (elapsed / 1000);
entry.speed = formatSpeed(bps);
speedAccum = 0;
speedSampleAt = Date.now();
}
});
res.pipe(file);
const done = () => clearInterval(stallCheck);
const done = () => { clearInterval(stallCheck); activeRequests.delete(entry.hash); };
file.on('finish', () => { done(); file.close(resolve); });
file.on('error', (e) => { done(); reject(e); });
res.on('error', (e) => { done(); reject(e); });
@@ -398,4 +418,39 @@ function register(app) {
app.post('/api/v2/torrents/removeCategories', (req, res) => res.send('Ok.'));
}
module.exports = { register };
function getQueue() {
return [...downloads.values()].map(e => ({
hash: e.hash,
name: e.name,
size: e.size,
progress: e.progress,
state: e.state,
speed: e.speed || '',
}));
}
function enqueueDirect(ident, name, size) {
const hash = identToHash(ident);
if (downloads.has(hash)) return false;
const webseedUrl = `${process.env.BASE_URL || `http://localhost:${process.env.PORT || 3001}`}/stream/${ident}`;
const entry = { hash, name, size, progress: 0, state: 'downloading', category: '', speed: '' };
downloads.set(hash, entry);
enqueue(entry, webseedUrl);
return true;
}
function cancelDownload(hash) {
const entry = downloads.get(hash);
if (!entry) return;
// Remove from waiting queue
const wqIdx = waitingQueue.findIndex(item => item.entry.hash === hash);
if (wqIdx !== -1) {
waitingQueue.splice(wqIdx, 1);
}
// Delete partial file
try { fs.unlinkSync(path.join(SAVE_PATH, entry.name)); } catch {}
downloads.delete(hash);
saveState();
}
module.exports = { register, getQueue, enqueueDirect, cancelDownload };