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
## 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.

View File

@@ -3,3 +3,4 @@
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.

View File

@@ -36,6 +36,7 @@ Webshare credentials are **optional**: with empty `WEBSHARE_USERNAME`/`WEBSHARE_
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
@@ -48,7 +49,7 @@ 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 `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)

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 qbt = require('./qbt');
const { registerMcpHttp } = require('./mcp-http');
const { registerUiApi } = require('./ui-api');
const PORT = process.env.PORT || 3001;
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) ───────────────────
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' });
qbt.cancelDownload(hash);
res.json({ ok: true });
} catch (err) {
console.error('queue/cancel', err);
res.status(500).json({ error: err.message || 'cancel failed' });
}
});
registerUiApi(app, { qbt, client });
app.get('/api/health', (req, res) => {
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),
},
});
const ct = res.headers.get('content-type') || '';
if (!res.ok) {
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();
return res.text();
}
@@ -209,8 +216,11 @@ function renderQueue() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash }),
});
} catch {}
renderQueue();
renderQueue();
} catch {
btn.textContent = '[err]';
setTimeout(() => { btn.disabled = false; btn.textContent = '[x]'; }, 2000);
}
});
});
}).catch(err => {