Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -102,14 +103,16 @@ 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

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.

Expand Down Expand Up @@ -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.
35 changes: 35 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -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.
94 changes: 61 additions & 33 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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"
Expand All @@ -1094,52 +1094,80 @@ 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)
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": "",
}
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"])
Expand Down
Loading