Verifying the OPDS feed against real clients¶
Longbox's OPDS output is checked three ways, and they cover different things:
python tests/run.py— the feeds are correct as documents. OPDS 2.0 and the Divina manifests are validated against the published JSON schemas.python tests/live_check.py <url>— the deployment is correct. TLS, the reverse proxy,LONGBOX_PUBLIC_URL, and every link the server advertises, fetched as a client would fetch it.- This document — a real client can actually use it.
Only the first two can be automated. No Python suite can drive an iPad, so the third is a checklist run by a person. It is written so that it is run the same way twice and so a result means something when written down.
Do step 2 before step 3. If the live check passes and a client still will not open a comic, the fault is in the client — which is worth knowing before spending an evening on it.
The live check first¶
LONGBOX_CHECK_PASSWORD='the-account-password' \
python tests/live_check.py https://comics.example.com -u reader
Or with a per-account token, which is what an e-ink or TV client will use:
python tests/live_check.py https://comics.example.com --token KRQ7G673YM8M
The password comes from the environment on purpose. An argument is visible in
ps output and lands in shell history.
It walks the catalogue by following the links the server itself advertises,
rather than paths written into the script, and checks both auth paths, the
acquisition download, the cover, PSE page streaming at both ends of a comic,
the OPDS 2.0 feed and a Divina manifest. The check that matters most is the
one the suites structurally cannot make: that every link points at the public
host. A feed whose links all say http://127.0.0.1:8080 validates perfectly
and is useless on a phone.
Clients, and what each one is for¶
| Client | Platform | Cost | What it uniquely proves |
|---|---|---|---|
| Thorium | Windows, macOS, Linux | free | The OPDS 2.0 feed itself: navigation, facets, covers, metadata, acquisition. Cannot read comics — see below. |
| KOReader | Android, desktop, e-ink | free | OPDS 1.2 with PSE page streaming and resume. The client the short typed token was designed for. |
| Panels | iPhone, iPad, Mac | free app, OPDS behind Panels Plus | The iOS path. Says it supports all of OPDS 1.2 with 2.0 coming. |
| Chunky | iPad | free app, OPDS is a one-off purchase | The second iOS opinion, and long the reference for PSE on iPad — Komga's own docs point people at it. |
Verified: Panels (iOS), 2026-09-21¶
Connected to /opds with Basic auth against the live deployment. Confirmed by
watching journalctl -u longbox -f rather than by trusting the interface.
| Browsing, covers, thumbnails | works |
| PSE page streaming | works — sequential GET /opds/comics/<uuid>/page/N, no whole-file download |
Resume from pse:lastRead |
works — lastRead=4 in the feed opened the comic at page 4 |
| works, streamed page by page | |
| CBR | works, streamed — read to the last page |
| OPDS search | not exercised: Panels never requests /opds/search.xml; its search box filters its own local library |
Streaming makes the container irrelevant, in practice and not just in
theory. Panels almost certainly cannot open a .cbr itself. Streamed, it
received ordinary JPEGs and read the whole comic.
Progress travels one way. pse:lastRead carried the web reader's position
into Panels. Nothing carries Panels' position back, because the protocol has
no mechanism for it.
Verified: a token, and an account that has no password¶
Tested 2026-09-22 with a real Google Workspace account on the live deployment, connected to Panels on iOS.
The account signs in through Google and has no password at all, so HTTP Basic cannot work for it and never will: a reader cannot perform an OAuth flow, because that needs a browser and OPDS has nowhere to put one. The per-account token — twelve characters, built for typing on an e-ink keyboard — is the whole answer, and it works.
| Token in the feed URL, no credentials | works |
| Browsing past the root with only a token | works — every link carries it forward |
| An account with no local password | works, through the token alone |
This closes two questions at once. The token had been covered by the suites
and by live_check.py but never typed into a client, and external-provider
accounts had no verified route to OPDS at all.
The 401 for such an account stays deliberately unhelpful. Basic auth answers "Incorrect username or password", in constant time, for an account that has no password — because explaining the real reason to an unauthenticated caller would confirm the account exists. The guidance belongs on the account page, which knows who is asking, and which now says a token is the way when the account has no password.
Verified: progress travels one way, and no standard exists for the other¶
Confirmed 2026-09-21. Reading in Panels does not change pse:lastRead on the
server; Panels remembers its own position locally. Reading in Longbox's web
reader does reach Panels.
That is the protocol, not a gap in Longbox:
- OPDS-PSE has no write-back.
pse:lastReadis an attribute the server emits. There is no request a client can make to set it. Komga carries the same open request (komga#710). - No ratified OPDS standard covers it. OPSS — Open Publication Synchronization Service — was proposed in March 2019 (opds-community/drafts#49) and remains a discussion. Implementers use the Web Annotation Protocol (NYPL does) or something proprietary.
- Panels can sync, just not over OPDS. It writes progress to Komga and Kavita through each server's own progress API. Those are proprietary interfaces, not OPDS, so a server gets two-way sync by implementing one of them — that is, by presenting itself as a Komga or a Kavita.
So the choices are: accept one-way, implement a Kavita- or Komga-compatible progress endpoint, or implement the Web Annotation Protocol and hope clients find it. None of them is "turn on OPDS sync", because there is no such thing.
Verified: a client decodes the response, not the declared type¶
The question behind whether Longbox should transcode pages. All four were
advertised as image/jpeg, because OPDS-PSE permits nothing truer, and each
was served as its real type:
| Zero Signal | Pages are | Panels |
|---|---|---|
| #1 | PNG | reads |
| #4 | WebP | reads |
| #5 | AVIF | reads |
| #6 | JPEG XL | does not appear in the list at all |
So the declared type is not load-bearing for this client, and transcoding WebP or AVIF for PSE is unnecessary. That work is not worth doing on the evidence available.
#6 is a different finding. It is the only comic in the library with no
cover — no Pillow build in requirements.txt decodes JPEG XL — and it is the
only entry whose feed entry carries zero opds-spec.org/image links. Panels
appears to hide entries without a cover image. Everything else about the entry
is intact: PSE link, page count, acquisition link, metadata.
That generalises past JPEG XL: a cover is not decoration. Any comic whose cover extraction failed — a corrupt first page, an unusual format — may be invisible in a client rather than merely plain-looking, and nothing in the feed or the web UI would tell you.
Verified: Thorium cannot read comics, and that is not a Longbox fault¶
Tested 2026-09-21 against a real deployment. Thorium browses the OPDS 2.0 feed completely — series/publisher/year facets with counts, covers, thumbnails, titles, publication dates, page counts — and imports the PDF and the EPUB. A comic shows its metadata and offers no action at all.
That is correct behaviour on its side, confirmed by the maintainer in thorium-reader discussion #2636 (5 November 2024):
- CBZ is "supported at the parser level but there is currently no image-only rendering engine in Thorium."
- Divina is handled as a zipped package, and "Thorium itself was designed for packaged publications that are imported into a reserved filesystem and streamed from a local server. This part of the software architecture would need to be refactored in order to support resource streaming from remote servers."
So Thorium can validate neither the CBZ acquisition link nor the Divina manifest. It was originally listed here as the way to exercise Divina, which was wrong and is the reason this section exists.
The Divina manifest therefore has no client behind it yet. It is valid against the published Readium schema and every page it lists resolves, but no reader has opened one. Under the rule this project set at the start — the README claims nothing until a real client has opened it — Divina stays unclaimed. A browser-based Divina viewer is the obvious next candidate and would need CORS headers Longbox does not currently send, so it is a change, not just a test.
A minimum of two: Thorium and KOReader. Between them they cover every protocol question except Divina, which neither can open. Both are free and neither needs a phone. Thorium proves the 2.0 feed parses in a strict Readium client and that acquisition works; KOReader proves page streaming.
For iOS specifically, Panels first. It is the one a household actually uses, it runs on iPhone as well as iPad, and it is the one most likely to be kept up to date. Chunky is worth the few dollars as a second opinion if Panels disagrees with the live check, because two clients failing the same way means the server, and one failing alone means the client.
Treat the vendors' feature claims as claims. "Supports all OPDS 1.2 features" is marketing copy, not a conformance statement; the first job below is to find out what is true of this server.
The checklist¶
Run it per client. Write the answers down — an untaken note is the reason this gets re-run from scratch in six months.
Connecting¶
- [ ] The server can be added by URL — the base
/opds, not a deep link. - [ ] Basic auth works: username and password, no token.
- [ ] Token auth works:
https://.../opds?token=XXXXXXXXXXXX, no password. Type it by hand rather than pasting, which is the case it exists for. - [ ] Wrong credentials are refused with a prompt to retry, not a blank screen or a hang. A client that cannot report a 401 usefully is worth knowing about before a family member hits it.
Browsing¶
- [ ] All four destinations appear: All comics, By series, By publisher, By year.
- [ ] Series, publisher and year listings show their counts.
- [ ] Paging past the first 50 comics works, if the library is that large.
- [ ] Search returns matches.
- [ ] A search that matches nothing comes back as an empty shelf rather
than an error or a hang. This is the known OPDS 2.0 schema deviation —
see
render_feed()inapp/opds/json_feed.py— and the only way to find out whether it matters in practice. - [ ] Covers and thumbnails render in the list.
Reading¶
- [ ] Download a comic and open it.
- [ ] Stream a comic without downloading it, if the client supports PSE.
- [ ] Jump to the last page directly rather than paging to it.
-
[ ] Resume, in the only direction that exists. Read to page 5 in Longbox's own web reader, then refresh the catalogue in the client and open that comic: it should start at page 5.
Closing and reopening inside the client proves nothing — every reader remembers its own position locally, so that passes whether or not
pse:lastReadis emitted, read, or correct. OPDS-PSE carries progress one way only, server to client; nothing in the protocol sends a client's position back, so progress made in the client will not appear in Longbox. Say so to anyone who expects sync.This is the assertion most likely to be off by one in either direction: Longbox stores the page 0-indexed and PSE counts from 1, so page 5 in the web reader is
current_page=4andpse:lastRead="5". Landing on page 4 or 6 is the bug.
Watching the server settles what a client is actually doing. Run
journalctl -u longbox -f while you read. Requests to
/opds/comics/<uuid>/page/N are page streaming; a single request to
/opds/comics/<uuid>/file is a whole-archive download wearing a read button.
No amount of watching the client's UI distinguishes those two.
Streaming makes the container irrelevant, which is worth testing on purpose: a CB7 or CBT streamed page by page arrives as ordinary JPEG or PNG, so a client that cannot open those archives at all can still read one. Try a CB7 both ways.
The WebP question¶
This is the one experiment worth running deliberately, and it only works because both delivery paths exist for the same comic.
OPDS-PSE allows only image/jpeg, image/gif and image/png on a stream
link. Longbox copies pages out of the archive byte for byte, so a WebP comic
is advertised as JPEG and served as WebP. A Divina manifest has no such
restriction and declares the truth. Same comic, same bytes, two descriptions.
Put a WebP comic in the library — python tests/make_samples.py somewhere
generates one, under Zero Signal — and open it both ways:
| PSE | Divina | What it means |
|---|---|---|
| renders | renders | Nothing to fix. The declared type is not load-bearing. |
| fails | renders | The client trusts the declared type. Transcoding is justified — see the page-format decision in CLAUDE.md. |
| fails | fails | The client has no WebP decoder at all. Transcoding would fix it. |
| renders | fails | Something else is wrong with the manifest; worth reporting. |
Record which row each client lands on. That table is the whole reason the question is still open rather than guessed at.
Formats¶
python tests/make_samples.py <folder> generates one comic per supported
format. Import them and open one of each:
- [ ] CBZ - [ ] CBR - [ ] CB7 - [ ] CBT - [ ] PDF
- [ ] EPUB is expected to download but not read in a comic client. It is reflowable text and Longbox has no reader for it yet.
Writing up what happened¶
The README does not claim OPDS support for a version until a real client has opened it. That rule is why nothing is advertised yet.
When a client passes, record which client, which version, on what date — not "OPDS 1.2 works". A client's OPDS support changes between releases, and a claim with no version behind it cannot be checked later by anyone, including the person who wrote it.