Files
readest/apps/readest-calibre-plugin/tests/test_wire.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

347 lines
13 KiB
Python

import json
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from wire import ( # noqa: E402
build_metadata,
build_wire_book,
index_rows_by_uuid,
iso_to_ms,
merge_for_push,
pick_format,
pick_server_row,
plan_push,
tombstone_record,
)
NOW = 1_800_000_000_000
SRC = 's' * 32 # partial MD5 of the raw calibre library file
BOOK = {
'title': 'The Test Book',
'authors': ['Alice Author', 'Bob Writer'],
'languages': ['eng'],
'publisher': 'Test House',
'pubdate': '2020-05-01T00:00:00+00:00',
'comments': '<p>A very good book.</p>',
'tags': ['Fiction', 'Test'],
'series': 'Test Series',
'series_index': 2.0,
'uuid': 'cafebabe-0000-0000-0000-000000000001',
'isbn': '9781234567897',
'custom_columns': {'read_status': 'done'},
'source_hash': SRC,
}
def server_row(**overrides):
row = {
'book_hash': 'a' * 32,
'meta_hash': 'b' * 32,
'format': 'EPUB',
'title': 'The Test Book',
'author': 'Alice Author, Bob Writer',
'tags': ['Fiction', 'Test'],
'group_id': 'g1',
'group_name': 'Group One',
'progress': [3, 100],
'reading_status': 'reading',
'reading_status_updated_at': '2024-01-02T00:00:00.000Z',
'cover_hash': 'c' * 32,
'cover_updated_at': '2024-01-02T00:00:00.000Z',
'metadata': None,
'created_at': '2024-01-01T00:00:00.000Z',
'updated_at': '2024-01-02T00:00:00.000Z',
'deleted_at': None,
'uploaded_at': '2024-01-01T12:00:00.000Z',
}
row.update(overrides)
return row
def wire_for(book=BOOK, file_hash='a' * 32, fmt='EPUB', now=NOW):
return build_wire_book(book, file_hash, fmt, now)
def synced_row(wire):
"""A server row that matches `wire` (as if we pushed it earlier)."""
return server_row(
title=wire['title'],
author=wire['author'],
tags=wire.get('tags'),
metadata=json.dumps(wire['metadata']),
)
class PickFormatTest(unittest.TestCase):
def test_prefers_epub(self):
self.assertEqual(pick_format(['MOBI', 'EPUB', 'PDF']), 'EPUB')
def test_case_insensitive(self):
self.assertEqual(pick_format(['azw3', 'txt']), 'AZW3')
def test_unsupported_only(self):
self.assertIsNone(pick_format(['DOCX', 'LRF']))
def test_empty(self):
self.assertIsNone(pick_format([]))
class BuildMetadataTest(unittest.TestCase):
def test_fields(self):
meta = build_metadata(BOOK)
self.assertEqual(meta['title'], 'The Test Book')
self.assertEqual(meta['author'], ['Alice Author', 'Bob Writer'])
self.assertEqual(meta['language'], 'eng')
self.assertEqual(meta['publisher'], 'Test House')
self.assertEqual(meta['published'], '2020-05-01T00:00:00+00:00')
self.assertEqual(meta['description'], '<p>A very good book.</p>')
self.assertEqual(meta['subject'], ['Fiction', 'Test'])
self.assertEqual(meta['series'], 'Test Series')
self.assertEqual(meta['seriesIndex'], 2.0)
self.assertEqual(meta['identifier'], 'urn:uuid:cafebabe-0000-0000-0000-000000000001')
self.assertEqual(meta['isbn'], '9781234567897')
self.assertEqual(meta['customColumns'], {'read_status': 'done'})
self.assertEqual(meta['calibreSourceHash'], SRC)
def test_single_author_is_string(self):
meta = build_metadata(dict(BOOK, authors=['Solo']))
self.assertEqual(meta['author'], 'Solo')
def test_omits_empty_fields(self):
meta = build_metadata({'title': 'T', 'authors': []})
self.assertNotIn('publisher', meta)
self.assertNotIn('series', meta)
self.assertNotIn('customColumns', meta)
self.assertNotIn('isbn', meta)
self.assertNotIn('calibreSourceHash', meta)
def test_strips_nul_characters(self):
meta = build_metadata({'title': 'T\x00itle', 'authors': ['A\x00nn']})
self.assertEqual(meta['title'], 'Title')
self.assertEqual(meta['author'], 'Ann')
class BuildWireBookTest(unittest.TestCase):
def test_record_shape(self):
wire = wire_for()
self.assertEqual(wire['hash'], 'a' * 32)
self.assertEqual(wire['bookHash'], 'a' * 32)
self.assertEqual(wire['format'], 'EPUB')
self.assertEqual(wire['title'], 'The Test Book')
self.assertEqual(wire['sourceTitle'], 'The Test Book')
self.assertEqual(wire['author'], 'Alice Author, Bob Writer')
self.assertEqual(wire['tags'], ['Fiction', 'Test'])
self.assertEqual(wire['createdAt'], NOW)
self.assertEqual(wire['updatedAt'], NOW)
self.assertEqual(len(wire['metaHash']), 32)
self.assertEqual(wire['metadata']['title'], 'The Test Book')
def test_meta_hash_uses_uuid_identifier(self):
import hashlib
import unicodedata
wire = wire_for()
source = 'The Test Book|Alice Author,Bob Writer|cafebabe-0000-0000-0000-000000000001'
expected = hashlib.md5(unicodedata.normalize('NFC', source).encode('utf-8')).hexdigest()
self.assertEqual(wire['metaHash'], expected)
class IsoToMsTest(unittest.TestCase):
def test_iso_with_ms(self):
self.assertEqual(iso_to_ms('2024-01-01T00:00:00.000Z'), 1704067200000)
def test_iso_without_ms(self):
self.assertEqual(iso_to_ms('2024-01-01T00:00:00Z'), 1704067200000)
def test_none(self):
self.assertIsNone(iso_to_ms(None))
class PlanPushTest(unittest.TestCase):
def test_new_book(self):
plan = plan_push(None, wire_for(), 'c' * 32, SRC)
self.assertEqual(plan['action'], 'new')
self.assertTrue(plan['upload_cover'])
def test_new_book_without_cover(self):
plan = plan_push(None, wire_for(), None, SRC)
self.assertEqual(plan['action'], 'new')
self.assertFalse(plan['upload_cover'])
def test_unchanged_book_is_skipped(self):
wire = wire_for()
plan = plan_push(synced_row(wire), wire, 'c' * 32, SRC)
self.assertEqual(plan['action'], 'skip')
def test_changed_metadata_is_update(self):
wire = wire_for(dict(BOOK, title='Renamed Title'))
row = synced_row(wire_for())
plan = plan_push(row, wire, 'c' * 32, SRC)
self.assertEqual(plan['action'], 'update')
self.assertFalse(plan['upload_cover'])
def test_changed_cover_only_is_update_with_cover(self):
wire = wire_for()
plan = plan_push(synced_row(wire), wire, 'd' * 32, SRC)
self.assertEqual(plan['action'], 'update')
self.assertTrue(plan['upload_cover'])
def test_tombstoned_row_is_resurrected(self):
wire = wire_for()
row = synced_row(wire)
row['deleted_at'] = '2024-06-01T00:00:00.000Z'
plan = plan_push(row, wire, 'c' * 32, SRC)
self.assertEqual(plan['action'], 'update')
def test_tags_change_is_update(self):
wire = wire_for(dict(BOOK, tags=['Fiction']))
row = synced_row(wire_for())
self.assertEqual(plan_push(row, wire, 'c' * 32, SRC)['action'], 'update')
def test_missing_local_cover_does_not_force_update(self):
wire = wire_for()
plan = plan_push(synced_row(wire), wire, None, SRC)
self.assertEqual(plan['action'], 'skip')
def test_row_without_file_is_replaced(self):
wire = wire_for()
row = synced_row(wire)
row['uploaded_at'] = None
plan = plan_push(row, wire, 'c' * 32, SRC)
self.assertEqual(plan['action'], 'replace')
self.assertTrue(plan['upload_cover']) # new hash namespace needs its own cover
def test_changed_source_file_is_replaced(self):
wire = wire_for(dict(BOOK, source_hash='n' * 32))
row = synced_row(wire_for()) # cloud copy built from SRC
plan = plan_push(row, wire, 'c' * 32, 'n' * 32)
self.assertEqual(plan['action'], 'replace')
def test_v1_row_with_matching_raw_hash_is_not_replaced(self):
# v1 rows have no calibreSourceHash but their book_hash IS the raw
# file hash (v1 uploaded the file unmodified).
book = dict(BOOK)
del book['source_hash']
v1_wire = wire_for(book)
row = synced_row(v1_wire)
row['book_hash'] = SRC
plan = plan_push(row, wire_for(), 'c' * 32, SRC)
self.assertEqual(plan['action'], 'update') # gains calibreSourceHash
def test_v1_row_with_changed_file_is_replaced(self):
book = dict(BOOK)
del book['source_hash']
row = synced_row(wire_for(book))
row['book_hash'] = 'o' * 32 # raw hash of the OLD file
plan = plan_push(row, wire_for(), 'c' * 32, SRC)
self.assertEqual(plan['action'], 'replace')
class ServerRowLookupTest(unittest.TestCase):
def test_index_rows_by_uuid(self):
wire = wire_for()
row = synced_row(wire)
index = index_rows_by_uuid([row, server_row(book_hash='x' * 32, metadata='not json')])
self.assertEqual(index, {'cafebabe-0000-0000-0000-000000000001': row})
def test_index_prefers_live_row(self):
wire = wire_for()
dead = synced_row(wire)
dead['deleted_at'] = '2024-06-01T00:00:00.000Z'
live = synced_row(wire)
live['book_hash'] = 'x' * 32
for rows in ([dead, live], [live, dead]):
index = index_rows_by_uuid(rows)
self.assertEqual(index['cafebabe-0000-0000-0000-000000000001'], live)
def test_index_prefers_newer_row(self):
wire = wire_for()
old = synced_row(wire)
old['updated_at'] = '2024-01-01T00:00:00.000Z'
new = synced_row(wire)
new['book_hash'] = 'x' * 32
new['updated_at'] = '2024-06-01T00:00:00.000Z'
index = index_rows_by_uuid([old, new])
self.assertEqual(index['cafebabe-0000-0000-0000-000000000001'], new)
def test_pick_prefers_live_hash_match(self):
hash_row = server_row()
uuid_row = server_row(book_hash='x' * 32)
self.assertIs(pick_server_row(hash_row, uuid_row), hash_row)
def test_pick_falls_back_to_live_uuid_row(self):
dead = server_row(deleted_at='2024-06-01T00:00:00.000Z')
live = server_row(book_hash='x' * 32)
self.assertIs(pick_server_row(dead, live), live)
def test_pick_returns_tombstone_when_nothing_live(self):
dead = server_row(deleted_at='2024-06-01T00:00:00.000Z')
self.assertIs(pick_server_row(None, dead), dead)
self.assertIsNone(pick_server_row(None, None))
class TombstoneRecordTest(unittest.TestCase):
def test_shape(self):
rec = tombstone_record(server_row(), NOW)
self.assertEqual(rec['hash'], 'a' * 32)
self.assertEqual(rec['bookHash'], 'a' * 32)
self.assertEqual(rec['format'], 'EPUB')
self.assertEqual(rec['title'], 'The Test Book')
self.assertEqual(rec['author'], 'Alice Author, Bob Writer')
self.assertEqual(rec['createdAt'], iso_to_ms('2024-01-01T00:00:00.000Z'))
self.assertEqual(rec['updatedAt'], NOW)
self.assertEqual(rec['deletedAt'], NOW)
class MergeForPushTest(unittest.TestCase):
def test_new_book_merge(self):
wire = wire_for()
rec = merge_for_push(wire, None, NOW, uploaded_at_ms=NOW, cover_hash='e' * 32)
self.assertEqual(rec['createdAt'], NOW)
self.assertEqual(rec['updatedAt'], NOW)
self.assertEqual(rec['uploadedAt'], NOW)
self.assertEqual(rec['coverHash'], 'e' * 32)
self.assertEqual(rec['coverUpdatedAt'], NOW)
self.assertIsNone(rec['deletedAt'])
def test_update_carries_server_fields(self):
wire = wire_for(dict(BOOK, title='Renamed'))
row = server_row(metadata=json.dumps(build_metadata(BOOK)))
rec = merge_for_push(wire, row, NOW)
# Fields the server would explicit-null if omitted must be carried over.
self.assertEqual(rec['groupId'], 'g1')
self.assertEqual(rec['groupName'], 'Group One')
self.assertEqual(rec['progress'], [3, 100])
self.assertEqual(rec['readingStatus'], 'reading')
self.assertEqual(rec['readingStatusUpdatedAt'], iso_to_ms('2024-01-02T00:00:00.000Z'))
self.assertEqual(rec['uploadedAt'], iso_to_ms('2024-01-01T12:00:00.000Z'))
self.assertEqual(rec['coverHash'], 'c' * 32)
self.assertEqual(rec['coverUpdatedAt'], iso_to_ms('2024-01-02T00:00:00.000Z'))
self.assertEqual(rec['createdAt'], iso_to_ms('2024-01-01T00:00:00.000Z'))
# LWW: the push must win over the server row.
self.assertEqual(rec['updatedAt'], NOW)
# Our fresh metadata wins.
self.assertEqual(rec['title'], 'Renamed')
# Resurrects tombstones.
self.assertIsNone(rec['deletedAt'])
def test_reupload_overrides_uploaded_at(self):
wire = wire_for()
row = server_row(uploaded_at=None)
rec = merge_for_push(wire, row, NOW, uploaded_at_ms=NOW)
self.assertEqual(rec['uploadedAt'], NOW)
def test_new_cover_overrides_server_cover(self):
wire = wire_for()
rec = merge_for_push(wire, server_row(), NOW, cover_hash='f' * 32)
self.assertEqual(rec['coverHash'], 'f' * 32)
self.assertEqual(rec['coverUpdatedAt'], NOW)
if __name__ == '__main__':
unittest.main()