From 8be798d96228fe457f7f50a92a7932d0214dff07 Mon Sep 17 00:00:00 2001 From: Garrett3Nelson Date: Mon, 29 Jun 2026 06:14:37 -0300 Subject: [PATCH 01/10] Fix OpenLibrary 403 and harden LazyLibrarian request resolution Set an identifying User-Agent on outbound requests so Open Library / Internet Archive stops returning 403 for the python-requests default UA (upstream issues #9, #10). Port upstream PR #11: drop summary/study-guide editions when resolving a LazyLibrarian request, rank by title+author overlap, fail loudly on non-LazyLibrarian (OL...W) ids instead of silently adding the wrong book, and fall back to name search when ISBN searchItem returns HTTP 500. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QAAfNHLFzwaD5CXerQU8g --- app.py | 7 ++ lazylibrarian.py | 139 +++++++++++++++++-------- tests/test_lazylibrarian_resolution.py | 45 ++++++++ 3 files changed, 148 insertions(+), 43 deletions(-) create mode 100644 tests/test_lazylibrarian_resolution.py diff --git a/app.py b/app.py index 70e5d61..4b2d155 100644 --- a/app.py +++ b/app.py @@ -9,6 +9,13 @@ import requests as http_requests from flask import Flask, jsonify, redirect, render_template, request, url_for + +# 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. +# 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 from flask_login import LoginManager, current_user, login_required, login_user, logout_user from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.security import check_password_hash, generate_password_hash 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/tests/test_lazylibrarian_resolution.py b/tests/test_lazylibrarian_resolution.py new file mode 100644 index 0000000..16b2f69 --- /dev/null +++ b/tests/test_lazylibrarian_resolution.py @@ -0,0 +1,45 @@ +"""Tier 2: LazyLibrarian request-resolution hardening (pure functions, no network). + +Covers the upstream fix for picking the real book over summary/study-guide +editions and rejecting non-LazyLibrarian ids instead of silently grabbing the +wrong book. See zamnzim/Libreseerr PR #11. +""" +from lazylibrarian import _is_junk_title, _looks_like_ll_id, _rank + + +def test_is_junk_title_flags_summaries_and_guides(): + assert _is_junk_title("Book Summary of THE MARTIAN by Andy Weir") + assert _is_junk_title("Study Guide: Dune") + assert _is_junk_title("Key Takeaways from Atomic Habits") + assert not _is_junk_title("The Martian") + assert not _is_junk_title("Dune") + + +def test_looks_like_ll_id_only_accepts_numeric_ids(): + assert _looks_like_ll_id("18007564") + assert not _looks_like_ll_id("OL20823239W") # Open Library work id + assert not _looks_like_ll_id("") + assert not _looks_like_ll_id(None) + + +def _book(title, author="", book_id="1"): + return { + "title": title, + "author": {"authorName": author}, + "foreignBookId": book_id, + } + + +def test_rank_drops_junk_and_prefers_real_match(): + books = [ + _book("Book Summary of THE MARTIAN", "Instaread", "111"), + _book("The Martian", "Andy Weir", "18007564"), + ] + ranked = _rank(books, "The Martian Andy Weir") + assert len(ranked) == 1 # summary dropped + assert ranked[0]["foreignBookId"] == "18007564" + + +def test_rank_skips_entries_without_book_id(): + books = [_book("The Martian", "Andy Weir", "")] + assert _rank(books, "The Martian") == [] From 0bf3314e5d1798125bd0e8b7ea0aadcceff31ccb Mon Sep 17 00:00:00 2001 From: Garrett3Nelson Date: Mon, 29 Jun 2026 06:58:06 -0300 Subject: [PATCH 02/10] Track Bindery backend support as TODO (upstream issue #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bindery is not a Readarr API clone — it splits lookup into separate /search/book?q= and /book/lookup?isbn= endpoints, so the Readarr client's term-based lookups 400. Park as a dedicated-backend feature rather than a Readarr patch. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QAAfNHLFzwaD5CXerQU8g --- TODO.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 TODO.md 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. From 2b4fc8e68bf817548d73ed0bbfb007aed7876999 Mon Sep 17 00:00:00 2001 From: Garrett3Nelson Date: Mon, 29 Jun 2026 08:38:26 -0300 Subject: [PATCH 03/10] Add design spec: request both ebook + audiobook at once (issue #8) Multi-select format toggles with per-slot quality profile + root folder; unconfigured slots disabled with a hover tooltip. Backend creates two independent request entries from one submit via a shared helper, so refresh and delete stay per-entry and partial failure is isolated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QAAfNHLFzwaD5CXerQU8g --- .../2026-06-29-request-both-formats-design.md | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-29-request-both-formats-design.md 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`). From 8eb52d0a63bad56911cce2d09b9d94707beba1fd Mon Sep 17 00:00:00 2001 From: Garrett3Nelson Date: Mon, 29 Jun 2026 09:12:04 -0300 Subject: [PATCH 04/10] Add implementation plan: request both formats at once (issue #8) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QAAfNHLFzwaD5CXerQU8g --- .../plans/2026-06-29-request-both-formats.md | 599 ++++++++++++++++++ 1 file changed, 599 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-29-request-both-formats.md 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)`). From 4f855900a9b1b4ad1a3d7626b5bb6025415b942f Mon Sep 17 00:00:00 2001 From: Garrett3Nelson Date: Mon, 29 Jun 2026 09:21:37 -0300 Subject: [PATCH 05/10] Add _create_single_request helper for per-slot requests (issue #8) --- app.py | 65 +++++++++++++++++++++++++++++++++++ tests/test_request_targets.py | 50 +++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 tests/test_request_targets.py diff --git a/app.py b/app.py index 4b2d155..33a9ca7 100644 --- a/app.py +++ b/app.py @@ -1064,6 +1064,71 @@ def get_root_folders(server_type): # ─── Download / Request API ─── + +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 + + @app.route("/api/request", methods=["POST"]) @login_required def create_request(): diff --git a/tests/test_request_targets.py b/tests/test_request_targets.py new file mode 100644 index 0000000..f8dc297 --- /dev/null +++ b/tests/test_request_targets.py @@ -0,0 +1,50 @@ +"""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"] From 7694e7f10d64d70b2808ab537182f7be3970715b Mon Sep 17 00:00:00 2001 From: Garrett3Nelson Date: Mon, 29 Jun 2026 09:26:48 -0300 Subject: [PATCH 06/10] Accept targets array in /api/request to request both formats (issue #8) --- app.py | 105 ++++++++++------------------------ tests/test_request_targets.py | 82 ++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 75 deletions(-) diff --git a/app.py b/app.py index 33a9ca7..d9a7df4 100644 --- a/app.py +++ b/app.py @@ -1132,86 +1132,41 @@ def _create_single_request(server_type, book_data, quality_profile_id, root_fold @app.route("/api/request", methods=["POST"]) @login_required def create_request(): - data = request.json - server_type = data.get("server_type") + data = request.json or {} 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 - - 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: - # First, try to find the book in Readarr via ISBN lookup - 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: - # 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": "", - } - app.logger.info( - "Readarr 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": "", - }, - "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" - - 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) + 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/tests/test_request_targets.py b/tests/test_request_targets.py index f8dc297..0a3355c 100644 --- a/tests/test_request_targets.py +++ b/tests/test_request_targets.py @@ -48,3 +48,85 @@ def test_single_request_no_client(monkeypatch): entry = app_module._create_single_request("ebook", {"title": "X"}, 1, "/books") assert entry["status"] == "error" assert "not configured" in entry["error"] + + +@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.""" + monkeypatch.setitem(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 From ac14f9c2907b73b2a153fbd7d6d8ae26e83cb753 Mon Sep 17 00:00:00 2001 From: Garrett3Nelson Date: Mon, 29 Jun 2026 09:32:35 -0300 Subject: [PATCH 07/10] Multi-select format toggles + per-slot options in download modal (issue #8) --- static/css/style.css | 12 ++++ static/js/app.js | 154 +++++++++++++++++++++++++++---------------- templates/index.html | 15 ++--- 3 files changed, 114 insertions(+), 67 deletions(-) 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..e1b0259 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) : null; }); - await loadModalOptions(server); } -async function loadModalOptions(server) { - const profileSelect = document.getElementById("quality-profile"); - const folderSelect = document.getElementById("root-folder"); - profileSelect.innerHTML = ''; - folderSelect.innerHTML = ''; +function toggleServer(slot) { + if (selectedServers.has(slot)) selectedServers.delete(slot); + else selectedServers.add(slot); + renderServerButtons(); + 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.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 @@ `) .join(""); @@ -376,14 +376,14 @@ async function loadSlotOptions(slot) { 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; From 6d76d178c937fa9c6b7d22af490a8188ecec0a33 Mon Sep 17 00:00:00 2001 From: Garrett3Nelson Date: Mon, 29 Jun 2026 13:07:01 -0300 Subject: [PATCH 09/10] Fix ruff E402: move User-Agent patch below imports The OpenLibrary UA override was placed between imports, triggering E402 (module level import not at top of file) on every following import and failing CI lint. Moved it below the import block; it still runs at import time before any HTTP request. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QAAfNHLFzwaD5CXerQU8g --- app.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app.py b/app.py index d9a7df4..fee7cb2 100644 --- a/app.py +++ b/app.py @@ -9,13 +9,6 @@ import requests as http_requests from flask import Flask, jsonify, redirect, render_template, request, url_for - -# 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. -# 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 from flask_login import LoginManager, current_user, login_required, login_user, logout_user from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.security import check_password_hash, generate_password_hash @@ -37,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.) From b690b916ae71016760697d4c573be2b19cc8449e Mon Sep 17 00:00:00 2001 From: Garrett3Nelson Date: Mon, 29 Jun 2026 13:12:48 -0300 Subject: [PATCH 10/10] Update README: request-both, Hardcover metadata, dev testing/linting - Usage and Features now describe selecting ebook, audiobook, or both (each format with its own profile/folder; one request per format). - Document optional Hardcover metadata source (Open Library is the default). - Add a Testing and Linting section (requirements-dev.txt, ruff, pytest, CI). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019QAAfNHLFzwaD5CXerQU8g --- README.md | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) 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.