Skip to content

Commit 8467b51

Browse files
perf(kobo): defer kepub conversion and optimize sync queries (#1344)
* Fix slow KoboReadingState handling in the sync handler: Saves up to ~11s on Kobo sync requests. The prevoius implementation was executing a new query per book returned by the first. In addition, it inserted a new KoboReadingState entry when none existed. On the 1st sync request of a 5000 book unread library, the previous implementation spent 7.7 ms in get_or_create_reading_state + 3.5 ms in get_kobo_reading_state_response * Eager load book metadata tables in Kobo sync Saves another 3s on Kobo sync requests. The previous implementation resulted in 5 additional db queries per book * Fix bad log line in kobo * Defer kepub book conversion to book download if supported Saves another 1.25s on the library/sync request Note that this only returns a single downloadUrl. If kepub conversion fails, the download handler will fallback to the epub file, which the Android Kobo app at least handles fines. LOCAL: Disable kobo_auth epub conversion
1 parent b0fc954 commit 8467b51

4 files changed

Lines changed: 87 additions & 45 deletions

File tree

cps/helper.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
# SPDX-License-Identifier: GPL-3.0-or-later
66
# See CONTRIBUTORS for full list of authors.
77

8+
import glob
89
import os
910
import random
1011
import io
@@ -48,15 +49,15 @@
4849
from . import gdriveutils as gd
4950
from .constants import (STATIC_DIR as _STATIC_DIR, CACHE_TYPE_THUMBNAILS, THUMBNAIL_TYPE_COVER, THUMBNAIL_TYPE_SERIES,
5051
SUPPORTED_CALIBRE_BINARIES)
51-
from .subproc_wrapper import process_wait
52+
from .subproc_wrapper import process_wait, process_open
5253

5354
# Track books with pending thumbnail generation to prevent duplicate tasks
5455
_pending_thumbnail_books = set()
5556

5657
import sys
5758
sys.path.insert(1, '/app/calibre-web-automated/scripts/')
5859
from cwa_db import CWA_DB
59-
from .services.worker import WorkerThread
60+
from .services.worker import WorkerThread, STAT_FINISH_SUCCESS
6061
from .tasks.mail import TaskEmail
6162
from .tasks.thumbnail import TaskClearCoverThumbnailCache, TaskGenerateCoverThumbnails
6263
from .tasks.metadata_backup import TaskBackupMetadata
@@ -85,7 +86,8 @@ def _directory_contains_only_nfs_placeholders(path):
8586

8687

8788
# Convert existing book entry to new format
88-
def convert_book_format(book_id, calibre_path, old_book_format, new_book_format, user_id, ereader_mail=None, subject=None):
89+
def convert_book_format(book_id, calibre_path, old_book_format, new_book_format, user_id,
90+
ereader_mail=None, subject=None, blocking=False):
8991
book = calibre_db.get_book(book_id)
9092
data = calibre_db.get_book_format(book.id, old_book_format)
9193
if not data:
@@ -119,7 +121,14 @@ def convert_book_format(book_id, calibre_path, old_book_format, new_book_format,
119121
link)
120122
settings['old_book_format'] = old_book_format
121123
settings['new_book_format'] = new_book_format
122-
WorkerThread.add(user_id, TaskConvert(file_path, book.id, txt, settings, ereader_mail, user_id))
124+
task = TaskConvert(file_path, book.id, txt, settings, ereader_mail, user_id)
125+
WorkerThread.add(user_id, task)
126+
if blocking:
127+
finished = task.done_event.wait(timeout=120)
128+
if not finished:
129+
return _("Conversion timed out for book id: %(book)d", book=book_id)
130+
if task.stat != STAT_FINISH_SUCCESS:
131+
return task.error or _("Conversion failed for book id: %(book)d", book=book_id)
123132
return None
124133

125134

@@ -1444,6 +1453,16 @@ def get_download_link(book_id, book_format, client):
14441453
abort(404)
14451454

14461455
data1 = calibre_db.get_book_format(book.id, book_format.upper())
1456+
if not data1 and book_format == "kepub" and config.config_kepubifypath:
1457+
data1 = calibre_db.get_book_format(book.id, "EPUB")
1458+
if data1:
1459+
log.info("KEPUB not found for book %d; converting on demand", book.id)
1460+
err = convert_book_format(book.id, config.get_book_path(), 'EPUB', 'KEPUB', None, blocking=True)
1461+
if not err:
1462+
data1 = calibre_db.get_book_format(book.id, "KEPUB")
1463+
else:
1464+
log.error("On-demand KEPUB conversion failed for book %d: %s", book.id, err)
1465+
book_format = "epub"
14471466
if not data1:
14481467
log.error("Requested format %s for book id %s not found in database", book_format.upper(), book_id)
14491468
abort(404)

cps/kobo.py

Lines changed: 61 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from sqlalchemy import func
3434
from sqlalchemy.sql.expression import and_, or_
3535
from sqlalchemy.exc import StatementError
36+
from sqlalchemy.orm import joinedload
3637
from sqlalchemy.sql import select
3738
import requests
3839

@@ -232,14 +233,20 @@ def HandleSyncRequest():
232233

233234
log.debug("Kobo Sync: books last modified: {}".format(sync_token.books_last_modified))
234235

236+
rstate_join = and_(
237+
db.Books.id == ub.KoboReadingState.book_id,
238+
ub.KoboReadingState.user_id == current_user.id,
239+
)
235240
if only_kobo_shelves:
236241
changed_entries = calibre_db.session.query(db.Books,
237242
ub.ArchivedBook.last_modified,
238243
ub.BookShelf.date_added,
239-
ub.ArchivedBook.is_archived)
244+
ub.ArchivedBook.is_archived,
245+
ub.KoboReadingState)
240246
changed_entries = (changed_entries
241247
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
242248
ub.ArchivedBook.user_id == current_user.id))
249+
.outerjoin(ub.KoboReadingState, rstate_join)
243250
.filter(db.Books.id.notin_(calibre_db.session.query(ub.KoboSyncedBooks.book_id)
244251
.filter(ub.KoboSyncedBooks.user_id == current_user.id)))
245252
.filter(or_(
@@ -257,37 +264,50 @@ def HandleSyncRequest():
257264
and_(ub.Shelf.user_id == current_user.id, ub.Shelf.kobo_sync == True),
258265
db.Books.id.in_(magic_shelf_book_ids) if magic_shelf_book_ids else False
259266
))
267+
.options(joinedload(db.Books.authors),
268+
joinedload(db.Books.publishers),
269+
joinedload(db.Books.series),
270+
joinedload(db.Books.languages),
271+
joinedload(db.Books.comments),
272+
joinedload(db.Books.data))
260273
.distinct())
261274
else:
262275
changed_entries = calibre_db.session.query(db.Books,
263276
ub.ArchivedBook.last_modified,
264-
ub.ArchivedBook.is_archived)
277+
ub.ArchivedBook.is_archived,
278+
ub.KoboReadingState)
265279
changed_entries = (changed_entries
266280
.join(db.Data).outerjoin(ub.ArchivedBook, and_(db.Books.id == ub.ArchivedBook.book_id,
267281
ub.ArchivedBook.user_id == current_user.id))
282+
.outerjoin(ub.KoboReadingState, rstate_join)
268283
.filter(db.Books.id.notin_(calibre_db.session.query(ub.KoboSyncedBooks.book_id)
269284
.filter(ub.KoboSyncedBooks.user_id == current_user.id)))
270285
.filter(calibre_db.common_filters(allow_show_archived=True))
271286
.filter(db.Data.format.in_(KOBO_FORMATS))
272287
.order_by(db.Books.last_modified)
273-
.order_by(db.Books.id))
288+
.order_by(db.Books.id)
289+
.options(joinedload(db.Books.authors),
290+
joinedload(db.Books.publishers),
291+
joinedload(db.Books.series),
292+
joinedload(db.Books.languages),
293+
joinedload(db.Books.comments),
294+
joinedload(db.Books.data)))
274295
log.debug("Kobo Sync: changed entries: {}".format(changed_entries.count()))
275296

