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