Support Webshare guest downloads without login.

Credentials optional: empty WEBSHARE_* uses file_link without wst (free CDN). Verified against live API; docs and tests updated. Includes cancel→dequeue fix for queued downloads.
This commit is contained in:
2026-08-07 11:31:19 +02:00
parent 97b9215917
commit 798d7d7251
9 changed files with 259 additions and 88 deletions

View File

@@ -23,9 +23,14 @@ const STATE_FILE = path.join(SAVE_PATH, '.queue-state.json');
// hash → download entry (persisted to disk)
const downloads = new Map(); // hash → entry
const activeRequests = new Map(); // hash → request (destroy to abort)
const cancelled = new Set(); // hashes cancelled while in-flight (skip retry / error bookkeeping)
let activeDownloads = 0;
const waitingQueue = []; // { entry, webseedUrl }
function isCancelled(hash) {
return cancelled.has(hash);
}
function saveState() {
try {
const data = [...downloads.values()].map(e => ({
@@ -93,14 +98,31 @@ function cleanupAfterDownload(entry) {
}
function dequeue() {
if (activeDownloads >= MAX_CONCURRENT || waitingQueue.length === 0) return;
const { entry, webseedUrl } = waitingQueue.shift();
activeDownloads++;
entry.state = 'downloading';
while (activeDownloads < MAX_CONCURRENT && waitingQueue.length > 0) {
const { entry, webseedUrl } = waitingQueue.shift();
// Skip entries cancelled while sitting in the wait list
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
cancelled.delete(entry.hash);
continue;
}
activeDownloads++;
entry.state = 'downloading';
saveState();
startDownload(entry, webseedUrl);
}
}
function startDownload(entry, webseedUrl) {
(async () => {
try {
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
throw new Error('cancelled');
}
console.log(`[qbt] downloading ${entry.name} via ${webseedUrl.slice(0, 60)}…`);
await streamToFile(webseedUrl, entry);
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
throw new Error('cancelled');
}
// 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;
@@ -108,30 +130,43 @@ function dequeue() {
console.log(`[qbt] done: ${entry.name}`);
cleanupAfterDownload(entry);
} catch (err) {
// Delete partial file before retry
const msg = err && err.message ? err.message : String(err);
// Delete partial file before retry / after cancel
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);
if (isCancelled(entry.hash) || msg === 'cancelled') {
console.log(`[qbt] cancelled: ${entry.name}`);
} 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);
// "File temporarily unavailable" is Webshare's FATAL error — not actually retryable.
const retryable = msg.includes('stalled') || msg.includes('ECONNRESET')
|| msg.includes('HTTP 5');
if (retryable && downloads.has(entry.hash)) {
console.warn(`[qbt] will retry ${entry.name} in 2 min: ${msg}`);
entry.state = 'queuedDL';
saveState();
}, 3 * 60 * 1000);
setTimeout(() => {
if (!isCancelled(entry.hash) && downloads.has(entry.hash)) {
enqueue(entry, webseedUrl);
}
}, 2 * 60 * 1000);
} else if (downloads.has(entry.hash)) {
entry.state = 'error';
console.error(`[qbt] failed ${entry.name}: ${msg}`);
// 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 {
cancelled.delete(entry.hash);
activeRequests.delete(entry.hash);
activeDownloads--;
if (activeDownloads < 0) activeDownloads = 0;
saveState();
dequeue();
}
@@ -166,6 +201,11 @@ function streamToFile(url, entry) {
return reject(new Error(`HTTP ${res.statusCode}`));
}
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
res.resume();
return reject(new Error('cancelled'));
}
activeRequests.set(entry.hash, req);
entry.size = parseInt(res.headers['content-length'] || '0', 10);
@@ -185,6 +225,10 @@ function streamToFile(url, entry) {
// Poll every 10s — avoids setTimeout/clearTimeout on every chunk
const stallCheck = setInterval(() => {
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
res.destroy(new Error('cancelled'));
return;
}
if (Date.now() - lastDataAt > 60_000)
res.destroy(new Error('Download stalled (60s no data)'));
}, 10_000);
@@ -192,6 +236,10 @@ function streamToFile(url, entry) {
let speedAccum = 0;
let speedSampleAt = Date.now();
res.on('data', chunk => {
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
res.destroy(new Error('cancelled'));
return;
}
received += chunk.length;
lastDataAt = Date.now();
if (entry.size > 0) entry.progress = received / entry.size;
@@ -205,11 +253,25 @@ function streamToFile(url, entry) {
}
});
res.pipe(file);
const done = () => { clearInterval(stallCheck); activeRequests.delete(entry.hash); };
file.on('finish', () => { done(); file.close(resolve); });
const done = () => {
clearInterval(stallCheck);
if (activeRequests.get(entry.hash) === req) {
activeRequests.delete(entry.hash);
}
};
file.on('finish', () => {
done();
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
try { fs.unlinkSync(dest); } catch { /* ignore */ }
return reject(new Error('cancelled'));
}
file.close(resolve);
});
file.on('error', (e) => { done(); reject(e); });
res.on('error', (e) => { done(); reject(e); });
}).on('error', reject);
req.on('error', (e) => { done(); reject(e); });
});
req.on('error', reject);
};
follow(url, 0);
});
@@ -395,17 +457,11 @@ function register(app) {
// ── control ───────────────────────────────────────────────────────────
app.post('/api/v2/torrents/delete', (req, res) => {
const hashes = (req.body.hashes || '').split('|');
const deleteFiles = req.body.deleteFiles === 'true';
const hashes = (req.body.hashes || '').split('|').filter(Boolean);
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);
// Same path as UI cancel: abort stream, free concurrent slot, start next queued
cancelDownload(h);
}
saveState();
res.send('Ok.');
});
app.post('/api/v2/torrents/pause', (req, res) => res.send('Ok.'));
@@ -439,18 +495,56 @@ function enqueueDirect(ident, name, size) {
return true;
}
/**
* Cancel a download by hash (UI / Sonarr delete).
* Aborts any in-flight HTTP stream, drops waiting-queue entries, frees a concurrent
* slot (via the download task's finally), and starts the next queued item.
*/
function cancelDownload(hash) {
if (!hash) return false;
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);
const inWaiting = waitingQueue.findIndex(item => item.entry.hash === hash);
const hadActiveReq = activeRequests.has(hash);
const wasTracked = Boolean(entry) || inWaiting !== -1 || hadActiveReq;
if (!wasTracked) return false;
cancelled.add(hash);
// Drop from wait list (may be multiple if re-queued oddly — remove all)
for (let i = waitingQueue.length - 1; i >= 0; i--) {
if (waitingQueue[i].entry.hash === hash) waitingQueue.splice(i, 1);
}
// Abort HTTP stream so the download task rejects and its finally() runs:
// activeDownloads-- + dequeue() → next queued item starts.
const req = activeRequests.get(hash);
if (req) {
try {
req.destroy(new Error('cancelled'));
} catch { /* ignore */ }
activeRequests.delete(hash);
}
const wasInFlight = Boolean(entry && entry.state === 'downloading') || hadActiveReq;
if (entry) {
try { fs.unlinkSync(path.join(SAVE_PATH, entry.name)); } catch { /* ignore */ }
downloads.delete(hash);
console.log(`[qbt] cancel: ${entry.name} (${entry.state})`);
} else {
console.log(`[qbt] cancel: ${hash.slice(0, 12)}…`);
}
// Delete partial file
try { fs.unlinkSync(path.join(SAVE_PATH, entry.name)); } catch {}
downloads.delete(hash);
saveState();
// Waiting-only cancel never enters startDownload finally — clear flag now.
// In-flight cancel keeps the flag until the task's finally block.
if (!wasInFlight) cancelled.delete(hash);
// Waiting-only cancel does not free an active slot; still try dequeue in case
// capacity is free. Active cancel relies on startDownload finally → dequeue,
// but also nudge immediately in case the request was already idle/gone.
dequeue();
return true;
}
module.exports = { register, getQueue, enqueueDirect, cancelDownload };