Skip to content

feat(libby): API client, dual-tier pairing, and settings - #132

Open
serabi wants to merge 24 commits into
devfrom
feat/libby-api-client
Open

serabi wants to merge 24 commits into
devfrom
feat/libby-api-client

Conversation

@serabi

@serabi serabi commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

Part 1/3 of the Libby integration (#131). Stacks: #133 builds on this.

What

  • LibbyClient over 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 store
  • Dual-tier pairing (see LIBBY.md for why): 8-digit setup-code chips give long-lived sync access (loans/holds/cards); browser identity tokens (dewey:sentry.identity) add position access — clone-code chips can never read positions, verified exhaustively live
  • Settings tab with both connect flows, capability status, poll interval (10-min floor); token stored encrypted at rest, never logged

Validation

Live end-to-end against a real account: pairing → cards → loans → passport → positions (ebook at 3.48%, audiobook at 77.07%).

Note

Add LibbyClient API client, dual-tier pairing, and settings UI

  • Introduces libby_client.py: pairs via setup code (shelf-only) or browser identity token (full access), fetches sync state, opens reader passport sessions, reads possession state, and persists tokens/device ID
  • Adds POST /api/libby/connect and POST /api/libby/disconnect in settings_bp.py plus a libby test-connection entry, wiring the client through the DI container
  • Registers Libby settings (LIBBY_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
  • Adds a green --color-libby CSS token and matching accent styles
  • Risk: disconnect returns 502 and leaves local settings intact when revocation cannot be confirmed; LIBBY_POLL_MINS is 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
  • line 248: The implementation sketch tells PageKeeper to use the setup-code pairing flow and then poll reader positions, but the same research states that clone-code chips are always refused by /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) ]
  • line 251: The polling sketch reads only urls.possession, despite the preceding position-read section explicitly requiring both stores and documenting a real case where possession was null while 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 query card/{cardId}/{format}/data/{titleId} and reconcile both results. [ Out of scope (post-validation triage) ]
  • line 251: The poll loop goes directly from issuing a passport to GET urls.possession, omitting the mandatory HEAD {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
  • line 609: libbyPair opens the “Libby Connected” modal and then immediately calls window.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) ]
  • line 640: The rejection handler for libbyDisconnect only restores the button and never updates libby_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) ]

serabi added 24 commits August 24, 2026 10:37
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.
@github-actions github-actions Bot added the size:XXL 1,000+ effective changed lines (test files excluded in mixed PRs). label Aug 24, 2026
@serabi serabi changed the title feat/libby api client feat(libby): API client, dual-tier pairing, and settings Aug 24, 2026
Comment thread src/api/libby_client.py
Comment on lines +539 to +543
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))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread templates/settings.html
</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') == '') %}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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 {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Suggested change
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.

Comment thread src/api/libby_client.py
@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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread src/api/libby_client.py
Comment on lines +124 to +125
except Exception as e:
logger.error("Libby: failed to persist setting %s: %s", key, e)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread src/api/libby_client.py
except (requests.RequestException, ValueError):
state = None

if state is None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread src/api/libby_client.py
"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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Comment thread src/api/libby_client.py
Comment on lines +233 to +234
except (requests.RequestException, ValueError):
state = {}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Suggested change
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.

Comment thread src/api/libby_client.py
headers={"Authorization": f"Bearer {clean_token}"},
timeout=REQUEST_TIMEOUT,
)
state = response.json() if response.status_code == 200 else None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ effective changed lines (test files excluded in mixed PRs).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant