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

@@ -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=

View File

@@ -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

View File

@@ -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 () => {

View File

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

View File

@@ -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' }));
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) => {

View File

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

View File

@@ -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})`);
});

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;
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,19 +130,28 @@ 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 { }
if (isCancelled(entry.hash) || msg === 'cancelled') {
console.log(`[qbt] cancelled: ${entry.name}`);
} else {
// "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}`);
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();
setTimeout(() => enqueue(entry, webseedUrl), 2 * 60 * 1000);
} else {
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}: ${err.message}`);
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
@@ -130,8 +161,12 @@ function dequeue() {
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 { }
// Same path as UI cancel: abort stream, free concurrent slot, start next queued
cancelDownload(h);
}
downloads.delete(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);
}
// Delete partial file
try { fs.unlinkSync(path.join(SAVE_PATH, entry.name)); } catch {}
// 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)}`);
}
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 };

View File

@@ -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', {
const _request = async () => {
const body = {
ident,
download_type: 'video_stream',
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',
wst: this.wst,
});
};
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();