diff --git a/Dockerfile b/Dockerfile index 028dee5..b2752ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,5 +3,6 @@ WORKDIR /app COPY package.json package-lock.json ./ RUN npm ci --omit=dev COPY src/ ./src/ +COPY ui/ ./ui/ EXPOSE 3001 CMD ["node", "src/index.js"] diff --git a/README.md b/README.md index 297d9a6..e83144c 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Official Webshare HTTP API documentation: [https://webshare.cz/apidoc/](https:// | **Stream** | `GET /stream/:ident` | Fresh Webshare `file_link` + HTTP 302 to CDN | | **Fake qBittorrent** | `/api/v2/*` | Download client API; streams the HTTPS file to disk | | **Debug** | `GET /resolve/:ident` | JSON with the resolved CDN URL | +| **Web UI** | `GET /` | Two-tab UI: search files and add to queue; queue view with progress, speed, and cancel | | **MCP server** | `POST /mcp` (Streamable HTTP) | Tools for AI agents: search, resolve links, login check | Webshare download links expire after roughly ten minutes, so the torrent never embeds a CDN URL—only a path on this service that resolves a fresh link at fetch time. @@ -28,6 +29,7 @@ src/ mcp-server.js # MCP tool registration (shared) mcp-http.js # Streamable HTTP transport mount for Express mcp.js # Optional MCP-only HTTP process + ui/ # Web UI: search & queue management __tests__/ compose.yaml Dockerfile diff --git a/src/index.js b/src/index.js index 2b596e4..b22ad60 100644 --- a/src/index.js +++ b/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); diff --git a/src/qbt.js b/src/qbt.js index c1f4f5b..e3cbe73 100644 --- a/src/qbt.js +++ b/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 }; diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..5f82d62 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,30 @@ + + + + + +Webshare Queue + + + + +
+ + +
+ + + +
+
+
+ + + + diff --git a/ui/scripts.js b/ui/scripts.js new file mode 100644 index 0000000..89c4a6d --- /dev/null +++ b/ui/scripts.js @@ -0,0 +1,201 @@ +const API_BASE = window.location.origin; +let queuePoll = null; + +// ── Tabs ──────────────────────────────────────────────────────────────────── + +function startQueuePoll() { + stopQueuePoll(); + queuePoll = setInterval(renderQueue, 2000); +} + +function stopQueuePoll() { + if (queuePoll) { clearInterval(queuePoll); queuePoll = null; } +} + +function switchTab(name) { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + document.querySelector(`.tab[data-tab="${name}"]`).classList.add('active'); + document.getElementById('tab-' + name).classList.add('active'); + if (name === 'queue') { renderQueue(); startQueuePoll(); } + else stopQueuePoll(); +} + +document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => switchTab(tab.dataset.tab)); +}); + +// ── Search ────────────────────────────────────────────────────────────────── + +const searchInput = document.getElementById('searchInput'); +const searchBtn = document.getElementById('searchBtn'); +const searchResults = document.getElementById('searchResults'); + +async function doSearch() { + const q = searchInput.value.trim(); + if (!q) return; + searchResults.innerHTML = '
Searching...
'; + try { + const res = await fetch(`${API_BASE}/api/search?q=${encodeURIComponent(q)}&limit=30`); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + renderSearchResults(data); + } catch (err) { + searchResults.innerHTML = `
Search failed: ${err.message}
`; + } +} + +searchBtn.addEventListener('click', doSearch); +searchInput.addEventListener('keydown', e => { if (e.key === 'Enter') doSearch(); }); + +function formatSize(bytes) { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let i = 0; + let size = bytes; + while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } + return size.toFixed(i > 0 ? 1 : 0) + ' ' + units[i]; +} + +function renderSearchResults(files) { + if (!files || files.length === 0) { + searchResults.innerHTML = '
No results found.
'; + return; + } + searchResults.innerHTML = files.map(f => { + const added = Boolean(addedIdents[f.ident]); + return ` +
+
+
${escapeHtml(f.name)}
+
${formatSize(f.size)} · votes: ${f.votes}
+
+ +
+ `; + }).join(''); + + searchResults.querySelectorAll('button:not(.added)').forEach(btn => { + btn.addEventListener('click', async () => { + const ident = btn.dataset.ident; + const name = btn.dataset.name; + const size = parseInt(btn.dataset.size); + btn.disabled = true; + btn.textContent = 'Adding...'; + try { + await addDownload(ident, name, size); + btn.classList.add('added'); + btn.textContent = 'Added'; + } catch (err) { + btn.textContent = 'Failed'; + setTimeout(() => { btn.disabled = false; btn.textContent = 'Add to Queue'; }, 2000); + } + }); + }); +} + +// ── Add download via new API route ────────────────────────────────────────── + +const addedIdents = {}; + +async function addDownload(ident, name, size) { + const res = await fetch(`${API_BASE}/api/queue/add`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ident, name, size }), + }); + if (!res.ok) throw new Error(await res.text()); + addedIdents[ident] = true; +} + +// ── Queue ─────────────────────────────────────────────────────────────────── + +const queueList = document.getElementById('queueList'); + +async function fetchQueue() { + const res = await fetch(`${API_BASE}/api/queue`); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +function renderQueue() { + fetchQueue().then(entries => { + if (!entries || entries.length === 0) { + queueList.innerHTML = '
Queue is empty.
'; + return; + } + queueList.innerHTML = entries.map(e => { + const pct = Math.round((e.progress || 0) * 100); + const downloaded = formatSize(e.progress * e.size); + const total = formatSize(e.size); + let fillClass = ''; + if (e.state === 'uploading') fillClass = 'done'; + else if (e.state === 'error') fillClass = 'error'; + else if (e.state === 'queuedDL') fillClass = 'queued'; + return ` +
+
+ ${e.state} + ${escapeHtml(e.name)} + +
+
+ ${downloaded} / ${total} + ${pct}% +
+
${e.speed || ''}
+
+
+
+
+ `; + }).join(''); + + // Attach cancel handlers + queueList.querySelectorAll('.cancel-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const hash = btn.dataset.hash; + btn.disabled = true; + btn.textContent = '...'; + try { + await fetch(`${API_BASE}/api/queue/cancel`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hash }), + }); + } catch {} + renderQueue(); + }); + }); + }).catch(err => { + queueList.innerHTML = `
Failed to load queue: ${err.message}
`; + }); +} + +// On load, switch to queue tab if there are active downloads +fetchQueue().then(entries => { + if (entries && entries.length > 0) switchTab('queue'); +}); + +// Stop polling when tab hidden +document.addEventListener('visibilitychange', () => { + if (document.hidden) stopQueuePoll(); + else { + const active = document.querySelector('.tab.active'); + if (active && active.dataset.tab === 'queue') startQueuePoll(); + } +}); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function escapeHtml(str) { + const d = document.createElement('div'); + d.textContent = str; + return d.innerHTML; +} + +function escapeAttr(str) { + return str.replace(/"/g, '"').replace(/'/g, '''); +} diff --git a/ui/styles.css b/ui/styles.css new file mode 100644 index 0000000..8a2f059 --- /dev/null +++ b/ui/styles.css @@ -0,0 +1,262 @@ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root { + --bg: #0d1117; + --surface: #161b22; + --surface-hover: #1c2333; + --border: #30363d; + --text: #e6edf3; + --text-dim: #8b949e; + --accent: #58a6ff; + --accent-hover: #79b8ff; + --green: #3fb950; + --orange: #d29922; + --red: #f85149; + --radius: 0px; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--text); + max-width: 860px; + margin: 0 auto; + padding: 24px 16px 80px; +} + +/* ── Tabs ─────────────────────────────────────────────── */ + +.tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--border); + margin-bottom: 24px; +} + +.tab { + background: none; + border: none; + color: var(--text-dim); + font-size: 14px; + font-weight: 500; + padding: 10px 20px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} + +.tab:hover { color: var(--text); } + +.tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.tab-content { display: none; } +.tab-content.active { display: block; } + +/* ── Search bar ───────────────────────────────────────── */ + +.search-bar { + display: flex; + gap: 8px; + margin-bottom: 20px; +} + +.search-bar input { + flex: 1; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-size: 16px; + padding: 12px 16px; + outline: none; + transition: border-color 0.15s; +} + +.search-bar input:focus { border-color: var(--accent); } +.search-bar input::placeholder { color: var(--text-dim); } + +.search-bar button { + background: var(--accent); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 14px; + font-weight: 600; + padding: 0 20px; + cursor: pointer; + transition: background 0.15s; +} + +.search-bar button:hover { background: var(--accent-hover); } + +/* ── Result items ─────────────────────────────────────── */ + +.results { display: flex; flex-direction: column; gap: 8px; } + +.result-item { + display: flex; + align-items: center; + gap: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 16px; + transition: border-color 0.15s; +} + +.result-item:hover { border-color: var(--text-dim); } + +.result-info { + flex: 1; + min-width: 0; +} + +.result-name { + font-size: 14px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.result-meta { + font-size: 12px; + color: var(--text-dim); + margin-top: 2px; +} + +.result-item button { + background: var(--green); + color: #fff; + border: none; + border-radius: var(--radius); + font-size: 13px; + font-weight: 600; + padding: 6px 14px; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s; +} + +.result-item button:hover { filter: brightness(1.15); } + +.result-item button.added { + background: var(--text-dim); + cursor: default; +} + +/* ── Queue items ──────────────────────────────────────── */ + +.queue-item { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 16px; +} + +.queue-name { + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.queue-name .badge { flex-shrink: 0; } + +.queue-name > span:last-child { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cancel-btn { + flex-shrink: 0; + margin-left: auto; + background: var(--red); + color: #fff; + border: none; + font-size: 11px; + font-weight: 600; + padding: 4px 10px; + cursor: pointer; + transition: filter 0.15s; +} + +.cancel-btn:hover { filter: brightness(1.2); } +.cancel-btn:disabled { opacity: 0.5; cursor: default; } + +.queue-stats { + display: flex; + justify-content: space-between; + font-size: 12px; + color: var(--text-dim); + margin-bottom: 6px; +} + +.queue-speed { + font-size: 12px; + color: var(--text-dim); + margin-bottom: 8px; +} + +/* ── Progress bar ─────────────────────────────────────── */ + +.progress-bar { + height: 6px; + background: var(--border); + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: var(--accent); + transition: width 0.5s ease; +} + +.progress-fill.done { background: var(--green); } +.progress-fill.error { background: var(--red); } +.progress-fill.queued { background: var(--orange); } + +/* ── Status badges ────────────────────────────────────── */ + +.badge { + display: inline-block; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 0; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.badge.downloading { background: #1f3a5f; color: var(--accent); } +.badge.uploading { background: #1a3a1a; color: var(--green); } +.badge.queuedDL { background: #3a2e1a; color: var(--orange); } +.badge.error { background: #3a1a1a; color: var(--red); } + +/* ── Empty state ──────────────────────────────────────── */ + +.empty { + text-align: center; + color: var(--text-dim); + padding: 40px 0; + font-size: 14px; +} + +.spinner { + text-align: center; + color: var(--text-dim); + padding: 20px 0; + font-size: 13px; +}