feat(queue): 404 on unknown hash, show error in UI

POST /api/queue/cancel answers 404 {"error":"hash not in queue"} for
untracked hashes instead of 200 ok:true. The UI surfaces the server's
error message instead of swallowing it.
This commit is contained in:
2026-10-19 19:11:40 +02:00
parent 44c2888a2e
commit 99dcb298f8
8 changed files with 297 additions and 52 deletions

182
__tests__/ui-api.test.js Normal file
View 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' });
});
});

View File

@@ -1,4 +1,5 @@
# Update Log # Update Log
## 2026-10-19 ## 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. - **Creation**: Bundle created; [project overview](/structure/overview.md) added.

View File

@@ -3,3 +3,4 @@
What the project is, how it is laid out, and how to build, test and run it. 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. - [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.

View File

@@ -36,6 +36,7 @@ Webshare credentials are **optional**: with empty `WEBSHARE_USERNAME`/`WEBSHARE_
src/ # source (CommonJS) src/ # source (CommonJS)
index.js # express app + app.listen at module load (server entry) index.js # express app + app.listen at module load (server entry)
qbt.js # fake qBittorrent client / download queue; cancelDownload returns a boolean 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) mcp.js # MCP server entry (Streamable HTTP)
webshare.js # Webshare API client webshare.js # Webshare API client
torznab.js # Torznab caps/feed builders torznab.js # Torznab caps/feed builders
@@ -48,7 +49,7 @@ compose.yaml # Docker Compose
data/ # host download data dir (Compose mount target) 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 `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) ## Build / test / run (verified)

36
doc/structure/ui-api.md Normal file
View 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.

View File

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

59
src/ui-api.js Normal file
View 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 };

View File

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