Files
bookshelf/docs/METADATA-SOURCES.md
T
Spriteandclaude ab83287b5d Move Open Library lookup to the Editions API; stop asking Google Books for its placeholder
Two user-reported shelf-testing symptoms, both a third party answering
misleadingly and the app believing it.

1. Open Library's /api/books?bibkeys=... now 404s for EVERY ISBN, including
   books OL demonstrably still holds, with OL's own x-ol-stats header on the
   response while /isbn/, /search.json, /api/volumes/brief and covers.* all
   serve normally. OL's docs call it the "Legacy Books API" that "may be phased
   out" and it is gone from their API index, so this reads as a retirement
   rather than an outage. The app had been effectively single-sourced on Google
   Books since it broke: every failure the user saw was a book GB lacks.

   Lookup now uses /isbn/{isbn}.json — current, non-legacy, edition-level, and
   the only option of the three that carries a description. /api/volumes/brief
   is a near drop-in for the old response shape and was rejected precisely
   because it is also legacy.

   Its costs, all handled: authors are references, so AuthorNameCache resolves
   and caches them for the process lifetime (books by one author get scanned in
   runs off one shelf); an edition may carry NO authors, in which case they live
   on the work — 9780898707168 on the user's own shelf is exactly this, so
   without the work fallback the move would have silently dropped its author;
   author/work requests are best-effort and can only degrade a record, never
   turn Found into Unavailable.

   404 on this endpoint is authoritative NotFound. The legacy endpoint reported
   a miss as 200 with an empty object, which is why every non-2xx there was a
   failure. Every other non-2xx still is.

2. Google Books answers zoom=2 with a grey "image not available" PNG at HTTP
   200 — not a 404 — for any volume it holds no full preview of. Coil loads it
   as a success, so BookCover's placeholder never fires and the cover pipeline
   uploads Google's placeholder to PocketBase as the book's cover. Measured over
   18 real volumes: 11 placeholders at zoom=2, 0 at zoom=1&w=400. zoom=0/3/6 are
   placeholders too. normalizeCoverUrl now pins zoom=1, adds w=400 and strips
   edge=curl.

SPEC.md's "Book metadata lookup" is rewritten with both rules and the evidence
for them — it was the source of the zoom=2 instruction, and would otherwise be
the reason someone restores it.

Also fixed, because it blocked verification: LibraryViewModelTest never cleared
the view models it built, and LibraryViewModel's eleven WhileSubscribed(5_000)
flows kept running five seconds into later tests, racing resetMain(). It now
cancels each viewModelScope in tearDown.

NOT fixed, reported instead: AddBookViewModel.performSave's in-flight guard is a
check-then-act and two coroutines can both pass it. Unrelated to this change
(that VM has no metadata dependency) and out of scope. See HAZARD #13.

Verified: assembleDebug exit 0; testDebugUnitTest --rerun-tasks 378 tests,
2 skipped, 0 failures (was 355); verifyPaparazziDebug exit 0, no pixels moved;
0 "always 'false'" warnings; no build files touched. LIVE_METADATA=1 live test
ran (not skipped): 9/9 Found with cover art, with the GB key absent, so Open
Library alone answered through the new endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:13:01 +00:00

