Files
webshare-api/src/qbt.js
Michal Pemcak 3fcadfe973 Initial commit: webshare-api
Torznab + fake qBittorrent proxy for Webshare.cz, with Compose/Docker
packaging, MIT license, and tests.
2026-08-07 08:37:10 +02:00

402 lines
17 KiB
JavaScript

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 };