The signed-in issue page shows a Read in BookOrbit button (only when the
integration is active and the Standard EPUB exists) that hits
/issues/{date}/read. The route redirects to the cached BookOrbit reader
URL, or resolves the ids through OPDS on first click and caches them;
?refresh=1 re-resolves. Not indexed yet is a 503, upstream failures 502.
Includes the implementation plan.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Va5eMEmWEnjMXBsBob5FDW
13 KiB
"Read in BookOrbit" link on the issue page
Date: 2026-09-05
Repository: thallada/the-daily-epub
Status: small implementation plan, ready to execute
Builds on: docs/plans/2026-09-03-web-dashboard.md (the signed-in issue page this adds a button to)
Written for a fresh implementation agent. Every fact about BookOrbit below was verified on 2026-09-05 against its source (github.com/bookorbit/bookorbit, HEAD) and against the live instance on this host. Do not re-derive them; do re-check anything marked assumption.
1. Goal
On the signed-in issue page (src/web/templates/issue_full.html), next to the existing Download EPUB / X4 EPUB / XTC buttons, add a Read in BookOrbit button that opens the normal (Standard) edition of that issue in BookOrbit's in-browser EPUB reader at https://bookorbit.hallada.net. The operator reads on desktop through this link instead of downloading.
Out of scope: the X4 edition, the public (anonymous) issue page, the OPDS feed, the EPUB itself.
2. Verified facts
About this repo
- The pipeline does not upload to BookOrbit.
publish::publish_issueatomically copies both EPUB editions intopublish.epub_dir(/home/thallada/bookorbit/books/daily-epubon the host); BookOrbit's watched folder scanner picks them up later. Nothing in the run ever learns a BookOrbit id. - BookOrbit runs on the same host as Docker (
~/bookorbit/docker-compose.yml): app containerbookorbit-apppublished on127.0.0.1:3498, Postgres inbookorbit-dbwith no published port, and thedaily-epubservice user has no Docker socket access. Reading BookOrbit's database directly is therefore not an option. - Issue titles:
Issue::title()isThe Daily EPUB — <date>(em dash), andtitle_for(Edition::Standard)is that exact string whiletitle_for(Edition::X4)appends(X4)(src/types.rs~531–544). Filenames use a hyphen instead:publish::issue_filename(date, edition, "epub")→The Daily EPUB - <date>.epub/... (X4).epub. - The signed-in issue view is built in
src/web/issue.rs:IssueView(line ~33) carriesdownloads: Vec<Download>, assembled around line 258 from theissuesrow (row.epub_path,row.x4_path,row.xtc_path). The template renders them atsrc/web/templates/issue_full.html:6as<a class="btn" href="{{ download.href }}">Download …</a>. - Signed-in issue routes live in the
full_issuesrouter insrc/web/mod.rs(~line 659:/issues/{date}/articles/{article_id},/issues/{date}/world,/issues/{date}/behind, behindlogin_required!andmap_forbidden); public routes (/issues,/issues/{date}) are on the root router ~line 682. Put the new route infull_issues. issuestable (migrations/0001_init.sql:70):date TEXT PK, issue_number, generated_at, epub_path, x4_path, xtc_path, front_page_html, report_json(+issue_jsonadded later). Migrations run viasqlx::migrate!("./migrations")(src/db.rs:24); latest file is0004_web.sql, so the new one is0005_bookorbit.sql.Db::issue_by_date(date) -> Option<IssueRow>is atsrc/db.rs:537;upsert_issueat:496(do not touch it, ids are resolved lazily, see §3).- Config: sections are
#[serde(deny_unknown_fields, default)]structs hung offConfig(src/config.rs:82–85, e.g.pub server: ServerConfig), withDefaultimpls. Env overrides areDAILY_EPUB_<SECTION>__<KEY>.config.example.tomldocuments every key; the README has a configuration table (~line 350). - The settings dashboard (
src/web/dashboard/settings.rs) has a hardcoded section registry (constlist around line 248–259 ending"publish", "xtc", "server", "miniflux"), a secret-field list (("server.basic_auth_pass", FieldKind::Secret)~line 219, plusSECRET_SUFFIXESat ~223), per-key help text (~line 360), and a test asserting the section list (~line 1683). A new section must be added to all of these or the settings page/test breaks. - Shared HTTP client:
crate::http::build_client(timeout)(src/http.rs:14), rustls + gzip, no cookies. reqwest 0.13 with thequeryandjsonfeatures is already a dependency; no XML parser is in the tree — the OPDS response is tiny and regular, so either addquick-xmlor match with a small hand parser (see §4).
About BookOrbit
- Web reader route (client router,
client/src/router/index.ts):/read/:bookId/:fileId. Both ids are required numeric integers. Book detail page is/book/:bookId. - An unauthenticated visit to
/read/...serves the SPA shell (200) and the client auth guard redirects to/login?redirect=<fullPath>, returning to the reader after sign-in. So the link can point straight at the reader. - OPDS (server
server/src/modules/opds/opds.service.ts,BASE = '/api/v1/opds'):- Auth is HTTP Basic against a dedicated OPDS user created in BookOrbit's settings (separate static credentials, not the web login). Verified live:
GET http://127.0.0.1:3498/api/v1/opds/catalog?q=Daily→401,www-authenticate: Basic realm="bookorbit OPDS". - Search:
GET /api/v1/opds/catalog?q=<terms>(Atom acquisition feed; server-sideILIKEon title, accent-insensitive). Paged with?page=N. - Each
<entry>contains<title>…</title>,<id>urn:bookorbit:book:<bookId></id>, and one acquisition link per file:<link rel="http://opds-spec.org/acquisition" href="/api/v1/opds/<bookId>/download?fileId=<fileId>" type="application/epub+zip" title="EPUB"/>. That href carries both numbers the reader URL needs. Each daily issue edition is its own book with a single EPUB file.
- Auth is HTTP Basic against a dedicated OPDS user created in BookOrbit's settings (separate static credentials, not the web login). Verified live:
- The JSON API (
/api/v1/books/search?q=,/api/v1/books/:idwithfiles[].id) exists too but needs a JWT from/auth/login(5/min throttle, refresh cookie). Not worth it; OPDS is enough. - Book titles come from the EPUB
dc:title, so the Standard edition is titledThe Daily EPUB — 2026-09-05and the X4 oneThe Daily EPUB — 2026-09-05 (X4). Assumption: BookOrbit's metadata fetcher has not renamed them; the matcher in §4 tolerates a fallback to the filename form.
3. Design decisions (settled)
| Topic | Decision | Why |
|---|---|---|
| Where ids are resolved | Lazily, on click, by a new redirect route GET /issues/{date}/read in the signed-in router. |
BookOrbit indexes the folder some time after the run ends, so the id does not exist at publish time. Rendering the issue page must stay free of cross-service calls (see perf work in main 17a8804 and earlier). |
| Caching | Two nullable columns on issues: bookorbit_book_id INTEGER, bookorbit_file_id INTEGER. Filled on first successful lookup; later clicks redirect from the cache with no network call. |
One OPDS call per issue, ever. |
| Stale cache | If BookOrbit answers the reader page, we can't tell from a redirect. Instead: GET /issues/{date}/read?refresh=1 clears the cached ids and re-resolves. Expose it as a tiny "wrong book?" link only if trivial; otherwise document the query param in the README. |
Books can be deleted/re-added in BookOrbit, changing ids. |
| Not indexed yet | Route returns 503 with the existing error template (src/web/templates/error.html), message "BookOrbit has not indexed this issue yet. Try again in a minute." Nothing cached. |
Honest, cheap, retry-friendly. |
| Which edition | Standard only. The entry whose <title> equals issue.title_for(Edition::Standard) exactly; entries ending in (X4) are never chosen. |
The whole point is desktop reading. |
| Auth to BookOrbit | HTTP Basic with an OPDS user's credentials from config. | Static creds, no token lifecycle; same mechanism KOReader already uses. |
| Feature flag | Whole feature off unless [bookorbit] enabled = true and the three credentials/URL keys are set. Button and route are absent when off. |
Repo stays usable without BookOrbit, as today. |
| Link target | <public_url>/read/<bookId>/<fileId>, public_url from config (https://bookorbit.hallada.net), opened with target="_blank" rel="noopener". |
BookOrbit's own login redirect handles the signed-out case. |
4. Implementation steps
-
Config (
src/config.rs,config.example.toml, README table):[bookorbit] enabled = false public_url = "https://bookorbit.hallada.net" # what the browser opens api_url = "http://127.0.0.1:3498" # where the server talks OPDS; same host opds_user = "" # an OPDS user from BookOrbit → Settings → OPDS # opds_pass: environment only (DAILY_EPUB_BOOKORBIT__OPDS_PASS)BookorbitConfig { enabled: bool, public_url: String, api_url: String, opds_user: Option<String>, opds_pass: Option<String> }withdeny_unknown_fields, default. Addpub bookorbit: BookorbitConfigtoConfigand itsDefault. Add afn is_active(&self) -> bool(enabled + user + pass non-empty). Validate URLs have no trailing slash (or trim). -
Settings dashboard (
src/web/dashboard/settings.rs): add"bookorbit"to the section list,("bookorbit.opds_pass", FieldKind::Secret)(and"opds_pass"toSECRET_SUFFIXES), help text for the five keys, update the section-list test. Copy the pattern used forserver.basic_auth_pass. -
Migration
migrations/0005_bookorbit.sql:ALTER TABLE issues ADD COLUMN bookorbit_book_id INTEGER; ALTER TABLE issues ADD COLUMN bookorbit_file_id INTEGER;Extend
IssueRowandissue_by_date'sSELECTwith the two columns; addDb::set_bookorbit_ids(date, Option<(i64, i64)>)(Noneclears). -
OPDS client — new module
src/bookorbit.rs(~100 lines):pub async fn find_issue(client: &reqwest::Client, cfg: &BookorbitConfig, issue_title: &str, date: Date) -> Result<Option<(i64, i64)>>.GET {api_url}/api/v1/opds/catalog?q={date}with.basic_auth(user, Some(pass))andAccept: application/atom+xml. Searching by the ISO date string avoids em-dash/hyphen and ILIKE-escaping questions, and returns at most the two editions.- Parse entries: split on
<entry>…</entry>, take<title>(XML-unescape&etc.), and the first acquisitionhrefmatching^/api/v1/opds/(\d+)/download\?fileId=(\d+)$. Choose the entry whose title equalsissue_titleexactly; if none, accept the title equal to the filename stemThe Daily EPUB - <date>(hyphen form); never accept a title ending in(X4). Preferquick-xmlif you'd rather not hand-roll; either is fine, keep it small and unit-tested against a fixture built from the real XML shape in §2. - Map
401/403to a clear error ("BookOrbit rejected the OPDS credentials"), connection errors to "BookOrbit unreachable". Timeout 5 s viahttp::build_client. - Log at
infoon first resolution with both ids.
-
Route
GET /issues/{date}/read(src/web/issue.rs, registered insrc/web/mod.rsnext to/issues/{date}/behind, same session guard as the other signed-in issue routes):- 404 if the feature is inactive or the issue row is missing.
- If
refresh=1, clear cached ids first. - Cached ids →
303 See Otherto{public_url}/read/{book}/{file}. - Else call
bookorbit::find_issue; onSome, store and redirect; onNone, 503 with the "not indexed yet" message; onErr, 502 with the error text (operator-only page, so the text can be specific). - Hold a
reqwest::ClientinWebState(src/web/mod.rs:144) or build one per call; per-call is acceptable at this frequency.
-
Template: add
read_href: Option<String>toIssueView(set to/issues/{date}/readwhen the feature is active androw.epub_pathis present, i.e. only alongside a real Standard EPUB). Inissue_full.html:6, render<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>before the download buttons, inside the same flex row, and keep the row visible when it is the only button. -
Tests: unit tests for the XML matcher (exact title, X4 skipped, hyphen fallback, no match, malformed href); a
src/webtest that the route is 404 when inactive and 303 to the right URL when ids are cached (no network). Followdownloads_are_listed_only_while_the_files_exist(src/web/issue.rs:~2059) for the fixture style. -
Docs/ops: README configuration table rows for
[bookorbit]; a note under Delivery: create an OPDS user in BookOrbit (Settings → OPDS), put its name inconfig.tomland the password in/etc/daily-epub/envasDAILY_EPUB_BOOKORBIT__OPDS_PASS, restartdaily-epub.service. No systemd change is needed:systemd/daily-epub.servicesets onlyRestrictAddressFamilies=AF_INET AF_INET6 AF_UNIXand noIPAddressDeny, so loopback HTTP to port 3498 is allowed.
5. Acceptance
- With the section absent or
enabled = false: no button,/issues/<date>/read→ 404, all existing tests pass. - With valid OPDS credentials: first click on today's issue → 303 to
https://bookorbit.hallada.net/read/<n>/<m>and theissuesrow now has both ids; second click makes no request to BookOrbit (check the journal). - Clicking before BookOrbit has scanned the new file → 503 "not indexed yet", nothing cached, later click succeeds.
- The X4 edition is never the target even though it matches the same search.
cargo fmt,cargo clippy,cargo testclean.