276297
reading_states_in_new_entitlements = []
277298
books = changed_entries.limit(SYNC_ITEM_LIMIT)
278299
log.debug("Kobo Sync: selected to sync: {}".format(len(books.all())))
279300
for book in books:
280301
formats = [data.format for data in book.Books.data]
281-
if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats:
282-
helper.convert_book_format(book.Books.id, config.get_book_path(), 'EPUB', 'KEPUB', current_user.name)
283302

284-
kobo_reading_state = get_or_create_reading_state(book.Books.id)
303+
kobo_reading_state = book.KoboReadingState # None when no record exists yet
285304
entitlement = {
286305
"BookEntitlement": create_book_entitlement(book.Books, archived=(book.is_archived==True)),
287306
"BookMetadata": get_metadata(book.Books),
288307
}
289308

290-
if kobo_reading_state.last_modified > sync_token.reading_state_last_modified:
309+
if (kobo_reading_state is not None
310+
and kobo_reading_state.last_modified > sync_token.reading_state_last_modified):
291311
entitlement["ReadingState"] = get_kobo_reading_state_response(book.Books, kobo_reading_state)
292312
new_reading_state_last_modified = max(new_reading_state_last_modified, kobo_reading_state.last_modified)
293313
reading_states_in_new_entitlements.append(book.Books.id)
@@ -581,28 +601,32 @@ def _get_cover_image_id(book):
581601

582602
def get_metadata(book):
583603
download_urls = []
584-
kepub = [data for data in book.data if data.format == 'KEPUB']
585604

