Files
readest/apps/readest-calibre-plugin/api.py
T
Huang Xin 6b403d019e feat(calibre): add Readest calibre plugin to push books and metadata (#4918)
* feat(calibre): add Readest calibre plugin to push books and metadata (#4863)

Add apps/readest-calibre-plugin, a calibre GUI plugin that uploads
selected books with their metadata into the user's Readest cloud
library, modeled on BookFusion's open-source plugin.

- Selective manual push from the calibre toolbar with per-book status
  (uploaded / updated / up to date / failed) and quota handling
- Books are content-addressed with the same partial MD5 as the apps,
  so re-pushing updates the existing entry instead of duplicating;
  metadata edits re-push without re-uploading the file
- Metadata mapping includes series, tags, identifiers and optional
  calibre custom columns; carries over server-side fields (progress,
  reading status, grouping, cover) that POST /sync would null out
- Auth mirrors readest.koplugin and the desktop app: email/password
  plus browser OAuth (Google/Apple/GitHub/Discord) through a localhost
  callback server with the fragment-to-query relay
- Pure-logic modules (api.py, wire.py, oauth.py) are calibre-free and
  covered by 56 unit tests (make test); make zip builds the plugin

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): package readest-calibre-plugin in releases

Mirror the KOReader plugin packaging: a build-calibre-plugin job stamps
PLUGIN_VERSION in __init__.py with the release version from
apps/readest-app/package.json, builds the zip via make, and uploads
Readest-<version>.calibre-plugin.zip to the GitHub release. The version
committed in git stays a development placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(agent): add calibre plugin project memory

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(calibre): embed metadata in OPF and dedupe by calibre uuid

Rework book identity so metadata can be embedded into the uploaded file
without creating duplicates, as requested in review:

- Embed calibre metadata (including custom columns) into a temporary
  copy of the book file at upload time via calibre's set_metadata; the
  library file is never modified
- Dedupe by the calibre book uuid carried in the entry's metadata
  identifier, which survives file-byte changes, with a live-row
  preference when both hash and uuid match rows
- Detect file content changes via calibreSourceHash, the raw library
  file fingerprint stored in the pushed metadata, so detection works
  from any machine; v1 rows fall back to book_hash which equals the
  raw hash for them
- A changed file now replaces the old entry in one sync push (new row
  with carried-over reading status, grouping, progress and created
  date, plus a tombstone for the old row) and deletes the old cloud
  files to reclaim quota
- Metadata-only edits still update the library entry without
  re-uploading the file

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(calibre): set copyright holder to Bilingify LLC

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:40:16 +02:00

333 lines
12 KiB
Python

__license__ = 'AGPL v3'
__copyright__ = '2026, Bilingify LLC'
"""HTTP client + content hashes for the Readest cloud API.
Standard-library only (no calibre / Qt imports) so it can be unit-tested
outside calibre. The endpoints and shapes mirror the ones already consumed by
readest.koplugin (readest-sync-api.json / supabase-auth-api.json) and served
by apps/readest-app/src/pages/api/.
"""
import hashlib
import json
import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_API_BASE = 'https://web.readest.com/api'
DEFAULT_SUPABASE_URL = 'https://readest.supabase.co'
# Public anon key, same one readest.koplugin ships (base64-encoded in main.lua).
DEFAULT_ANON_KEY = (
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'
'eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZic3l4ZnVzampxZHhranFseXNjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3'
'MzQxMjM2NzEsImV4cCI6MjA0OTY5OTY3MX0.'
'3U5Uqaou_1SgrVe1eo9rApc0uKjqhpQdUXhvwUHmUfg'
)
TIMEOUT = 30
UPLOAD_TIMEOUT = 600
class ReadestAPIError(Exception):
def __init__(self, message, status=None):
super().__init__(message)
self.status = status
class AuthRequiredError(ReadestAPIError):
"""No valid session — the user must log in."""
class QuotaExceededError(ReadestAPIError):
"""The server rejected an upload for insufficient storage quota."""
# ---------------------------------------------------------------------------
# Content hashes
# ---------------------------------------------------------------------------
def _partial_md5_ranges(size):
"""Chunk ranges of apps/readest-app/src/utils/md5.ts::partialMD5.
1024-byte chunks at offsets 0, 1024, 4096, ..., 1024 << 20 (the JS loop
runs i in -1..10; `1024 << -2` wraps to 0 under JS 32-bit shift).
"""
ranges = []
for i in range(-1, 11):
offset = 0 if i == -1 else 1024 << (2 * i)
start = min(size, offset)
if start >= size:
break
ranges.append((start, min(start + 1024, size)))
return ranges
def partial_md5(file_or_path, size=None):
"""Readest's Book.hash: partial MD5 of a file (KOReader-compatible)."""
if isinstance(file_or_path, str):
with open(file_or_path, 'rb') as f:
f.seek(0, 2)
return partial_md5(f, f.tell())
f = file_or_path
hasher = hashlib.md5()
for start, end in _partial_md5_ranges(size):
f.seek(start)
hasher.update(f.read(end - start))
return hasher.hexdigest()
def partial_md5_bytes(data):
hasher = hashlib.md5()
for start, end in _partial_md5_ranges(len(data)):
hasher.update(data[start:end])
return hasher.hexdigest()
def _normalize_identifier(identifier):
# Mirrors utils/book.ts::normalizeIdentifier.
if 'urn:' in identifier:
return identifier.rsplit(':', 1)[-1]
if ':' in identifier:
return identifier.split(':', 1)[1]
return identifier
def _identifiers_list(identifiers):
# Mirrors utils/book.ts::getIdentifiersList / getPreferredIdentifier.
if not identifiers:
return []
for scheme in ('uuid', 'calibre', 'isbn'):
for identifier in identifiers:
if scheme in identifier.lower():
return [_normalize_identifier(identifier)]
return [_normalize_identifier(i) for i in identifiers if i]
def meta_hash(title, authors, identifiers):
"""Readest's Book.metaHash: md5 over "title|authors|identifiers" (NFC).
`identifiers` are raw identifier strings, scheme-prefixed where known
(e.g. "urn:uuid:...", "isbn:...").
"""
source = '%s|%s|%s' % (
title or '',
','.join(authors or []),
','.join(_identifiers_list(identifiers)),
)
return hashlib.md5(unicodedata.normalize('NFC', source).encode('utf-8')).hexdigest()
# ---------------------------------------------------------------------------
# HTTP client
# ---------------------------------------------------------------------------
def _default_transport(request, timeout):
try:
with urllib.request.urlopen(request, timeout=timeout) as res:
return res.status, res.read()
except urllib.error.HTTPError as err:
return err.code, err.read()
class ReadestClient:
"""Supabase auth + Readest sync/storage API client.
tokens: dict with access_token / refresh_token / expires_at (epoch s) /
expires_in (s), persisted through the on_tokens callback whenever they
change. `transport(request, timeout) -> (status, body_bytes)` is
injectable for tests.
"""
def __init__(
self,
api_base=DEFAULT_API_BASE,
supabase_url=DEFAULT_SUPABASE_URL,
anon_key=DEFAULT_ANON_KEY,
tokens=None,
on_tokens=None,
transport=None,
):
self.api_base = api_base.rstrip('/')
self.supabase_url = supabase_url.rstrip('/')
self.anon_key = anon_key
self.tokens = dict(tokens) if tokens else None
self.on_tokens = on_tokens
self.transport = transport or _default_transport
# -- plumbing -----------------------------------------------------------
def _request(self, method, url, headers, body=None, timeout=TIMEOUT):
data = None
if body is not None:
data = json.dumps(body).encode('utf-8')
headers = dict(headers, **{'Content-Type': 'application/json'})
request = urllib.request.Request(url, data=data, headers=headers, method=method)
status, payload = self.transport(request, timeout)
parsed = None
if payload:
try:
parsed = json.loads(payload.decode('utf-8'))
except (ValueError, UnicodeDecodeError):
parsed = None
return status, parsed, payload
@staticmethod
def _error_message(parsed, payload, fallback):
if isinstance(parsed, dict):
for key in ('error_description', 'msg', 'message', 'error'):
value = parsed.get(key)
if isinstance(value, str) and value:
return value
if payload:
return payload.decode('utf-8', 'replace')[:200]
return fallback
# -- Supabase auth ------------------------------------------------------
def _auth_headers(self):
return {'apikey': self.anon_key, 'Accept': 'application/json'}
def _store_tokens(self, body):
self.tokens = {
'access_token': body.get('access_token'),
'refresh_token': body.get('refresh_token'),
'expires_at': body.get('expires_at'),
'expires_in': body.get('expires_in'),
}
if self.on_tokens:
self.on_tokens(dict(self.tokens))
def sign_in_password(self, email, password):
status, parsed, payload = self._request(
'POST',
self.supabase_url + '/auth/v1/token?grant_type=password',
self._auth_headers(),
body={'email': email, 'password': password},
)
if status != 200 or not isinstance(parsed, dict):
raise ReadestAPIError(self._error_message(parsed, payload, 'Login failed'), status)
self._store_tokens(parsed)
return parsed.get('user') or {}
def set_session(self, tokens):
"""Adopt tokens obtained externally (browser OAuth callback)."""
self._store_tokens(tokens)
def refresh(self):
refresh_token = self.tokens and self.tokens.get('refresh_token')
if not refresh_token:
raise AuthRequiredError('Not logged in')
status, parsed, payload = self._request(
'POST',
self.supabase_url + '/auth/v1/token?grant_type=refresh_token',
self._auth_headers(),
body={'refresh_token': refresh_token},
)
if status != 200 or not isinstance(parsed, dict):
raise AuthRequiredError(
self._error_message(parsed, payload, 'Session expired, please log in again'),
status,
)
self._store_tokens(parsed)
def ensure_fresh_token(self):
# Mirrors readest_syncauth.lua: refresh once less than half the TTL
# remains; a token past its final minute is unusable without refresh.
if not self.tokens or not self.tokens.get('access_token'):
raise AuthRequiredError('Not logged in')
expires_at = self.tokens.get('expires_at') or 0
expires_in = self.tokens.get('expires_in') or 3600
if expires_at < time.time() + max(60, expires_in / 2):
self.refresh()
def get_user(self):
self.ensure_fresh_token()
headers = dict(self._auth_headers())
headers['Authorization'] = 'Bearer ' + self.tokens['access_token']
status, parsed, payload = self._request(
'GET', self.supabase_url + '/auth/v1/user', headers
)
if status != 200 or not isinstance(parsed, dict):
raise AuthRequiredError(self._error_message(parsed, payload, 'Not logged in'), status)
return parsed
def sign_out(self):
if not self.tokens or not self.tokens.get('access_token'):
return
headers = dict(self._auth_headers())
headers['Authorization'] = 'Bearer ' + self.tokens['access_token']
try:
self._request('POST', self.supabase_url + '/auth/v1/logout', headers, body={})
except Exception:
pass # best-effort; local tokens are cleared regardless
self.tokens = None
if self.on_tokens:
self.on_tokens(None)
# -- Readest API --------------------------------------------------------
def _api(self, method, path, body=None):
self.ensure_fresh_token()
headers = {
'Authorization': 'Bearer ' + self.tokens['access_token'],
'Accept': 'application/json',
}
status, parsed, payload = self._request(method, self.api_base + path, headers, body=body)
if status in (401, 403):
message = self._error_message(parsed, payload, 'Not authenticated')
if 'quota' in message.lower():
raise QuotaExceededError(message, status)
raise AuthRequiredError(message, status)
if status != 200:
raise ReadestAPIError(
self._error_message(parsed, payload, 'Request failed (%s)' % status), status
)
return parsed
def pull_books(self, since=0):
result = self._api('GET', '/sync?type=books&since=%d' % since)
return (result or {}).get('books') or []
def push_books(self, records):
return self._api('POST', '/sync', body={'books': records, 'notes': [], 'configs': []})
def get_upload_url(self, file_name, file_size, book_hash):
return self._api(
'POST',
'/storage/upload',
body={'fileName': file_name, 'fileSize': file_size, 'bookHash': book_hash},
)
def list_files(self, book_hash):
result = self._api('GET', '/storage/list?bookHash=' + urllib.parse.quote(book_hash))
return (result or {}).get('files') or []
def delete_file(self, file_key):
return self._api(
'DELETE', '/storage/delete?fileKey=' + urllib.parse.quote(file_key, safe='')
)
def put_file(self, url, fileobj, size):
"""PUT raw bytes to a presigned URL (no auth headers)."""
request = urllib.request.Request(
url,
data=fileobj,
headers={'Content-Length': str(size)},
method='PUT',
)
status, payload = self.transport(request, UPLOAD_TIMEOUT)
if status not in (200, 201, 204):
message = 'Upload failed (%s)' % status
if payload:
# S3/R2 error responses are XML; surface the <Code> tag.
text = payload.decode('utf-8', 'replace')
start, end = text.find('<Code>'), text.find('</Code>')
if 0 <= start < end:
message += ': ' + text[start + 6 : end]
raise ReadestAPIError(message, status)