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:
@@ -1,5 +1,6 @@
|
|||||||
PORT=3001
|
PORT=3001
|
||||||
BASE_URL=http://webshare-api: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_USERNAME=
|
||||||
WEBSHARE_PASSWORD=
|
WEBSHARE_PASSWORD=
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ Environment variables (required unless noted):
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `WEBSHARE_USERNAME` | — | Webshare username or email |
|
| `WEBSHARE_USERNAME` | _(empty)_ | Webshare username or email. **Optional** — if empty (with password), runs in **guest** mode |
|
||||||
| `WEBSHARE_PASSWORD` | — | Webshare password (plain; hashed client-side) |
|
| `WEBSHARE_PASSWORD` | _(empty)_ | Webshare password (plain; hashed client-side). Optional with username for VIP/session downloads |
|
||||||
| `PORT` | `3001` | HTTP listen port |
|
| `PORT` | `3001` | HTTP listen port |
|
||||||
| `BASE_URL` | `http://localhost:$PORT` | Public base URL Sonarr must reach (use the Compose service hostname when linking containers) |
|
| `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 |
|
| `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_ENABLED` | `true` | Set to `0`/`false` to disable the MCP HTTP endpoint |
|
||||||
| `MCP_PATH` | `/mcp` | HTTP path for the Streamable HTTP MCP 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
|
## Run with Compose
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,44 @@ describe('WebshareClient', () => {
|
|||||||
client = new WebshareClient('user', 'pass');
|
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 ─────────────────────────────────────────────────
|
// ── ensureAuth / _login ─────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('ensureAuth()', () => {
|
describe('ensureAuth()', () => {
|
||||||
@@ -157,7 +195,7 @@ describe('WebshareClient', () => {
|
|||||||
mockResponse({ status: 'OK', salt: 'salt123a' });
|
mockResponse({ status: 'OK', salt: 'salt123a' });
|
||||||
mockResponse({ status: 'OK', token: 'tok2' });
|
mockResponse({ status: 'OK', token: 'tok2' });
|
||||||
mockResponse({ status: 'FATAL', message: 'File unavailable' });
|
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 () => {
|
it('calls ensureAuth before requesting link', async () => {
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
BASE_URL: ${BASE_URL:-http://webshare-api:3001}
|
BASE_URL: ${BASE_URL:-http://webshare-api:3001}
|
||||||
WEBSHARE_USERNAME: ${WEBSHARE_USERNAME:?set WEBSHARE_USERNAME}
|
# Optional — omit both for guest/free downloads (slower CDN)
|
||||||
WEBSHARE_PASSWORD: ${WEBSHARE_PASSWORD:?set WEBSHARE_PASSWORD}
|
WEBSHARE_USERNAME: ${WEBSHARE_USERNAME:-}
|
||||||
|
WEBSHARE_PASSWORD: ${WEBSHARE_PASSWORD:-}
|
||||||
DOWNLOAD_PATH: ${DOWNLOAD_PATH:-/downloads/webshare}
|
DOWNLOAD_PATH: ${DOWNLOAD_PATH:-/downloads/webshare}
|
||||||
MEDIA_ROOT: ${MEDIA_ROOT:-/data}
|
MEDIA_ROOT: ${MEDIA_ROOT:-/data}
|
||||||
MAX_CONCURRENT_DOWNLOADS: ${MAX_CONCURRENT_DOWNLOADS:-2}
|
MAX_CONCURRENT_DOWNLOADS: ${MAX_CONCURRENT_DOWNLOADS:-2}
|
||||||
|
|||||||
13
src/index.js
13
src/index.js
@@ -9,23 +9,22 @@ const { registerMcpHttp } = require('./mcp-http');
|
|||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3001;
|
||||||
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
|
const BASE_URL = process.env.BASE_URL || `http://localhost:${PORT}`;
|
||||||
const USERNAME = process.env.WEBSHARE_USERNAME;
|
const USERNAME = process.env.WEBSHARE_USERNAME || '';
|
||||||
const PASSWORD = process.env.WEBSHARE_PASSWORD;
|
const PASSWORD = process.env.WEBSHARE_PASSWORD || '';
|
||||||
const MCP_PATH = process.env.MCP_PATH || '/mcp';
|
const MCP_PATH = process.env.MCP_PATH || '/mcp';
|
||||||
const MCP_ENABLED = process.env.MCP_ENABLED !== '0' && process.env.MCP_ENABLED !== 'false';
|
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 client = new WebshareClient(USERNAME, PASSWORD);
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({ extended: false }));
|
||||||
app.use(express.json({ limit: '4mb' }));
|
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));
|
client.ensureAuth().catch(err => console.error('Initial auth failed:', err.message));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Torznab indexer ───────────────────────────────────────────────────────
|
// ── Torznab indexer ───────────────────────────────────────────────────────
|
||||||
app.get('/api', async (req, res) => {
|
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.
|
* 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 }) {
|
function createWebshareMcpServer({ username, password } = {}) {
|
||||||
const client = new WebshareClient(username, password);
|
const client = new WebshareClient(username || '', password || '');
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
name: 'webshare-api',
|
name: 'webshare-api',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
@@ -93,7 +94,7 @@ function createWebshareMcpServer({ username, password }) {
|
|||||||
{
|
{
|
||||||
title: 'Resolve Webshare download link',
|
title: 'Resolve Webshare download link',
|
||||||
description:
|
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: {
|
inputSchema: {
|
||||||
ident: z
|
ident: z
|
||||||
.string()
|
.string()
|
||||||
@@ -127,21 +128,30 @@ function createWebshareMcpServer({ username, password }) {
|
|||||||
{
|
{
|
||||||
title: 'Check Webshare authentication',
|
title: 'Check Webshare authentication',
|
||||||
description:
|
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: {},
|
inputSchema: {},
|
||||||
},
|
},
|
||||||
async () => {
|
async () => {
|
||||||
try {
|
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();
|
await client.ensureAuth();
|
||||||
return {
|
return {
|
||||||
content: [{ type: 'text', text: 'Webshare authentication OK.' }],
|
content: [{ type: 'text', text: 'Webshare authentication OK.' }],
|
||||||
structuredContent: { ok: true },
|
structuredContent: { ok: true, guest: false },
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return {
|
return {
|
||||||
isError: true,
|
isError: true,
|
||||||
content: [{ type: 'text', text: `Authentication failed: ${err.message}` }],
|
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 PORT = process.env.PORT || 3001;
|
||||||
const MCP_PATH = process.env.MCP_PATH || '/mcp';
|
const MCP_PATH = process.env.MCP_PATH || '/mcp';
|
||||||
const USERNAME = process.env.WEBSHARE_USERNAME;
|
const USERNAME = process.env.WEBSHARE_USERNAME || '';
|
||||||
const PASSWORD = process.env.WEBSHARE_PASSWORD;
|
const PASSWORD = process.env.WEBSHARE_PASSWORD || '';
|
||||||
|
|
||||||
if (!USERNAME || !PASSWORD) {
|
|
||||||
console.error('WEBSHARE_USERNAME and WEBSHARE_PASSWORD must be set');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: '4mb' }));
|
app.use(express.json({ limit: '4mb' }));
|
||||||
|
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ ok: true, mcp: MCP_PATH });
|
res.json({ ok: true, mcp: MCP_PATH, guest: !USERNAME || !PASSWORD });
|
||||||
});
|
});
|
||||||
|
|
||||||
registerMcpHttp(app, {
|
registerMcpHttp(app, {
|
||||||
@@ -35,5 +30,6 @@ registerMcpHttp(app, {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
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})`);
|
||||||
});
|
});
|
||||||
|
|||||||
150
src/qbt.js
150
src/qbt.js
@@ -23,9 +23,14 @@ const STATE_FILE = path.join(SAVE_PATH, '.queue-state.json');
|
|||||||
// hash → download entry (persisted to disk)
|
// hash → download entry (persisted to disk)
|
||||||
const downloads = new Map(); // hash → entry
|
const downloads = new Map(); // hash → entry
|
||||||
const activeRequests = new Map(); // hash → request (destroy to abort)
|
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;
|
let activeDownloads = 0;
|
||||||
const waitingQueue = []; // { entry, webseedUrl }
|
const waitingQueue = []; // { entry, webseedUrl }
|
||||||
|
|
||||||
|
function isCancelled(hash) {
|
||||||
|
return cancelled.has(hash);
|
||||||
|
}
|
||||||
|
|
||||||
function saveState() {
|
function saveState() {
|
||||||
try {
|
try {
|
||||||
const data = [...downloads.values()].map(e => ({
|
const data = [...downloads.values()].map(e => ({
|
||||||
@@ -93,14 +98,31 @@ function cleanupAfterDownload(entry) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dequeue() {
|
function dequeue() {
|
||||||
if (activeDownloads >= MAX_CONCURRENT || waitingQueue.length === 0) return;
|
while (activeDownloads < MAX_CONCURRENT && waitingQueue.length > 0) {
|
||||||
const { entry, webseedUrl } = waitingQueue.shift();
|
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++;
|
activeDownloads++;
|
||||||
entry.state = 'downloading';
|
entry.state = 'downloading';
|
||||||
|
saveState();
|
||||||
|
startDownload(entry, webseedUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDownload(entry, webseedUrl) {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
|
||||||
|
throw new Error('cancelled');
|
||||||
|
}
|
||||||
console.log(`[qbt] downloading ${entry.name} via ${webseedUrl.slice(0, 60)}…`);
|
console.log(`[qbt] downloading ${entry.name} via ${webseedUrl.slice(0, 60)}…`);
|
||||||
await streamToFile(webseedUrl, entry);
|
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
|
// Use actual file size — Content-Length header can lie
|
||||||
try { entry.size = fs.statSync(path.join(SAVE_PATH, entry.name)).size; } catch {}
|
try { entry.size = fs.statSync(path.join(SAVE_PATH, entry.name)).size; } catch {}
|
||||||
entry.progress = 1.0;
|
entry.progress = 1.0;
|
||||||
@@ -108,19 +130,28 @@ function dequeue() {
|
|||||||
console.log(`[qbt] done: ${entry.name}`);
|
console.log(`[qbt] done: ${entry.name}`);
|
||||||
cleanupAfterDownload(entry);
|
cleanupAfterDownload(entry);
|
||||||
} catch (err) {
|
} 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 { }
|
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.
|
// "File temporarily unavailable" is Webshare's FATAL error — not actually retryable.
|
||||||
const retryable = err.message.includes('stalled') || err.message.includes('ECONNRESET')
|
const retryable = msg.includes('stalled') || msg.includes('ECONNRESET')
|
||||||
|| err.message.includes('HTTP 5');
|
|| msg.includes('HTTP 5');
|
||||||
if (retryable) {
|
if (retryable && downloads.has(entry.hash)) {
|
||||||
console.warn(`[qbt] will retry ${entry.name} in 2 min: ${err.message}`);
|
console.warn(`[qbt] will retry ${entry.name} in 2 min: ${msg}`);
|
||||||
entry.state = 'queuedDL';
|
entry.state = 'queuedDL';
|
||||||
saveState();
|
saveState();
|
||||||
setTimeout(() => enqueue(entry, webseedUrl), 2 * 60 * 1000);
|
setTimeout(() => {
|
||||||
} else {
|
if (!isCancelled(entry.hash) && downloads.has(entry.hash)) {
|
||||||
|
enqueue(entry, webseedUrl);
|
||||||
|
}
|
||||||
|
}, 2 * 60 * 1000);
|
||||||
|
} else if (downloads.has(entry.hash)) {
|
||||||
entry.state = 'error';
|
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
|
// Sonarr treats qBittorrent's "error" state as an advisory warning, not
|
||||||
// a failure — it never auto-blocklists/redownloads on its own. Removing
|
// a failure — it never auto-blocklists/redownloads on its own. Removing
|
||||||
// the entry makes the download disappear from the client's list, which
|
// the entry makes the download disappear from the client's list, which
|
||||||
@@ -130,8 +161,12 @@ function dequeue() {
|
|||||||
saveState();
|
saveState();
|
||||||
}, 3 * 60 * 1000);
|
}, 3 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
cancelled.delete(entry.hash);
|
||||||
|
activeRequests.delete(entry.hash);
|
||||||
activeDownloads--;
|
activeDownloads--;
|
||||||
|
if (activeDownloads < 0) activeDownloads = 0;
|
||||||
saveState();
|
saveState();
|
||||||
dequeue();
|
dequeue();
|
||||||
}
|
}
|
||||||
@@ -166,6 +201,11 @@ function streamToFile(url, entry) {
|
|||||||
return reject(new Error(`HTTP ${res.statusCode}`));
|
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);
|
activeRequests.set(entry.hash, req);
|
||||||
|
|
||||||
entry.size = parseInt(res.headers['content-length'] || '0', 10);
|
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
|
// Poll every 10s — avoids setTimeout/clearTimeout on every chunk
|
||||||
const stallCheck = setInterval(() => {
|
const stallCheck = setInterval(() => {
|
||||||
|
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
|
||||||
|
res.destroy(new Error('cancelled'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (Date.now() - lastDataAt > 60_000)
|
if (Date.now() - lastDataAt > 60_000)
|
||||||
res.destroy(new Error('Download stalled (60s no data)'));
|
res.destroy(new Error('Download stalled (60s no data)'));
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
@@ -192,6 +236,10 @@ function streamToFile(url, entry) {
|
|||||||
let speedAccum = 0;
|
let speedAccum = 0;
|
||||||
let speedSampleAt = Date.now();
|
let speedSampleAt = Date.now();
|
||||||
res.on('data', chunk => {
|
res.on('data', chunk => {
|
||||||
|
if (isCancelled(entry.hash) || !downloads.has(entry.hash)) {
|
||||||
|
res.destroy(new Error('cancelled'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
received += chunk.length;
|
received += chunk.length;
|
||||||
lastDataAt = Date.now();
|
lastDataAt = Date.now();
|
||||||
if (entry.size > 0) entry.progress = received / entry.size;
|
if (entry.size > 0) entry.progress = received / entry.size;
|
||||||
@@ -205,11 +253,25 @@ function streamToFile(url, entry) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
res.pipe(file);
|
res.pipe(file);
|
||||||
const done = () => { clearInterval(stallCheck); activeRequests.delete(entry.hash); };
|
const done = () => {
|
||||||
file.on('finish', () => { done(); file.close(resolve); });
|
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); });
|
file.on('error', (e) => { done(); reject(e); });
|
||||||
res.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);
|
follow(url, 0);
|
||||||
});
|
});
|
||||||
@@ -395,17 +457,11 @@ function register(app) {
|
|||||||
|
|
||||||
// ── control ───────────────────────────────────────────────────────────
|
// ── control ───────────────────────────────────────────────────────────
|
||||||
app.post('/api/v2/torrents/delete', (req, res) => {
|
app.post('/api/v2/torrents/delete', (req, res) => {
|
||||||
const hashes = (req.body.hashes || '').split('|');
|
const hashes = (req.body.hashes || '').split('|').filter(Boolean);
|
||||||
const deleteFiles = req.body.deleteFiles === 'true';
|
|
||||||
for (const h of hashes) {
|
for (const h of hashes) {
|
||||||
const entry = downloads.get(h);
|
// Same path as UI cancel: abort stream, free concurrent slot, start next queued
|
||||||
if (entry && deleteFiles) {
|
cancelDownload(h);
|
||||||
const fpath = path.join(SAVE_PATH, entry.name);
|
|
||||||
try { fs.unlinkSync(fpath); console.log(`[qbt] deleted file: ${entry.name}`); } catch { }
|
|
||||||
}
|
}
|
||||||
downloads.delete(h);
|
|
||||||
}
|
|
||||||
saveState();
|
|
||||||
res.send('Ok.');
|
res.send('Ok.');
|
||||||
});
|
});
|
||||||
app.post('/api/v2/torrents/pause', (req, res) => 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;
|
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) {
|
function cancelDownload(hash) {
|
||||||
|
if (!hash) return false;
|
||||||
const entry = downloads.get(hash);
|
const entry = downloads.get(hash);
|
||||||
if (!entry) return;
|
const inWaiting = waitingQueue.findIndex(item => item.entry.hash === hash);
|
||||||
// Remove from waiting queue
|
const hadActiveReq = activeRequests.has(hash);
|
||||||
const wqIdx = waitingQueue.findIndex(item => item.entry.hash === hash);
|
const wasTracked = Boolean(entry) || inWaiting !== -1 || hadActiveReq;
|
||||||
if (wqIdx !== -1) {
|
if (!wasTracked) return false;
|
||||||
waitingQueue.splice(wqIdx, 1);
|
|
||||||
|
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);
|
downloads.delete(hash);
|
||||||
|
console.log(`[qbt] cancel: ${entry.name} (${entry.state})`);
|
||||||
|
} else {
|
||||||
|
console.log(`[qbt] cancel: ${hash.slice(0, 12)}…`);
|
||||||
|
}
|
||||||
saveState();
|
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 };
|
module.exports = { register, getQueue, enqueueDirect, cancelDownload };
|
||||||
|
|||||||
@@ -14,15 +14,31 @@ async function post(endpoint, data) {
|
|||||||
return parsed.response;
|
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 {
|
class WebshareClient {
|
||||||
|
/**
|
||||||
|
* @param {string} [username]
|
||||||
|
* @param {string} [password]
|
||||||
|
*/
|
||||||
constructor(username, password) {
|
constructor(username, password) {
|
||||||
this.username = username;
|
this.username = (username || '').trim();
|
||||||
this.password = password;
|
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.wst = null;
|
||||||
this._loginPromise = null;
|
this._loginPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _login() {
|
async _login() {
|
||||||
|
if (this.guest) return;
|
||||||
const r = await post('salt', { username_or_email: this.username });
|
const r = await post('salt', { username_or_email: this.username });
|
||||||
if (r.status !== 'OK') throw new Error(`salt() failed: ${r.message}`);
|
if (r.status !== 'OK') throw new Error(`salt() failed: ${r.message}`);
|
||||||
const digest = passwordDigest(this.password, r.salt);
|
const digest = passwordDigest(this.password, r.salt);
|
||||||
@@ -38,9 +54,16 @@ class WebshareClient {
|
|||||||
console.error('Webshare: authenticated');
|
console.error('Webshare: authenticated');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure session when credentials are configured.
|
||||||
|
* Guest mode is a no-op (file_link works without wst).
|
||||||
|
*/
|
||||||
async ensureAuth() {
|
async ensureAuth() {
|
||||||
|
if (this.guest) return;
|
||||||
if (this.wst) 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;
|
await this._loginPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,23 +81,30 @@ class WebshareClient {
|
|||||||
})).slice(0, limit);
|
})).slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a temporary CDN URL for `ident`.
|
||||||
|
* Authenticated sessions send `wst`; guest mode omits it (free tier, slower).
|
||||||
|
*/
|
||||||
async getFileLink(ident) {
|
async getFileLink(ident) {
|
||||||
await this.ensureAuth();
|
if (!this.guest) await this.ensureAuth();
|
||||||
|
|
||||||
const _request = async () => post('file_link', {
|
const _request = async () => {
|
||||||
|
const body = {
|
||||||
ident,
|
ident,
|
||||||
download_type: 'video_stream',
|
download_type: 'file_download',
|
||||||
device_uuid: 'webshare-api',
|
device_uuid: 'webshare-api',
|
||||||
device_vendor: 'Linux',
|
device_vendor: 'Linux',
|
||||||
device_model: 'WebshareApi',
|
device_model: 'WebshareApi',
|
||||||
device_res_x: '1920',
|
device_res_x: '1920',
|
||||||
device_res_y: '1080',
|
device_res_y: '1080',
|
||||||
force_https: '1',
|
force_https: '1',
|
||||||
wst: this.wst,
|
};
|
||||||
});
|
if (this.wst) body.wst = this.wst;
|
||||||
|
return post('file_link', body);
|
||||||
|
};
|
||||||
|
|
||||||
let r = await _request();
|
let r = await _request();
|
||||||
if (r.status !== 'OK') {
|
if (r.status !== 'OK' && !this.guest) {
|
||||||
// token expired — re-auth and retry once
|
// token expired — re-auth and retry once
|
||||||
this.wst = null;
|
this.wst = null;
|
||||||
await this.ensureAuth();
|
await this.ensureAuth();
|
||||||
|
|||||||
Reference in New Issue
Block a user