586-
for book_data in kepub if len(kepub) > 0 else book.data:
587-
if book_data.format not in KOBO_FORMATS:
588-
continue
589-
for kobo_format in KOBO_FORMATS[book_data.format]:
590-
# log.debug('Id: %s, Format: %s' % (book.id, kobo_format))
591-
try:
592-
if get_epub_layout(book, book_data) == 'pre-paginated':
593-
kobo_format = 'EPUB3FL'
594-
download_urls.append(
595-
{
596-
"Format": kobo_format,
597-
"Size": book_data.uncompressed_size,
598-
"Url": get_download_url_for_book(book.id, book_data.format),
599-
# The Kobo forma accepts platforms: (Generic, Android)
600-
"Platform": "Generic",
601-
# "DrmType": "None", # Not required
602-
}
603-
)
604-
except (zipfile.BadZipfile, FileNotFoundError) as e:
605-
log.error(e)
605+
kepub_data = next((d for d in book.data if d.format == 'KEPUB'), None)
606+
epub_data = next((d for d in book.data if d.format == 'EPUB'), None)
607+
608+
if kepub_data:
609+
book_data, dl_format, published_format = kepub_data, 'kepub', 'KEPUB'
610+
elif epub_data and config.config_kepubifypath:
611+
book_data, dl_format, published_format = epub_data, 'kepub', 'KEPUB'
612+
elif epub_data:
613+
book_data, dl_format, published_format = epub_data, 'epub', 'EPUB3'
614+
else:
615+
book_data = None
616+
617+
if book_data:
618+
try:
619+
if get_epub_layout(book, book_data) == 'pre-paginated':
620+
published_format = 'EPUB3FL'
621+
except (zipfile.BadZipfile, FileNotFoundError) as e:
622+
log.error(e)
623+
download_urls.append({
624+
"Format": published_format,
625+
"Size": book_data.uncompressed_size,
626+
"Url": get_download_url_for_book(book.id, dl_format),
627+
"Platform": "Generic",
628+
"DrmType": "None",
629+
})
606630

607631
book_uuid = book.uuid
608632
cover_image_id = _get_cover_image_id(book)
@@ -634,11 +658,12 @@ def get_metadata(book):
634658
}
635659
metadata.update(get_author(book))
636660

637-
if get_series(book):
638-
name = get_series(book)
661+
series_name = get_series(book)
662+
if series_name:
663+
name = series_name
639664
try:
640665
metadata["Series"] = {
641-
"Name": get_series(book),
666+
"Name": series_name,
642667
"Number": get_seriesindex(book), # ToDo Check int() ?
643668
"NumberFloat": float(get_seriesindex(book)),
644669
# Get a deterministic id based on the series name.
@@ -1044,8 +1069,10 @@ def get_ub_read_status(kobo_read_status):
10441069

10451070

10461071
def get_or_create_reading_state(book_id):
1047-
book_read = ub.session.query(ub.ReadBook).filter(ub.ReadBook.book_id == book_id,
1048-
ub.ReadBook.user_id == int(current_user.id)).one_or_none()
1072+
book_read = ub.session.query(ub.ReadBook).filter(
1073+
ub.ReadBook.book_id == book_id,
1074+
ub.ReadBook.user_id == int(current_user.id),
1075+
).one_or_none()
10491076
if not book_read:
10501077
book_read = ub.ReadBook(user_id=current_user.id, book_id=book_id)
10511078
if not book_read.kobo_reading_state:
@@ -1123,7 +1150,7 @@ def HandleCoverImageRequest(book_uuid, width, height, Quality, isGreyscale):
11231150
else:
11241151
resolution = COVER_THUMBNAIL_SMALL
11251152
except ValueError:
1126-
log.error("Requested height %s of book %s is invalid" % (book_uuid, height))
1153+
log.error("Requested height %s of book %s is invalid" % (height, book_uuid))
11271154
resolution = COVER_THUMBNAIL_SMALL
11281155
book_cover = helper.get_book_cover_with_uuid(book_uuid, resolution=resolution)
11291156
if book_cover:

cps/kobo_auth.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,13 +94,6 @@ def generate_auth_token(user_id):
9494
ub.session.add(auth_token)
9595
ub.session_commit()
9696

97-
books = calibre_db.session.query(db.Books).join(db.Data).all()
98-
99-
for book in books:
100-
formats = [data.format for data in book.data]
101-
if 'KEPUB' not in formats and config.config_kepubifypath and 'EPUB' in formats:
102-
helper.convert_book_format(book.id, config.config_calibre_dir, 'EPUB', 'KEPUB', current_user.name)
103-
10497
return render_title_template(
10598
"generate_kobo_auth_url.html",
10699
title=_("Kobo Setup"),

cps/services/worker.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ def __init__(self, message):
232232
self.id = uuid.uuid4()
233233
self.self_cleanup = False
234234
self._scheduled = False
235+
self.done_event = threading.Event()
235236

236237
@abc.abstractmethod
237238
def run(self, worker_thread):
@@ -320,10 +321,12 @@ def _handleError(self, error_message):
320321
self.stat = STAT_FAIL
321322
self.progress = 1
322323
self.error = error_message
324+
self.done_event.set()
323325

324326
def _handleSuccess(self):
325327
self.stat = STAT_FINISH_SUCCESS
326328
self.progress = 1
329+
self.done_event.set()
327330

328331
def __str__(self):
329332
return self.name

0 commit comments

Comments
 (0)