const https = require('https'); const http = require('http'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); const { extractWebseed, extractInfoHash } = require('./torrent'); const SAVE_PATH = process.env.DOWNLOAD_PATH || '/downloads/webshare'; const MEDIA_ROOT = process.env.MEDIA_ROOT || '/data'; const upload = multer({ storage: multer.memoryStorage() }); 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(); let activeDownloads = 0; const waitingQueue = []; // { entry, webseedUrl } function saveState() { try { const data = [...downloads.values()].map(e => ({ hash: e.hash, name: e.name, size: e.size, progress: e.progress, state: e.state, category: e.category, webseedUrl: e.webseedUrl, })); fs.writeFileSync(STATE_FILE, JSON.stringify(data)); } catch { } } function loadState() { try { const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); for (const e of data) { if (e.state === 'downloading' || e.state === 'queuedDL') { // Always re-download — partial files from interrupted downloads are corrupt const filePath = path.join(SAVE_PATH, e.name); try { fs.unlinkSync(filePath); } catch { } if (e.webseedUrl) { e.progress = 0; downloads.set(e.hash, e); enqueue(e, e.webseedUrl); console.log(`[qbt] re-queued: ${e.name}`); } } else if (e.state === 'uploading') { const filePath = path.join(SAVE_PATH, e.name); if (fs.existsSync(filePath)) { // Sync size to actual file — Content-Length may have been wrong try { e.size = fs.statSync(filePath).size; } catch {} downloads.set(e.hash, e); console.log(`[qbt] restored uploading: ${e.name}`); } // File gone → already imported and cleaned up; drop entry. // Sonarr tracks hasFile=true so it won't re-search. } else if (e.state === 'error') { // Keep as error — don't auto-retry on restart; Sonarr will re-search if needed. downloads.set(e.hash, e); setTimeout(() => { downloads.delete(e.hash); saveState(); }, 3 * 60 * 1000); } else { downloads.set(e.hash, e); } } } catch { } } function cleanupAfterDownload(entry) { const fpath = path.join(SAVE_PATH, entry.name); let attempts = 0; const check = setInterval(() => { attempts++; try { if (fs.statSync(fpath).nlink > 1) { fs.unlinkSync(fpath); console.log(`[cleanup] auto-deleted after import: ${entry.name}`); clearInterval(check); } else if (attempts >= 60) { clearInterval(check); } } catch { clearInterval(check); } }, 10_000); } function dequeue() { if (activeDownloads >= MAX_CONCURRENT || waitingQueue.length === 0) return; const { entry, webseedUrl } = waitingQueue.shift(); activeDownloads++; entry.state = 'downloading'; (async () => { try { console.log(`[qbt] downloading ${entry.name} via ${webseedUrl.slice(0, 60)}…`); await streamToFile(webseedUrl, entry); // Use actual file size — Content-Length header can lie try { entry.size = fs.statSync(path.join(SAVE_PATH, entry.name)).size; } catch {} entry.progress = 1.0; entry.state = 'uploading'; console.log(`[qbt] done: ${entry.name}`); cleanupAfterDownload(entry); } catch (err) { // Delete partial file before retry try { fs.unlinkSync(path.join(SAVE_PATH, entry.name)); } catch { } // "File temporarily unavailable" is Webshare's FATAL error — not actually retryable. const retryable = err.message.includes('stalled') || err.message.includes('ECONNRESET') || err.message.includes('HTTP 5'); if (retryable) { console.warn(`[qbt] will retry ${entry.name} in 2 min: ${err.message}`); entry.state = 'queuedDL'; saveState(); setTimeout(() => enqueue(entry, webseedUrl), 2 * 60 * 1000); } else { entry.state = 'error'; console.error(`[qbt] failed ${entry.name}: ${err.message}`); // Sonarr treats qBittorrent's "error" state as an advisory warning, not // a failure — it never auto-blocklists/redownloads on its own. Removing // the entry makes the download disappear from the client's list, which // Sonarr does treat as failed (triggering its existing auto-redownload). setTimeout(() => { downloads.delete(entry.hash); saveState(); }, 3 * 60 * 1000); } } finally { activeDownloads--; saveState(); dequeue(); } })(); } function enqueue(entry, webseedUrl) { entry.state = 'queuedDL'; entry.webseedUrl = webseedUrl; waitingQueue.push({ entry, webseedUrl }); console.log(`[qbt] queued (${waitingQueue.length} waiting, ${activeDownloads}/${MAX_CONCURRENT} active): ${entry.name}`); saveState(); dequeue(); } function identToHash(ident) { return crypto.createHash('sha1').update('ws:' + ident).digest('hex'); } function streamToFile(url, entry) { return new Promise((resolve, reject) => { 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 => { if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) { res.resume(); return follow(res.headers.location, hops + 1); } if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); } entry.size = parseInt(res.headers['content-length'] || '0', 10); // try to grab filename from Content-Disposition const cd = res.headers['content-disposition'] || ''; const m = cd.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)/i); if (m) { const fn = decodeURIComponent(m[1].trim().replace(/^["']|["']$/g, '')); if (fn) entry.name = fn; } let received = 0; let lastDataAt = Date.now(); const dest = path.join(SAVE_PATH, entry.name); const file = fs.createWriteStream(dest); // Poll every 10s — avoids setTimeout/clearTimeout on every chunk const stallCheck = setInterval(() => { if (Date.now() - lastDataAt > 60_000) res.destroy(new Error('Download stalled (60s no data)')); }, 10_000); res.on('data', chunk => { received += chunk.length; lastDataAt = Date.now(); if (entry.size > 0) entry.progress = received / entry.size; }); res.pipe(file); const done = () => clearInterval(stallCheck); file.on('finish', () => { done(); file.close(resolve); }); file.on('error', (e) => { done(); reject(e); }); res.on('error', (e) => { done(); reject(e); }); }).on('error', reject); }; follow(url, 0); }); } function register(app) { fs.mkdirSync(SAVE_PATH, { recursive: true }); // ── auth ────────────────────────────────────────────────────────────── app.post('/api/v2/auth/login', (req, res) => { res.cookie('SID', 'webshare-session', { httpOnly: true }); res.send('Ok.'); }); app.get('/api/v2/auth/logout', (req, res) => res.send('Ok.')); // ── app info ────────────────────────────────────────────────────────── app.get('/api/v2/app/version', (req, res) => res.send('5.0.0')); app.get('/api/v2/app/webapiVersion', (req, res) => res.send('2.8.3')); app.get('/api/v2/app/buildInfo', (req, res) => res.json({ bitness: 64, boost: '1.84.0', libtorrent: '2.0.10', openssl: '3.2.1', qt: '6.6.2', zlib: '1.3.1', })); app.get('/api/v2/app/preferences', (req, res) => res.json({ save_path: SAVE_PATH + '/', temp_path_enabled: false, create_subfolder_enabled: false, incomplete_files_ext: false, })); // ── torrent list ────────────────────────────────────────────────────── app.get('/api/v2/torrents/info', (req, res) => { const list = [...downloads.values()].map(d => ({ hash: d.hash, name: d.name, state: d.state, progress: d.progress, size: d.size, downloaded: Math.floor(d.progress * d.size), save_path: SAVE_PATH + '/', content_path: path.join(SAVE_PATH, d.name), num_seeds: 0, num_leechs: 0, ratio: 0, eta: (d.state === 'downloading' || d.state === 'queuedDL') ? 9999 : 0, category: d.category || '', tags: '', })); res.json(list); }); app.get('/api/v2/torrents/properties', (req, res) => { const d = downloads.get(req.query.hash); if (!d) return res.status(404).json({}); res.json({ save_path: SAVE_PATH + '/', hash: d.hash }); }); // Sonarr calls this to discover the file list inside a torrent. // Without it Sonarr falls back to directory-scan mode and reports // "No files found are eligible for import in /path/to/file.mkv". app.get('/api/v2/torrents/files', (req, res) => { const d = downloads.get(req.query.hash); if (!d) return res.status(404).json({}); res.json([{ name: d.name, size: d.size, progress: d.progress, priority: 1, is_seed: d.state === 'uploading', piece_range: [0, 0], availability: d.state === 'uploading' ? 1 : -1, }]); }); app.get('/api/v2/torrents/categories', (req, res) => res.json({ webshare: { name: 'webshare', savePath: SAVE_PATH + '/' }, })); // ── add download — accepts both url list AND .torrent file upload ───── app.post('/api/v2/torrents/add', upload.fields([{ name: 'torrents' }]), (req, res) => { res.send('Ok.'); const body = req.body || {}; const category = body.category || ''; // Case 1: Sonarr sends a .torrent file (multipart upload) const files = req.files && req.files['torrents']; if (files && files.length) { for (const f of files) { const webseedUrl = extractWebseed(f.buffer); const hash = extractInfoHash(f.buffer) || crypto.createHash('sha1').update(f.buffer).digest('hex'); if (!webseedUrl) { // Real multi-file torrent — we can't download it. // Record as 'error' so Sonarr stops re-adding this release. if (!downloads.has(hash)) { const name = f.originalname.replace(/\.torrent$/i, '') || hash; downloads.set(hash, { hash, name, state: 'error', progress: 0, size: 0, category }); saveState(); console.warn(`[qbt] no url-list, recording as error: ${name}`); } continue; } // If we already have this webseed URL under any hash (e.g. old sha1(url) hash), // re-map it to the correct info hash so Sonarr can track it. const existingByUrl = [...downloads.values()] .find(e => e.webseedUrl === webseedUrl); if (existingByUrl) { if (existingByUrl.hash !== hash) { downloads.delete(existingByUrl.hash); existingByUrl.hash = hash; downloads.set(hash, existingByUrl); saveState(); console.log(`[qbt] re-hashed existing entry: ${existingByUrl.name}`); } continue; } if (downloads.has(hash)) continue; const name = f.originalname.replace(/\.torrent$/i, '') || hash; const entry = { hash, name, state: 'downloading', progress: 0, size: 0, category }; downloads.set(hash, entry); console.log(`[qbt] queued via .torrent: ${name}`); enqueue(entry, webseedUrl); } return; } // Case 2: url list (our old path, kept as fallback) const urls = (body.urls || '').trim().split(/\s+/).filter(Boolean); for (const url of urls) { const m = url.match(/\/(?:download|stream)\/([^/?&#]+)/); const ident = m ? m[1] : null; const hash = crypto.createHash('sha1').update(url).digest('hex'); if (downloads.has(hash)) continue; const entry = { hash, name: (ident || hash), state: 'downloading', progress: 0, size: 0, category }; downloads.set(hash, entry); console.log(`[qbt] queued via url: ${url}`); enqueue(entry, url); } }); // ── cleanup: delete staging files that have been imported or are stale ─── // Fast path: Sonarr hardlinks → nlink > 1 → delete immediately. // Fallback: Sonarr copies instead of hardlinks (nlink stays 1) → delete // any completed file whose mtime is older than 2 hours. Never touch files // that are actively downloading or queued. function cleanupStaging() { let staged; try { staged = fs.readdirSync(SAVE_PATH); } catch { return; } const now = Date.now(); const TWO_HOURS = 5 * 60 * 1000; const activeFiles = new Set( [...downloads.values()] .filter(e => e.state === 'downloading' || e.state === 'queuedDL') .map(e => e.name) ); for (const fname of staged) { if (fname.startsWith('.')) continue; if (activeFiles.has(fname)) continue; const fpath = path.join(SAVE_PATH, fname); try { const st = fs.statSync(fpath); if (!st.isFile()) continue; if (st.nlink > 1) { fs.unlinkSync(fpath); console.log(`[cleanup] deleted after hardlink: ${fname}`); } else if (now - st.mtimeMs > TWO_HOURS) { fs.unlinkSync(fpath); console.log(`[cleanup] deleted stale staging file (>2h): ${fname}`); } } catch { } } } // Load persisted state on startup, then run cleanup loadState(); cleanupStaging(); setInterval(cleanupStaging, 15 * 60 * 1000); // ── control ─────────────────────────────────────────────────────────── app.post('/api/v2/torrents/delete', (req, res) => { const hashes = (req.body.hashes || '').split('|'); const deleteFiles = req.body.deleteFiles === 'true'; for (const h of hashes) { const entry = downloads.get(h); if (entry && deleteFiles) { const fpath = path.join(SAVE_PATH, entry.name); try { fs.unlinkSync(fpath); console.log(`[qbt] deleted file: ${entry.name}`); } catch { } } downloads.delete(h); } saveState(); res.send('Ok.'); }); app.post('/api/v2/torrents/pause', (req, res) => res.send('Ok.')); app.post('/api/v2/torrents/resume', (req, res) => res.send('Ok.')); app.post('/api/v2/torrents/setCategory', (req, res) => res.send('Ok.')); app.post('/api/v2/torrents/setLocation', (req, res) => res.send('Ok.')); app.post('/api/v2/torrents/rename', (req, res) => res.send('Ok.')); app.post('/api/v2/torrents/createCategory', (req, res) => res.send('Ok.')); app.post('/api/v2/torrents/editCategory', (req, res) => res.send('Ok.')); app.post('/api/v2/torrents/removeCategories', (req, res) => res.send('Ok.')); } module.exports = { register };