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>
268 lines
9.5 KiB
Python
268 lines
9.5 KiB
Python
__license__ = 'AGPL v3'
|
|
__copyright__ = '2026, Bilingify LLC'
|
|
|
|
"""Background push worker: one QThread, sequential per-book processing."""
|
|
|
|
import io
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
import traceback
|
|
|
|
from qt.core import QThread, pyqtSignal
|
|
|
|
from calibre_plugins.readest.api import (
|
|
AuthRequiredError,
|
|
QuotaExceededError,
|
|
partial_md5,
|
|
partial_md5_bytes,
|
|
)
|
|
from calibre_plugins.readest.wire import (
|
|
EXTS,
|
|
book_file_name,
|
|
build_wire_book,
|
|
cover_file_name,
|
|
index_rows_by_uuid,
|
|
merge_for_push,
|
|
pick_format,
|
|
pick_server_row,
|
|
plan_push,
|
|
tombstone_record,
|
|
)
|
|
|
|
STATUS_LABELS = {
|
|
'uploaded': 'Uploaded',
|
|
'replaced': 'Replaced',
|
|
'updated': 'Updated',
|
|
'skipped': 'Up to date',
|
|
'failed': 'Failed',
|
|
}
|
|
|
|
|
|
def _jsonable(value):
|
|
if hasattr(value, 'isoformat'):
|
|
return value.isoformat()
|
|
if isinstance(value, (list, tuple)):
|
|
return [_jsonable(v) for v in value]
|
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
|
return value
|
|
return str(value)
|
|
|
|
|
|
def _custom_columns(mi):
|
|
columns = {}
|
|
for key in mi.custom_field_keys():
|
|
try:
|
|
value = mi.get(key)
|
|
except Exception:
|
|
continue
|
|
if value in (None, '', []) or value == ():
|
|
continue
|
|
columns[key.lstrip('#')] = _jsonable(value)
|
|
return columns
|
|
|
|
|
|
def _book_dict(mi, include_custom_columns, source_hash):
|
|
pubdate = getattr(mi, 'pubdate', None)
|
|
# calibre uses year-101 dates as "undefined".
|
|
if pubdate is not None and getattr(pubdate, 'year', 0) < 1000:
|
|
pubdate = None
|
|
return {
|
|
'title': mi.title,
|
|
'authors': [a for a in (mi.authors or []) if a],
|
|
'languages': list(mi.languages or []),
|
|
'publisher': mi.publisher,
|
|
'pubdate': pubdate.isoformat() if pubdate else None,
|
|
'comments': mi.comments,
|
|
'tags': sorted(mi.tags or []),
|
|
'series': mi.series,
|
|
'series_index': mi.series_index if mi.series else None,
|
|
'uuid': getattr(mi, 'uuid', None),
|
|
'isbn': (mi.get_identifiers() or {}).get('isbn'),
|
|
'custom_columns': _custom_columns(mi) if include_custom_columns else None,
|
|
'source_hash': source_hash,
|
|
}
|
|
|
|
|
|
def _embed_metadata_copy(path, mi, fmt):
|
|
"""Copy the library file to a temp path and embed `mi` into it.
|
|
|
|
The library file itself is never touched. Formats without a metadata
|
|
writer (or a failing writer) fall back to the unmodified copy.
|
|
"""
|
|
fd, tmp = tempfile.mkstemp(suffix='.' + EXTS[fmt], prefix='readest-')
|
|
os.close(fd)
|
|
shutil.copyfile(path, tmp)
|
|
try:
|
|
from calibre.ebooks.metadata.meta import set_metadata
|
|
|
|
with open(tmp, 'r+b') as stream:
|
|
set_metadata(stream, mi, EXTS[fmt])
|
|
except Exception:
|
|
traceback.print_exc()
|
|
return tmp
|
|
|
|
|
|
class PushWorker(QThread):
|
|
progress = pyqtSignal(int, int) # done, total
|
|
book_status = pyqtSignal(int, str, str) # book_id, status key, detail
|
|
done = pyqtSignal(bool, str) # ok, message
|
|
|
|
def __init__(self, parent, db, book_ids, client, include_custom_columns):
|
|
QThread.__init__(self, parent)
|
|
self.db = db # calibre new_api (thread-safe)
|
|
self.book_ids = list(book_ids)
|
|
self.client = client
|
|
self.include_custom_columns = include_custom_columns
|
|
self.canceled = False
|
|
|
|
def cancel(self):
|
|
self.canceled = True
|
|
|
|
def run(self):
|
|
try:
|
|
rows = [r for r in self.client.pull_books() if r.get('book_hash')]
|
|
self.by_hash = {r['book_hash']: r for r in rows}
|
|
self.by_uuid = index_rows_by_uuid(rows)
|
|
except AuthRequiredError as err:
|
|
self.done.emit(False, 'Please log in to Readest first. (%s)' % err)
|
|
return
|
|
except Exception as err:
|
|
self.done.emit(False, 'Could not reach Readest: %s' % err)
|
|
return
|
|
|
|
counts = {}
|
|
for index, book_id in enumerate(self.book_ids):
|
|
if self.canceled:
|
|
self.done.emit(False, 'Canceled.')
|
|
return
|
|
try:
|
|
status, detail = self._push_one(book_id)
|
|
except QuotaExceededError as err:
|
|
self.book_status.emit(book_id, 'failed', str(err))
|
|
self.done.emit(False, 'Readest storage quota exceeded — push stopped.')
|
|
return
|
|
except AuthRequiredError as err:
|
|
self.book_status.emit(book_id, 'failed', str(err))
|
|
self.done.emit(False, 'Session expired — please log in again.')
|
|
return
|
|
except Exception as err:
|
|
traceback.print_exc()
|
|
status, detail = 'failed', str(err)
|
|
counts[status] = counts.get(status, 0) + 1
|
|
self.book_status.emit(book_id, status, detail)
|
|
self.progress.emit(index + 1, len(self.book_ids))
|
|
|
|
summary = ', '.join(
|
|
'%d %s' % (counts[key], STATUS_LABELS[key].lower())
|
|
for key in ('uploaded', 'replaced', 'updated', 'skipped', 'failed')
|
|
if key in counts
|
|
)
|
|
self.done.emit('failed' not in counts, summary or 'Nothing to push.')
|
|
|
|
def _upload(self, file_name, fileobj, size, book_hash):
|
|
upload = self.client.get_upload_url(file_name, size, book_hash)
|
|
self.client.put_file(upload['uploadUrl'], fileobj, size)
|
|
|
|
def _upload_cover(self, book_hash, cover_bytes):
|
|
self._upload(cover_file_name(book_hash), io.BytesIO(cover_bytes), len(cover_bytes), book_hash)
|
|
|
|
def _delete_cloud_files(self, book_hash):
|
|
# Best-effort quota reclaim for a replaced book, mirroring the
|
|
# koplugin's deleteCloudFiles: list to learn exact keys, then DELETE.
|
|
try:
|
|
for record in self.client.list_files(book_hash):
|
|
if record.get('file_key'):
|
|
self.client.delete_file(record['file_key'])
|
|
except Exception:
|
|
traceback.print_exc()
|
|
|
|
def _remember(self, record, uuid):
|
|
# Keep the lookup maps current so a duplicate calibre entry later in
|
|
# this run resolves to what we just pushed.
|
|
row = {
|
|
'book_hash': record['hash'],
|
|
'title': record['title'],
|
|
'author': record['author'],
|
|
'tags': record.get('tags'),
|
|
'metadata': record['metadata'],
|
|
'uploaded_at': 'pushed',
|
|
'cover_hash': record.get('coverHash'),
|
|
}
|
|
self.by_hash[record['hash']] = row
|
|
if uuid:
|
|
self.by_uuid[uuid] = row
|
|
|
|
def _push_one(self, book_id):
|
|
mi = self.db.get_metadata(book_id)
|
|
fmt = pick_format(self.db.formats(book_id))
|
|
if fmt is None:
|
|
return 'failed', 'No Readest-supported format (EPUB, PDF, ...)'
|
|
path = self.db.format_abspath(book_id, fmt)
|
|
if not path or not os.path.exists(path):
|
|
return 'failed', 'Book file is missing from the calibre library'
|
|
|
|
source_hash = partial_md5(path)
|
|
cover_bytes = self.db.cover(book_id)
|
|
cover_hash = partial_md5_bytes(cover_bytes) if cover_bytes else None
|
|
|
|
now_ms = int(time.time() * 1000)
|
|
book = _book_dict(mi, self.include_custom_columns, source_hash)
|
|
wire = build_wire_book(book, source_hash, fmt, now_ms)
|
|
uuid = (book.get('uuid') or '').lower()
|
|
server_row = pick_server_row(
|
|
self.by_hash.get(source_hash), self.by_uuid.get(uuid) if uuid else None
|
|
)
|
|
plan = plan_push(server_row, wire, cover_hash, source_hash)
|
|
|
|
if plan['action'] == 'skip':
|
|
return 'skipped', ''
|
|
|
|
if plan['action'] in ('new', 'replace'):
|
|
blob_path = _embed_metadata_copy(path, mi, fmt)
|
|
try:
|
|
blob_hash = partial_md5(blob_path)
|
|
wire['hash'] = wire['bookHash'] = blob_hash
|
|
size = os.path.getsize(blob_path)
|
|
with open(blob_path, 'rb') as f:
|
|
self._upload(book_file_name(blob_hash, fmt), f, size, blob_hash)
|
|
finally:
|
|
try:
|
|
os.unlink(blob_path)
|
|
except OSError:
|
|
pass
|
|
if plan['upload_cover'] and cover_bytes:
|
|
self._upload_cover(blob_hash, cover_bytes)
|
|
records = [
|
|
merge_for_push(
|
|
wire,
|
|
server_row,
|
|
now_ms,
|
|
uploaded_at_ms=now_ms,
|
|
cover_hash=cover_hash if plan['upload_cover'] and cover_bytes else None,
|
|
)
|
|
]
|
|
replaced_hash = None
|
|
if server_row is not None and server_row['book_hash'] != blob_hash:
|
|
replaced_hash = server_row['book_hash']
|
|
records.append(tombstone_record(server_row, now_ms))
|
|
self.client.push_books(records)
|
|
if replaced_hash:
|
|
self._delete_cloud_files(replaced_hash)
|
|
self._remember(records[0], uuid)
|
|
return ('replaced' if plan['action'] == 'replace' else 'uploaded'), ''
|
|
|
|
# action == 'update': the cloud file is current, only the row (and
|
|
# possibly the cover) changed. Keep the row's hash namespace.
|
|
wire['hash'] = wire['bookHash'] = server_row['book_hash']
|
|
pushed_cover = plan['upload_cover'] and cover_bytes
|
|
if pushed_cover:
|
|
self._upload_cover(server_row['book_hash'], cover_bytes)
|
|
record = merge_for_push(
|
|
wire, server_row, now_ms, cover_hash=cover_hash if pushed_cover else None
|
|
)
|
|
self.client.push_books([record])
|
|
self._remember(record, uuid)
|
|
return 'updated', ''
|