472 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Book metadata lookup — why scans miss, and what else we could ask
Research note, 2026-09-09. Written in response to "out of the 3 barcodes I've
scanned, only 1 has been discovered properly."
**Nothing in here has been implemented.** SPEC's two-source design (Open Library
primary, Google Books fallback) is unchanged. This is the evidence for deciding
whether to change it.
> **Update, same day — the coverage hypothesis is dead.** The user supplied the
> two ISBNs that failed: 9781883937386 (*The Hittite Warrior*) and 9781883937676
> (*Shadow Hawk*), both Bethlehem Books. **Both are fully present in Open
> Library** — title, author, publisher, page count and cover art — and the app's
> own parser handles both real responses correctly (regression test:
> `OpenLibraryClientTest.parses the real responses for the two books the app
> failed to identify`). Whatever went wrong on the phone was upstream of the
> metadata sources. Everything below still holds as background, but do not act
> on "add a third source" until we know why a book Open Library *has* did not
> reach the lookup. See [What actually failed](#what-actually-failed).
---
## Short version
Three separate defects were making lookups *look* far worse than the underlying
data actually is, and all three are now fixed (commit `356f639`). They are not
the same problem as "this book isn't in the database":
1. A cover that loaded fine still rendered as nothing, so a **successful** lookup
looked like a failed one. That alone could account for the book you did find
appearing broken.
2. Open Library's cover URL was synthesized for every book whether or not art
existed, and a missing cover comes back as a **200 with a 43-byte 1×1
transparent GIF** — a "successful" load that paints nothing.
3. Because that synthesized URL was never blank, the merge rule could never fall
through to Google Books' thumbnail. The documented fallback was dead code for
covers.
What is left is a real coverage question, and there the measurements point at one
thing above all others: **the Google Books fallback is probably not answering at
all.** Every keyless request from this machine returned HTTP 429, and the app
turns any non-200 into `null`, which the UI presents as "No match found — enter
the details by hand." A rate-limited lookup and a book that genuinely exists
nowhere are, right now, indistinguishable to both you and me.
My recommendation is to fix the diagnosis before buying more data. Details in
[Recommendation](#recommendation).
---
## What I measured
**Sample.** 60 ISBNs drawn from the Harvard Library catalog — deliberately a
third party, so the sample doesn't presuppose the answer by coming from one of
the two sources under test. Ten publishers, weighted toward the small Catholic
and homeschool presses that a MODG family's shelf actually carries (Ignatius,
TAN, Sophia Institute, Bethlehem Books, Baronius) alongside mainstream trade
(Penguin, Random House, Scholastic, Crossway, Loyola).
**Method.** Direct HTTP against each API, one ISBN at a time, 1.2 s apart. A
source "hits" only if it returns a usable title.
### Results
| Source | Hit | Miss | Error | Hit rate |
|---|---|---|---|---|
| Open Library Books API (what the app calls today) | 53 | 4 | 3 | **88%** |
| Google Books, keyless (the app's fallback) | 0 | 0 | **60 × HTTP 429** | **0%** |
| Harvard LibraryCloud | 56 | 4 | 0 | 93% * |
| Open Library cover art exists for the ISBN | 47 | 13 | 0 | 78% |
\* Harvard is where the sample came from, so its number is inflated by
construction. It's here to show the API works and answers by ISBN-13, not as a
fair comparison.
Two further observations from the same runs:
- **Concurrency is punished.** The same 60 ISBNs run six-at-a-time dropped Open
Library from 88% to 70%, entirely through transport errors. The app makes one
request per scan, so this doesn't bite in normal use — but it does mean
"Open Library missed" in a log is not proof the book is absent. By the end of
this research my own IP was refused outright for a while.
- **17% of successful Open Library lookups have no cover art at all** (9 of 53).
Even with everything working, roughly one book in six will legitimately show
the placeholder. That is a data fact, not a bug, and it's worth knowing before
you read a placeholder as a failure.
### What this does not tell us
Worth saying plainly, because it bounds how much weight the numbers carry:
- n = 60, and the sample comes from a research library. It under-represents
recent mass-market paperbacks, reprints and print-on-demand editions — which
is exactly where Open Library is thinnest. Real shelf coverage is probably
*below* 88%.
- Every request came from a datacenter IP. The Google Books 429 may partly be
this host sharing a quota pool with other tenants; **your phone, on a
residential or mobile IP, may well get answers.** That's precisely why the
app needs to be able to tell us which it got.
> **CORRECTED 2026-09-09 — this guess was WRONG.** The user tested from a
> residential IP and got the same refusal, naming a shared *project* quota
> rather than an IP one. See "Measured again 2026-09-09" at the end of this
> file. Do not act on the sentence above.
- I don't know which three ISBNs you scanned. If you still have the books to
hand, those three numbers are worth more than another 60 sampled ones.
---
## The options
### A. Give Google Books an API key — **DONE 2026-09-11, see the end of this file**
Free, 1,000 requests/day, no billing account required. Turns the fallback from
"silently 429" into a working source. Roughly a dozen lines: a key in
`local.properties` → `BuildConfig` → `&key=` on the query.
The key ships inside the APK and can be extracted, so restrict it to the Books
API in the Google Cloud console. At our volume, someone stealing it costs us
nothing but the quota.
**Effort: hours. Cost: free. Likely the single biggest win.**
### B. Tell the difference between "not found" and "couldn't ask"
Both clients collapse every non-200, timeout and parse failure into `null`, and
`MetadataRepository` collapses that into "no match", and the UI writes "No match
found." A book that's offline, rate-limited, or hit a 500 is reported to you as
a book that does not exist.
Distinguishing these gets you a retry button instead of a manual-entry form, and
gets me a real answer next time you say "it missed."
**Effort: half a day. Cost: free. Do this regardless of what else we choose.**
### C. Retry with backoff
One retry on 429/5xx, a couple of seconds apart. Standing at a bookshelf, a
two-second retry is invisible; a manual-entry form is not.
**Effort: an hour. Cost: free.**
### D. Add a third free source
Only worth doing after A–C, when we can see what's actually still missing.
Ranked by what I'd try first:
| Source | Key? | Notes |
|---|---|---|
| **Open Library `search.json`** | No | Searches the whole OL index rather than the edition table the Books API reads. Cheapest possible fallback — same service, one more request, no new failure modes. |
| ~~**Harvard LibraryCloud**~~ | No | **Demoted.** Free, no registration, answers by ISBN-13 (`?identifier=<isbn>`), verified working — but it returned `numFound: 0` for *both* of the user's real failing books. It is a research library: strong on scholarly and older material, and it simply does not hold small-press children's historical fiction. The 93% in the table above is inflated by construction (the sample came from Harvard) and is misleading in exactly the direction that matters. Not a fit for this shelf. |
| **Library of Congress** | No | The SRU endpoint (port 210) is blocked from here; the `loc.gov` JSON API responds. Excellent for US imprints. Needs more probing before I'd commit. |
| **K10plus SRU** | No | Free German-led union catalogue, large and international. Cataloguing conventions differ enough that merging would need care. |
Dead ends, so nobody re-investigates them: **OCLC Classify** (retired 2021),
**Goodreads API** (retired 2020), **Amazon Product Advertising API** (requires an
affiliate account with qualifying sales), **WorldCat Search** (requires OCLC
membership — institutional pricing).
### E. Pay for ISBNdb
~$15–50/month depending on tier. Genuinely better coverage than anything free,
including cover art, and a single clean API. It is also a subscription for a
two-person home library, and I'd want proof that A–D leave a real gap before
recommending it.
**Effort: hours. Cost: $180–600/year.**
---
## What actually failed
Both failing ISBNs resolve cleanly against the source the app already uses:
| Check | 9781883937386 | 9781883937676 |
|---|---|---|
| Open Library Books API | *Hittite warrior*, Joanne S. Williamson | *Shadow hawk*, Andre Norton |
| Cover art | yes | yes |
| App's own parser (unit test) | parses | parses |
| ISBN-13 checksum | valid | valid |
| Harvard | not held | not held |
So the request either never went out, or went out and failed in a way the app
reported as "not found". Candidates, in the order I'd chase them:
1. **The barcode never decoded into a valid ISBN-13.** `ScanCodeFilter` drops
anything that fails the checksum, silently and with no UI feedback at all —
no sheet, no message, nothing. A book whose barcode carries a price add-on,
or is worn, or is a UPC-A rather than a Bookland EAN, looks to the user
exactly like a camera that isn't working. This is my leading theory, and it
fits "wasn't able to scan" better than "wasn't found".
2. **The HTTP request failed.** Both clients turn every non-200, timeout and
parse error into `null`, which reaches the user as "No match found". The
metadata `OkHttpClient` is constructed with no call timeout, so a stalled
connection hangs on default socket timeouts.
3. **The lookup ran and the sheet was dismissed before it landed.** While
Loading, the sheet passes an empty `onDismissRequest`, so it can't be
swiped away — but `onScanned` early-returns whenever a sheet is already
showing, so a stuck sheet blocks every subsequent scan.
Nothing here is a data-source problem. Note the sting in (1) and (2): both
failure modes are invisible or actively misleading, which is why three scans
produced no usable diagnosis.
## Recommendation
**Revised after the two real ISBNs came in.** Adding sources is now the *wrong*
next move: the books that failed are already in the source we query.
Do **B** first and on its own — make the app say what happened. A scan that
decodes nothing should say so on the camera screen; a lookup that fails should
offer retry, not a manual-entry form captioned "No match found". Add a call
timeout while in there.
Then rescan those two books. The app will tell us which of the three candidates
above it is, and that determines everything after it. **A** (the free Google
Books key) and **C** (retry/backoff) are still worth doing — cheap, and the 429
result is real — but they are no longer the leading theory.
**D** and **E** are on hold. Harvard specifically is off the list for this
shelf. Paying ISBNdb for coverage we demonstrably already have would be the
wrong order.
One thing worth deciding separately: 17% of books legitimately have no cover art
anywhere. The placeholder now looks deliberate rather than broken, but if you
want covers on everything, that's a different feature — photograph the book,
store it as the cover — and not a metadata-source problem at all.
## Measured again 2026-09-09, after the user tested from a residential IP
Two things were settled that the first round could only guess at.
### Google Books keyless is dead everywhere, not just from this box
The user ran the app's exact Google Books call from their home connection and got:
Quota exceeded for quota metric 'Queries' and limit 'Queries per day'
of service 'books.googleapis.com' for consumer 'project_number:624717413613'
That names a **Google Cloud project, not an IP**. Every keyless caller on the
internet is billed to that one shared anonymous project and its daily quota is
exhausted. So:
- The earlier caveat — "your phone, on a residential or mobile IP, may well get
answers" — is **WRONG**. Delete it from your mental model. It was tested and it
is not true.
- Backoff cannot help. This is a daily quota, not a per-second rate limit.
- A free API key is the only fix, and it is a complete one: it moves the app into
its own project with its own quota (free tier 1,000 req/day).
The knock-on is the part that actually hurt the user. `MetadataRepository.combine`
turns "any source Failed, none Found" into `Unavailable`. Google Books is a
PERMANENT standing failure, so **every** Open Library hiccup became `Unavailable`.
The app has effectively been single-sourced this whole time while reporting
failures as though two sources had been consulted.
**The user deferred the API key at the time; they asked for it on 2026-09-11 and it
is now implemented and verified.** See "The key landed" at the end of this file.
### Open Library: 13% failure, and our own timeout was manufacturing more
30 requests, the exact call `OpenLibraryClient` makes, 1.5s apart, from the sprite:
| | |
|---|---|
| failure rate | **13%** (4 of 30) |
| every failure | curl exit 35 — TLS-stage `Connection reset by peer` |
| failure latency | 0.23s, 0.31s, 0.59s, 2.46s — **all fast** |
| success latency | median **4.3s**, p75 6.0s, p90 **9.2s**, max **22.0s** |
| successes over the old 12s callTimeout | **2 of 26 (8%)** |
Two conclusions, and they point in opposite directions:
1. **Failures are cheap and transient**, so retrying is nearly free. 13% → ~1.7%
at two attempts → ~0.2% at three. This is why `RetryPolicy` exists and why its
backoff is milliseconds rather than the conventional seconds.
2. **Successes are slow and long-tailed**, and the app's own
`callTimeout(12s)`/`connectTimeout(10s)` were cutting off roughly 8% of
lookups that were about to succeed — then reporting them to the user as
"couldn't be reached". The app was generating a meaningful share of its own
failures. Timeouts are now 25s/20s/20s, above the 22.0s worst observed success.
The asymmetry is the whole design: a short timeout buys nothing on the failure
path (failures return in under 2.5s regardless) and costs real successes on the
slow path. That is also why `RetryPolicy.isRetryable` refuses to repeat a
TIMEOUT — a timeout means the budget was already spent, and the evidence says
slow requests mostly succeed if you let them finish.
Connection reuse is visible in the data and matters in real use: cold connects
ran 2-19s while warm ones ran 0.07s. OkHttp pools connections for 5 minutes, so
scanning a box of books in sequence stays on the fast path after the first book.
### What still is not known
- All 30 requests came from this datacenter IP. The user's phone may see a
different failure rate. The reason string now shown on the scan sheet
(`SourceResult.Failed.reason`, e.g. "tls connection reset, 3 attempts") is how
we find out — it is the only diagnostic channel we have from a real device.
- The user's own 3-request sample showed 2 failures. That is consistent with 13%
(p ~ 5%) but does not confirm it. If their phone reports "3 attempts" often,
their network is worse than this one and the retry count deserves revisiting.
## The key landed — 2026-09-11
The user obtained a restricted Google Books API key and it is wired in
(commit below). This closes option A, which had been the single biggest
outstanding win in this file.
### Verified live, not just in tests
The key was tested from this machine against the real API before any code was
written, and again afterwards in the exact URL shape the app now builds:
| ISBN | Result |
|---|---|
| 9781883937386 *Hittite Warrior* | HTTP 200, 1 item |
| 9781883937676 *Shadow Hawk* | HTTP 200, 1 item |
| 9780140449136 *Crime and Punishment* (control) | HTTP 200, 1 item |
**Both of the books that failed on the user's phone are in Google Books.** They
were always in Open Library too — what actually failed was the TLS-stage
connection reset documented above, not coverage. The significance is that the
fallback would now cover exactly that failure mode: an Open Library transport
error no longer leaves the lookup with nothing to fall back to. The app has been
effectively single-sourced since it was written; it is now genuinely two-sourced.
Note the second URL test was not redundant. `HttpUrl.Builder` percent-encodes the
colon, so the app sends `q=isbn%3A9781883937386` where every hand-run test in this
file sent `q=isbn:9781883937386`. Offline unit tests cannot tell those apart and
the API accepts both — but that is a fact worth having measured rather than
assumed, because the failure mode would have been a feature that passes every
test and returns nothing on the phone.
### What was built
- The key lives in `app/local.properties` (gitignored) as `GOOGLE_BOOKS_API_KEY`,
read by `app/app/build.gradle.kts` into `BuildConfig.GOOGLE_BOOKS_API_KEY`,
falling back to an environment variable of the same name and then to empty.
**A blank key is a supported state** — a fresh clone builds a working app that
falls back to the keyless (429ing) endpoint rather than failing to build.
- `GoogleBooksClient` takes the key and appends it only when non-blank, with URL
construction in a pure `requestUrl()` so it is testable with no socket.
- **The key is scrubbed out of `SourceResult.Failed.reason`** before it can reach
the scan sheet. That string is rendered to the user and is our only diagnostic
channel from a real phone; some okhttp/JDK IOExceptions embed the full request
URL in their message, so the scrub is defensive rather than a response to an
observed leak. Do not remove it on the grounds that nothing currently leaks.
- The `RetryPolicy` RATE_LIMITED decision parked in its KDoc is now resolved: a
**keyed** 429 is the short per-user rate limit and gets exactly one retry,
honouring `Retry-After` capped at 2s; a **keyless** 429 is still the dead daily
quota and is still never retried.
### What this does NOT fix
The 17% of books with no cover art anywhere is unchanged, and so is Open
Library's ~13% transport failure rate. What changes is that a failure of one
source is now much more likely to be covered by the other instead of surfacing
as `Unavailable`.
---
## The Open Library endpoint move — 2026-09-20
The user came back from a round of shelf testing with two symptoms. Both had the
same character: a third party answering 200-with-something-useless, or 404-without-
meaning-it, and the app believing it.
### 1. `/api/books` is answering 404 to everything
Reported symptom: intermittent "ISBN 9781328613042 — one or more sources couldn't be
reached … open library: http 404".
Measured from this box, and independently reproduced from the user's phone network:
| Request | Result |
|---|---|
| `/api/books?bibkeys=ISBN:9781328613042` (*The Fall of Gondolin*) | **404, 0 bytes** |
| `/api/books?bibkeys=ISBN:9780140328721` (*Fantastic Mr Fox*) | **404, 0 bytes** |
| `/api/books?bibkeys=OLID:OL1017798M` | **404, 0 bytes** |
| same, `jscmd=viewapi` / no `jscmd` | **404** |
| same, browser UA / descriptive UA / `okhttp` UA / HTTP/1.1 | **404** |
| `/isbn/9781328613042.json` | 200 — full edition record |
| `/api/volumes/brief/isbn/9781328613042.json` | 200 — edition `OL26961988M` |
| `/search.json?q=isbn:…` | 200 |
| `covers.openlibrary.org/b/isbn/…` | 200 |
The 404 carries `content-type: application/json` and OL's own `x-ol-stats` header, so
it is OL's application answering, not a CDN error page. **Open Library still holds the
book** — only that one door is shut.
**This was first written up as an outage. That was probably wrong.** OL's docs
(`/dev/docs/api/books`) call `/api/books` the "Legacy Books API" and say "Please
consider using the Book Search API above; this is a legacy endpoint and may be phased
out in the future", and it does not appear in their API index at all. A blanket,
header-independent 404 while every neighbouring endpoint serves normally fits a
retirement. There is no 410 and no `Sunset` header, so intent can't be proven from
outside — but "wait for it to come back" was never a plan either way.
**Consequence while it lasted: the app was single-sourced on Google Books.** Every
lookup that reached the user as a failure was a book Google Books doesn't have.
9781328613042 is exactly that — Google Books returns `totalItems: 0` for it, with the
key. So the error message named only Open Library because `combine()` names only
sources that *failed*, and Google Books had answered authoritatively.
### Which endpoint replaces it
| | `/api/volumes/brief` (legacy) | **`/isbn/{isbn}.json`** (chosen) | `search.json` (OL's own suggestion) |
|---|---|---|---|
| Requests per lookup | 1 | 1 + 1 per author | 1 |
| Title | edition | **edition** | *work* title |
| Author names | inline | **refs — needs a fetch** | inline |
| Publisher | edition | **edition** | all editions mashed together |
| Year | 2018 (this edition) | **2018** | 1985 (first ed. of the work) |
| Description | — | **yes, when present** | — |
| Cover | `cover` object | **`covers: [id]`** | `cover_i` |
| Status | **legacy** | current | current |
`search.json` is work-level: for *The Fall of Gondolin* it reports 1985 and eight
publishers, which is wrong for a catalogue of specific editions someone owns.
`/api/volumes/brief` is the least work — its `data` block is nearly the old
`jscmd=data` shape — and that is the trap: swapping a retired legacy endpoint for
another legacy endpoint buys one migration and no safety.
### What the Editions API costs, and two things that would have been silent bugs
- **`authors` are references, not names.** Hence `AuthorNameCache` (process-lifetime,
unbounded by design: hundreds of short strings at most). Books by one author get
scanned in runs off one shelf, so the cache hits constantly.
- **An edition can carry no authors at all.** 9780898707168 — *The Harp and Laurel
Wreath*, on the user's own shelf — has `authors: None`; they exist only on the work.
The legacy endpoint resolved that and returned "Laura M. Berquist". Without a
work-level fallback the migration would have silently dropped the author for books
like it. Caught before shipping, and pinned by a test.
- **`covers` uses `-1` as a "no cover" sentinel** (9780898707168 -> `[698345, -1]`,
9780140328721 -> `[15152634, 8739161, -1]`). Taking `.first()` would eventually
build `b/id/-1-L.jpg`. Take the first positive id.
- **`description` is a bare string on some records and `{type, value}` on others**,
and absent on most: of 15 real editions sampled, 1 string, 5 objects, 9 absent.
- **404 now means NotFound.** The legacy endpoint reported a miss as `200 {}`, so
every non-2xx there was rightly a failure. This endpoint 404s instead, and that is
authoritative — reporting it as Unavailable would offer a retry for a book no
amount of retrying will find. Every other non-2xx is still Unavailable.
### 2. Google Books serves a placeholder image at `zoom=2`
Reported symptom: two different cover placeholders — the app's own two-tone/gold one,
and "an ugly gray-text-on-white saying image not available".
The second is Google's, and the app was asking for it. SPEC said to force `zoom=2` on
GB `imageLinks`; Google's `thumbnail` is `zoom=1`. For a volume Google has no full
preview of — the metadata-only `…AAAACAAJ` records, i.e. most small-press and older
material — **`zoom=2` is not a valid rendition and Google substitutes a placeholder
with HTTP 200** instead of 404ing. Coil loads it as a success, so `BookCover`'s
placeholder never fires, and the cover pipeline uploads it to PocketBase as the cover.
Measured over 18 volumes that have `imageLinks`:
| URL form | real cover | "image not available" |
|---|---|---|
| `zoom=2` (what the app sent) | 7 | **11** |
| `zoom=1&w=400`, `edge=curl` stripped | **18** | 0 |
The 11 were byte-identical: 15,567 bytes, md5 `c96309220b9cbd205c36d879d09a3647`.
`zoom=0`, `3` and `6` return the same artwork at 575x750 and 1280x1670 — so there is
no bigger-zoom escape, and hash-detection would mean chasing renditions forever.
`w=` is what buys resolution: Google honours it up to the source scan's native width.
**Confirmed in the live library, not just on screen.** *Abraham Lincoln's World*
(`9plhmyj5s68f58e`) has a `zoom=2` `cover_source_url`, and the file stored on
PocketBase for it is that exact placeholder — 15,567 bytes, same md5, served as
`.jpg`. The user is handling the already-poisoned covers themselves.
### Verification
`LiveMetadataLookupTest` (opt-in, `LIVE_METADATA=1`) against the rewritten client:
9 lookups, all **Found with cover art**, 1.7s–4.9s — and with the Google Books key
absent, so that is Open Library alone answering through the new endpoint.