Compare commits
7 Commits
5b71d9f54e
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| fc16eab20c | |||
| 0f857e3699 | |||
| 99dcb298f8 | |||
| 44c2888a2e | |||
| 86ccf2c47a | |||
| b04e70e261 | |||
| ac97862edb |
14
.githooks/check-large-files.sh
Executable file
14
.githooks/check-large-files.sh
Executable file
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Block staged files over the limit: checkpoints, datasets and binaries stay out of git.
|
||||||
|
# Called by lefthook as: check-large-files.sh {staged_files}
|
||||||
|
limit_mb="${MAX_FILE_MB:-5}"
|
||||||
|
limit=$((limit_mb * 1024 * 1024))
|
||||||
|
status=0
|
||||||
|
for file in "$@"; do
|
||||||
|
size=$(git cat-file -s ":$file" 2>/dev/null) || continue
|
||||||
|
if [ "$size" -gt "$limit" ]; then
|
||||||
|
echo "large file: $file is $((size / 1024 / 1024)) MB (limit ${limit_mb} MB). Add it to .gitignore." >&2
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
exit $status
|
||||||
27
.githooks/commit-msg-check.sh
Executable file
27
.githooks/commit-msg-check.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Conventional Commits with a short subject; no AI attribution.
|
||||||
|
# Called by lefthook as: commit-msg-check.sh <message-file>
|
||||||
|
msg_file="$1"
|
||||||
|
subject=$(sed -n '1p' "$msg_file")
|
||||||
|
|
||||||
|
case "$subject" in
|
||||||
|
Merge\ * | Revert\ * | fixup!\ * | squash!\ * | amend!\ *) exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
types='feat|fix|refactor|perf|docs|test|build|ci|chore|style|revert|exp'
|
||||||
|
if ! printf '%s\n' "$subject" | grep -Eq "^($types)(\([a-z0-9._/-]+\))?!?: [^ ].*$"; then
|
||||||
|
echo "commit-msg: subject must be '<type>(<scope>): <summary>'" >&2
|
||||||
|
echo " types: ${types}" >&2
|
||||||
|
echo " got: $subject" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#subject}" -gt 72 ]; then
|
||||||
|
echo "commit-msg: subject is ${#subject} chars, keep it at 72 or less (aim for ~50)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -Eiq 'co-authored-by:[[:space:]]*claude|noreply@anthropic\.com|generated with \[?claude code' "$msg_file"; then
|
||||||
|
echo "commit-msg: no AI attribution lines in commit messages" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
## [0.1.0] - 2026-10-19
|
||||||
|
|
||||||
|
### 🚀 Features
|
||||||
|
|
||||||
|
- *(queue)* 404 on unknown hash, show error in UI
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
- TWO_HOURS constant in staging cleanup was actually 5 minutes
|
||||||
|
|
||||||
|
### 📚 Documentation
|
||||||
|
|
||||||
|
- Add OKF knowledge base
|
||||||
|
|
||||||
|
### 💼 Other
|
||||||
|
|
||||||
|
- Mention MCP server in intro
|
||||||
|
- Add commit hooks
|
||||||
|
- Add git-cliff config
|
||||||
1
CLAUDE.md
Normal file
1
CLAUDE.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Knowledge for this project is in doc/ (start at doc/index.md).
|
||||||
88
__tests__/mcp-entry.test.js
Normal file
88
__tests__/mcp-entry.test.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* mcp.js is a side-effect entrypoint (listen). Load it with mocks so we cover
|
||||||
|
* wiring without binding a real port.
|
||||||
|
*/
|
||||||
|
describe('mcp.js entry', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
let listenCb;
|
||||||
|
let mockApp;
|
||||||
|
let registerMcpHttp;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
listenCb = null;
|
||||||
|
mockApp = {
|
||||||
|
use: jest.fn(),
|
||||||
|
get: jest.fn((path, handler) => {
|
||||||
|
mockApp._health = handler;
|
||||||
|
}),
|
||||||
|
listen: jest.fn((port, cb) => {
|
||||||
|
listenCb = cb;
|
||||||
|
return { close: jest.fn() };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const expressFn = jest.fn(() => mockApp);
|
||||||
|
expressFn.json = jest.fn(() => (req, res, next) => next && next());
|
||||||
|
jest.doMock('express', () => expressFn);
|
||||||
|
registerMcpHttp = jest.fn();
|
||||||
|
jest.doMock('../src/mcp-http', () => ({ registerMcpHttp }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
jest.resetModules();
|
||||||
|
jest.dontMock('express');
|
||||||
|
jest.dontMock('../src/mcp-http');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers health + MCP and listens (auth mode)', () => {
|
||||||
|
process.env.WEBSHARE_USERNAME = 'user';
|
||||||
|
process.env.WEBSHARE_PASSWORD = 'pass';
|
||||||
|
process.env.PORT = '3456';
|
||||||
|
process.env.MCP_PATH = '/custom-mcp';
|
||||||
|
|
||||||
|
require('../src/mcp');
|
||||||
|
|
||||||
|
expect(mockApp.use).toHaveBeenCalled();
|
||||||
|
expect(registerMcpHttp).toHaveBeenCalledWith(mockApp, {
|
||||||
|
username: 'user',
|
||||||
|
password: 'pass',
|
||||||
|
path: '/custom-mcp',
|
||||||
|
});
|
||||||
|
expect(mockApp.listen).toHaveBeenCalledWith('3456', expect.any(Function));
|
||||||
|
|
||||||
|
const res = { json: jest.fn() };
|
||||||
|
mockApp._health({}, res);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
ok: true,
|
||||||
|
mcp: '/custom-mcp',
|
||||||
|
guest: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
listenCb();
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringMatching(/auth/));
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports guest mode when credentials missing', () => {
|
||||||
|
delete process.env.WEBSHARE_USERNAME;
|
||||||
|
delete process.env.WEBSHARE_PASSWORD;
|
||||||
|
delete process.env.PORT;
|
||||||
|
delete process.env.MCP_PATH;
|
||||||
|
|
||||||
|
require('../src/mcp');
|
||||||
|
const res = { json: jest.fn() };
|
||||||
|
mockApp._health({}, res);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({
|
||||||
|
ok: true,
|
||||||
|
mcp: '/mcp',
|
||||||
|
guest: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = jest.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
listenCb();
|
||||||
|
expect(log).toHaveBeenCalledWith(expect.stringMatching(/guest/));
|
||||||
|
log.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
98
__tests__/mcp-http.test.js
Normal file
98
__tests__/mcp-http.test.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
jest.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
||||||
|
StreamableHTTPServerTransport: jest.fn().mockImplementation(() => ({
|
||||||
|
handleRequest: jest.fn(async (req, res) => {
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
}),
|
||||||
|
close: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('../src/mcp-server', () => ({
|
||||||
|
createWebshareMcpServer: jest.fn(() => ({
|
||||||
|
connect: jest.fn(async () => {}),
|
||||||
|
close: jest.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { StreamableHTTPServerTransport } = require('@modelcontextprotocol/sdk/server/streamableHttp.js');
|
||||||
|
const { createWebshareMcpServer } = require('../src/mcp-server');
|
||||||
|
const { registerMcpHttp } = require('../src/mcp-http');
|
||||||
|
|
||||||
|
describe('registerMcpHttp', () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
createWebshareMcpServer.mockImplementation(() => ({
|
||||||
|
connect: jest.fn(async () => {}),
|
||||||
|
close: jest.fn(),
|
||||||
|
}));
|
||||||
|
StreamableHTTPServerTransport.mockImplementation(() => ({
|
||||||
|
handleRequest: jest.fn(async (req, res) => {
|
||||||
|
res.status(200).json({ ok: true });
|
||||||
|
}),
|
||||||
|
close: jest.fn(),
|
||||||
|
}));
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
registerMcpHttp(app, { username: 'u', password: 'p', path: '/mcp' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /mcp connects transport and handles request', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/mcp')
|
||||||
|
.send({ jsonrpc: '2.0', method: 'initialize', id: 1 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(createWebshareMcpServer).toHaveBeenCalledWith({ username: 'u', password: 'p' });
|
||||||
|
expect(StreamableHTTPServerTransport).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /mcp returns 405 with method-not-allowed body', async () => {
|
||||||
|
const res = await request(app).get('/mcp');
|
||||||
|
expect(res.status).toBe(405);
|
||||||
|
expect(res.body.error.message).toMatch(/Method not allowed/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /mcp returns 405', async () => {
|
||||||
|
const res = await request(app).delete('/mcp');
|
||||||
|
expect(res.status).toBe(405);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /mcp returns 500 JSON-RPC when connect throws and headers not sent', async () => {
|
||||||
|
createWebshareMcpServer.mockImplementation(() => ({
|
||||||
|
connect: jest.fn(async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
}),
|
||||||
|
close: jest.fn(),
|
||||||
|
}));
|
||||||
|
const res = await request(app).post('/mcp').send({});
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body.error.code).toBe(-32603);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default path /mcp when path option omitted', () => {
|
||||||
|
const app2 = express();
|
||||||
|
registerMcpHttp(app2, { username: 'a', password: 'b' });
|
||||||
|
const layer = app2._router.stack.find(
|
||||||
|
(l) => l.route && l.route.path === '/mcp' && l.route.methods.post
|
||||||
|
);
|
||||||
|
expect(layer).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips 500 body when headers already sent after handler error', async () => {
|
||||||
|
StreamableHTTPServerTransport.mockImplementation(() => ({
|
||||||
|
handleRequest: jest.fn(async (req, res) => {
|
||||||
|
res.status(202).json({ partial: true });
|
||||||
|
throw new Error('late failure');
|
||||||
|
}),
|
||||||
|
close: jest.fn(),
|
||||||
|
}));
|
||||||
|
const res = await request(app).post('/mcp').send({});
|
||||||
|
// first write wins — not overwritten with 500
|
||||||
|
expect(res.status).toBe(202);
|
||||||
|
expect(res.body).toEqual({ partial: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
100
__tests__/mcp-tools.test.js
Normal file
100
__tests__/mcp-tools.test.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
jest.mock('../src/webshare', () => {
|
||||||
|
const instances = [];
|
||||||
|
class WebshareClient {
|
||||||
|
constructor(username, password) {
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.guest = !username || !password;
|
||||||
|
this.ensureAuth = jest.fn(async () => {});
|
||||||
|
this.search = jest.fn(async () => []);
|
||||||
|
this.getFileLink = jest.fn(async () => 'https://cdn.example/file');
|
||||||
|
instances.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WebshareClient._instances = instances;
|
||||||
|
return { WebshareClient };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { WebshareClient } = require('../src/webshare');
|
||||||
|
const { formatSize, createWebshareMcpServer } = require('../src/mcp-server');
|
||||||
|
|
||||||
|
function lastClient() {
|
||||||
|
return WebshareClient._instances[WebshareClient._instances.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('formatSize', () => {
|
||||||
|
it('handles non-finite and large units', () => {
|
||||||
|
expect(formatSize(NaN)).toBe('0 B');
|
||||||
|
expect(formatSize(-1)).toBe('0 B');
|
||||||
|
expect(formatSize(1024 ** 3)).toBe('1.0 GB');
|
||||||
|
expect(formatSize(1024 ** 4)).toBe('1.0 TB');
|
||||||
|
expect(formatSize(512)).toBe('512 B');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MCP tool handlers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
WebshareClient._instances.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('webshare_search formats hits and empty results', async () => {
|
||||||
|
const server = createWebshareMcpServer({ username: 'u', password: 'p' });
|
||||||
|
const handler = server._registeredTools.webshare_search.handler;
|
||||||
|
const client = lastClient();
|
||||||
|
|
||||||
|
client.search.mockResolvedValueOnce([]);
|
||||||
|
let out = await handler({ query: 'nope' });
|
||||||
|
expect(out.content[0].text).toMatch(/No results/);
|
||||||
|
expect(out.structuredContent.count).toBe(0);
|
||||||
|
|
||||||
|
client.search.mockResolvedValueOnce([
|
||||||
|
{ name: 'Movie.mkv', ident: 'abc', size: 2048, votes: 3 },
|
||||||
|
]);
|
||||||
|
out = await handler({ query: 'movie', limit: 10, offset: 0, sort: 'recent', category: 'video' });
|
||||||
|
expect(out.content[0].text).toMatch(/Found 1 result/);
|
||||||
|
expect(out.content[0].text).toMatch(/ident: abc/);
|
||||||
|
expect(out.structuredContent.results[0].ident).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('webshare_search returns isError on failure', async () => {
|
||||||
|
const server = createWebshareMcpServer({ username: 'u', password: 'p' });
|
||||||
|
const handler = server._registeredTools.webshare_search.handler;
|
||||||
|
lastClient().ensureAuth.mockRejectedValueOnce(new Error('auth down'));
|
||||||
|
const out = await handler({ query: 'x' });
|
||||||
|
expect(out.isError).toBe(true);
|
||||||
|
expect(out.content[0].text).toMatch(/webshare_search failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('webshare_get_link resolves and surfaces errors', async () => {
|
||||||
|
const server = createWebshareMcpServer({ username: 'u', password: 'p' });
|
||||||
|
const handler = server._registeredTools.webshare_get_link.handler;
|
||||||
|
const client = lastClient();
|
||||||
|
|
||||||
|
let out = await handler({ ident: 'id1' });
|
||||||
|
expect(out.structuredContent.link).toBe('https://cdn.example/file');
|
||||||
|
expect(out.content[0].text).toMatch(/Resolved link/);
|
||||||
|
|
||||||
|
client.getFileLink.mockRejectedValueOnce(new Error('gone'));
|
||||||
|
out = await handler({ ident: 'id2' });
|
||||||
|
expect(out.isError).toBe(true);
|
||||||
|
expect(out.content[0].text).toMatch(/webshare_get_link failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('webshare_login_check guest / auth / error paths', async () => {
|
||||||
|
let server = createWebshareMcpServer({});
|
||||||
|
let handler = server._registeredTools.webshare_login_check.handler;
|
||||||
|
let out = await handler({});
|
||||||
|
expect(out.structuredContent.guest).toBe(true);
|
||||||
|
expect(out.content[0].text).toMatch(/guest mode/i);
|
||||||
|
|
||||||
|
server = createWebshareMcpServer({ username: 'u', password: 'p' });
|
||||||
|
handler = server._registeredTools.webshare_login_check.handler;
|
||||||
|
out = await handler({});
|
||||||
|
expect(out.structuredContent).toEqual({ ok: true, guest: false });
|
||||||
|
|
||||||
|
lastClient().ensureAuth.mockRejectedValueOnce(new Error('bad creds'));
|
||||||
|
out = await handler({});
|
||||||
|
expect(out.isError).toBe(true);
|
||||||
|
expect(out.structuredContent.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -149,12 +149,18 @@ describe('POST /api/v2/torrents/add (.torrent upload)', () => {
|
|||||||
expect(body).toHaveLength(1);
|
expect(body).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips torrent with no url-list', async () => {
|
it('records torrent with no url-list as error (does not queue download)', async () => {
|
||||||
// A buffer that decodes to a dict without url-list
|
// Valid bencode dict with info but no url-list (pieces empty string)
|
||||||
const invalid = Buffer.from('d4:infod6:lengthi0e4:name4:teste12:piece lengthi524288e6:piecese', 'utf8');
|
const noWebseed = Buffer.from(
|
||||||
await request(app).post('/api/v2/torrents/add').attach('torrents', invalid, 'bad.torrent');
|
'd4:infod6:lengthi0e4:name4:test12:piece lengthi524288e6:pieces0:ee',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', noWebseed, 'bad.torrent');
|
||||||
const { body } = await request(app).get('/api/v2/torrents/info');
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
expect(body).toHaveLength(0);
|
// Product keeps an error entry so Sonarr stops re-adding the release
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].state).toBe('error');
|
||||||
|
expect(body[0].name).toBe('bad');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('item has correct save_path and content_path', async () => {
|
it('item has correct save_path and content_path', async () => {
|
||||||
@@ -248,7 +254,8 @@ describe('POST /api/v2/torrents/delete', () => {
|
|||||||
expect(body).toHaveLength(0);
|
expect(body).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deleteFiles=false does not delete file', async () => {
|
it('delete always cancels and removes incomplete staging file', async () => {
|
||||||
|
// cancelDownload unlinks partials for queuedDL/downloading regardless of deleteFiles
|
||||||
const hash = await addAndGetHash('http://webshare-api:3001/stream/nodelf');
|
const hash = await addAndGetHash('http://webshare-api:3001/stream/nodelf');
|
||||||
const name = (await request(app).get('/api/v2/torrents/info')).body[0].name;
|
const name = (await request(app).get('/api/v2/torrents/info')).body[0].name;
|
||||||
const fpath = path.join(tmpDir, name);
|
const fpath = path.join(tmpDir, name);
|
||||||
@@ -256,8 +263,9 @@ describe('POST /api/v2/torrents/delete', () => {
|
|||||||
|
|
||||||
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`);
|
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`);
|
||||||
|
|
||||||
expect(fs.existsSync(fpath)).toBe(true);
|
expect(fs.existsSync(fpath)).toBe(false);
|
||||||
fs.unlinkSync(fpath);
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores unknown hashes gracefully', async () => {
|
it('ignores unknown hashes gracefully', async () => {
|
||||||
@@ -340,7 +348,8 @@ describe('state persistence', () => {
|
|||||||
fs.unlinkSync(path.join(tmpDir, fname));
|
fs.unlinkSync(path.join(tmpDir, fname));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loadState re-queues uploading items when file is missing', async () => {
|
it('loadState drops uploading items when file is missing', async () => {
|
||||||
|
// Already imported/cleaned — Sonarr has hasFile; do not re-queue
|
||||||
const webseedUrl = 'http://webshare-api:3001/stream/missingup';
|
const webseedUrl = 'http://webshare-api:3001/stream/missingup';
|
||||||
const state = [{ hash: 'ghi789', name: 'missing.mkv', size: 0, progress: 1.0,
|
const state = [{ hash: 'ghi789', name: 'missing.mkv', size: 0, progress: 1.0,
|
||||||
state: 'uploading', category: '', webseedUrl }];
|
state: 'uploading', category: '', webseedUrl }];
|
||||||
@@ -348,11 +357,10 @@ describe('state persistence', () => {
|
|||||||
|
|
||||||
const { app: app4 } = makeApp();
|
const { app: app4 } = makeApp();
|
||||||
const { body } = await request(app4).get('/api/v2/torrents/info');
|
const { body } = await request(app4).get('/api/v2/torrents/info');
|
||||||
expect(body).toHaveLength(1);
|
expect(body).toHaveLength(0);
|
||||||
expect(body[0].state).toBe('queuedDL');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loadState re-queues error items', async () => {
|
it('loadState keeps error items as error (no auto-retry on restart)', async () => {
|
||||||
const webseedUrl = 'http://webshare-api:3001/stream/errretry';
|
const webseedUrl = 'http://webshare-api:3001/stream/errretry';
|
||||||
const state = [{ hash: 'jkl000', name: 'errored.mkv', size: 0, progress: 0,
|
const state = [{ hash: 'jkl000', name: 'errored.mkv', size: 0, progress: 0,
|
||||||
state: 'error', category: '', webseedUrl }];
|
state: 'error', category: '', webseedUrl }];
|
||||||
@@ -361,7 +369,7 @@ describe('state persistence', () => {
|
|||||||
const { app: app5 } = makeApp();
|
const { app: app5 } = makeApp();
|
||||||
const { body } = await request(app5).get('/api/v2/torrents/info');
|
const { body } = await request(app5).get('/api/v2/torrents/info');
|
||||||
expect(body).toHaveLength(1);
|
expect(body).toHaveLength(1);
|
||||||
expect(body[0].state).toBe('queuedDL');
|
expect(body[0].state).toBe('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loadState preserves other states as-is', async () => {
|
it('loadState preserves other states as-is', async () => {
|
||||||
@@ -402,8 +410,10 @@ describe('cleanupStaging (runs on startup)', () => {
|
|||||||
|
|
||||||
makeApp(); // triggers cleanupStaging
|
makeApp(); // triggers cleanupStaging
|
||||||
|
|
||||||
expect(fs.existsSync(fpath)).toBe(false);
|
// First name in readdir with nlink>1 is unlinked; the other then has nlink=1 and stays
|
||||||
fs.unlinkSync(link); // cleanup the link
|
const remaining = [fpath, link].filter(p => fs.existsSync(p));
|
||||||
|
expect(remaining).toHaveLength(1);
|
||||||
|
remaining.forEach(p => fs.unlinkSync(p));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes files older than 2 hours that are not active downloads', () => {
|
it('deletes files older than 2 hours that are not active downloads', () => {
|
||||||
@@ -418,25 +428,23 @@ describe('cleanupStaging (runs on startup)', () => {
|
|||||||
expect(fs.existsSync(fpath)).toBe(false);
|
expect(fs.existsSync(fpath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves files that are currently downloading', () => {
|
it('loadState deletes partial files when re-queueing a downloading entry', () => {
|
||||||
const fname = 'active-download.mkv';
|
const fname = 'active-download.mkv';
|
||||||
const fpath = path.join(tmpDir, fname);
|
const fpath = path.join(tmpDir, fname);
|
||||||
fs.writeFileSync(fpath, 'partial');
|
fs.writeFileSync(fpath, 'partial');
|
||||||
// Backdate to 3 hours ago — would normally be deleted
|
// Backdate to 3 hours ago
|
||||||
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
||||||
fs.utimesSync(fpath, old, old);
|
fs.utimesSync(fpath, old, old);
|
||||||
|
|
||||||
// Write state showing this file is actively downloading
|
// Interrupted download — loadState unlinks partial and re-queues
|
||||||
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify([{
|
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify([{
|
||||||
hash: 'activehash', name: fname, size: 0, progress: 0.5,
|
hash: 'activehash', name: fname, size: 0, progress: 0.5,
|
||||||
state: 'downloading', category: '', webseedUrl: 'http://webshare-api:3001/stream/active',
|
state: 'downloading', category: '', webseedUrl: 'http://webshare-api:3001/stream/active',
|
||||||
}]));
|
}]));
|
||||||
|
|
||||||
makeApp(); // triggers cleanupStaging
|
makeApp();
|
||||||
|
|
||||||
// File should still exist because it's in active download state
|
expect(fs.existsSync(fpath)).toBe(false);
|
||||||
expect(fs.existsSync(fpath)).toBe(true);
|
|
||||||
fs.unlinkSync(fpath);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves recent files (< 2 hours old)', () => {
|
it('preserves recent files (< 2 hours old)', () => {
|
||||||
@@ -539,7 +547,7 @@ describe('download pipeline', () => {
|
|||||||
expect(body[0].state).toBe('error');
|
expect(body[0].state).toBe('error');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retries on stalled download (ECONNRESET)', async () => {
|
it('schedules retry on stalled download (ECONNRESET)', async () => {
|
||||||
const httpMod = require('http');
|
const httpMod = require('http');
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
@@ -551,11 +559,7 @@ describe('download pipeline', () => {
|
|||||||
res.destroy = jest.fn();
|
res.destroy = jest.fn();
|
||||||
res.pipe = jest.fn((dest) => {
|
res.pipe = jest.fn((dest) => {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
if (callCount === 1) {
|
res.emit('error', new Error('ECONNRESET'));
|
||||||
res.emit('error', new Error('ECONNRESET'));
|
|
||||||
} else {
|
|
||||||
dest.emit('finish');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return dest;
|
return dest;
|
||||||
});
|
});
|
||||||
@@ -574,7 +578,10 @@ describe('download pipeline', () => {
|
|||||||
|
|
||||||
await new Promise(r => setTimeout(r, 200));
|
await new Promise(r => setTimeout(r, 200));
|
||||||
|
|
||||||
expect(callCount).toBeGreaterThan(1);
|
// Immediate second attempt is deferred (2 min); entry returns to queuedDL
|
||||||
|
expect(callCount).toBe(1);
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].state).toBe('queuedDL');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('follows HTTP redirects', async () => {
|
it('follows HTTP redirects', async () => {
|
||||||
@@ -656,3 +663,219 @@ describe('download pipeline', () => {
|
|||||||
expect(body[0].name).toBe('renamed.mkv');
|
expect(body[0].name).toBe('renamed.mkv');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── exported helpers + extra routes ──────────────────────────────────────────
|
||||||
|
describe('getQueue / enqueueDirect / files / re-hash', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanState();
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getQueue returns queue snapshot', async () => {
|
||||||
|
const { app, qbt } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/gq1');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'gq.torrent');
|
||||||
|
const q = qbt.getQueue();
|
||||||
|
expect(q).toHaveLength(1);
|
||||||
|
expect(q[0]).toMatchObject({ name: 'gq', state: 'queuedDL' });
|
||||||
|
expect(q[0].path).toContain(tmpDir);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enqueueDirect stages a webshare download and rejects duplicates', () => {
|
||||||
|
process.env.BASE_URL = 'http://localhost:3001';
|
||||||
|
const { qbt } = makeApp();
|
||||||
|
expect(qbt.enqueueDirect('ident99', 'Show.S01E01.mkv', 1000)).toBe(true);
|
||||||
|
expect(qbt.enqueueDirect('ident99', 'Show.S01E01.mkv', 1000)).toBe(false);
|
||||||
|
const q = qbt.getQueue();
|
||||||
|
expect(q).toHaveLength(1);
|
||||||
|
expect(q[0].name).toBe('Show.S01E01.mkv');
|
||||||
|
expect(q[0].path).toContain('Show.S01E01.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/v2/torrents/files returns file list for known hash', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/files1');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'files.torrent');
|
||||||
|
const { body: items } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
const hash = items[0].hash;
|
||||||
|
|
||||||
|
const res = await request(app).get(`/api/v2/torrents/files?hash=${hash}`);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body[0].name).toBe('files');
|
||||||
|
expect(res.body[0].priority).toBe(1);
|
||||||
|
|
||||||
|
const missing = await request(app).get('/api/v2/torrents/files?hash=nope');
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-maps existing entry when same webseed is re-added with different hash', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const webseed = 'http://webshare-api:3001/stream/rehash1';
|
||||||
|
const t1 = makeTorrentBuf(webseed);
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', t1, 'old.torrent');
|
||||||
|
|
||||||
|
// Manually corrupt hash in state by adding via url first then torrent — add via url uses sha1(url)
|
||||||
|
await request(app).post('/api/v2/torrents/add').send(`urls=${webseed}`);
|
||||||
|
// second torrent add with same webseed should re-hash path, not create second download
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', t1, 'new.torrent');
|
||||||
|
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
// one logical download for the webseed
|
||||||
|
const sameUrl = body.filter((b) => b.name === 'old' || b.name === 'new' || b.name === 'rehash1');
|
||||||
|
expect(sameUrl.length).toBeGreaterThanOrEqual(1);
|
||||||
|
// overall should not keep exploding
|
||||||
|
expect(body.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancelDownload returns false for unknown hash and true for queued', async () => {
|
||||||
|
const { app, qbt } = makeApp();
|
||||||
|
expect(qbt.cancelDownload('missing')).toBe(false);
|
||||||
|
expect(qbt.cancelDownload('')).toBe(false);
|
||||||
|
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/canc1');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'c.torrent');
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(qbt.cancelDownload(body[0].hash)).toBe(true);
|
||||||
|
expect((await request(app).get('/api/v2/torrents/info')).body).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('safeFileName strips path and unsafe characters via enqueueDirect', () => {
|
||||||
|
const { qbt } = makeApp();
|
||||||
|
qbt.enqueueDirect('x', '../../evil:name?.mkv', 1);
|
||||||
|
expect(qbt.getQueue()[0].name).toBe('evil_name_.mkv');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-hashes entry when torrent re-add has same webseed but different info-hash name', async () => {
|
||||||
|
const { app } = makeApp();
|
||||||
|
const webseed = 'http://webshare-api:3001/stream/samews';
|
||||||
|
// First: add via URL (hash = sha1(url), name = ident)
|
||||||
|
await request(app).post('/api/v2/torrents/add').send(`urls=${webseed}`);
|
||||||
|
let { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
const oldHash = body[0].hash;
|
||||||
|
|
||||||
|
// Second: same webseed via .torrent → extractInfoHash differs from sha1(url)
|
||||||
|
const torrent = makeTorrentBuf(webseed);
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'samews.torrent');
|
||||||
|
({ body } = await request(app).get('/api/v2/torrents/info'));
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0].hash).not.toBe(oldHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancelDownload aborts active HTTP request', async () => {
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const httpMod = require('http');
|
||||||
|
const reqObj = { destroy: jest.fn(), on: jest.fn() };
|
||||||
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.headers = { 'content-length': '1000000' };
|
||||||
|
res.destroy = jest.fn();
|
||||||
|
res.resume = jest.fn();
|
||||||
|
res.pipe = jest.fn(() => res);
|
||||||
|
// never finish — stays "downloading"
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return reqObj;
|
||||||
|
});
|
||||||
|
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
|
||||||
|
(() => {
|
||||||
|
const w = new EventEmitter();
|
||||||
|
w.close = (cb) => cb && cb();
|
||||||
|
return w;
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { app, qbt } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/actcancel');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'ac.torrent');
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].state).toBe('downloading');
|
||||||
|
expect(qbt.cancelDownload(body[0].hash)).toBe(true);
|
||||||
|
expect(reqObj.destroy).toHaveBeenCalled();
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('follows 303 redirect then completes', async () => {
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const httpMod = require('http');
|
||||||
|
let call = 0;
|
||||||
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
|
call++;
|
||||||
|
const res = new EventEmitter();
|
||||||
|
if (call === 1) {
|
||||||
|
res.statusCode = 303;
|
||||||
|
res.headers = { location: 'http://cdn.example.com/f.mkv' };
|
||||||
|
res.resume = jest.fn();
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
} else {
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.headers = { 'content-length': '4' };
|
||||||
|
res.destroy = jest.fn();
|
||||||
|
res.pipe = jest.fn((dest) => {
|
||||||
|
setImmediate(() => dest.emit('finish'));
|
||||||
|
return dest;
|
||||||
|
});
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
}
|
||||||
|
return { on: jest.fn() };
|
||||||
|
});
|
||||||
|
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
|
||||||
|
(() => {
|
||||||
|
const w = new EventEmitter();
|
||||||
|
w.close = (cb) => cb && cb();
|
||||||
|
return w;
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
const { app } = makeApp();
|
||||||
|
await request(app)
|
||||||
|
.post('/api/v2/torrents/add')
|
||||||
|
.attach('torrents', makeTorrentBuf('http://webshare-api:3001/stream/r303'), 'r.torrent');
|
||||||
|
await new Promise((r) => setTimeout(r, 120));
|
||||||
|
expect(call).toBe(2);
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates speed on data chunks during download', async () => {
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '1';
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const httpMod = require('http');
|
||||||
|
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
|
||||||
|
const res = new EventEmitter();
|
||||||
|
res.statusCode = 200;
|
||||||
|
res.headers = { 'content-length': '10000' };
|
||||||
|
res.destroy = jest.fn();
|
||||||
|
res.resume = jest.fn();
|
||||||
|
res.pipe = jest.fn((dest) => {
|
||||||
|
setImmediate(() => {
|
||||||
|
res.emit('data', Buffer.alloc(3000));
|
||||||
|
res.emit('data', Buffer.alloc(3000));
|
||||||
|
dest.emit('finish');
|
||||||
|
});
|
||||||
|
return dest;
|
||||||
|
});
|
||||||
|
setImmediate(() => cb(res));
|
||||||
|
return { on: jest.fn() };
|
||||||
|
});
|
||||||
|
jest.spyOn(fs, 'createWriteStream').mockReturnValue(
|
||||||
|
(() => {
|
||||||
|
const w = new EventEmitter();
|
||||||
|
w.close = (cb) => cb && cb();
|
||||||
|
return w;
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { app } = makeApp();
|
||||||
|
const torrent = makeTorrentBuf('http://webshare-api:3001/stream/speed1');
|
||||||
|
await request(app).post('/api/v2/torrents/add').attach('torrents', torrent, 'sp.torrent');
|
||||||
|
await new Promise((r) => setTimeout(r, 150));
|
||||||
|
const { body } = await request(app).get('/api/v2/torrents/info');
|
||||||
|
expect(body[0].state).toBe('uploading');
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '0';
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const { makeTorrent, extractWebseed } = require('../src/torrent');
|
const { makeTorrent, extractWebseed, extractInfoHash } = require('../src/torrent');
|
||||||
|
|
||||||
describe('makeTorrent + extractWebseed round-trip', () => {
|
describe('makeTorrent + extractWebseed round-trip', () => {
|
||||||
it('extracts the webseed URL back from a generated torrent', () => {
|
it('extracts the webseed URL back from a generated torrent', () => {
|
||||||
@@ -23,6 +23,16 @@ describe('makeTorrent + extractWebseed round-trip', () => {
|
|||||||
const buf = makeTorrent({ name: 'movie.mkv', size: 500, webseedUrl: 'http://x.com/s/z' });
|
const buf = makeTorrent({ name: 'movie.mkv', size: 500, webseedUrl: 'http://x.com/s/z' });
|
||||||
expect(buf.toString()).toContain('movie.mkv');
|
expect(buf.toString()).toContain('movie.mkv');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('encodes multi-piece torrents for large sizes', () => {
|
||||||
|
const buf = makeTorrent({
|
||||||
|
name: 'big.mkv',
|
||||||
|
size: 512 * 1024 * 3,
|
||||||
|
webseedUrl: 'http://x.com/s/big',
|
||||||
|
});
|
||||||
|
expect(Buffer.isBuffer(buf)).toBe(true);
|
||||||
|
expect(buf.length).toBeGreaterThan(60);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('extractWebseed', () => {
|
describe('extractWebseed', () => {
|
||||||
@@ -34,6 +44,17 @@ describe('extractWebseed', () => {
|
|||||||
expect(extractWebseed(Buffer.from('not a torrent'))).toBeNull();
|
expect(extractWebseed(Buffer.from('not a torrent'))).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns null for truncated dict (no busy-loop)', () => {
|
||||||
|
// Previously: dict key "pieces" without value → bdecode infinite loop
|
||||||
|
const truncated = Buffer.from(
|
||||||
|
'd4:infod6:lengthi0e4:name4:teste12:piece lengthi524288e6:piecese',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const t0 = Date.now();
|
||||||
|
expect(extractWebseed(truncated)).toBeNull();
|
||||||
|
expect(Date.now() - t0).toBeLessThan(100);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns null when url-list is missing', () => {
|
it('returns null when url-list is missing', () => {
|
||||||
// Bencode a dict without url-list
|
// Bencode a dict without url-list
|
||||||
const buf = makeTorrent({ name: 'f.mkv', size: 0, webseedUrl: 'http://x.com/s/a' });
|
const buf = makeTorrent({ name: 'f.mkv', size: 0, webseedUrl: 'http://x.com/s/a' });
|
||||||
@@ -44,12 +65,42 @@ describe('extractWebseed', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles url-list as array', () => {
|
it('handles url-list as array', () => {
|
||||||
// Manually build a torrent-like bencode with url-list as list
|
|
||||||
const { makeTorrent: mt } = require('../src/torrent');
|
|
||||||
// We test via the actual makeTorrent which uses a string — round-trip test above covers it.
|
|
||||||
// Here just verify the array branch: if url-list is a Buffer (string) it still returns a string.
|
|
||||||
const url = 'http://cdn.example.com/stream/abcdef';
|
const url = 'http://cdn.example.com/stream/abcdef';
|
||||||
const buf = mt({ name: 't.mkv', size: 50, webseedUrl: url });
|
// d8:url-list l <len>:<url> e e
|
||||||
expect(typeof extractWebseed(buf)).toBe('string');
|
const buf = Buffer.from(`d8:url-listl${url.length}:${url}ee`);
|
||||||
|
expect(extractWebseed(buf)).toBe(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for empty url-list array', () => {
|
||||||
|
const buf = Buffer.from('d8:url-listlee');
|
||||||
|
expect(extractWebseed(buf)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractInfoHash', () => {
|
||||||
|
it('returns stable sha1 hex for a generated torrent', () => {
|
||||||
|
const buf = makeTorrent({ name: 't.mkv', size: 50, webseedUrl: 'http://x/s/a' });
|
||||||
|
const h = extractInfoHash(buf);
|
||||||
|
expect(h).toMatch(/^[0-9a-f]{40}$/);
|
||||||
|
expect(extractInfoHash(buf)).toBe(h);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null without info dict or on garbage', () => {
|
||||||
|
expect(extractInfoHash(Buffer.from('d3:fooi1ee'))).toBeNull();
|
||||||
|
expect(extractInfoHash(Buffer.from('not-bencode'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('bdecode edge cases via extractWebseed', () => {
|
||||||
|
it('decodes lists and rejects unclosed list', () => {
|
||||||
|
const url = 'http://a/b';
|
||||||
|
expect(extractWebseed(Buffer.from(`d8:url-listl${url.length}:${url}ee`))).toBe(url);
|
||||||
|
// unclosed list → catch → null
|
||||||
|
expect(extractWebseed(Buffer.from('d8:url-listl5:hello'))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects bad string lengths and unclosed dicts', () => {
|
||||||
|
expect(extractWebseed(Buffer.from('d3:fo'))).toBeNull();
|
||||||
|
expect(extractWebseed(Buffer.from('d4:name5:hello'))).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
182
__tests__/ui-api.test.js
Normal file
182
__tests__/ui-api.test.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// ── shared temp dir ─────────────────────────────────────────────────────────
|
||||||
|
let tmpDir;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ui-api-test-'));
|
||||||
|
process.env.DOWNLOAD_PATH = tmpDir;
|
||||||
|
process.env.MEDIA_ROOT = tmpDir;
|
||||||
|
process.env.MAX_CONCURRENT_DOWNLOADS = '0'; // no actual downloads
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fresh qbt module + express app with the UI API for each test
|
||||||
|
// (module-level state isolation; qbt.test.js's env must not be relied on)
|
||||||
|
function makeApp(client) {
|
||||||
|
let qbtModule;
|
||||||
|
jest.isolateModules(() => {
|
||||||
|
qbtModule = require('../src/qbt');
|
||||||
|
});
|
||||||
|
const { registerUiApi } = require('../src/ui-api');
|
||||||
|
const app = express();
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
app.use(express.json());
|
||||||
|
registerUiApi(app, { qbt: qbtModule, client });
|
||||||
|
return { app, qbt: qbtModule, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── POST /api/queue/cancel ─────────────────────────────────────────────────
|
||||||
|
describe('POST /api/queue/cancel', () => {
|
||||||
|
it('returns 400 when hash is missing', async () => {
|
||||||
|
const { app } = makeApp({ search: jest.fn() });
|
||||||
|
const res = await request(app).post('/api/queue/cancel').send({});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body).toEqual({ error: 'hash required' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 with error body when hash is not in queue', async () => {
|
||||||
|
const { app } = makeApp({ search: jest.fn() });
|
||||||
|
const res = await request(app).post('/api/queue/cancel').send({ hash: 'deadbeef' });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body).toEqual({ error: 'hash not in queue' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels an enqueued download and empties the queue', async () => {
|
||||||
|
const { app, qbt } = makeApp({ search: jest.fn() });
|
||||||
|
qbt.enqueueDirect('ident1', 'Show.mkv', 1000);
|
||||||
|
const hash = qbt.getQueue()[0].hash;
|
||||||
|
|
||||||
|
const res = await request(app).post('/api/queue/cancel').send({ hash });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const queueRes = await request(app).get('/api/queue');
|
||||||
|
expect(queueRes.status).toBe(200);
|
||||||
|
expect(queueRes.body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── regression smoke for the other moved routes ─────────────────────────────
|
||||||
|
describe('moved routes regression', () => {
|
||||||
|
it('GET /api/queue returns 200 with an array', async () => {
|
||||||
|
const { app } = makeApp({ search: jest.fn() });
|
||||||
|
const res = await request(app).get('/api/queue');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Array.isArray(res.body)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/queue/add: missing ident → 400, duplicate → 409, valid → 200', async () => {
|
||||||
|
const { app } = makeApp({ search: jest.fn() });
|
||||||
|
|
||||||
|
const missing = await request(app)
|
||||||
|
.post('/api/queue/add')
|
||||||
|
.send({ name: 'Show.mkv', size: 1000 });
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
expect(missing.body).toEqual({ error: 'ident required' });
|
||||||
|
|
||||||
|
const valid = await request(app)
|
||||||
|
.post('/api/queue/add')
|
||||||
|
.send({ ident: 'identX', name: 'Show.mkv', size: 1000 });
|
||||||
|
expect(valid.status).toBe(200);
|
||||||
|
expect(valid.body).toEqual({ ok: true });
|
||||||
|
|
||||||
|
const dup = await request(app)
|
||||||
|
.post('/api/queue/add')
|
||||||
|
.send({ ident: 'identX', name: 'Show.mkv', size: 1000 });
|
||||||
|
expect(dup.status).toBe(409);
|
||||||
|
expect(dup.body).toEqual({ error: 'already in queue' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/search?q=foo returns the client results', async () => {
|
||||||
|
const results = [
|
||||||
|
{ ident: 'foo-1', name: 'Foo S01E01.mkv', size: 12345 },
|
||||||
|
{ ident: 'foo-2', name: 'Foo S01E02.mkv', size: 12346 },
|
||||||
|
];
|
||||||
|
const { app, client } = makeApp({ search: jest.fn().mockResolvedValue(results) });
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/search?q=foo');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual(results);
|
||||||
|
expect(client.search).toHaveBeenCalledWith('foo', { limit: 30, offset: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 500 error branches + search edge cases ──────────────────────────────────
|
||||||
|
describe('error branches', () => {
|
||||||
|
it('GET /api/search without q → 200 []', async () => {
|
||||||
|
const { app, client } = makeApp({ search: jest.fn() });
|
||||||
|
const res = await request(app).get('/api/search');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual([]);
|
||||||
|
expect(client.search).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/search honors explicit limit and offset', async () => {
|
||||||
|
const { app, client } = makeApp({ search: jest.fn().mockResolvedValue([]) });
|
||||||
|
const res = await request(app).get('/api/search?q=foo&limit=5&offset=3');
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(client.search).toHaveBeenCalledWith('foo', { limit: 5, offset: 3 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/search → 500 when client.search rejects', async () => {
|
||||||
|
const { app } = makeApp({ search: jest.fn().mockRejectedValue(new Error('search failed')) });
|
||||||
|
const res = await request(app).get('/api/search?q=foo');
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body).toEqual({ error: 'search failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/queue/add without name/size still enqueues (defaults)', async () => {
|
||||||
|
const { app } = makeApp({ search: jest.fn() });
|
||||||
|
const res = await request(app).post('/api/queue/add').send({ ident: 'only' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/queue/add → 500 with message when enqueueDirect throws', async () => {
|
||||||
|
const { app, qbt } = makeApp({ search: jest.fn() });
|
||||||
|
jest.spyOn(qbt, 'enqueueDirect').mockImplementation(() => {
|
||||||
|
throw new Error('db down');
|
||||||
|
});
|
||||||
|
const res = await request(app).post('/api/queue/add').send({ ident: 'a', name: 'b' });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body).toEqual({ error: 'db down' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/queue/add → 500 with fallback when error has no message', async () => {
|
||||||
|
const { app, qbt } = makeApp({ search: jest.fn() });
|
||||||
|
jest.spyOn(qbt, 'enqueueDirect').mockImplementation(() => {
|
||||||
|
throw new Error('');
|
||||||
|
});
|
||||||
|
const res = await request(app).post('/api/queue/add').send({ ident: 'a', name: 'b' });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body).toEqual({ error: 'queue add failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/queue → 500 when getQueue throws', async () => {
|
||||||
|
const { app, qbt } = makeApp({ search: jest.fn() });
|
||||||
|
jest.spyOn(qbt, 'getQueue').mockImplementation(() => {
|
||||||
|
throw new Error('');
|
||||||
|
});
|
||||||
|
const res = await request(app).get('/api/queue');
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body).toEqual({ error: 'queue failed' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/queue/cancel → 500 when cancelDownload throws', async () => {
|
||||||
|
const { app, qbt } = makeApp({ search: jest.fn() });
|
||||||
|
jest.spyOn(qbt, 'cancelDownload').mockImplementation(() => {
|
||||||
|
throw new Error('');
|
||||||
|
});
|
||||||
|
const res = await request(app).post('/api/queue/cancel').send({ hash: 'abc' });
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(res.body).toEqual({ error: 'cancel failed' });
|
||||||
|
});
|
||||||
|
});
|
||||||
96
cliff.toml
Normal file
96
cliff.toml
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# git-cliff ~ configuration file
|
||||||
|
# https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
# Used by `--prepend` to remove the previous header.
|
||||||
|
header_marker = "<!-- git-cliff: end of header -->"
|
||||||
|
# A Tera template to be rendered for each release in the changelog.
|
||||||
|
# See https://keats.github.io/tera/#getting-started
|
||||||
|
body = """
|
||||||
|
{% if version %}\
|
||||||
|
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||||
|
{% else %}\
|
||||||
|
## [unreleased]
|
||||||
|
{% endif %}\
|
||||||
|
{% for entry in commits | commit_groups(groups=commit_parsers_groups) %}
|
||||||
|
### {{ entry.group | striptags | trim | upper_first }}
|
||||||
|
{% for commit in entry.commits %}
|
||||||
|
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
|
||||||
|
{% if commit.breaking %}[**breaking**] {% endif %}\
|
||||||
|
{{ commit.message | upper_first }}\
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
"""
|
||||||
|
# Remove leading and trailing whitespaces from the changelog's body.
|
||||||
|
trim = true
|
||||||
|
# Render body even when there are no releases to process.
|
||||||
|
render_always = true
|
||||||
|
# An array of regex based postprocessors to modify the changelog.
|
||||||
|
postprocessors = [
|
||||||
|
# Replace the placeholder <REPO> with a URL.
|
||||||
|
#{ pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" },
|
||||||
|
]
|
||||||
|
# render body even when there are no releases to process
|
||||||
|
# render_always = true
|
||||||
|
# output file path
|
||||||
|
# output = "test.md"
|
||||||
|
|
||||||
|
[git]
|
||||||
|
# Parse commits according to the conventional commits specification.
|
||||||
|
# See https://www.conventionalcommits.org
|
||||||
|
conventional_commits = true
|
||||||
|
# Exclude commits that do not match the conventional commits specification.
|
||||||
|
filter_unconventional = true
|
||||||
|
# Require all commits to be conventional.
|
||||||
|
# Takes precedence over filter_unconventional.
|
||||||
|
require_conventional = false
|
||||||
|
# Split commits on newlines, treating each line as an individual commit.
|
||||||
|
split_commits = false
|
||||||
|
# An array of regex based parsers to modify commit messages prior to further processing.
|
||||||
|
commit_preprocessors = [
|
||||||
|
# Replace issue numbers with link templates to be updated in `changelog.postprocessors`.
|
||||||
|
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
|
||||||
|
# Check spelling of the commit message using https://github.com/crate-ci/typos.
|
||||||
|
# If the spelling is incorrect, it will be fixed automatically.
|
||||||
|
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
|
||||||
|
]
|
||||||
|
# Prevent commits that are breaking from being excluded by commit parsers.
|
||||||
|
protect_breaking_commits = false
|
||||||
|
# An array of regex based parsers for extracting data from the commit message.
|
||||||
|
# Assigns commits to groups.
|
||||||
|
# Optionally sets the commit's scope and can decide to exclude commits from further processing.
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
|
||||||
|
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
|
||||||
|
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
|
||||||
|
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
|
||||||
|
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
|
||||||
|
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
|
||||||
|
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
|
||||||
|
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||||
|
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||||
|
{ message = "^chore\\(pr\\)", skip = true },
|
||||||
|
{ message = "^chore\\(pull\\)", skip = true },
|
||||||
|
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
|
||||||
|
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
|
||||||
|
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
|
||||||
|
{ message = ".*", group = "<!-- 10 -->💼 Other" },
|
||||||
|
]
|
||||||
|
# Exclude commits that are not matched by any commit parser.
|
||||||
|
filter_commits = false
|
||||||
|
# Fail on a commit that is not matched by any commit parser.
|
||||||
|
fail_on_unmatched_commit = false
|
||||||
|
# An array of link parsers for extracting external references, and turning them into URLs, using regex.
|
||||||
|
link_parsers = []
|
||||||
|
# Include only the tags that belong to the current branch.
|
||||||
|
use_branch_tags = false
|
||||||
|
# Order releases topologically instead of chronologically.
|
||||||
|
topo_order = false
|
||||||
|
# Order commits topologically instead of chronologically.
|
||||||
|
topo_order_commits = true
|
||||||
|
# Order of commits in each group/release within the changelog.
|
||||||
|
# Allowed values: newest, oldest
|
||||||
|
sort_commits = "oldest"
|
||||||
|
# Process submodules commits
|
||||||
|
recurse_submodules = false
|
||||||
9
doc/index.md
Normal file
9
doc/index.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
okf_version: "0.1"
|
||||||
|
---
|
||||||
|
|
||||||
|
# webshare-api
|
||||||
|
|
||||||
|
Webshare.cz HTTP API: a Torznab indexer, a fake qBittorrent client, a Web UI, and an MCP server for AI agents. It speaks the official [Webshare.cz](https://webshare.cz) HTTP API and re-exposes it as protocols *arr apps understand (Torznab search plus a qBittorrent-compatible download client), so Sonarr/Radarr can download Webshare files. Because Webshare download links expire after roughly ten minutes, this service resolves a fresh CDN link at fetch time instead of embedding one.
|
||||||
|
|
||||||
|
- [Structure](./structure/) - What the project is, its stack, repo layout, and the verified build/test/run commands.
|
||||||
5
doc/log.md
Normal file
5
doc/log.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Update Log
|
||||||
|
|
||||||
|
## 2026-10-19
|
||||||
|
- **Update**: Queue cancel now returns `404 {error: 'hash not in queue'}` for unknown hashes and the Web UI surfaces the server's error instead of swallowing it; [Web UI JSON API](/structure/ui-api.md) added documenting the four UI routes and the deliberate non-change to the fake qBittorrent API.
|
||||||
|
- **Creation**: Bundle created; [project overview](/structure/overview.md) added.
|
||||||
9
doc/okf-base.yaml
Normal file
9
doc/okf-base.yaml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Manifest for okflint: `okflint validate --manifest okf-base.yaml` (core OKF rules only).
|
||||||
|
okf_version: "0.1"
|
||||||
|
base:
|
||||||
|
name: webshare-api
|
||||||
|
roots:
|
||||||
|
- path: "."
|
||||||
|
reserved_files:
|
||||||
|
index: index.md
|
||||||
|
log: log.md
|
||||||
6
doc/structure/index.md
Normal file
6
doc/structure/index.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# Structure
|
||||||
|
|
||||||
|
What the project is, how it is laid out, and how to build, test and run it.
|
||||||
|
|
||||||
|
- [Project Overview](./overview.md) - purpose, stack, repo layout, verified build/test/run commands.
|
||||||
|
- [Web UI JSON API](./ui-api.md) - the UI-facing JSON routes (search, queue add/list/cancel), the 404-on-cancel contract, and UI error surfacing.
|
||||||
70
doc/structure/overview.md
Normal file
70
doc/structure/overview.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
type: Project
|
||||||
|
title: webshare-api Project Overview
|
||||||
|
description: Purpose, stack, repo layout, and the verified build/test/run commands for the Webshare.cz HTTP API service.
|
||||||
|
tags: [project, node, express, torznab, qbittorrent, mcp]
|
||||||
|
timestamp: 2026-10-19T00:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# webshare-api Project Overview
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`webshare-api` re-exposes [Webshare.cz](https://webshare.cz) as the protocols *arr apps understand, plus an [MCP](https://modelcontextprotocol.io) server for AI agents. The roles:
|
||||||
|
|
||||||
|
- **Torznab indexer** — `GET /api?t=caps|search|tvsearch|movie` turns Webshare search into Torznab RSS/XML, so Sonarr/Radarr can use it as an indexer.
|
||||||
|
- **Fake torrent** — `GET /download/:ident` returns a minimal `.torrent` whose webseed points at this service's `/stream/:ident`, never at a CDN URL (Webshare links expire in ~10 minutes).
|
||||||
|
- **Stream** — `GET /stream/:ident` fetches a fresh Webshare `file_link` and issues an HTTP 302 to the CDN.
|
||||||
|
- **Fake qBittorrent client** — `/api/v2/*` is a qBittorrent-compatible download API that streams the HTTPS file to disk and reports progress; this is what *arr apps hand the torrent to.
|
||||||
|
- **Web UI** — `GET /`, a two-tab TUI-style page (Search, Queue) sharing the same download engine as the fake qBittorrent client.
|
||||||
|
- **MCP server** — `POST /mcp` (Streamable HTTP, stateless POST-only) exposes `webshare_search`, `webshare_get_link`, and `webshare_login_check` to AI agents.
|
||||||
|
|
||||||
|
Webshare credentials are **optional**: with empty `WEBSHARE_USERNAME`/`WEBSHARE_PASSWORD` the app runs in guest mode using unauthenticated `file_link` (no `wst`). Guest downloads work but use the free-tier CDN, which is typically much slower than an authenticated account.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Runtime**: Node.js `>=18`, CommonJS modules.
|
||||||
|
- **Framework**: Express 4 (CORS wide open for LAN UI access; 4 MB JSON body limit).
|
||||||
|
- **Build**: [esbuild](https://esbuild.github.io) (`scripts/build.mjs`) bundles the server and the optional MCP entry each into a **single `.js` file** with dependencies inlined (no `node_modules` at runtime), and inlines the Web UI into one `dist/ui/index.html` (CSS + JS inlined).
|
||||||
|
- **Tests**: Jest 30 + supertest. `npm test` runs `jest --coverage --runInBand`. Coverage thresholds are enforced globally: **90% lines, 80% branches, 90% functions, 90% statements** over `src/**/*.js` (excluding `src/index.js`, which is the entrypoint that starts listening). Test timeout is 15 s and Jest is configured with `forceExit: true`.
|
||||||
|
- **Docker**: multi-stage build (`Dockerfile`); the final image copies only `dist/` (no source or `node_modules`), so the image is far smaller than `npm ci --omit=dev`.
|
||||||
|
- **Commit hooks**: [lefthook](https://github.com/evilmartians/lefthook) (`lefthook.yml`) runs gitleaks (secret scan), a large-file guard, and a conventional-commit subject check. **No repo-wide formatter is enforced.**
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/ # source (CommonJS)
|
||||||
|
index.js # express app + app.listen at module load (server entry)
|
||||||
|
qbt.js # fake qBittorrent client / download queue; cancelDownload returns a boolean
|
||||||
|
ui-api.js # Web UI JSON API routes (search, queue add/list/cancel), mounted before static
|
||||||
|
mcp.js # MCP server entry (Streamable HTTP)
|
||||||
|
webshare.js # Webshare API client
|
||||||
|
torznab.js # Torznab caps/feed builders
|
||||||
|
torrent.js # minimal .torrent generation
|
||||||
|
ui/ # Web UI source (index.html, styles.css, scripts.js)
|
||||||
|
scripts/build.mjs # esbuild: server.js + mcp.js + single-file UI
|
||||||
|
dist/ # build output (gitignored; produced by npm run build or Docker)
|
||||||
|
__tests__/ # Jest + supertest tests
|
||||||
|
compose.yaml # Docker Compose
|
||||||
|
data/ # host download data dir (Compose mount target)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `src/` modules: `qbt.js` is the shared download engine used by both the Web UI queue and the fake qBittorrent API; `cancelDownload(hash)` returns a boolean indicating whether a matching download was cancelled. The UI-facing JSON routes live in `ui-api.js` (extracted from `index.js` so they are testable with supertest); see [Web UI JSON API](./ui-api.md) for their contracts, including the `404` on cancelling a hash that is not in the queue. The fake qBittorrent API in `qbt.js` deliberately keeps its always-`Ok.` contract for real *arr clients.
|
||||||
|
|
||||||
|
## Build / test / run (verified)
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|---------|--------------|
|
||||||
|
| `npm install` | Install dependencies. |
|
||||||
|
| `npm run build` | `node scripts/build.mjs` → `dist/server.js` (~1.9 MB, all deps bundled), `dist/mcp.js` (~1.8 MB), `dist/ui/index.html` (CSS+JS inlined). |
|
||||||
|
| `npm start` | `node dist/server.js` — run the built server (also serves MCP on `PORT`). |
|
||||||
|
| `npm run start:dev` | `node src/index.js` — run unbundled `src/` for local hacking (no build step). |
|
||||||
|
| `npm run mcp` / `npm run mcp:dev` | Run the MCP-only process (no Torznab/qBittorrent), serving `POST /mcp`. |
|
||||||
|
| `npm test` | `jest --coverage --runInBand` (enforces the coverage thresholds above). |
|
||||||
|
| `docker compose up -d --build` | Run via Compose (see `compose.yaml`; copy `.env.example` to `.env` first). |
|
||||||
|
|
||||||
|
Default listen port is `3001` (`PORT`). Webshare credentials are read from `WEBSHARE_USERNAME` / `WEBSHARE_PASSWORD`; full configuration reference lives in the root `README.md`. Do not commit a filled `.env`.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Full configuration table, *arr integration steps, and MCP client config: root [`README.md`](../../README.md).
|
||||||
36
doc/structure/ui-api.md
Normal file
36
doc/structure/ui-api.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
type: API
|
||||||
|
title: Web UI JSON API
|
||||||
|
description: The UI-facing JSON routes served by src/ui-api.js (search, queue add/list/cancel), the 404-on-cancel contract, and the UI error surfacing.
|
||||||
|
tags: [api, ui, queue, express]
|
||||||
|
timestamp: 2026-10-19T00:00:00Z
|
||||||
|
---
|
||||||
|
|
||||||
|
# Web UI JSON API
|
||||||
|
|
||||||
|
The Web UI talks to the app over four JSON routes registered by `registerUiApi(app, { qbt, client })` in `src/ui-api.js`, which `src/index.js` mounts **before** static file serving so `/api/*` is never swallowed. `__tests__/ui-api.test.js` covers all four routes.
|
||||||
|
|
||||||
|
| Route | Success | Error |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| `GET /api/search?q=…` | `200` result array (`[]` when `q` is empty) | `500` `{error}` when the Webshare client fails |
|
||||||
|
| `POST /api/queue/add` | `200` `{ok: true}` | `400` `{error: 'ident required'}`, `409` `{error: 'already in queue'}`, `500` `{error}` |
|
||||||
|
| `GET /api/queue` | `200` queue array | `500` `{error}` |
|
||||||
|
| `POST /api/queue/cancel` | `200` `{ok: true}` | `400` `{error: 'hash required'}`, **`404` `{error: 'hash not in queue'}`** when the hash is not in the queue, `500` `{error}` |
|
||||||
|
|
||||||
|
## Cancel returns 404 for unknown hashes
|
||||||
|
|
||||||
|
`POST /api/queue/cancel` honors the return value of `qbt.cancelDownload(hash)` (see [Project Overview](./overview.md)), which is a boolean: `false` means no queued download matched the hash, and the route responds `404 {error: 'hash not in queue'}`. Before this, the route always answered `200 {ok: true}` even when nothing was cancelled.
|
||||||
|
|
||||||
|
The 404 body message is deliberately human-readable — the UI surfaces `error` verbatim (below), and a raw body read is still intelligible.
|
||||||
|
|
||||||
|
## The UI surfaces the error
|
||||||
|
|
||||||
|
`apiFetch` in `ui/scripts.js` parses a failed response's JSON body when `content-type` is `application/json` and throws `new Error(data.error)` (falling back to the raw text or `HTTP <status>` for non-JSON or malformed bodies). The cancel-button handler then shows the failure inline as a transient `[err]` button state (re-enabled after 2 s) instead of swallowing it in `catch {}`; the 2 s queue poll repaints the row. This mirrors the established inline-error pattern of the add button — the codebase has no toasts or alerts.
|
||||||
|
|
||||||
|
## The fake qBittorrent API is intentionally different
|
||||||
|
|
||||||
|
The fake qBittorrent Web API in `src/qbt.js` (`POST /api/v2/torrents/delete` and friends) was **not** changed: it answers `Ok.` for real qBittorrent-API clients such as Sonarr, whose contract is always `Ok.`, and Sonarr deletes finished entries — a `404` there would break it. The two APIs serve different consumers and keep different error contracts on purpose.
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- [Project Overview](./overview.md) — module layout and build/test commands.
|
||||||
22
lefthook.yml
Normal file
22
lefthook.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Commit checks. Generated by project-bootstrap; coding agents may not edit it.
|
||||||
|
# Remove the sections for languages the project doesn't use.
|
||||||
|
# Formatter (biome) intentionally not enforced repo-wide: the codebase is
|
||||||
|
# pre-existing, unformatted (spaces), and has no config — it gets formatted
|
||||||
|
# file by file as files are touched, not in a mass reformat.
|
||||||
|
pre-commit:
|
||||||
|
parallel: true
|
||||||
|
commands:
|
||||||
|
gitleaks:
|
||||||
|
run: gitleaks git --pre-commit --staged --no-banner --redact
|
||||||
|
large-files:
|
||||||
|
run: .githooks/check-large-files.sh {staged_files}
|
||||||
|
|
||||||
|
# --- shell ---
|
||||||
|
shellcheck:
|
||||||
|
glob: "*.sh"
|
||||||
|
run: shellcheck {staged_files}
|
||||||
|
|
||||||
|
commit-msg:
|
||||||
|
commands:
|
||||||
|
conventional:
|
||||||
|
run: .githooks/commit-msg-check.sh {1}
|
||||||
49
src/index.js
49
src/index.js
@@ -6,6 +6,7 @@ const { caps, feed } = require('./torznab');
|
|||||||
const { makeTorrent } = require('./torrent');
|
const { makeTorrent } = require('./torrent');
|
||||||
const qbt = require('./qbt');
|
const qbt = require('./qbt');
|
||||||
const { registerMcpHttp } = require('./mcp-http');
|
const { registerMcpHttp } = require('./mcp-http');
|
||||||
|
const { registerUiApi } = require('./ui-api');
|
||||||
|
|
||||||
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}`;
|
||||||
@@ -124,53 +125,7 @@ app.get('/resolve/:ident', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── UI API (before static so /api/* is never swallowed) ───────────────────
|
// ── UI API (before static so /api/* is never swallowed) ───────────────────
|
||||||
app.get('/api/search', async (req, res) => {
|
registerUiApi(app, { qbt, client });
|
||||||
const q = (req.query.q || '').trim();
|
|
||||||
if (!q) return res.json([]);
|
|
||||||
try {
|
|
||||||
const results = await client.search(q, {
|
|
||||||
limit: Math.min(parseInt(req.query.limit) || 30, 100),
|
|
||||||
offset: parseInt(req.query.offset) || 0,
|
|
||||||
});
|
|
||||||
res.json(results);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/queue/add', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { ident, name, size } = req.body || {};
|
|
||||||
if (!ident) return res.status(400).json({ error: 'ident required' });
|
|
||||||
const ok = qbt.enqueueDirect(ident, name || ident, parseInt(size) || 0);
|
|
||||||
if (!ok) return res.status(409).json({ error: 'already in queue' });
|
|
||||||
res.json({ ok: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('queue/add', err);
|
|
||||||
res.status(500).json({ error: err.message || 'queue add failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/queue', (req, res) => {
|
|
||||||
try {
|
|
||||||
res.json(qbt.getQueue());
|
|
||||||
} catch (err) {
|
|
||||||
console.error('queue', err);
|
|
||||||
res.status(500).json({ error: err.message || 'queue failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/queue/cancel', (req, res) => {
|
|
||||||
try {
|
|
||||||
const { hash } = req.body || {};
|
|
||||||
if (!hash) return res.status(400).json({ error: 'hash required' });
|
|
||||||
qbt.cancelDownload(hash);
|
|
||||||
res.json({ ok: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error('queue/cancel', err);
|
|
||||||
res.status(500).json({ error: err.message || 'cancel failed' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
res.json({ ok: true, guest: client.guest });
|
res.json({ ok: true, guest: client.guest });
|
||||||
|
|||||||
@@ -17,38 +17,57 @@ function bencode(v) {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minimal bencode decoder — returns Buffers for byte strings
|
// Minimal bencode decoder — returns Buffers for byte strings.
|
||||||
|
// Throws on truncated / malformed input (never busy-loops).
|
||||||
function bdecode(buf, pos = { i: 0 }) {
|
function bdecode(buf, pos = { i: 0 }) {
|
||||||
|
if (!Buffer.isBuffer(buf) || pos.i < 0 || pos.i >= buf.length) {
|
||||||
|
throw new Error('bdecode: truncated');
|
||||||
|
}
|
||||||
const c = buf[pos.i];
|
const c = buf[pos.i];
|
||||||
if (c === 0x69) { // 'i'
|
if (c === 0x69) { // 'i'
|
||||||
pos.i++;
|
pos.i++;
|
||||||
const end = buf.indexOf(0x65, pos.i); // 'e'
|
const end = buf.indexOf(0x65, pos.i); // 'e'
|
||||||
|
if (end < 0) throw new Error('bdecode: bad int');
|
||||||
const n = parseInt(buf.slice(pos.i, end).toString(), 10);
|
const n = parseInt(buf.slice(pos.i, end).toString(), 10);
|
||||||
|
if (!Number.isFinite(n)) throw new Error('bdecode: bad int');
|
||||||
pos.i = end + 1;
|
pos.i = end + 1;
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
if (c === 0x6c) { // 'l'
|
if (c === 0x6c) { // 'l'
|
||||||
pos.i++;
|
pos.i++;
|
||||||
const arr = [];
|
const arr = [];
|
||||||
while (buf[pos.i] !== 0x65) arr.push(bdecode(buf, pos));
|
while (pos.i < buf.length && buf[pos.i] !== 0x65) {
|
||||||
|
arr.push(bdecode(buf, pos));
|
||||||
|
}
|
||||||
|
if (pos.i >= buf.length || buf[pos.i] !== 0x65) {
|
||||||
|
throw new Error('bdecode: unclosed list');
|
||||||
|
}
|
||||||
pos.i++;
|
pos.i++;
|
||||||
return arr;
|
return arr;
|
||||||
}
|
}
|
||||||
if (c === 0x64) { // 'd'
|
if (c === 0x64) { // 'd'
|
||||||
pos.i++;
|
pos.i++;
|
||||||
const obj = {};
|
const obj = {};
|
||||||
while (buf[pos.i] !== 0x65) {
|
while (pos.i < buf.length && buf[pos.i] !== 0x65) {
|
||||||
const key = bdecode(buf, pos).toString('utf8');
|
const key = bdecode(buf, pos).toString('utf8');
|
||||||
obj[key] = bdecode(buf, pos);
|
obj[key] = bdecode(buf, pos);
|
||||||
}
|
}
|
||||||
|
if (pos.i >= buf.length || buf[pos.i] !== 0x65) {
|
||||||
|
throw new Error('bdecode: unclosed dict');
|
||||||
|
}
|
||||||
pos.i++;
|
pos.i++;
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
// byte string: "N:..."
|
// byte string: "N:..."
|
||||||
const colon = buf.indexOf(0x3a, pos.i); // ':'
|
const colon = buf.indexOf(0x3a, pos.i); // ':'
|
||||||
|
if (colon < 0 || colon >= buf.length) throw new Error('bdecode: bad string');
|
||||||
const len = parseInt(buf.slice(pos.i, colon).toString(), 10);
|
const len = parseInt(buf.slice(pos.i, colon).toString(), 10);
|
||||||
pos.i = colon + 1 + len;
|
if (!Number.isFinite(len) || len < 0) throw new Error('bdecode: bad string length');
|
||||||
return buf.slice(colon + 1, pos.i);
|
const start = colon + 1;
|
||||||
|
const end = start + len;
|
||||||
|
if (end > buf.length) throw new Error('bdecode: string past end');
|
||||||
|
pos.i = end;
|
||||||
|
return buf.slice(start, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a minimal .torrent with an HTTP webseed and zeroed piece hashes.
|
// Build a minimal .torrent with an HTTP webseed and zeroed piece hashes.
|
||||||
|
|||||||
59
src/ui-api.js
Normal file
59
src/ui-api.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* Mount the Web UI's JSON API on an Express app.
|
||||||
|
* Must be registered before static file serving so /api/* is never swallowed.
|
||||||
|
*
|
||||||
|
* @param {import('express').Express} app
|
||||||
|
* @param {{ qbt: import('./qbt'), client: import('./webshare').WebshareClient }} options
|
||||||
|
*/
|
||||||
|
function registerUiApi(app, { qbt, client }) {
|
||||||
|
app.get('/api/search', async (req, res) => {
|
||||||
|
const q = (req.query.q || '').trim();
|
||||||
|
if (!q) return res.json([]);
|
||||||
|
try {
|
||||||
|
const results = await client.search(q, {
|
||||||
|
limit: Math.min(parseInt(req.query.limit) || 30, 100),
|
||||||
|
offset: parseInt(req.query.offset) || 0,
|
||||||
|
});
|
||||||
|
res.json(results);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/queue/add', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ident, name, size } = req.body || {};
|
||||||
|
if (!ident) return res.status(400).json({ error: 'ident required' });
|
||||||
|
const ok = qbt.enqueueDirect(ident, name || ident, parseInt(size) || 0);
|
||||||
|
if (!ok) return res.status(409).json({ error: 'already in queue' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('queue/add', err);
|
||||||
|
res.status(500).json({ error: err.message || 'queue add failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/queue', (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(qbt.getQueue());
|
||||||
|
} catch (err) {
|
||||||
|
console.error('queue', err);
|
||||||
|
res.status(500).json({ error: err.message || 'queue failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/queue/cancel', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { hash } = req.body || {};
|
||||||
|
if (!hash) return res.status(400).json({ error: 'hash required' });
|
||||||
|
const ok = qbt.cancelDownload(hash);
|
||||||
|
if (!ok) return res.status(404).json({ error: 'hash not in queue' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('queue/cancel', err);
|
||||||
|
res.status(500).json({ error: err.message || 'cancel failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerUiApi };
|
||||||
@@ -11,11 +11,18 @@ async function apiFetch(path, opts) {
|
|||||||
...(opts && opts.headers),
|
...(opts && opts.headers),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const ct = res.headers.get('content-type') || '';
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => '');
|
const text = await res.text().catch(() => '');
|
||||||
throw new Error(text || `HTTP ${res.status}`);
|
let msg = text || `HTTP ${res.status}`;
|
||||||
|
if (ct.includes('application/json')) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
if (data && data.error) msg = data.error;
|
||||||
|
} catch {} // Malformed JSON: keep the raw text
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
}
|
}
|
||||||
const ct = res.headers.get('content-type') || '';
|
|
||||||
if (ct.includes('application/json')) return res.json();
|
if (ct.includes('application/json')) return res.json();
|
||||||
return res.text();
|
return res.text();
|
||||||
}
|
}
|
||||||
@@ -209,8 +216,11 @@ function renderQueue() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ hash }),
|
body: JSON.stringify({ hash }),
|
||||||
});
|
});
|
||||||
} catch {}
|
renderQueue();
|
||||||
renderQueue();
|
} catch {
|
||||||
|
btn.textContent = '[err]';
|
||||||
|
setTimeout(() => { btn.disabled = false; btn.textContent = '[x]'; }, 2000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
|
|||||||
Reference in New Issue
Block a user