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:
40
src/index.js
40
src/index.js
@@ -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);
|
||||
|
||||
|
||||
63
src/qbt.js
63
src/qbt.js
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user