diff --git a/README.md b/README.md index be389cb..cd105a4 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Libreseerr is a book request management application for [Readarr](https://readar ## Features - Search for books by title, author, or ISBN -- Request ebooks or audiobooks from separate Readarr, Bookshelf, or LazyLibrarian instances +- Request ebooks, audiobooks, or both at once from separate Readarr, Bookshelf, or LazyLibrarian instances +- Optional Hardcover integration for richer search/discovery metadata (falls back to Open Library) - Track download progress with real-time status updates - View quality profiles and root folders from your configured server - Manage and remove requests from a dedicated requests page @@ -102,6 +103,8 @@ Select the **Server Software** dropdown to choose between Readarr, Bookshelf, or Click **Test Connection** to verify each server is reachable, then **Save**. +Optionally, add a **Hardcover** API token in **Settings** to use [Hardcover](https://hardcover.app/) as the search/discovery metadata source. When no token is set, Libreseerr uses the public Open Library API by default. + ![Libreseerr Settings Page](screenshots/settings.png) ## Usage @@ -109,7 +112,7 @@ Click **Test Connection** to verify each server is reachable, then **Save**. 1. Log in with your admin credentials (default: `admin` / `admin`). 2. Go to the **Discover** page and search for a book by title, author, or ISBN. 3. Click a book card to open the download dialog. -4. Select **ebook** or **audiobook**, choose a quality profile and root folder, then click **Download**. +4. Select **ebook**, **audiobook**, or both. Each selected format gets its own quality profile and root folder (formats whose server isn't configured are disabled). Click **Download** to submit — requesting both creates one request per format. 5. Switch to the **Requests** page to monitor progress. 6. Click **Refresh Status** to poll your server for the latest download status. @@ -187,6 +190,20 @@ python app.py The development server starts on `http://0.0.0.0:5000` with debug mode enabled. +### Testing and Linting + +Install the dev dependencies, then run the linter and test suite: + +```bash +pip install -r requirements-dev.txt +ruff check . # lint +pytest # test suite +``` + +Both run in CI (`.github/workflows/ci.yml`) on every push and pull request, +alongside a job that builds the Docker image and verifies the container serves. +Run `ruff check .` and `pytest` locally before opening a PR — both gate the build. + ## License See [LICENSE](LICENSE) for details. diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e590912 --- /dev/null +++ b/TODO.md @@ -0,0 +1,35 @@ +# TODO + +## Bindery backend support (upstream issue #12) + +**What:** Users can connect Libreseerr to a Bindery server (Service Type: +Readarr) and pass the connection test, but every book request fails with +`HTTP 400 {"error":"isbn or asin parameter required"}`. + +**Why it happens:** Bindery (`vavallee/bindery`) is *not* a Readarr API clone. +It shares the `/api/v1` prefix and `X-Api-Key` header but deliberately splits +lookup into different endpoints: + +| Purpose | Bindery | Readarr (what Libreseerr sends) | +|-----------------|----------------------------------|----------------------------------------| +| Free-text search| `GET /api/v1/search/book?q=…` | `GET /api/v1/book/lookup?term=…` | +| ISBN/ASIN lookup| `GET /api/v1/book/lookup?isbn=…` | `GET /api/v1/book/lookup?term=isbn:…` | +| Author search | `GET /api/v1/search/author?q=…` | `GET /api/v1/author/lookup?term=…` | + +Bindery only advertises arr-compatibility for `/api/queue`; the rest is its own +contract. So `ReadarrClient.search_books` and `.lookup_by_isbn` both 400. + +**Decision:** This is a *new backend*, not a Readarr bugfix. Add a dedicated +`BinderyClient` (like `bookshelf.py` / `lazylibrarian.py`) and a `server_software` +option, rather than special-casing `ReadarrClient`. + +**Before building:** +- Verify the write path against a live Bindery instance. Only `/api/queue` is + confirmed arr-shaped; the `POST /book` + author-ensure + `command BookSearch` + flow in `readarr.py:196-291` is unverified and may differ. +- Confirm `get_queue` / `get_book_status` / `get_history` shapes on Bindery. + +**Already tried:** None — scoped only. Worth doing because Readarr was archived +June 2025 (dead metadata backend) and Bindery is the main migration target. + +Refs: https://github.com/vavallee/bindery , docs/API.md in that repo. diff --git a/app.py b/app.py index 70e5d61..fee7cb2 100644 --- a/app.py +++ b/app.py @@ -30,6 +30,14 @@ from lazylibrarian import LazyLibrarianClient from readarr import ReadarrClient +# Open Library / Internet Archive filters requests whose User-Agent starts with +# "python-requests" (the requests library default), returning 403 on every API +# call. Send an identifying UA so search/discover work on a fresh install. This +# runs at import time, before any HTTP request is issued. +# https://github.com/zamnzim/Libreseerr/issues/10 +_USER_AGENT = "Libreseerr/1.0 (+https://github.com/zamnzim/Libreseerr)" +http_requests.utils.default_user_agent = lambda *a, **kw: _USER_AGENT + app = Flask(__name__) # Honor X-Forwarded-* headers from a reverse proxy (haproxy, nginx, traefik, etc.) @@ -1057,22 +1065,14 @@ def get_root_folders(server_type): # ─── Download / Request API ─── -@app.route("/api/request", methods=["POST"]) -@login_required -def create_request(): - data = request.json - server_type = data.get("server_type") - book_data = data.get("book") - quality_profile_id = data.get("quality_profile_id") - root_folder = data.get("root_folder") - - if not all([server_type, book_data, quality_profile_id, root_folder]): - return jsonify({"error": "Missing required fields"}), 400 - client = get_client(server_type) - if not client: - return jsonify({"error": f"{server_type} server not configured"}), 400 +def _create_single_request(server_type, book_data, quality_profile_id, root_folder): + """Build and submit one request entry for a single server slot. + Returns the request_entry dict (status 'processing' on success, 'error' on + failure). Does NOT insert into requests_history or persist — the caller does + that for all entries together under `lock`. + """ title = book_data.get("title", "Unknown") authors = book_data.get("authors", []) author_name = authors[0] if authors else "Unknown" @@ -1094,7 +1094,10 @@ def create_request(): } try: - # First, try to find the book in Readarr via ISBN lookup + client = get_client(server_type) + if not client: + raise ValueError(f"{server_type} server not configured") + readarr_books = [] if isbn: readarr_books = client.lookup_by_isbn(isbn) @@ -1102,44 +1105,69 @@ def create_request(): readarr_books = client.search_books(f"{title} {author_name}") if readarr_books: - # Use the full Readarr lookup result — it has the correct - # editions, images, links, etc. that Readarr expects. - # We only override the author if Readarr returned empty data. readarr_book = readarr_books[0] if not readarr_book.get("author", {}).get("authorName"): - readarr_book["author"] = { - "authorName": author_name, - "foreignAuthorId": "", - } + readarr_book["author"] = {"authorName": author_name, "foreignAuthorId": ""} app.logger.info( - "Readarr match for '%s': title='%s', author=%s", + "Backend match for '%s': title='%s', author=%s", title, readarr_book.get("title"), json.dumps(readarr_book.get("author", {})), ) - request_entry["status"] = "processing" else: - # Fallback: build data from Open Library readarr_book = { "title": title, - "author": { - "authorName": author_name, - "foreignAuthorId": "", - }, + "author": {"authorName": author_name, "foreignAuthorId": ""}, "foreignBookId": isbn or book_data.get("id", ""), } - app.logger.info("No Readarr match, using Open Library fallback for '%s' by '%s'", title, author_name) - request_entry["status"] = "processing" + app.logger.info("No backend match, using Open Library fallback for '%s' by '%s'", title, author_name) + request_entry["status"] = "processing" result = client.add_book(readarr_book, quality_profile_id, root_folder) request_entry["readarr_book_id"] = result.get("id") except Exception as e: request_entry["status"] = "error" request_entry["error"] = str(e) + return request_entry + + +@app.route("/api/request", methods=["POST"]) +@login_required +def create_request(): + data = request.json or {} + book_data = data.get("book") + targets = data.get("targets") + + if not book_data or not isinstance(targets, list) or not targets: + return jsonify({"error": "Request must include 'book' and a non-empty 'targets' list"}), 400 + + for t in targets: + if not isinstance(t, dict): + return jsonify({"error": "Each target must be an object"}), 400 + st = t.get("server_type") + if st not in ("ebook", "audiobook"): + return jsonify({"error": "target server_type must be 'ebook' or 'audiobook'"}), 400 + if not t.get("quality_profile_id") or not t.get("root_folder"): + return jsonify({"error": f"{st} target missing quality_profile_id or root_folder"}), 400 + if not get_client(st): + return jsonify({"error": f"{st} server not configured"}), 400 + + entries = [ + _create_single_request( + t["server_type"], book_data, t["quality_profile_id"], t["root_folder"] + ) + for t in targets + ] + with lock: - requests_history.insert(0, request_entry) + used_ids = {r["id"] for r in requests_history} + for entry in entries: + while entry["id"] in used_ids: + entry["id"] += 1 # avoid same-millisecond / existing-history id collisions + used_ids.add(entry["id"]) + requests_history.insert(0, entry) save_requests() - return jsonify(request_entry) + return jsonify(entries) @app.route("/api/requests", methods=["GET"]) diff --git a/docs/superpowers/plans/2026-06-29-request-both-formats.md b/docs/superpowers/plans/2026-06-29-request-both-formats.md new file mode 100644 index 0000000..6afd5d8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-request-both-formats.md @@ -0,0 +1,599 @@ +# Request Both Formats Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a user request a book for the ebook slot, the audiobook slot, or both at once from the download modal, each format keeping its own quality profile and root folder. + +**Architecture:** "Both" = two independent request entries created from one submit. Backend extracts the per-slot logic into a Flask-free helper (`_create_single_request`) unit-tested directly; the `/api/request` route becomes a thin wrapper accepting a `targets` array and isolating per-target failures. Frontend turns the two format buttons into multi-select toggles, renders one quality-profile + root-folder section per selected slot, and disables unconfigured slots with a hover tooltip. + +**Tech Stack:** Flask (Python), vanilla JS + plain HTML/CSS (no bundler), pytest. + +## Global Constraints + +- No new dependencies; vanilla JS only, no bundler/build step (`CLAUDE.md`). +- Backend state pattern: mutate module globals under `lock`, then `save_*` (`CLAUDE.md` "State & persistence"). +- All three backend clients are duck-typed; tests use fakes, never real network. +- Two server slots only: `"ebook"` and `"audiobook"`. +- Frontend has no JS test harness — frontend changes are verified in the browser, and only what is visible on screen counts as verified (`~/.claude/CLAUDE.md` "Browser Verification"). + +--- + +### Task 1: Backend helper `_create_single_request` + +Extract the existing single-request body into a reusable, Flask-free helper. Pure refactor of behavior for one slot; no route change yet. + +**Files:** +- Modify: `app.py` (the `create_request` route, currently ~`app.py:1075-1149` — locate by the `@app.route("/api/request", methods=["POST"])` decorator) +- Test: `tests/test_request_targets.py` (create) + +**Interfaces:** +- Produces: `_create_single_request(server_type: str, book_data: dict, quality_profile_id: int, root_folder: str) -> dict` — returns a request_entry dict with `status` `"processing"` on success (and a `readarr_book_id` key) or `"error"` (with `error` set). Does NOT insert into `requests_history` or persist. +- Consumes: existing module-level `get_client`, `time`, `datetime`, `UTC`, `json`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_request_targets.py`: + +```python +"""Tier 2: /api/request multi-target (request both formats) behavior.""" +import pytest + +import app as app_module + + +class _FakeClient: + """Duck-typed stand-in for a backend client.""" + + def __init__(self, add_raises=False, found=True): + self.add_raises = add_raises + self.found = found + + def _hit(self): + return [{"title": "X", "author": {"authorName": "A"}, "foreignBookId": "1"}] if self.found else [] + + def lookup_by_isbn(self, isbn): + return self._hit() + + def search_books(self, query): + return self._hit() + + def add_book(self, book, quality_profile_id, root_folder): + if self.add_raises: + raise RuntimeError("backend boom") + return {"id": 42} + + +def test_single_request_success(monkeypatch): + monkeypatch.setattr(app_module, "get_client", lambda st: _FakeClient()) + book = {"title": "Dune", "authors": ["Frank Herbert"], "isbn_13": "9780441013593"} + entry = app_module._create_single_request("ebook", book, 1, "/books") + assert entry["status"] == "processing" + assert entry["server_type"] == "ebook" + assert entry["readarr_book_id"] == 42 + + +def test_single_request_add_book_error(monkeypatch): + monkeypatch.setattr(app_module, "get_client", lambda st: _FakeClient(add_raises=True)) + book = {"title": "Dune", "authors": ["Frank Herbert"]} + entry = app_module._create_single_request("audiobook", book, 2, "/audio") + assert entry["status"] == "error" + assert "boom" in entry["error"] + + +def test_single_request_no_client(monkeypatch): + monkeypatch.setattr(app_module, "get_client", lambda st: None) + entry = app_module._create_single_request("ebook", {"title": "X"}, 1, "/books") + assert entry["status"] == "error" + assert "not configured" in entry["error"] +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_request_targets.py -q` +Expected: FAIL with `AttributeError: module 'app' has no attribute '_create_single_request'`. + +- [ ] **Step 3: Add the helper** + +In `app.py`, directly above the `@app.route("/api/request", methods=["POST"])` decorator, add: + +```python +def _create_single_request(server_type, book_data, quality_profile_id, root_folder): + """Build and submit one request entry for a single server slot. + + Returns the request_entry dict (status 'processing' on success, 'error' on + failure). Does NOT insert into requests_history or persist — the caller does + that for all entries together under `lock`. + """ + title = book_data.get("title", "Unknown") + authors = book_data.get("authors", []) + author_name = authors[0] if authors else "Unknown" + cover_url = book_data.get("cover", "") + isbn = book_data.get("isbn_13") or book_data.get("isbn_10", "") + + request_entry = { + "id": int(time.time() * 1000), + "title": title, + "author": author_name, + "cover_url": cover_url, + "server_type": server_type, + "quality_profile_id": quality_profile_id, + "isbn": isbn, + "status": "pending", + "progress": 0, + "error": None, + "created_at": datetime.now(UTC).isoformat(), + } + + try: + client = get_client(server_type) + if not client: + raise ValueError(f"{server_type} server not configured") + + readarr_books = [] + if isbn: + readarr_books = client.lookup_by_isbn(isbn) + if not readarr_books: + readarr_books = client.search_books(f"{title} {author_name}") + + if readarr_books: + readarr_book = readarr_books[0] + if not readarr_book.get("author", {}).get("authorName"): + readarr_book["author"] = {"authorName": author_name, "foreignAuthorId": ""} + app.logger.info( + "Backend match for '%s': title='%s', author=%s", + title, readarr_book.get("title"), json.dumps(readarr_book.get("author", {})), + ) + else: + readarr_book = { + "title": title, + "author": {"authorName": author_name, "foreignAuthorId": ""}, + "foreignBookId": isbn or book_data.get("id", ""), + } + app.logger.info("No backend match, using Open Library fallback for '%s' by '%s'", title, author_name) + + request_entry["status"] = "processing" + result = client.add_book(readarr_book, quality_profile_id, root_folder) + request_entry["readarr_book_id"] = result.get("id") + except Exception as e: + request_entry["status"] = "error" + request_entry["error"] = str(e) + + return request_entry +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `python -m pytest tests/test_request_targets.py -q` +Expected: PASS (3 passed). + +- [ ] **Step 5: Commit** + +```bash +git add app.py tests/test_request_targets.py +git commit -m "Add _create_single_request helper for per-slot requests (issue #8)" +``` + +--- + +### Task 2: `/api/request` accepts a `targets` array + +Rewrite the route to consume the helper for 1–2 targets, validate up front, assign unique ids, persist once, and return the list of entries. + +**Files:** +- Modify: `app.py` (the `create_request` route body) +- Test: `tests/test_request_targets.py` (add route tests + fixture) + +**Interfaces:** +- Consumes: `_create_single_request` (Task 1), module globals `lock`, `requests_history`, `save_requests`, `get_client`. +- Produces: `POST /api/request` with JSON `{ "book": {...}, "targets": [ {server_type, quality_profile_id, root_folder}, ... ] }` → `200` with a JSON **list** of request entries, or `400` `{ "error": ... }`. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/test_request_targets.py`: + +```python +@pytest.fixture +def auth_client(flask_app, monkeypatch): + """Test client with auth bypassed and persistence/reload neutralized so the + route mutates an in-memory requests_history we control.""" + flask_app.config["LOGIN_DISABLED"] = True + monkeypatch.setattr(app_module, "save_requests", lambda: None) + monkeypatch.setattr(app_module, "load_requests", lambda: None) # reload_state no-op + monkeypatch.setattr(app_module, "requests_history", []) + return flask_app.test_client() + + +_BOOK = {"title": "Dune", "authors": ["Frank Herbert"]} + + +def test_request_both_creates_two_entries(auth_client, monkeypatch): + monkeypatch.setattr(app_module, "get_client", lambda st: _FakeClient()) + resp = auth_client.post("/api/request", json={ + "book": _BOOK, + "targets": [ + {"server_type": "ebook", "quality_profile_id": 1, "root_folder": "/books"}, + {"server_type": "audiobook", "quality_profile_id": 1, "root_folder": "/audio"}, + ], + }) + assert resp.status_code == 200 + entries = resp.get_json() + assert len(entries) == 2 + assert {e["server_type"] for e in entries} == {"ebook", "audiobook"} + assert all(e["status"] == "processing" for e in entries) + assert entries[0]["id"] != entries[1]["id"] # unique ids + + +def test_request_isolates_partial_failure(auth_client, monkeypatch): + monkeypatch.setattr(app_module, "get_client", + lambda st: _FakeClient(add_raises=(st == "audiobook"))) + resp = auth_client.post("/api/request", json={ + "book": _BOOK, + "targets": [ + {"server_type": "ebook", "quality_profile_id": 1, "root_folder": "/books"}, + {"server_type": "audiobook", "quality_profile_id": 1, "root_folder": "/audio"}, + ], + }) + assert resp.status_code == 200 + by_type = {e["server_type"]: e for e in resp.get_json()} + assert by_type["ebook"]["status"] == "processing" + assert by_type["audiobook"]["status"] == "error" + + +def test_request_single_target_still_works(auth_client, monkeypatch): + monkeypatch.setattr(app_module, "get_client", lambda st: _FakeClient()) + resp = auth_client.post("/api/request", json={ + "book": _BOOK, + "targets": [{"server_type": "ebook", "quality_profile_id": 1, "root_folder": "/books"}], + }) + assert resp.status_code == 200 + assert len(resp.get_json()) == 1 + + +def test_request_empty_targets_400(auth_client): + resp = auth_client.post("/api/request", json={"book": _BOOK, "targets": []}) + assert resp.status_code == 400 + + +def test_request_unconfigured_slot_400(auth_client, monkeypatch): + monkeypatch.setattr(app_module, "get_client", + lambda st: None if st == "audiobook" else _FakeClient()) + resp = auth_client.post("/api/request", json={ + "book": _BOOK, + "targets": [{"server_type": "audiobook", "quality_profile_id": 1, "root_folder": "/a"}], + }) + assert resp.status_code == 400 + assert "not configured" in resp.get_json()["error"] + + +def test_request_missing_profile_400(auth_client, monkeypatch): + monkeypatch.setattr(app_module, "get_client", lambda st: _FakeClient()) + resp = auth_client.post("/api/request", json={ + "book": _BOOK, + "targets": [{"server_type": "ebook", "root_folder": "/books"}], # no quality_profile_id + }) + assert resp.status_code == 400 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `python -m pytest tests/test_request_targets.py -q` +Expected: FAIL — the route still expects the old single `server_type` body, so the new tests get 400s / wrong shapes. + +- [ ] **Step 3: Rewrite the route** + +Replace the entire `create_request` function body (everything under `@app.route("/api/request", methods=["POST"])` / `@login_required` / `def create_request():`, down to its `return`) with: + +```python +def create_request(): + data = request.json or {} + book_data = data.get("book") + targets = data.get("targets") + + if not book_data or not isinstance(targets, list) or not targets: + return jsonify({"error": "Request must include 'book' and a non-empty 'targets' list"}), 400 + + for t in targets: + if not isinstance(t, dict): + return jsonify({"error": "Each target must be an object"}), 400 + st = t.get("server_type") + if st not in ("ebook", "audiobook"): + return jsonify({"error": "target server_type must be 'ebook' or 'audiobook'"}), 400 + if not t.get("quality_profile_id") or not t.get("root_folder"): + return jsonify({"error": f"{st} target missing quality_profile_id or root_folder"}), 400 + if not get_client(st): + return jsonify({"error": f"{st} server not configured"}), 400 + + entries = [ + _create_single_request( + t["server_type"], book_data, t["quality_profile_id"], t["root_folder"] + ) + for t in targets + ] + + with lock: + used_ids = {r["id"] for r in requests_history} + for entry in entries: + while entry["id"] in used_ids: + entry["id"] += 1 # avoid same-millisecond / existing-history id collisions + used_ids.add(entry["id"]) + requests_history.insert(0, entry) + save_requests() + + return jsonify(entries) +``` + +- [ ] **Step 4: Run the full backend suite** + +Run: `python -m pytest -q` +Expected: PASS (all prior tests + the new `tests/test_request_targets.py` tests). + +- [ ] **Step 5: Commit** + +```bash +git add app.py tests/test_request_targets.py +git commit -m "Accept targets array in /api/request to request both formats (issue #8)" +``` + +--- + +### Task 3: Frontend multi-select modal with per-slot options + +Turn the format buttons into multi-select toggles, disable unconfigured slots with a tooltip, render one profile+folder section per selected slot, gate the Download button, and submit a `targets` array. + +**Files:** +- Modify: `templates/index.html` (download modal, ~lines 301-316) +- Modify: `static/js/app.js` (modal section ~lines 3, 300-406; `loadConfig` ~line 488) +- Modify: `static/css/style.css` (after the `.server-btn.active` rule, ~line 506) + +**Interfaces:** +- Consumes: `GET /api/config` (`configured` booleans), `GET /api/profiles/`, `GET /api/rootfolders/`, `POST /api/request` (targets array from Task 2). +- Produces: browser-visible behavior only (no JS API for other tasks). + +- [ ] **Step 1: Update the modal HTML** + +In `templates/index.html`, replace the format/profile/folder block (the `
` containing `` through the closing `
` of the root-folder group, currently lines ~302-316) with: + +```html +
+ +
+ + +
+
+
+``` + +- [ ] **Step 2: Add CSS for disabled buttons and slot sections** + +In `static/css/style.css`, immediately after the `.server-btn.active { ... }` rule (ends ~line 506), add: + +```css +.server-btn:disabled, .server-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.slot-section { margin-top: 0.75rem; } +.slot-heading { + margin: 0.5rem 0 0.25rem; + font-size: 0.9rem; + color: var(--text-secondary); +} +``` + +- [ ] **Step 3: Add module-level state and extend `loadConfig`** + +In `static/js/app.js`, replace the line: + +```javascript +let selectedServer = "ebook"; +``` + +with: + +```javascript +let selectedServers = new Set(["ebook"]); +let serverConfigured = { ebook: false, audiobook: false }; +let slotOptionsCache = {}; +``` + +Then in `loadConfig()`, immediately after `const data = await resp.json();`, add: + +```javascript + serverConfigured = { ebook: !!data.ebook.configured, audiobook: !!data.audiobook.configured }; + slotOptionsCache = {}; +``` + +- [ ] **Step 4: Replace the modal functions** + +In `static/js/app.js`, replace the entire modal block spanning ~lines 302-406 — from `async function openDownloadModal(book) {` through the end of the `confirm-download-btn` click handler. This span includes `openDownloadModal`, `closeModal`, `selectServer`, `loadModalOptions`, and the click handler; the replacement below re-includes `closeModal` unchanged and drops `selectServer`/`loadModalOptions` (replaced by the new functions). Use: + +```javascript +async function openDownloadModal(book) { + currentModalBook = book; + selectedServers = new Set(); + if (serverConfigured.ebook) selectedServers.add("ebook"); + else if (serverConfigured.audiobook) selectedServers.add("audiobook"); + + document.getElementById("modal-title").textContent = "Download: " + (book.title || "Unknown"); + renderServerButtons(); + await renderSlotOptions(); + document.getElementById("download-modal").classList.add("active"); +} + +function closeModal() { + document.getElementById("download-modal").classList.remove("active"); + currentModalBook = null; +} + +function renderServerButtons() { + document.querySelectorAll(".server-btn").forEach((btn) => { + const slot = btn.dataset.server; + const label = slot === "ebook" ? "Ebook" : "Audiobook"; + const configured = serverConfigured[slot]; + btn.disabled = !configured; + btn.classList.toggle("disabled", !configured); + btn.title = configured + ? "" + : `${label} server isn't configured. Configure it in Settings to request this format.`; + btn.classList.toggle("active", selectedServers.has(slot)); + btn.onclick = configured ? () => toggleServer(slot) : null; + }); +} + +function toggleServer(slot) { + if (selectedServers.has(slot)) selectedServers.delete(slot); + else selectedServers.add(slot); + renderServerButtons(); + renderSlotOptions(); +} + +async function renderSlotOptions() { + const container = document.getElementById("slot-options"); + const slots = ["ebook", "audiobook"].filter((s) => selectedServers.has(s)); + container.innerHTML = slots + .map((s) => ` +
+

${s === "ebook" ? "Ebook" : "Audiobook"}

+
+ + +
+
+ + +
+
`) + .join(""); + await Promise.all(slots.map(loadSlotOptions)); + updateDownloadEnabled(); +} + +async function loadSlotOptions(slot) { + const profileSelect = document.querySelector(`.slot-profile[data-slot="${slot}"]`); + const folderSelect = document.querySelector(`.slot-folder[data-slot="${slot}"]`); + if (!profileSelect || !folderSelect) return; + try { + let opts = slotOptionsCache[slot]; + if (!opts) { + const [pr, fr] = await Promise.all([ + fetch("/api/profiles/" + slot), + fetch("/api/rootfolders/" + slot), + ]); + opts = { profiles: await pr.json(), folders: await fr.json() }; + slotOptionsCache[slot] = opts; + } + profileSelect.innerHTML = opts.profiles.error + ? `` + : opts.profiles.map((p) => ``).join(""); + folderSelect.innerHTML = opts.folders.error + ? `` + : opts.folders.map((f) => ``).join(""); + } catch (err) { + profileSelect.innerHTML = ''; + folderSelect.innerHTML = ''; + } + profileSelect.onchange = updateDownloadEnabled; + folderSelect.onchange = updateDownloadEnabled; +} + +function updateDownloadEnabled() { + const btn = document.getElementById("confirm-download-btn"); + const slots = ["ebook", "audiobook"].filter((s) => selectedServers.has(s)); + let ok = slots.length > 0; + for (const s of slots) { + const p = document.querySelector(`.slot-profile[data-slot="${s}"]`); + const f = document.querySelector(`.slot-folder[data-slot="${s}"]`); + if (!p || !f || !p.value || !f.value) { ok = false; break; } + } + btn.disabled = !ok; +} + +document.getElementById("confirm-download-btn").addEventListener("click", async () => { + if (!currentModalBook) return; + const slots = ["ebook", "audiobook"].filter((s) => selectedServers.has(s)); + if (!slots.length) return; + + const targets = []; + for (const s of slots) { + const qp = parseInt(document.querySelector(`.slot-profile[data-slot="${s}"]`).value); + const rf = document.querySelector(`.slot-folder[data-slot="${s}"]`).value; + if (!qp || !rf) { + alert("Please select a quality profile and root folder for each format."); + return; + } + targets.push({ server_type: s, quality_profile_id: qp, root_folder: rf }); + } + + const btn = document.getElementById("confirm-download-btn"); + btn.disabled = true; + btn.textContent = "Sending..."; + try { + const resp = await fetch("/api/request", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ book: currentModalBook, targets }), + }); + const data = await resp.json(); + if (data.error) { + alert("Error: " + data.error); + } else { + const failed = (Array.isArray(data) ? data : []).filter((e) => e.status === "error"); + if (failed.length) { + alert("Some formats failed: " + failed.map((e) => `${e.server_type} (${e.error})`).join("; ")); + } + closeModal(); + document.querySelector('[data-page="requests"]').click(); + } + } catch (err) { + alert("Error: " + err.message); + } finally { + btn.disabled = false; + btn.textContent = "Download"; + } +}); +``` + +- [ ] **Step 5: Browser verification** + +Start the app: `python app.py` (dev server on `0.0.0.0:5000`). Log in, then verify on screen (report only what is visible): + +1. Open a book's download modal. The **Ebook** and **Audiobook** toggles appear; the default-selected slot shows its Quality Profile + Root Folder section. +2. If a slot is unconfigured, its toggle is greyed/disabled and hovering shows the "…isn't configured. Configure it in Settings…" tooltip. +3. With both slots configured, clicking the second toggle adds a second section (its own profile + folder); clicking again removes it. +4. The Download button is disabled until each selected slot has a profile and folder chosen. +5. Selecting both and clicking Download lands on the Requests page showing **two** new entries (one ebook, one audiobook). + +If backends aren't configured in the environment, verify what is reachable (toggles, disabled state + tooltip, gating) and note which end-to-end steps could not be confirmed. + +- [ ] **Step 6: Commit** + +```bash +git add templates/index.html static/js/app.js static/css/style.css +git commit -m "Multi-select format toggles + per-slot options in download modal (issue #8)" +``` + +--- + +### Task 4: Final full-suite check + +**Files:** none (verification only). + +- [ ] **Step 1: Run the entire backend test suite** + +Run: `python -m pytest -q` +Expected: PASS (all tests green, including `tests/test_request_targets.py`). + +- [ ] **Step 2: Confirm no stray references to removed symbols** + +Run: `grep -rn "selectedServer\b\|loadModalOptions\|selectServer\|getElementById(\"quality-profile\")\|getElementById(\"root-folder\")" static/js/app.js` +Expected: no output (all old single-select symbols removed). + +--- + +## Notes for the implementer + +- The route intentionally drops the old single-`server_type` body shape; the frontend (Task 3) is the only caller and is updated in lockstep. +- Per-target failures (e.g. one backend down) become `status: "error"` entries — they are NOT a 400. Only structural/validation problems (empty targets, unknown slot, missing profile/folder, unconfigured slot) return 400 before any entry is created. +- Unique-id loop in the route guards against two entries minted in the same millisecond colliding (the `id` is `int(time.time()*1000)`). diff --git a/docs/superpowers/specs/2026-06-29-request-both-formats-design.md b/docs/superpowers/specs/2026-06-29-request-both-formats-design.md new file mode 100644 index 0000000..97bed0c --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-request-both-formats-design.md @@ -0,0 +1,103 @@ +# Request both ebook + audiobook at once — design + +Upstream feature request: zamnzim/Libreseerr issue #8. A user (LazyLibrarian +backend) almost always wants both the ebook and the audiobook and asked to +request both in one action instead of two separate requests. + +## Goal + +From the download modal, let the user request a book for the ebook slot, the +audiobook slot, or **both at once**, with each format keeping its own quality +profile and root folder. + +## Constraints / context + +- Two independent server slots exist: `"ebook"` and `"audiobook"`, each with its + own URL/API key/software and its own quality profiles + root folders + (`config["ebook"]`, `config["audiobook"]`). Profiles/folders are fetched + per-slot via `/api/profiles/` and `/api/rootfolders/`. +- A request entry already carries exactly one `server_type`. Status refresh + (`/api/requests/refresh`) and delete (`/api/requests/`) operate per entry. +- Current modal: two mutually-exclusive `.server-btn` buttons, one quality-profile + ``, one Download button. Switching slot + reloads that slot's options (`loadModalOptions` in `static/js/app.js`). + +## Approach + +"Both" = **two independent request entries**, one per slot, created from one +submit. No new combined/linked entity — this reuses the existing per-entry +tracking, refresh, and delete with zero changes to those paths, and makes +partial failure natural (one slot can error while the other proceeds). + +### UI (`templates/index.html`, `static/js/app.js`, `static/css`) + +- The two server buttons become **toggles (multi-select)**. Selection is tracked + as a `Set` of slot names, defaulting to `{"ebook"}` (preserves today's default). +- A slot whose server is **not configured** is rendered **disabled/greyed** and + cannot be toggled on. It carries a native hover tooltip (`title` attribute), + e.g. `"Audiobook server isn't configured. Configure it in Settings to request + this format."` Configuration state comes from the `configured` booleans already + present in the `/api/config` response (`app.py:760` / `app.py:766`). `loadConfig()` + already runs at app init and after every settings save; it is extended to also + store a module-level `serverConfigured = {ebook: data.ebook.configured, + audiobook: data.audiobook.configured}` which the modal reads. No new endpoint, + no extra API-key exposure beyond the existing init fetch. +- The single profile/folder pair becomes **one section per selected slot**, + rendered dynamically: + - Toggling a slot **on** appends a section (`Ebook` + quality ``) and lazy-loads that slot's profiles/folders. + - Toggling a slot **off** removes its section. + - Sections cache their loaded options so re-toggling doesn't refetch. +- **Download** button is disabled until: at least one slot is selected AND every + selected slot has both a profile and a folder chosen. + +### Backend (`app.py`) + +- Extract the body of `create_request` (lines ~1083–1147: build entry, lookup, + add_book, status) into a helper: + `_create_single_request(server_type, book_data, quality_profile_id, root_folder) -> dict` + returning the request_entry (without inserting/saving). +- `/api/request` accepts a **`targets`** array: + `{ "book": {...}, "targets": [ {server_type, quality_profile_id, root_folder}, ... ] }` + (length 1 or 2). For each target it calls the helper; failures are captured on + that entry (`status: "error"`) and do not abort the others. All resulting + entries are inserted into `requests_history` (under `lock`) and saved once. + The endpoint returns the **list** of created entries. +- Validation: `targets` must be a non-empty list; each target needs + `server_type` in `("ebook","audiobook")`, `quality_profile_id`, `root_folder`; + the target's slot must be configured (`get_client` returns a client). An + invalid/empty `targets` → 400. + +### Frontend submit (`static/js/app.js`) + +- `confirm-download-btn` handler builds `targets` from the selected slots and + their per-section selector values, POSTs once to `/api/request`, then navigates + to the Requests page (which now lists both new entries). If the response array + contains any entry with `status: "error"`, surface a non-blocking notice + naming which format(s) failed; entries that succeeded still appear. + +## Edge cases + +- Only one slot configured → other toggle disabled with tooltip; behaves exactly + like today's single request. +- Both selected, one backend errors → two entries created, one `error` + one + `processing`; modal closes, Requests page shows both. +- Neither slot selected → Download disabled (cannot submit). + +## Testing + +- `targets` with two valid slots → two entries returned, both non-error + (fake clients). +- `targets` where one slot's `add_book` raises → that entry `status == "error"`, + the other `status == "processing"`; both persisted. +- Single-element `targets` → one entry (back-compat after the refactor). +- Empty/invalid `targets` → 400. +- Tests use fake client objects in the existing `tests/` style + (`test_client_contract.py` / `conftest.py`), no network. + +## Out of scope (YAGNI) + +- Remembering the last format selection across modal opens. +- A combined/linked "both" request entity or paired status. +- A "request both" shortcut on the search-result cards (this is modal-only). +- Any change to Bindery support (tracked separately in `TODO.md`). diff --git a/lazylibrarian.py b/lazylibrarian.py index 731bb5e..059b6ae 100644 --- a/lazylibrarian.py +++ b/lazylibrarian.py @@ -1,9 +1,55 @@ import logging +import re import requests logger = logging.getLogger(__name__) +# --- Request-resolution hardening ------------------------------------------- +# Fixes three failure modes when handing a request to LazyLibrarian: +# (1) findBook results[0] grabs "summary"/"study guide" editions instead of +# the real book (e.g. "The Martian" -> "Book Summary of THE MARTIAN"), +# (2) a non-LazyLibrarian id (e.g. an Open Library OL...W id) is passed to +# addBook and silently does nothing -> request stuck on "processing", +# (3) the ISBN lookup (searchItem) can return HTTP 500 and hang the caller. +_JUNK_TITLE_MARKERS = ( + "summary", "study guide", "studyguide", "reviewed by", + "conversation starters", "key takeaways", "instaread", "quicklet", + "blinkist", "sidekick by", "summaries", "trivia-on", +) + + +def _norm(s: str) -> str: + return re.sub(r"[^a-z0-9 ]", " ", (s or "").lower()) + + +def _is_junk_title(title: str) -> bool: + t = _norm(title) + return any(m in t for m in _JUNK_TITLE_MARKERS) + + +def _looks_like_ll_id(value) -> bool: + """True for a plausible LazyLibrarian/GoodReads numeric BookID (NOT an Open Library OL...W id).""" + return bool(value) and str(value).isdigit() + + +def _rank(books: list, query: str) -> list: + """Drop junk editions (summaries etc.) and sort by how well title+author match the query.""" + qtokens = set(_norm(query).split()) + scored = [] + for b in books: + if not b.get("foreignBookId"): + continue + if _is_junk_title(b.get("title", "")): + continue + ttokens = set(_norm(b.get("title", "")).split()) + atokens = set(_norm((b.get("author") or {}).get("authorName", "")).split()) + title_cov = len(ttokens & qtokens) / len(ttokens) if ttokens else 0.0 + author_hit = 1.0 if (atokens and (atokens & qtokens)) else 0.0 + scored.append((title_cov + 0.5 * author_hit, b)) + scored.sort(key=lambda x: x[0], reverse=True) + return [b for _, b in scored] + class LazyLibrarianClient: """Client for interacting with a LazyLibrarian instance.""" @@ -35,46 +81,39 @@ def test_connection(self) -> dict: return result return {"version": "unknown"} + def _map_book(self, b: dict) -> dict: + return { + "title": b.get("bookname", "Unknown"), + "author": { + "authorName": b.get("authorname", "Unknown"), + "foreignAuthorId": b.get("authorid", ""), + }, + "foreignBookId": b.get("bookid", ""), + "foreignEditionId": b.get("bookisbn", ""), + "overview": b.get("bookdesc", ""), + "releaseDate": b.get("bookdate", ""), + "ratings": {"value": float(b.get("bookrate", 0))} if b.get("bookrate") else {}, + } + def search_books(self, query: str) -> list: - """Search for books by name using findBook.""" + """Search for books by name using findBook. Junk editions (summaries/study + guides) are dropped and the best title/author match is returned first.""" result = self._get("findBook", name=query) if not isinstance(result, list): return [] - return [ - { - "title": b.get("bookname", "Unknown"), - "author": { - "authorName": b.get("authorname", "Unknown"), - "foreignAuthorId": b.get("authorid", ""), - }, - "foreignBookId": b.get("bookid", ""), - "foreignEditionId": b.get("bookisbn", ""), - "overview": b.get("bookdesc", ""), - "releaseDate": b.get("bookdate", ""), - "ratings": {"value": float(b.get("bookrate", 0))} if b.get("bookrate") else {}, - } - for b in result - ] + return _rank([self._map_book(b) for b in result], query) def lookup_by_isbn(self, isbn: str) -> list: - """Look up a book by ISBN using searchItem.""" - result = self._get("searchItem", item=isbn) + """Look up a book by ISBN using searchItem. Returns [] on LazyLibrarian + errors (searchItem can return HTTP 500) so callers fall back to name search.""" + try: + result = self._get("searchItem", item=isbn) + except requests.exceptions.RequestException as e: + logger.warning("ISBN lookup (searchItem) failed for %s: %s", isbn, e) + return [] if not isinstance(result, list): return [] - return [ - { - "title": b.get("bookname", "Unknown"), - "author": { - "authorName": b.get("authorname", "Unknown"), - "foreignAuthorId": b.get("authorid", ""), - }, - "foreignBookId": b.get("bookid", ""), - "foreignEditionId": b.get("bookisbn", ""), - "overview": b.get("bookdesc", ""), - "releaseDate": b.get("bookdate", ""), - } - for b in result - ] + return [self._map_book(b) for b in result] def lookup_author(self, name: str) -> list: """Look up an author by name using findAuthor.""" @@ -98,18 +137,32 @@ def get_root_folders(self) -> list: return [{"path": "/books"}] def add_book(self, book_data: dict, quality_profile_id: int, root_folder: str) -> dict: - """Add a book to LazyLibrarian and mark it as wanted.""" - book_id = book_data.get("foreignBookId", "") + """Add a book to LazyLibrarian and mark it as wanted. + + Resolves the correct BookID robustly: an incoming numeric id is trusted only + if its title is not an obvious junk edition; otherwise we re-resolve by + title+author via findBook and take the best real match. Raises ValueError + (loud, not silent) when only a non-LazyLibrarian id (e.g. an Open Library + OL...W id) is available, so a bad request surfaces as an error instead of + grabbing the wrong book. + """ title = book_data.get("title", "Unknown") - - if not book_id: - # Try to find the book first - results = self.search_books(f"{title} {book_data.get('author', {}).get('authorName', '')}") - if results: - book_id = results[0].get("foreignBookId", "") - - if not book_id: - raise ValueError(f"Could not find book ID for '{title}' in LazyLibrarian") + author = (book_data.get("author") or {}).get("authorName", "") or "" + incoming_id = str(book_data.get("foreignBookId", "") or "") + + if _looks_like_ll_id(incoming_id) and not _is_junk_title(title): + book_id = incoming_id + else: + matches = self.search_books(f"{title} {author}".strip()) + book_id = matches[0].get("foreignBookId", "") if matches else "" + + if not _looks_like_ll_id(book_id): + raise ValueError( + f"No valid LazyLibrarian book id could be resolved for '{title}' by " + f"'{author or 'unknown'}' (only non-matching/summary results or a " + f"non-LazyLibrarian id '{incoming_id}'). Rejecting the request " + f"instead of adding the wrong book." + ) # Add the book to the database logger.info("Adding book to LazyLibrarian: '%s' (id=%s)", title, book_id) diff --git a/static/css/style.css b/static/css/style.css index f7c7134..8a26e5a 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -505,6 +505,18 @@ body.is-admin .sidebar-role { border-color: var(--pink-primary); } +.server-btn:disabled, .server-btn.disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.slot-section { margin-top: 0.75rem; } +.slot-heading { + margin: 0.5rem 0 0.25rem; + font-size: 0.9rem; + color: var(--text-secondary); +} + /* ─── Settings ─── */ .settings-grid { display: grid; diff --git a/static/js/app.js b/static/js/app.js index f789f10..ac73cf0 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,6 +1,8 @@ // State let currentModalBook = null; -let selectedServer = "ebook"; +let selectedServers = new Set(["ebook"]); +let serverConfigured = { ebook: false, audiobook: false }; +let slotOptionsCache = {}; let currentUser = null; let editingUsername = null; let cachedAvailability = null; @@ -301,16 +303,14 @@ async function loadDiscovery() { async function openDownloadModal(book) { currentModalBook = book; - selectedServer = "ebook"; + selectedServers = new Set(); + if (serverConfigured.ebook) selectedServers.add("ebook"); + else if (serverConfigured.audiobook) selectedServers.add("audiobook"); document.getElementById("modal-title").textContent = "Download: " + (book.title || "Unknown"); - document.querySelectorAll(".server-btn").forEach((btn) => { - btn.classList.toggle("active", btn.dataset.server === selectedServer); - btn.onclick = () => selectServer(btn.dataset.server); - }); - + renderServerButtons(); + await renderSlotOptions(); document.getElementById("download-modal").classList.add("active"); - await loadModalOptions(selectedServer); } function closeModal() { @@ -318,83 +318,123 @@ function closeModal() { currentModalBook = null; } -async function selectServer(server) { - selectedServer = server; +function renderServerButtons() { document.querySelectorAll(".server-btn").forEach((btn) => { - btn.classList.toggle("active", btn.dataset.server === server); + const slot = btn.dataset.server; + const label = slot === "ebook" ? "Ebook" : "Audiobook"; + const configured = serverConfigured[slot]; + btn.disabled = !configured; + btn.classList.toggle("disabled", !configured); + btn.title = configured + ? "" + : `${label} server isn't configured. Configure it in Settings to request this format.`; + btn.classList.toggle("active", selectedServers.has(slot)); + btn.onclick = configured ? () => { toggleServer(slot).catch(console.error); } : null; }); - await loadModalOptions(server); } -async function loadModalOptions(server) { - const profileSelect = document.getElementById("quality-profile"); - const folderSelect = document.getElementById("root-folder"); - profileSelect.innerHTML = ''; - folderSelect.innerHTML = ''; +async function toggleServer(slot) { + if (selectedServers.has(slot)) selectedServers.delete(slot); + else selectedServers.add(slot); + renderServerButtons(); + await renderSlotOptions(); +} - try { - const [profilesResp, foldersResp] = await Promise.all([ - fetch("/api/profiles/" + server), - fetch("/api/rootfolders/" + server), - ]); - const profiles = await profilesResp.json(); - const folders = await foldersResp.json(); - - if (profiles.error) { - profileSelect.innerHTML = ``; - } else { - profileSelect.innerHTML = profiles - .map((p) => ``) - .join(""); - } +async function renderSlotOptions() { + const container = document.getElementById("slot-options"); + const slots = ["ebook", "audiobook"].filter((s) => selectedServers.has(s)); + container.innerHTML = slots + .map((s) => ` +
+

