What changed
Comprehensive Kobo subsystem audit triggered by a household report of the "Sync to last page read?" popup loop. The audit found six independent bugs spanning the reading-state PUT/GET cycle, sync-token parsing, magic-shelf truncation, and reverse-proxy handling. All six are fixed in this release. Reading on a Kobo synced to a Calibre-Web-NextGen instance should be noticeably more reliable.
The popup loop is fixed
The "Sync to last page read? It looks like you've been reading on another device" popup that fired after auto-sleep / wake on every Kobo device was caused by three independent bugs in the reading-state response, each of which made the device think the server's view diverged from its own:
- Float / int mismatch on
ProgressPercent. Kobo sends33(int); the SQLite Float column round-tripped it as33.0. The device read33 ≠ 33.0as a divergent position. ProgressPercent: 0was silently dropped from responses. Aif value:truthy check omitted the field whenever progress was exactly 0. Particularly bad for comics and manga, whereProgressPercentstays 0 whileContentSourceProgressPercentcarries page-by-page progress.priority_timestampadvanced server-side on every PUT. SQLAlchemy'sonupdate=datetime.now()rewrote the timestamp on every flush, even when the data didn't change. The device cachesPriorityTimestampfrom each GET; if the next GET returns a newer one, it shows the popup. After auto-sleep/wake, this fired reliably.
The fix mirrors the pattern that official Kobo cloud uses: the server reads LastModified from the device's PUT body and uses that timestamp as both LastModified and PriorityTimestamp for the affected reading-state row. The next GET returns the device's own timestamp back, eliminating the divergence trigger. Float values that are whole numbers are now coerced to int before they hit the wire, matching what the device sent.
Backports: janeczku/calibre-web PRs #3585 (@leahjessie) and #3601 (@jarynclouatre), plus the still-open janeczku PR #3607 (@leahjessie) which the upstream author has been running locally for weeks without seeing the popup.
Reverse-proxy users can now sync
HandleInitRequest rewrites a handful of Kobo resource URLs to point at the local Calibre-Web instance (image host, cover templates, OAuth, etc.) but the library_sync URL was left at its default https://storeapi.kobo.com/v1/library/sync. Kobo devices behind nginx, Caddy, Traefik, or any other reverse proxy dutifully called Kobo's actual store API for sync — which has no idea what's in your local library — so no books arrived on the device.
The fix adds the missing rewrite in both proxy / unproxied branches. Backport of janeczku commit a9713bd4 (@Rakjlou).
PUT /state robustness — three additional bugs
The same audit that surfaced the popup loop found three more independent failure modes in the same handler:
- 500 on Statistics-only / StatusInfo-only PUTs. Kobo legitimately PUTs without
CurrentBookmark(e.g. when reporting just time-spent). The hardcover-progress-push call accessedrequest_bookmark['ProgressPercent']outside theif request_bookmark:guard, raisingTypeErrorand returning 500 to the device. Device retries forever. - Partial
Statisticsrolled back the whole PUT. When Kobo sent onlySpentReadingMinutes(typical when a book is just opened), the unconditionalrequest_statistics["RemainingTimeMinutes"]raised KeyError, the request returned 400, and the bookmark + status updates in the same PUT were lost. - Falsy check dropped 0-valued reading minutes from responses. Same class of bug as the
ProgressPercent: 0issue above; the missing field reads as a divergent state to the device.
All three guarded with is not None / .get(...) patterns so partial Kobo payloads no longer break the round-trip.
SyncToken validation is now actually validation
The from_headers parser had four overlapping bugs that turned validation into a no-op and let malformed tokens crash the request:
data_schema_v1was applied to the wrapper object instead of the inner data payload.token_schemadeclaredversionanddataproperties but didn't require them — a token missing either crashed the next line.raw_kobo_store_tokenwas read outside the try/except, so a token that passed the (no-op) validation but omitted that key still 500'd.- The schema declared timestamp fields as
stringbut the wire format writes them as numbers (epoch seconds), guaranteeing a real validation would always fail.
Fixed end-to-end. A malformed sync token now degrades gracefully to a fresh SyncToken (which triggers a full resync) instead of returning 500 to the device.
Magic-shelf 1000-book cap removed
HandleSyncRequest's magic-shelf branch passed page_size=1000 to get_books_for_magic_shelf, silently truncating shelves over 1000 books. Anything past the first 1000 never reached the device — no error, no log, just missing books on the Kobo. The sibling helper get_magic_shelf_book_ids_for_kobo already passes page_size=None for full traversal; the sync path now matches.
sync_shelves only-Kobo-sync filter actually filters
The only_kobo_shelves branch of sync_shelves used not ub.Shelf.kobo_sync inside a SQLAlchemy filter chain. Python's not operator against a Column object doesn't produce a SQL NOT — depending on the SQLAlchemy version it either raises or coerces the column to a Python bool, leaving the filter unconstrained. Switched to the idiomatic ub.Shelf.kobo_sync == False.
To get the update
docker pull ghcr.io/new-usemame/calibre-web-nextgen:v4.0.44
Or :latest. No data migration; existing instances upgrade in place. The reading-state row's priority_timestamp no longer auto-bumps on flush, so existing rows are healed implicitly on the next Kobo PUT.
Credit
This release is almost entirely backports from upstream janeczku/calibre-web PRs that haven't yet shipped in a stock Calibre-Web release, plus internal-audit fixes:
- janeczku/calibre-web#3585 by @leahjessie (popup float/int + 0-truthy)
- janeczku/calibre-web#3601 by @jarynclouatre (PriorityTimestamp echo, superseded by #3607 and reverted in this build)
- janeczku/calibre-web#3607 by @leahjessie (the actual popup-loop fix; OPEN upstream)
- janeczku/calibre-web a9713bd4 by @Rakjlou (library_sync rewrite)
The PUT /state robustness, SyncToken validation, and shelf bugs were all found in our audit and don't have upstream counterparts yet.
E2E verification
cps/kobo.py+cps/ub.py+cps/services/SyncToken.pyregression suite: 19 new tests covering each of the six fixes; 16 of 19 fail onmainand pass on this build (the 3 negative cases are correct on both sides).- Full Kobo + KOSync test suite: 77 passed, no regressions in adjacent paths (sync, metadata, cover, magic shelf, kosync, hardcover).
- Maggie's daily Safari + Kobo Libra Color flow is the production verification once this image is deployed.