Repository navigation
Conversation
Reverse-engineered Libby (OverDrive) API reference: chip auth, passports, position reads/writes, payload schemas for ebook and audiobook. Source branch: explore/libby-sync.
…sion Read-only client over Libby's private API: 8-digit setup-code pairing (chip clone), chip revoke, /chip/sync parsing into normalized loans, passport issuance with base64 tData, and possession reads. Identity token is treated as a secret and never logged.
LIBBY_ENABLED / LIBBY_IDENTITY_TOKEN (secret) / LIBBY_POLL_MINS / LIBBY_DEVICE_ID join the settings table and env loader; client is registered in the container; MockContainer mirrors the accessor.
New Libby tab pairs via POST /api/libby/connect, shows linked cards, and disconnects through /api/libby/disconnect after server-side chip revoke. Poll interval input enforces a 10-minute floor.
Mocked-HTTP unit tests for pairing success/expired/revoked paths, chip sync normalization, passport request construction (tData shape, no bearer on session hosts), possession parsing, disconnect semantics, and config guards.
Freeze the two new Libby pairing endpoints added to the settings blueprint.
Connect now creates a fresh chip and displays an 8-digit code; the user enters it in their Libby app and PageKeeper polls /api/libby/pairing/status until the clone lands (cards appear). Matches the actual Libby app flow instead of the setup-code URL.
POST /chip/clone/code returns 404 — generation is GET (confirmed live and against libby-calibre-plugin). Session now sends a browser-like User-Agent and Accept header like the reference clients. LIBBY.md corrected with the verified auth semantics.
…antics Live testing showed clone codes are pull-only: entering a blank chip's generated code in the Libby app reports success but transfers nothing. Restore the proven submit flow (user pastes a code from Copy To Another Device or the setup-code interview), form-encoded per reference clients, with post-clone token rotation. LIBBY.md documents the verified semantics and the failed target-generate experiment.
Live pairing revealed: /chip/sync loans are flat (title is a string,
type.id holds the format, no ISBN on loans), cards use cardId/cardName/
advantageKey, and passports come from GET /open/{type}/card/{cardId}/
title/{titleId} with an unauthenticated HEAD {web}?{message} cookie
handshake — no tData query params. LIBBY.md corrections to follow.
Reverse-engineered dewey's fetchDervishPassport from the live bundle:
/open/{type}/card/{cardId}/title/{titleId}?t={b64}&website_id={card
library websiteId} where tData carries codex loan psnKey + library
context, dewey-url, and spec V31. Plain /open/ requests get 403
client_upgrade_required. Cards now expose their library website_id.
Live testing showed /open/ returns client_upgrade_required for chips created with an old browser UA (the calibre plugin's Safari-14 string) while /chip/sync passes: the server gates reader endpoints on the bound UA's browser version. Use a current Chrome UA.
Live protocol testing proved clone-code chips (any UA/version/tData) never gain reader access: /open/ answers client_upgrade_required or missing_chip while /chip/sync works. Only tokens minted by a real dewey session can read positions. Pairing now imports the user's identity JWT from their libbyapp.com localStorage (dewey:sentry.identity), verified against /chip/sync before acceptance. Also fixes loan/card payload shapes (flat loans, cardId/cardName/advantageKey, library websiteId), the dervish passport request (tData + website_id + spec), and the reader-host cookie handshake.
…res, payload shapes Documents the Aug 2026 live findings: clone-code chips never gain reader access (browser tokens required), the legacy sentry data store holds cross-device positions the possession endpoint lacks (audiobooks especially), flat /chip/sync loan/card shapes, dervish passport tData construction with website_id from card library, and the reader-host cookie handshake.
Two composable credentials: LIBBY_SYNC_TOKEN from an 8-digit setup code (long-lived, loans/holds/cards via /chip/sync) and LIBBY_IDENTITY_TOKEN from the browser session (full access incl. positions, ~weekly expiry). Either activates the integration; positions require the identity token. Disconnect revokes both chips. Settings UI offers both options with capability status.
| if web_url and message: | ||
| try: | ||
| self.session.head(f"{web_url}?{message}", headers={"Accept": "*/*"}, timeout=REQUEST_TIMEOUT) | ||
| except requests.RequestException as e: | ||
| logger.warning("Libby: reader-session cookie handshake failed: %s", sanitize_exception(e)) |
There was a problem hiding this comment.
🟡 Medium api/libby_client.py:539
get_passport returns the passport even when the required reader-session HEAD handshake receives an HTTP error or raises a network exception, so callers proceed with an unauthenticated session and later possession/content requests fail. Check the handshake status and return None when it is not successful.
- try:
- self.session.head(f"{web_url}?{message}", headers={"Accept": "*/*"}, timeout=REQUEST_TIMEOUT)
- except requests.RequestException as e:
- logger.warning("Libby: reader-session cookie handshake failed: %s", sanitize_exception(e))
+ try:
+ response = self.session.head(
+ f"{web_url}?{message}", headers={"Accept": "*/*"}, timeout=REQUEST_TIMEOUT
+ )
+ except requests.RequestException as e:
+ logger.warning("Libby: reader-session cookie handshake failed: %s", sanitize_exception(e))
+ return None
+ if not 200 <= response.status_code < 300:
+ logger.warning(
+ "Libby: reader-session cookie handshake returned HTTP %s", response.status_code
+ )
+ return None🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/api/libby_client.py around lines 539-543:
`get_passport` returns the passport even when the required reader-session `HEAD` handshake receives an HTTP error or raises a network exception, so callers proceed with an unauthenticated session and later possession/content requests fail. Check the handshake status and return `None` when it is not successful.
| </div> | ||
|
|
||
| {% set libby_paired = get_val('LIBBY_IDENTITY_TOKEN') != '' or get_val('LIBBY_SYNC_TOKEN') != '' %} | ||
| {% set libby_enabled = get_bool('LIBBY_ENABLED') or (libby_paired and get_val('LIBBY_ENABLED') == '') %} |
There was a problem hiding this comment.
🟠 High templates/settings.html:690
After a successful Libby pairing, the settings page reloads with Libby disabled, so its polling job never starts despite the success message claiming tracking or progress sync is active. LIBBY_ENABLED defaults to the non-empty string false, making the == '' fallback at line 690 false; the connect flow must persist LIBBY_ENABLED=true on successful pairing or otherwise preserve the enabled state.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @templates/settings.html around line 690:
After a successful Libby pairing, the settings page reloads with Libby disabled, so its polling job never starts despite the success message claiming tracking or progress sync is active. `LIBBY_ENABLED` defaults to the non-empty string `false`, making the `== ''` fallback at line 690 false; the connect flow must persist `LIBBY_ENABLED=true` on successful pairing or otherwise preserve the enabled state.
| def libby_connect(): | ||
| """Pair Libby. Body {"code": "NNNNNNNN"} pairs a sync-only chip; | ||
| {"token": "<jwt>"} pairs a full-access browser identity token.""" | ||
| payload = request.get_json(silent=True) or {} |
There was a problem hiding this comment.
🟠 High blueprints/settings_bp.py:300
Unauthenticated callers can pair Libby credentials and overwrite the persisted LIBBY_SYNC_TOKEN or LIBBY_IDENTITY_TOKEN, causing later synchronization to read loans and cards from the wrong Libby account. Enforce _is_secret_request_authorized() before processing the pairing payload, matching the authorization used by secret operations.
| payload = request.get_json(silent=True) or {} | |
| if not _is_secret_request_authorized(): | |
| return jsonify({"success": False, "detail": "Unauthorized"}), 403 | |
| payload = request.get_json(silent=True) or {} |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/blueprints/settings_bp.py around line 300:
Unauthenticated callers can pair Libby credentials and overwrite the persisted `LIBBY_SYNC_TOKEN` or `LIBBY_IDENTITY_TOKEN`, causing later synchronization to read loans and cards from the wrong Libby account. Enforce `_is_secret_request_authorized()` before processing the pairing payload, matching the authorization used by secret operations.
| @property | ||
| def active_sync_token(self) -> str | None: | ||
| """Token used for /chip/sync calls — prefer the full-access one.""" | ||
| return self.identity_token or self.sync_token |
There was a problem hiding this comment.
🟠 High api/libby_client.py:98
When LIBBY_IDENTITY_TOKEN has expired and LIBBY_SYNC_TOKEN is still valid, /chip/sync requests use the expired identity token and fail, so loans, holds, and cards stop syncing. Because active_sync_token always selects identity_token without falling back after authentication failure, the user must manually refresh or remove the expired token; retry failed requests with sync_token when available.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/api/libby_client.py around line 98:
When `LIBBY_IDENTITY_TOKEN` has expired and `LIBBY_SYNC_TOKEN` is still valid, `/chip/sync` requests use the expired identity token and fail, so loans, holds, and cards stop syncing. Because `active_sync_token` always selects `identity_token` without falling back after authentication failure, the user must manually refresh or remove the expired token; retry failed requests with `sync_token` when available.
| except Exception as e: | ||
| logger.error("Libby: failed to persist setting %s: %s", key, e) |
There was a problem hiding this comment.
🟡 Medium api/libby_client.py:124
When db.set_setting raises, _persist_setting logs the error and returns normally, so pair_with_setup_code and pair_with_identity_token report success: True with credentials stored only in the process environment. Those credentials disappear on restart, forcing the user to pair again. Propagate the persistence failure so pairing cannot report durable success.
except Exception as e:
logger.error("Libby: failed to persist setting %s: %s", key, e)
+ raise🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/api/libby_client.py around lines 124-125:
When `db.set_setting` raises, `_persist_setting` logs the error and returns normally, so `pair_with_setup_code` and `pair_with_identity_token` report `success: True` with credentials stored only in the process environment. Those credentials disappear on restart, forcing the user to pair again. Propagate the persistence failure so pairing cannot report durable success.
| except (requests.RequestException, ValueError): | ||
| state = None | ||
|
|
||
| if state is None: |
There was a problem hiding this comment.
🟠 High api/libby_client.py:304
pair_with_identity_token raises AttributeError instead of returning its documented failure result when /chip/sync responds with HTTP 200 and valid JSON that is not an object. The code only checks for None before calling state.get(...); validate that state is a dict first.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/api/libby_client.py around line 304:
`pair_with_identity_token` raises `AttributeError` instead of returning its documented failure result when `/chip/sync` responds with HTTP 200 and valid JSON that is not an object. The code only checks for `None` before calling `state.get(...)`; validate that `state` is a `dict` first.
| "detail": "Libby rejected that token — it may have expired. Refresh libbyapp.com and copy a fresh one.", | ||
| } | ||
|
|
||
| self._persist_setting(f"{self.env_prefix}_IDENTITY_TOKEN", clean_token) |
There was a problem hiding this comment.
🟠 High api/libby_client.py:314
Successful identity re-pairing leaves self._last_sync_state populated with the previous account's data, so get_active_loans(), get_holds(), and passport card lookup continue using the old account until a forced refresh. Store the verified state (or clear the cache) when persisting the new token.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/api/libby_client.py around line 314:
Successful identity re-pairing leaves `self._last_sync_state` populated with the previous account's data, so `get_active_loans()`, `get_holds()`, and passport card lookup continue using the old account until a forced refresh. Store the verified `state` (or clear the cache) when persisting the new token.
| except (requests.RequestException, ValueError): | ||
| state = {} |
There was a problem hiding this comment.
🟡 Medium api/libby_client.py:233
A requests.RequestException during the verification GET /chip/sync makes pair_with_setup_code return expired_code and discard the newly cloned token, even though the setup code was accepted; callers therefore never receive the documented network_error. Return the network_error result directly from this exception handler instead of converting the failure to an empty state.
- except (requests.RequestException, ValueError):
- state = {}
+ except (requests.RequestException, ValueError):
+ return {
+ "success": False,
+ "error": "network_error",
+ "detail": "Could not reach Libby.",
+ }🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/api/libby_client.py around lines 233-234:
A `requests.RequestException` during the verification `GET /chip/sync` makes `pair_with_setup_code` return `expired_code` and discard the newly cloned token, even though the setup code was accepted; callers therefore never receive the documented `network_error`. Return the `network_error` result directly from this exception handler instead of converting the failure to an empty state.
|
|
||
| def _test_libby() -> tuple[bool, str]: | ||
| client = get_container().libby_client() | ||
| if not client.identity_token: |
There was a problem hiding this comment.
🟡 Medium blueprints/settings_bp.py:613
Setup-code pairing stores only client.sync_token, so _test_libby returns Not paired without calling check_connection; the supported sync-only mode therefore always fails its settings connectivity test. Check both pairing credentials before rejecting the client.
| if not client.identity_token: | |
| if not client.identity_token and not client.sync_token: |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/blueprints/settings_bp.py around line 613:
Setup-code pairing stores only `client.sync_token`, so `_test_libby` returns `Not paired` without calling `check_connection`; the supported sync-only mode therefore always fails its settings connectivity test. Check both pairing credentials before rejecting the client.
| headers={"Authorization": f"Bearer {clean_token}"}, | ||
| timeout=REQUEST_TIMEOUT, | ||
| ) | ||
| state = response.json() if response.status_code == 200 else None |
There was a problem hiding this comment.
🟡 Medium api/libby_client.py:300
HTTP 401/403 responses from /chip/sync are reported as network_error with “Could not reach Libby,” so expired or revoked identity tokens receive the wrong outcome and refresh guidance. The non-200 condition sets state = None before the revoked_token check; preserve 401/403 as a revoked result so the documented error is returned.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/api/libby_client.py around line 300:
HTTP 401/403 responses from `/chip/sync` are reported as `network_error` with “Could not reach Libby,” so expired or revoked identity tokens receive the wrong outcome and refresh guidance. The non-200 condition sets `state = None` before the `revoked_token` check; preserve 401/403 as a revoked result so the documented error is returned.
Part 1/3 of the Libby integration (#131). Stacks: #133 builds on this.
What
LibbyClientover Libby's private sentry/passport APIs: chip/sync reads, dervish passports (tData+website_id+ spec V22), reader-host cookie handshake, possession reads, legacy cross-device position storedewey:sentry.identity) add position access — clone-code chips can never read positions, verified exhaustively liveValidation
Live end-to-end against a real account: pairing → cards → loans → passport → positions (ebook at 3.48%, audiobook at 77.07%).
Note
Add
LibbyClientAPI client, dual-tier pairing, and settings UIPOST /api/libby/connectandPOST /api/libby/disconnectin settings_bp.py plus alibbytest-connection entry, wiring the client through the DI containerLIBBY_ENABLED, tokens,LIBBY_POLL_MINS,LIBBY_DEVICE_ID) in config_loader.py and marks tokens secret in secret_settings.py"- Adds the Libby settings panel and sidebar tab in settings.html with code/token pairing, status display, poll interval input, and disconnect; backed by handlers in settings.js--color-libbyCSS token and matching accent stylesdisconnectreturns 502 and leaves local settings intact when revocation cannot be confirmed;LIBBY_POLL_MINSis floored to a minimum of 10 minutes in_clamp_poll_mins📊 Macroscope summarized 070fe78. 10 files reviewed, 24 issues evaluated, 5 issues filtered, 14 comments posted
🗂️ Filtered Issues
LIBBY.md — 1 comment posted, 4 evaluated, 3 filtered
/open/...and can never read positions. Implementing step 1 as written makes the recommended D-read integration unable to obtain passports or positions; it must instead use the documented browser-token pairing method (or another reader-capable credential flow). [ Out of scope (post-validation triage) ]urls.possession, despite the preceding position-read section explicitly requiring both stores and documenting a real case where possession wasnullwhile the legacy sentry store held 77% progress. A client implemented from this sketch will silently treat active phone-app reading as no progress; the loop must also querycard/{cardId}/{format}/data/{titleId}and reconcile both results. [ Out of scope (post-validation triage) ]GET urls.possession, omitting the mandatoryHEAD {urls.web}?{message}handshake documented above. A newly issued reader host has no session cookie until that request is made, so following this implementation sketch will make possession polling fail rather than return progress. [ Out of scope (post-validation triage) ]static/js/settings.js — 0 comments posted, 2 evaluated, 2 filtered
libbyPairopens the “Libby Connected” modal and then immediately callswindow.location.reload()synchronously. Navigation replaces the document before the user can read or dismiss the modal, so the linked-card list and progress-sync status assembled above are effectively never shown. [ Out of scope (post-validation triage) ]libbyDisconnectonly restores the button and never updateslibby_connect_result. On a network failure or non-JSON server response, the disconnect silently does nothing and gives the user no indication that revocation failed, unlike the handled{ success: false }path. [ Out of scope (post-validation triage) ]