${s === "ebook" ? "Ebook" : "Audiobook"}

+
+ + +
+
+ + +
+
`) + .join(""); + await Promise.all(slots.map(loadSlotOptions)); + updateDownloadEnabled(); +} - if (folders.error) { - folderSelect.innerHTML = ``; - } else { - folderSelect.innerHTML = folders - .map((f) => ``) - .join(""); +async function loadSlotOptions(slot) { + const profileSelect = document.querySelector(`.slot-profile[data-slot="${slot}"]`); + const folderSelect = document.querySelector(`.slot-folder[data-slot="${slot}"]`); + if (!profileSelect || !folderSelect) return; + try { + let opts = slotOptionsCache[slot]; + if (!opts) { + const [pr, fr] = await Promise.all([ + fetch("/api/profiles/" + slot), + fetch("/api/rootfolders/" + slot), + ]); + opts = { profiles: await pr.json(), folders: await fr.json() }; + slotOptionsCache[slot] = opts; } + profileSelect.innerHTML = opts.profiles.error + ? `` + : opts.profiles.map((p) => ``).join(""); + folderSelect.innerHTML = opts.folders.error + ? `` + : opts.folders.map((f) => ``).join(""); } catch (err) { - profileSelect.innerHTML = ''; - folderSelect.innerHTML = ''; + profileSelect.innerHTML = ''; + folderSelect.innerHTML = ''; + } + profileSelect.onchange = updateDownloadEnabled; + folderSelect.onchange = updateDownloadEnabled; +} + +function updateDownloadEnabled() { + const btn = document.getElementById("confirm-download-btn"); + const slots = ["ebook", "audiobook"].filter((s) => selectedServers.has(s)); + let ok = slots.length > 0; + for (const s of slots) { + const p = document.querySelector(`.slot-profile[data-slot="${s}"]`); + const f = document.querySelector(`.slot-folder[data-slot="${s}"]`); + if (!p || !f || !p.value || !f.value) { ok = false; break; } } + btn.disabled = !ok; } document.getElementById("confirm-download-btn").addEventListener("click", async () => { if (!currentModalBook) return; + const slots = ["ebook", "audiobook"].filter((s) => selectedServers.has(s)); + if (!slots.length) return; + + const targets = []; + for (const s of slots) { + const qp = parseInt(document.querySelector(`.slot-profile[data-slot="${s}"]`).value); + const rf = document.querySelector(`.slot-folder[data-slot="${s}"]`).value; + if (!qp || !rf) { + alert("Please select a quality profile and root folder for each format."); + return; + } + targets.push({ server_type: s, quality_profile_id: qp, root_folder: rf }); + } const btn = document.getElementById("confirm-download-btn"); btn.disabled = true; btn.textContent = "Sending..."; - - const qualityProfileId = parseInt(document.getElementById("quality-profile").value); - const rootFolder = document.getElementById("root-folder").value; - - if (!qualityProfileId || !rootFolder) { - alert("Please select a quality profile and root folder."); - btn.disabled = false; - btn.textContent = "Download"; - return; - } - try { const resp = await fetch("/api/request", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - server_type: selectedServer, - book: currentModalBook, - quality_profile_id: qualityProfileId, - root_folder: rootFolder, - }), + body: JSON.stringify({ book: currentModalBook, targets }), }); const data = await resp.json(); if (data.error) { alert("Error: " + data.error); } else { + const failed = (Array.isArray(data) ? data : []).filter((e) => e.status === "error"); + if (failed.length) { + alert("Some formats failed: " + failed.map((e) => `${e.server_type} (${e.error})`).join("; ")); + } closeModal(); - // Switch to requests page document.querySelector('[data-page="requests"]').click(); } } catch (err) { @@ -489,6 +529,8 @@ async function loadConfig() { try { const resp = await fetch("/api/config"); const data = await resp.json(); + serverConfigured = { ebook: !!data.ebook.configured, audiobook: !!data.audiobook.configured }; + slotOptionsCache = {}; document.getElementById("ebook-url").value = data.ebook.url || ""; document.getElementById("ebook-api").value = data.ebook.api_key || ""; document.getElementById("audiobook-url").value = data.audiobook.url || ""; diff --git a/templates/index.html b/templates/index.html index fa3872e..b88cd0b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -300,20 +300,13 @@