Files
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

293 lines
10 KiB
Python

__license__ = 'AGPL v3'
__copyright__ = '2026, Bilingify LLC'
"""Calibre metadata → Readest wire records, and per-book push planning.
Standard-library only (no calibre / Qt imports) so it can be unit-tested
outside calibre. The wire shape is the camelCase Book record consumed by
POST /api/sync (apps/readest-app/src/utils/transform.ts::transformBookToDB,
inverted — same as readest.koplugin's row_to_wire).
"""
import json
from datetime import datetime, timezone
try: # inside calibre the plugin loads as a package
from calibre_plugins.readest.api import meta_hash
except ImportError: # plain imports for the unit tests
from api import meta_hash
# Readest-supported formats, in upload preference order.
FORMAT_PRIORITY = ('EPUB', 'PDF', 'AZW3', 'MOBI', 'AZW', 'FB2', 'FBZ', 'CBZ', 'TXT', 'MD')
EXTS = {
'EPUB': 'epub',
'PDF': 'pdf',
'MOBI': 'mobi',
'AZW': 'azw',
'AZW3': 'azw3',
'CBZ': 'cbz',
'FB2': 'fb2',
'FBZ': 'fbz',
'TXT': 'txt',
'MD': 'md',
}
CLOUD_BOOKS_SUBDIR = 'Readest/Books'
def pick_format(available):
"""Best Readest-supported format among calibre's, or None."""
upper = {f.upper() for f in available}
for fmt in FORMAT_PRIORITY:
if fmt in upper:
return fmt
return None
def book_file_name(file_hash, fmt):
# Matches getRemoteBookFilename for S3 ({hash}/{hash}.{ext}); R2
# deployments resolve it through the download API's hash+extension
# fallback, same as the koplugin.
return '%s/%s/%s.%s' % (CLOUD_BOOKS_SUBDIR, file_hash, file_hash, EXTS[fmt])
def cover_file_name(file_hash):
return '%s/%s/cover.png' % (CLOUD_BOOKS_SUBDIR, file_hash)
def _clean(value):
# The server strips NUL from strings (utils/sanitize.ts::sanitizeString);
# strip locally too so pushed and pulled values compare equal.
if isinstance(value, str):
return value.replace('\x00', '')
return value
def build_metadata(book):
"""BookMetadata JSON for the books row (libs/document.ts::BookMetadata).
`book` is a plain dict extracted from calibre's Metadata object:
title, authors, languages, publisher, pubdate, comments, tags, series,
series_index, uuid, isbn, custom_columns.
"""
authors = [_clean(a) for a in book.get('authors') or [] if a]
meta = {
'title': _clean(book.get('title')) or '',
'author': authors[0] if len(authors) == 1 else authors,
}
languages = book.get('languages') or []
if isinstance(languages, str):
languages = [languages]
if languages:
meta['language'] = languages[0] if len(languages) == 1 else languages
optional = {
'publisher': _clean(book.get('publisher')),
'published': _clean(book.get('pubdate')),
'description': _clean(book.get('comments')),
'subject': [_clean(t) for t in book.get('tags') or []] or None,
'series': _clean(book.get('series')),
'isbn': _clean(book.get('isbn')),
# Partial MD5 of the RAW calibre library file. The uploaded blob has
# metadata embedded so its hash (book_hash) shifts with every embed;
# this stable fingerprint is how a later push detects "the file
# itself changed" without any machine-local state.
'calibreSourceHash': book.get('source_hash'),
}
if book.get('series'):
optional['seriesIndex'] = book.get('series_index')
if book.get('uuid'):
optional['identifier'] = 'urn:uuid:%s' % book['uuid']
if book.get('custom_columns'):
optional['customColumns'] = book['custom_columns']
for key, value in optional.items():
if value not in (None, '', []):
meta[key] = value
return meta
def _author_string(book):
return ', '.join(_clean(a) for a in book.get('authors') or [] if a)
def build_wire_book(book, file_hash, fmt, now_ms):
"""Base wire record for a calibre book (before server-row merging)."""
identifiers = []
if book.get('uuid'):
identifiers.append('urn:uuid:%s' % book['uuid'])
if book.get('isbn'):
identifiers.append('isbn:%s' % book['isbn'])
title = _clean(book.get('title')) or ''
authors = [_clean(a) for a in book.get('authors') or [] if a]
return {
'hash': file_hash,
'bookHash': file_hash,
'metaHash': meta_hash(title, authors, identifiers),
'format': fmt,
'title': title,
'sourceTitle': title,
'author': _author_string(book),
'tags': [_clean(t) for t in book.get('tags') or []],
'metadata': build_metadata(book),
'createdAt': now_ms,
'updatedAt': now_ms,
}
def iso_to_ms(iso):
if not iso:
return None
text = iso.replace('Z', '+00:00')
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
def _parse_row_metadata(row):
raw = row.get('metadata')
if isinstance(raw, dict):
return raw
if isinstance(raw, str) and raw:
try:
return json.loads(raw)
except ValueError:
return None
return None
def _row_matches_wire(row, wire):
if (row.get('title') or '') != wire['title']:
return False
if (row.get('author') or '') != wire['author']:
return False
if (row.get('tags') or []) != (wire.get('tags') or []):
return False
return _parse_row_metadata(row) == wire['metadata']
def row_source_hash(row):
"""The raw-file fingerprint a server row was built from.
v2 rows carry it as metadata.calibreSourceHash; v1 rows uploaded the raw
file unmodified, so their book_hash IS the raw hash.
"""
meta = _parse_row_metadata(row) or {}
return meta.get('calibreSourceHash') or row.get('book_hash')
def metadata_uuid(meta):
"""Calibre book uuid from a metadata dict's urn:uuid identifier."""
identifier = (meta or {}).get('identifier')
if isinstance(identifier, str) and 'urn:uuid:' in identifier:
return identifier.rsplit(':', 1)[-1].lower()
return None
def _prefer_row(row, over):
row_live, over_live = not row.get('deleted_at'), not over.get('deleted_at')
if row_live != over_live:
return row_live
return (iso_to_ms(row.get('updated_at')) or 0) > (iso_to_ms(over.get('updated_at')) or 0)
def index_rows_by_uuid(rows):
"""Map calibre uuid -> best server row (live over tombstoned, then newest).
This is the identity that survives file-content changes: the uploaded blob
has metadata embedded, so book_hash shifts whenever the file or its
metadata does, but the calibre uuid in metadata.identifier stays put.
"""
best = {}
for row in rows:
uuid = metadata_uuid(_parse_row_metadata(row))
if not uuid:
continue
current = best.get(uuid)
if current is None or _prefer_row(row, current):
best[uuid] = row
return best
def pick_server_row(hash_row, uuid_row):
"""Choose the row a push should target: live hash match, then live uuid
match, then whatever tombstone is left (for resurrection)."""
for row in (hash_row, uuid_row):
if row is not None and not row.get('deleted_at'):
return row
return hash_row if hash_row is not None else uuid_row
def plan_push(server_row, wire, local_cover_hash, source_hash):
"""Decide what to do for one book.
Returns {'action': 'new' | 'replace' | 'update' | 'skip',
'upload_cover': bool}.
- new: no server row — embed metadata, upload file, insert row
- replace: the raw file changed (or the row has no file blob) — upload a
fresh embedded blob under its new hash, tombstone the old row
- update: file unchanged, but metadata/cover/tombstone differ — row only
- skip: file + metadata + cover all unchanged
"""
if server_row is None:
return {'action': 'new', 'upload_cover': bool(local_cover_hash)}
if not server_row.get('uploaded_at') or row_source_hash(server_row) != source_hash:
# A replaced book gets a new hash namespace, so it needs its own cover.
return {'action': 'replace', 'upload_cover': bool(local_cover_hash)}
cover_changed = bool(local_cover_hash) and local_cover_hash != server_row.get('cover_hash')
changed = (
not _row_matches_wire(server_row, wire)
or bool(server_row.get('deleted_at'))
or cover_changed
)
return {'action': 'update' if changed else 'skip', 'upload_cover': cover_changed}
def tombstone_record(row, now_ms):
"""Wire record that soft-deletes a replaced server row."""
return {
'hash': row['book_hash'],
'bookHash': row['book_hash'],
'metaHash': row.get('meta_hash'),
'format': row.get('format'),
'title': row.get('title') or '',
'author': row.get('author') or '',
'createdAt': iso_to_ms(row.get('created_at')) or now_ms,
'updatedAt': now_ms,
'deletedAt': now_ms,
}
def merge_for_push(wire, server_row, now_ms, uploaded_at_ms=None, cover_hash=None):
"""Final record for POST /sync.
The server explicit-nulls any field absent from the wire record
(transformBookToDB), so an update must carry over the server row's
groupId/groupName, progress, readingStatus, uploadedAt and cover fields —
the koplugin learned this the hard way (see syncbooks.lua:291-299).
"""
record = dict(wire)
record['updatedAt'] = now_ms
record['deletedAt'] = None # an explicit push (re)activates the book
row = server_row or {}
record['createdAt'] = iso_to_ms(row.get('created_at')) or wire['createdAt']
if row.get('group_id'):
record['groupId'] = row['group_id']
if row.get('group_name'):
record['groupName'] = row['group_name']
if row.get('progress') is not None:
record['progress'] = row['progress']
if row.get('reading_status'):
record['readingStatus'] = row['reading_status']
record['readingStatusUpdatedAt'] = iso_to_ms(row.get('reading_status_updated_at'))
record['uploadedAt'] = (
uploaded_at_ms if uploaded_at_ms is not None else iso_to_ms(row.get('uploaded_at'))
)
if cover_hash:
record['coverHash'] = cover_hash
record['coverUpdatedAt'] = now_ms
elif row.get('cover_hash'):
record['coverHash'] = row['cover_hash']
record['coverUpdatedAt'] = iso_to_ms(row.get('cover_updated_at'))
return record