forked from akai/readest
6b403d019e
* 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>
293 lines
10 KiB
Python
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
|