Skip to content

Verifying the OPDS feed against real clients

Longbox's OPDS output is checked three ways, and they cover different things:

  1. python tests/run.py — the feeds are correct as documents. OPDS 2.0 and the Divina manifests are validated against the published JSON schemas.
  2. 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.
  3. 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
PDF 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:lastRead is 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() in app/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:lastRead is 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=4 and pse: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.