From 798d7d72514cb7b40ce3b2bc3fb5d0026a5ad3f8 Mon Sep 17 00:00:00 2001 From: Michal Pemcak Date: Fri, 7 Aug 2026 11:31:19 +0200 Subject: [PATCH] Support Webshare guest downloads without login. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 1 + README.md | 8 +- __tests__/webshare.test.js | 40 ++++++++- compose.yaml | 5 +- src/index.js | 15 ++-- src/mcp-server.js | 24 +++-- src/mcp.js | 14 ++- src/qbt.js | 178 ++++++++++++++++++++++++++++--------- src/webshare.js | 62 +++++++++---- 9 files changed, 259 insertions(+), 88 deletions(-) diff --git a/.env.example b/.env.example index 9783e6b..574c80d 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ PORT=3001 BASE_URL=http://webshare-api:3001 +# Optional. Leave both empty for guest/free mode (works without login; free CDN is slower). WEBSHARE_USERNAME= WEBSHARE_PASSWORD= diff --git a/README.md b/README.md index b008eb9..e04e65e 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ Environment variables (required unless noted): | Variable | Default | Description | |----------|---------|-------------| -| `WEBSHARE_USERNAME` | — | Webshare username or email | -| `WEBSHARE_PASSWORD` | — | Webshare password (plain; hashed client-side) | +| `WEBSHARE_USERNAME` | _(empty)_ | Webshare username or email. **Optional** — if empty (with password), runs in **guest** mode | +| `WEBSHARE_PASSWORD` | _(empty)_ | Webshare password (plain; hashed client-side). Optional with username for VIP/session downloads | | `PORT` | `3001` | HTTP listen port | | `BASE_URL` | `http://localhost:$PORT` | Public base URL Sonarr must reach (use the Compose service hostname when linking containers) | | `DOWNLOAD_PATH` | `/downloads/webshare` | Where the fake qBittorrent writes files | @@ -75,7 +75,9 @@ Environment variables (required unless noted): | `MCP_ENABLED` | `true` | Set to `0`/`false` to disable the MCP HTTP endpoint | | `MCP_PATH` | `/mcp` | HTTP path for the Streamable HTTP MCP endpoint | -Copy `.env.example` and fill in credentials. Do not commit a filled `.env`. +Copy `.env.example`. Credentials are **optional**: without them the app uses Webshare **guest** +`file_link` (no `wst`) — search and downloads work, free-tier CDN (`free.*.dl.wsfiles.cz`) is +typically much slower than an authenticated account. Do not commit a filled `.env`. ## Run with Compose diff --git a/__tests__/webshare.test.js b/__tests__/webshare.test.js index dfc8f8e..f997045 100644 --- a/__tests__/webshare.test.js +++ b/__tests__/webshare.test.js @@ -16,6 +16,44 @@ describe('WebshareClient', () => { client = new WebshareClient('user', 'pass'); }); + describe('guest mode', () => { + it('marks guest when username/password missing', () => { + expect(new WebshareClient('', '').guest).toBe(true); + expect(new WebshareClient(undefined, undefined).guest).toBe(true); + expect(new WebshareClient('u', '').guest).toBe(true); + expect(new WebshareClient('', 'p').guest).toBe(true); + expect(new WebshareClient('u', 'p').guest).toBe(false); + }); + + it('ensureAuth is a no-op in guest mode', async () => { + const guest = new WebshareClient('', ''); + await guest.ensureAuth(); + expect(axios.post).not.toHaveBeenCalled(); + expect(guest.wst).toBeNull(); + }); + + it('getFileLink works without login and omits wst', async () => { + const guest = new WebshareClient(); + mockResponse({ status: 'OK', link: 'https://free.1.dl.wsfiles.cz/file.mkv' }); + const link = await guest.getFileLink('identGuest'); + expect(link).toBe('https://free.1.dl.wsfiles.cz/file.mkv'); + expect(axios.post).toHaveBeenCalledTimes(1); + const [url, body] = axios.post.mock.calls[0]; + expect(url).toContain('file_link'); + const params = new URLSearchParams(body); + expect(params.get('ident')).toBe('identGuest'); + expect(params.get('wst')).toBeNull(); + expect(params.get('download_type')).toBe('file_download'); + }); + + it('getFileLink does not re-login when guest request fails', async () => { + const guest = new WebshareClient('', ''); + mockResponse({ status: 'FATAL', message: 'File not found' }); + await expect(guest.getFileLink('missing')).rejects.toThrow('file_link() FATAL: File not found'); + expect(axios.post).toHaveBeenCalledTimes(1); + }); + }); + // ── ensureAuth / _login ───────────────────────────────────────────────── describe('ensureAuth()', () => { @@ -157,7 +195,7 @@ describe('WebshareClient', () => { mockResponse({ status: 'OK', salt: 'salt123a' }); mockResponse({ status: 'OK', token: 'tok2' }); mockResponse({ status: 'FATAL', message: 'File unavailable' }); - await expect(client.getFileLink('badident')).rejects.toThrow('file_link() failed: File unavailable'); + await expect(client.getFileLink('badident')).rejects.toThrow('file_link() FATAL: File unavailable'); }); it('calls ensureAuth before requesting link', async () => { diff --git a/compose.yaml b/compose.yaml index 02c6f78..4b815e1 100644 --- a/compose.yaml +++ b/compose.yaml @@ -6,8 +6,9 @@ services: environment: PORT: "3001" BASE_URL: ${BASE_URL:-http://webshare-api:3001} - WEBSHARE_USERNAME: ${WEBSHARE_USERNAME:?set WEBSHARE_USERNAME} - WEBSHARE_PASSWORD: ${WEBSHARE_PASSWORD:?set WEBSHARE_PASSWORD} + # Optional — omit both for guest/free downloads (slower CDN) + WEBSHARE_USERNAME: ${WEBSHARE_USERNAME:-} + WEBSHARE_PASSWORD: ${WEBSHARE_PASSWORD:-} DOWNLOAD_PATH: ${DOWNLOAD_PATH:-/downloads/webshare} MEDIA_ROOT: ${MEDIA_ROOT:-/data} MAX_CONCURRENT_DOWNLOADS: ${MAX_CONCURRENT_DOWNLOADS:-2} diff --git a/src/index.js b/src/index.js index 7df74d1..259e49c 100644 --- a/src/index.js +++ b/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) => { diff --git a/src/mcp-server.js b/src/mcp-server.js index e46cdb9..c540dd5 100644 --- a/src/mcp-server.js +++ b/src/mcp-server.js @@ -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 }, }; } } diff --git a/src/mcp.js b/src/mcp.js index 1b13924..03fe2b1 100755 --- a/src/mcp.js +++ b/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})`); }); diff --git a/src/qbt.js b/src/qbt.js index e3cbe73..97d0296 100644 --- a/src/qbt.js +++ b/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 }; diff --git a/src/webshare.js b/src/webshare.js index cced515..7374816 100644 --- a/src/webshare.js +++ b/src/webshare.js @@ -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();