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:
15
src/index.js
15
src/index.js
@@ -9,23 +9,22 @@ const { registerMcpHttp } = require('./mcp-http');
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
|
||||
const USERNAME = process.env.WEBSHARE_USERNAME;
|
||||
const PASSWORD = process.env.WEBSHARE_PASSWORD;
|
||||
const USERNAME = process.env.WEBSHARE_USERNAME || '';
|
||||
const PASSWORD = process.env.WEBSHARE_PASSWORD || '';
|
||||
const MCP_PATH = process.env.MCP_PATH || '/mcp';
|
||||
const MCP_ENABLED = process.env.MCP_ENABLED !== '0' && process.env.MCP_ENABLED !== 'false';
|
||||
|
||||
if (!USERNAME || !PASSWORD) {
|
||||
console.error('WEBSHARE_USERNAME and WEBSHARE_PASSWORD must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const client = new WebshareClient(USERNAME, PASSWORD);
|
||||
const app = express();
|
||||
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(express.json({ limit: '4mb' }));
|
||||
|
||||
client.ensureAuth().catch(err => console.error('Initial auth failed:', err.message));
|
||||
if (client.guest) {
|
||||
console.log('Webshare: guest mode (no WEBSHARE_USERNAME/PASSWORD — free tier, slower downloads)');
|
||||
} else {
|
||||
client.ensureAuth().catch(err => console.error('Initial auth failed:', err.message));
|
||||
}
|
||||
|
||||
// ── Torznab indexer ───────────────────────────────────────────────────────
|
||||
app.get('/api', async (req, res) => {
|
||||
|
||||
@@ -16,10 +16,11 @@ function formatSize(bytes) {
|
||||
|
||||
/**
|
||||
* Build an MCP server that exposes Webshare search and link resolution tools.
|
||||
* @param {{ username: string, password: string }} credentials
|
||||
* @param {{ username?: string, password?: string }} credentials
|
||||
* Omit both for guest/free mode (slower downloads, no login).
|
||||
*/
|
||||
function createWebshareMcpServer({ username, password }) {
|
||||
const client = new WebshareClient(username, password);
|
||||
function createWebshareMcpServer({ username, password } = {}) {
|
||||
const client = new WebshareClient(username || '', password || '');
|
||||
const server = new McpServer({
|
||||
name: 'webshare-api',
|
||||
version: '1.0.0',
|
||||
@@ -93,7 +94,7 @@ function createWebshareMcpServer({ username, password }) {
|
||||
{
|
||||
title: 'Resolve Webshare download link',
|
||||
description:
|
||||
'Resolve a temporary HTTPS CDN download/stream URL for a Webshare file ident. Links expire after a short time (~10 minutes). Requires an authenticated Webshare account.',
|
||||
'Resolve a temporary HTTPS CDN download/stream URL for a Webshare file ident. Links expire after a short time (~10 minutes). Works with an authenticated account (faster) or guest/free mode without login (slower).',
|
||||
inputSchema: {
|
||||
ident: z
|
||||
.string()
|
||||
@@ -127,21 +128,30 @@ function createWebshareMcpServer({ username, password }) {
|
||||
{
|
||||
title: 'Check Webshare authentication',
|
||||
description:
|
||||
'Verify that WEBSHARE_USERNAME / WEBSHARE_PASSWORD work by performing a login against the Webshare API.',
|
||||
'Verify Webshare credentials when configured, or report guest/free mode when WEBSHARE_USERNAME / WEBSHARE_PASSWORD are unset.',
|
||||
inputSchema: {},
|
||||
},
|
||||
async () => {
|
||||
try {
|
||||
if (client.guest) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'Webshare guest mode (no credentials). Search and downloads work without login; free-tier CDN is typically slower.',
|
||||
}],
|
||||
structuredContent: { ok: true, guest: true },
|
||||
};
|
||||
}
|
||||
await client.ensureAuth();
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Webshare authentication OK.' }],
|
||||
structuredContent: { ok: true },
|
||||
structuredContent: { ok: true, guest: false },
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isError: true,
|
||||
content: [{ type: 'text', text: `Authentication failed: ${err.message}` }],
|
||||
structuredContent: { ok: false, error: err.message },
|
||||
structuredContent: { ok: false, guest: false, error: err.message },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
14
src/mcp.js
14
src/mcp.js
@@ -13,19 +13,14 @@ const { registerMcpHttp } = require('./mcp-http');
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
const MCP_PATH = process.env.MCP_PATH || '/mcp';
|
||||
const USERNAME = process.env.WEBSHARE_USERNAME;
|
||||
const PASSWORD = process.env.WEBSHARE_PASSWORD;
|
||||
|
||||
if (!USERNAME || !PASSWORD) {
|
||||
console.error('WEBSHARE_USERNAME and WEBSHARE_PASSWORD must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
const USERNAME = process.env.WEBSHARE_USERNAME || '';
|
||||
const PASSWORD = process.env.WEBSHARE_PASSWORD || '';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json({ limit: '4mb' }));
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ ok: true, mcp: MCP_PATH });
|
||||
res.json({ ok: true, mcp: MCP_PATH, guest: !USERNAME || !PASSWORD });
|
||||
});
|
||||
|
||||
registerMcpHttp(app, {
|
||||
@@ -35,5 +30,6 @@ registerMcpHttp(app, {
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`webshare-api MCP (HTTP) listening on :${PORT}${MCP_PATH}`);
|
||||
const mode = !USERNAME || !PASSWORD ? 'guest' : 'auth';
|
||||
console.log(`webshare-api MCP (HTTP) listening on :${PORT}${MCP_PATH} (${mode})`);
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
@@ -14,15 +14,31 @@ async function post(endpoint, data) {
|
||||
return parsed.response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Webshare.cz API client.
|
||||
*
|
||||
* With username+password: VIP/session downloads (wst token).
|
||||
* Without credentials: guest/free mode — search and file_link work without login
|
||||
* (CDN hosts like free.*.dl.wsfiles.cz; typically much slower than authenticated).
|
||||
*
|
||||
* Official API: https://webshare.cz/apidoc/
|
||||
*/
|
||||
class WebshareClient {
|
||||
/**
|
||||
* @param {string} [username]
|
||||
* @param {string} [password]
|
||||
*/
|
||||
constructor(username, password) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.username = (username || '').trim();
|
||||
this.password = password || '';
|
||||
/** @type {boolean} true when no credentials — free-tier file_link without wst */
|
||||
this.guest = !this.username || !this.password;
|
||||
this.wst = null;
|
||||
this._loginPromise = null;
|
||||
}
|
||||
|
||||
async _login() {
|
||||
if (this.guest) return;
|
||||
const r = await post('salt', { username_or_email: this.username });
|
||||
if (r.status !== 'OK') throw new Error(`salt() failed: ${r.message}`);
|
||||
const digest = passwordDigest(this.password, r.salt);
|
||||
@@ -38,9 +54,16 @@ class WebshareClient {
|
||||
console.error('Webshare: authenticated');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure session when credentials are configured.
|
||||
* Guest mode is a no-op (file_link works without wst).
|
||||
*/
|
||||
async ensureAuth() {
|
||||
if (this.guest) return;
|
||||
if (this.wst) return;
|
||||
if (!this._loginPromise) this._loginPromise = this._login().finally(() => { this._loginPromise = null; });
|
||||
if (!this._loginPromise) {
|
||||
this._loginPromise = this._login().finally(() => { this._loginPromise = null; });
|
||||
}
|
||||
await this._loginPromise;
|
||||
}
|
||||
|
||||
@@ -58,23 +81,30 @@ class WebshareClient {
|
||||
})).slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a temporary CDN URL for `ident`.
|
||||
* Authenticated sessions send `wst`; guest mode omits it (free tier, slower).
|
||||
*/
|
||||
async getFileLink(ident) {
|
||||
await this.ensureAuth();
|
||||
if (!this.guest) await this.ensureAuth();
|
||||
|
||||
const _request = async () => post('file_link', {
|
||||
ident,
|
||||
download_type: 'video_stream',
|
||||
device_uuid: 'webshare-api',
|
||||
device_vendor: 'Linux',
|
||||
device_model: 'WebshareApi',
|
||||
device_res_x: '1920',
|
||||
device_res_y: '1080',
|
||||
force_https: '1',
|
||||
wst: this.wst,
|
||||
});
|
||||
const _request = async () => {
|
||||
const body = {
|
||||
ident,
|
||||
download_type: 'file_download',
|
||||
device_uuid: 'webshare-api',
|
||||
device_vendor: 'Linux',
|
||||
device_model: 'WebshareApi',
|
||||
device_res_x: '1920',
|
||||
device_res_y: '1080',
|
||||
force_https: '1',
|
||||
};
|
||||
if (this.wst) body.wst = this.wst;
|
||||
return post('file_link', body);
|
||||
};
|
||||
|
||||
let r = await _request();
|
||||
if (r.status !== 'OK') {
|
||||
if (r.status !== 'OK' && !this.guest) {
|
||||
// token expired — re-auth and retry once
|
||||
this.wst = null;
|
||||
await this.ensureAuth();
|
||||
|
||||
Reference in New Issue
Block a user