diff --git a/cps/fs.py b/cps/fs.py index 4f835fa6..30ab552a 100644 --- a/cps/fs.py +++ b/cps/fs.py @@ -57,17 +57,9 @@ class FileSystem: def get_cache_file_path(self, filename, cache_type=None): return join(self.get_cache_dir(cache_type), filename) if filename else None - def list_cache_files(self, cache_type=None): - path = self.get_cache_dir(cache_type) - return [file for file in listdir(path) if isfile(join(path, file))] - - def list_existing_cache_files(self, filenames, cache_type=None): - path = self.get_cache_dir(cache_type) - return [file for file in listdir(path) if isfile(join(path, file)) and file in filenames] - - def list_missing_cache_files(self, filenames, cache_type=None): - path = self.get_cache_dir(cache_type) - return [file for file in listdir(path) if isfile(join(path, file)) and file not in filenames] + def get_cache_file_exists(self, filename, cache_type=None): + path = self.get_cache_file_path(filename, cache_type) + return isfile(path) def delete_cache_dir(self, cache_type=None): if not cache_type and isdir(self._cache_dir): diff --git a/cps/helper.py b/cps/helper.py index 688bc615..1652be23 100644 --- a/cps/helper.py +++ b/cps/helper.py @@ -35,7 +35,7 @@ from babel.units import format_unit from flask import send_from_directory, make_response, redirect, abort, url_for from flask_babel import gettext as _ from flask_login import current_user -from sqlalchemy.sql.expression import true, false, and_, text, func +from sqlalchemy.sql.expression import true, false, and_, or_, text, func from werkzeug.datastructures import Headers from werkzeug.security import generate_password_hash from markupsafe import escape @@ -550,26 +550,6 @@ def delete_book(book, calibrepath, book_format): return delete_book_file(book, calibrepath, book_format) -def get_thumbnails_for_books(books): - books_with_covers = list(filter(lambda b: b.has_cover, books)) - book_ids = list(map(lambda b: b.id, books_with_covers)) - cache = fs.FileSystem() - thumbnail_files = cache.list_cache_files(fs.CACHE_TYPE_THUMBNAILS) - - thumbnails = ub.session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.book_id.in_(book_ids))\ - .filter(ub.Thumbnail.expiration > datetime.utcnow())\ - .all() - - return list(filter(lambda t: t.filename in thumbnail_files, thumbnails)) - - -def get_thumbnails_for_book_series(series): - books = list(map(lambda s: s[0], series)) - return get_thumbnails_for_books(books) - - def get_cover_on_failure(use_generic_cover): if use_generic_cover: return send_from_directory(_STATIC_DIR, "generic_cover.jpg") @@ -577,9 +557,9 @@ def get_cover_on_failure(use_generic_cover): return None -def get_book_cover(book_id): +def get_book_cover(book_id, resolution=None): book = calibre_db.get_filtered_book(book_id, allow_show_archived=True) - return get_book_cover_internal(book, use_generic_cover_on_failure=True) + return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) def get_book_cover_with_uuid(book_uuid, use_generic_cover_on_failure=True): @@ -587,37 +567,6 @@ def get_book_cover_with_uuid(book_uuid, use_generic_cover_on_failure=True): return get_book_cover_internal(book, use_generic_cover_on_failure) -def get_cached_book_cover(cache_id): - parts = cache_id.split('_') - book_uuid = parts[0] if len(parts) else None - resolution = parts[2] if len(parts) > 2 else None - book = calibre_db.get_book_by_uuid(book_uuid) if book_uuid else None - return get_book_cover_internal(book, use_generic_cover_on_failure=True, resolution=resolution) - - -def get_cached_book_cover_thumbnail(cache_id): - parts = cache_id.split('_') - thumbnail_uuid = parts[0] if len(parts) else None - thumbnail = None - if thumbnail_uuid: - thumbnail = ub.session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.uuid == thumbnail_uuid)\ - .first() - - if thumbnail and thumbnail.expiration > datetime.utcnow(): - cache = fs.FileSystem() - if cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): - return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) - - elif thumbnail: - book = calibre_db.get_book(thumbnail.book_id) - return get_book_cover_internal(book, use_generic_cover_on_failure=True) - - else: - return get_cover_on_failure(True) - - def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None): if book and book.has_cover: @@ -626,7 +575,7 @@ def get_book_cover_internal(book, use_generic_cover_on_failure, resolution=None) thumbnail = get_book_cover_thumbnail(book, resolution) if thumbnail: cache = fs.FileSystem() - if cache.get_cache_file_path(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): + if cache.get_cache_file_exists(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): return send_from_directory(cache.get_cache_dir(fs.CACHE_TYPE_THUMBNAILS), thumbnail.filename) # Send the book cover from Google Drive if configured @@ -661,7 +610,7 @@ def get_book_cover_thumbnail(book, resolution): .query(ub.Thumbnail)\ .filter(ub.Thumbnail.book_id == book.id)\ .filter(ub.Thumbnail.resolution == resolution)\ - .filter(ub.Thumbnail.expiration > datetime.utcnow())\ + .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow()))\ .first() diff --git a/cps/jinjia.py b/cps/jinjia.py index 5f86478c..8c8b72a9 100644 --- a/cps/jinjia.py +++ b/cps/jinjia.py @@ -33,6 +33,7 @@ from flask_babel import get_locale from flask_login import current_user from markupsafe import escape from . import logger +from .tasks.thumbnail import THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X, THUMBNAIL_RESOLUTION_3X jinjia = Blueprint('jinjia', __name__) @@ -140,24 +141,17 @@ def uuidfilter(var): return uuid4() -@jinjia.app_template_filter('book_cover_cache_id') -def book_cover_cache_id(book, resolution=None): +@jinjia.app_template_filter('last_modified') +def book_cover_cache_id(book): timestamp = int(book.last_modified.timestamp() * 1000) - cache_bust = str(book.uuid) + '_' + str(timestamp) - return cache_bust if not resolution else cache_bust + '_' + str(resolution) + return str(timestamp) -@jinjia.app_template_filter('get_book_thumbnails') -def get_book_thumbnails(book_id, thumbnails=None): - return list(filter(lambda t: t.book_id == book_id, thumbnails)) if book_id > -1 and thumbnails else list() - - -@jinjia.app_template_filter('get_book_thumbnail_srcset') -def get_book_thumbnail_srcset(thumbnails): +@jinjia.app_template_filter('get_cover_srcset') +def get_cover_srcset(book): srcset = list() - for thumbnail in thumbnails: - timestamp = int(thumbnail.generated_at.timestamp() * 1000) - cache_id = str(thumbnail.uuid) + '_' + str(timestamp) - url = url_for('web.get_cached_cover_thumbnail', cache_id=cache_id) - srcset.append(url + ' ' + str(thumbnail.resolution) + 'x') + for resolution in [THUMBNAIL_RESOLUTION_1X, THUMBNAIL_RESOLUTION_2X, THUMBNAIL_RESOLUTION_3X]: + timestamp = int(book.last_modified.timestamp() * 1000) + url = url_for('web.get_cover', book_id=book.id, resolution=resolution, cache_bust=str(timestamp)) + srcset.append(f'{url} {resolution}x') return ', '.join(srcset) diff --git a/cps/schedule.py b/cps/schedule.py index d427d908..8c350bd5 100644 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -21,7 +21,7 @@ from __future__ import division, print_function, unicode_literals from .services.background_scheduler import BackgroundScheduler from .services.worker import WorkerThread from .tasks.database import TaskReconnectDatabase -from .tasks.thumbnail import TaskSyncCoverThumbnailCache, TaskGenerateCoverThumbnails +from .tasks.thumbnail import TaskGenerateCoverThumbnails def register_jobs(): @@ -31,9 +31,6 @@ def register_jobs(): # Reconnect metadata.db once every 12 hours scheduler.add_task(user=None, task=lambda: TaskReconnectDatabase(), trigger='cron', hour='4,16') - # Cleanup book cover cache once every 24 hours - scheduler.add_task(user=None, task=lambda: TaskSyncCoverThumbnailCache(), trigger='cron', hour=4) - # Generate all missing book cover thumbnails once every 24 hours scheduler.add_task(user=None, task=lambda: TaskGenerateCoverThumbnails(), trigger='cron', hour=4) diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index fed12e8b..688f6e4b 100644 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -22,7 +22,7 @@ import os from cps import config, db, fs, gdriveutils, logger, ub from cps.services.worker import CalibreTask from datetime import datetime, timedelta -from sqlalchemy import func +from sqlalchemy import or_ try: from urllib.request import urlopen @@ -41,9 +41,8 @@ THUMBNAIL_RESOLUTION_3X = 3 class TaskGenerateCoverThumbnails(CalibreTask): - def __init__(self, limit=100, task_message=u'Generating cover thumbnails'): + def __init__(self, task_message=u'Generating cover thumbnails'): super(TaskGenerateCoverThumbnails, self).__init__(task_message) - self.limit = limit self.log = logger.create() self.app_db_session = ub.get_new_session_instance() self.calibre_db = db.CalibreDB(expire_on_commit=False) @@ -55,74 +54,51 @@ class TaskGenerateCoverThumbnails(CalibreTask): def run(self, worker_thread): if self.calibre_db.session and use_IM: - expired_thumbnails = self.get_expired_thumbnails() - thumbnail_book_ids = self.get_thumbnail_book_ids() - books_without_thumbnails = self.get_books_without_thumbnails(thumbnail_book_ids) + books_with_covers = self.get_books_with_covers() + count = len(books_with_covers) - count = len(books_without_thumbnails) - if count == 0: - # Do not display this task on the frontend if there are no covers to update - self.self_cleanup = True + updated = 0 + generated = 0 + for i, book in enumerate(books_with_covers): + book_cover_thumbnails = self.get_book_cover_thumbnails(book.id) - for i, book in enumerate(books_without_thumbnails): - for resolution in self.resolutions: - expired_thumbnail = self.get_expired_thumbnail_for_book_and_resolution( - book, - resolution, - expired_thumbnails - ) - if expired_thumbnail: - self.update_book_thumbnail(book, expired_thumbnail) - else: - self.create_book_thumbnail(book, resolution) + # Generate new thumbnails for missing covers + resolutions = list(map(lambda t: t.resolution, book_cover_thumbnails)) + missing_resolutions = list(set(self.resolutions).difference(resolutions)) + for resolution in missing_resolutions: + generated += 1 + self.create_book_cover_thumbnail(book, resolution) - self.message = u'Generating cover thumbnail {0} of {1}'.format(i + 1, count) + # Replace outdated or missing thumbnails + for thumbnail in book_cover_thumbnails: + if book.last_modified > thumbnail.generated_at: + updated += 1 + self.update_book_cover_thumbnail(book, thumbnail) + + elif not self.cache.get_cache_file_exists(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS): + updated += 1 + self.update_book_cover_thumbnail(book, thumbnail) + + self.message = u'Processing book {0} of {1}'.format(i + 1, count) self.progress = (1.0 / count) * i self._handleSuccess() self.app_db_session.remove() - def get_expired_thumbnails(self): - return self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.expiration < datetime.utcnow())\ - .all() - - def get_thumbnail_book_ids(self): - return self.app_db_session\ - .query(ub.Thumbnail.book_id)\ - .group_by(ub.Thumbnail.book_id)\ - .having(func.min(ub.Thumbnail.expiration) > datetime.utcnow())\ - .distinct() - - def get_books_without_thumbnails(self, thumbnail_book_ids): + def get_books_with_covers(self): return self.calibre_db.session\ .query(db.Books)\ .filter(db.Books.has_cover == 1)\ - .filter(db.Books.id.notin_(thumbnail_book_ids))\ - .limit(self.limit)\ .all() - def get_expired_thumbnail_for_book_and_resolution(self, book, resolution, expired_thumbnails): - for thumbnail in expired_thumbnails: - if thumbnail.book_id == book.id and thumbnail.resolution == resolution: - return thumbnail + def get_book_cover_thumbnails(self, book_id): + return self.app_db_session\ + .query(ub.Thumbnail)\ + .filter(ub.Thumbnail.book_id == book_id)\ + .filter(or_(ub.Thumbnail.expiration.is_(None), ub.Thumbnail.expiration > datetime.utcnow()))\ + .all() - return None - - def update_book_thumbnail(self, book, thumbnail): - thumbnail.generated_at = datetime.utcnow() - thumbnail.expiration = datetime.utcnow() + timedelta(days=30) - - try: - self.app_db_session.commit() - self.generate_book_thumbnail(book, thumbnail) - except Exception as ex: - self.log.info(u'Error updating book thumbnail: ' + str(ex)) - self._handleError(u'Error updating book thumbnail: ' + str(ex)) - self.app_db_session.rollback() - - def create_book_thumbnail(self, book, resolution): + def create_book_cover_thumbnail(self, book, resolution): thumbnail = ub.Thumbnail() thumbnail.book_id = book.id thumbnail.format = 'jpeg' @@ -137,6 +113,18 @@ class TaskGenerateCoverThumbnails(CalibreTask): self._handleError(u'Error creating book thumbnail: ' + str(ex)) self.app_db_session.rollback() + def update_book_cover_thumbnail(self, book, thumbnail): + thumbnail.generated_at = datetime.utcnow() + + try: + self.app_db_session.commit() + self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) + self.generate_book_thumbnail(book, thumbnail) + except Exception as ex: + self.log.info(u'Error updating book thumbnail: ' + str(ex)) + self._handleError(u'Error updating book thumbnail: ' + str(ex)) + self.app_db_session.rollback() + def generate_book_thumbnail(self, book, thumbnail): if book and thumbnail: if config.config_use_google_drive: @@ -190,128 +178,6 @@ class TaskGenerateCoverThumbnails(CalibreTask): return "ThumbnailsGenerate" -class TaskSyncCoverThumbnailCache(CalibreTask): - def __init__(self, task_message=u'Syncing cover thumbnail cache'): - super(TaskSyncCoverThumbnailCache, self).__init__(task_message) - self.log = logger.create() - self.app_db_session = ub.get_new_session_instance() - self.calibre_db = db.CalibreDB(expire_on_commit=False) - self.cache = fs.FileSystem() - - def run(self, worker_thread): - cached_thumbnail_files = self.cache.list_cache_files(fs.CACHE_TYPE_THUMBNAILS) - - # Expire thumbnails in the database if the cached file is missing - # This case will happen if a user deletes the cache dir or cached files - if self.app_db_session: - self.expire_missing_thumbnails(cached_thumbnail_files) - self.progress = 0.25 - - # Delete thumbnails in the database if the book has been removed - # This case will happen if a book is removed in Calibre and the metadata.db file is updated in the filesystem - if self.app_db_session and self.calibre_db: - book_ids = self.get_book_ids() - self.delete_thumbnails_for_missing_books(book_ids) - self.progress = 0.50 - - # Expire thumbnails in the database if their corresponding book has been updated since they were generated - # This case will happen if the book was updated externally - if self.app_db_session and self.cache: - books = self.get_books_updated_in_the_last_day() - book_ids = list(map(lambda b: b.id, books)) - thumbnails = self.get_thumbnails_for_updated_books(book_ids) - self.expire_thumbnails_for_updated_book(books, thumbnails) - self.progress = 0.75 - - # Delete extraneous cached thumbnail files - # This case will happen if a book was deleted and the thumbnail OR the metadata.db file was changed externally - if self.app_db_session: - db_thumbnail_files = self.get_thumbnail_filenames() - self.delete_extraneous_thumbnail_files(cached_thumbnail_files, db_thumbnail_files) - - self._handleSuccess() - self.app_db_session.remove() - - def expire_missing_thumbnails(self, filenames): - try: - self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.filename.notin_(filenames))\ - .update({"expiration": datetime.utcnow()}, synchronize_session=False) - self.app_db_session.commit() - except Exception as ex: - self.log.info(u'Error expiring thumbnails for missing cache files: ' + str(ex)) - self._handleError(u'Error expiring thumbnails for missing cache files: ' + str(ex)) - self.app_db_session.rollback() - - def get_book_ids(self): - results = self.calibre_db.session\ - .query(db.Books.id)\ - .filter(db.Books.has_cover == 1)\ - .distinct() - - return [value for value, in results] - - def delete_thumbnails_for_missing_books(self, book_ids): - try: - self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.book_id.notin_(book_ids))\ - .delete(synchronize_session=False) - self.app_db_session.commit() - except Exception as ex: - self.log.info(str(ex)) - self._handleError(u'Error deleting thumbnails for missing books: ' + str(ex)) - self.app_db_session.rollback() - - def get_thumbnail_filenames(self): - results = self.app_db_session\ - .query(ub.Thumbnail.filename)\ - .all() - - return [thumbnail for thumbnail, in results] - - def delete_extraneous_thumbnail_files(self, cached_thumbnail_files, db_thumbnail_files): - extraneous_files = list(set(cached_thumbnail_files).difference(db_thumbnail_files)) - for file in extraneous_files: - self.cache.delete_cache_file(file, fs.CACHE_TYPE_THUMBNAILS) - - def get_books_updated_in_the_last_day(self): - return self.calibre_db.session\ - .query(db.Books)\ - .filter(db.Books.has_cover == 1)\ - .filter(db.Books.last_modified > datetime.utcnow() - timedelta(days=1, hours=1))\ - .all() - - def get_thumbnails_for_updated_books(self, book_ids): - return self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.book_id.in_(book_ids))\ - .all() - - def expire_thumbnails_for_updated_book(self, books, thumbnails): - thumbnail_ids = list() - for book in books: - for thumbnail in thumbnails: - if thumbnail.book_id == book.id and thumbnail.generated_at < book.last_modified: - thumbnail_ids.append(thumbnail.id) - - try: - self.app_db_session\ - .query(ub.Thumbnail)\ - .filter(ub.Thumbnail.id.in_(thumbnail_ids)) \ - .update({"expiration": datetime.utcnow()}, synchronize_session=False) - self.app_db_session.commit() - except Exception as ex: - self.log.info(u'Error expiring thumbnails for updated books: ' + str(ex)) - self._handleError(u'Error expiring thumbnails for updated books: ' + str(ex)) - self.app_db_session.rollback() - - @property - def name(self): - return "ThumbnailsSync" - - class TaskClearCoverThumbnailCache(CalibreTask): def __init__(self, book_id=None, task_message=u'Clearing cover thumbnail cache'): super(TaskClearCoverThumbnailCache, self).__init__(task_message) @@ -325,9 +191,9 @@ class TaskClearCoverThumbnailCache(CalibreTask): if self.book_id: thumbnails = self.get_thumbnails_for_book(self.book_id) for thumbnail in thumbnails: - self.expire_and_delete_thumbnail(thumbnail) + self.delete_thumbnail(thumbnail) else: - self.expire_and_delete_all_thumbnails() + self.delete_all_thumbnails() self._handleSuccess() self.app_db_session.remove() @@ -338,29 +204,19 @@ class TaskClearCoverThumbnailCache(CalibreTask): .filter(ub.Thumbnail.book_id == book_id)\ .all() - def expire_and_delete_thumbnail(self, thumbnail): - thumbnail.expiration = datetime.utcnow() - + def delete_thumbnail(self, thumbnail): try: - self.app_db_session.commit() self.cache.delete_cache_file(thumbnail.filename, fs.CACHE_TYPE_THUMBNAILS) except Exception as ex: - self.log.info(u'Error expiring book thumbnail: ' + str(ex)) - self._handleError(u'Error expiring book thumbnail: ' + str(ex)) - self.app_db_session.rollback() - - def expire_and_delete_all_thumbnails(self): - self.app_db_session\ - .query(ub.Thumbnail)\ - .update({'expiration': datetime.utcnow()}) + self.log.info(u'Error deleting book thumbnail: ' + str(ex)) + self._handleError(u'Error deleting book thumbnail: ' + str(ex)) + def delete_all_thumbnails(self): try: - self.app_db_session.commit() self.cache.delete_cache_dir(fs.CACHE_TYPE_THUMBNAILS) except Exception as ex: - self.log.info(u'Error expiring book thumbnails: ' + str(ex)) - self._handleError(u'Error expiring book thumbnails: ' + str(ex)) - self.app_db_session.rollback() + self.log.info(u'Error deleting book thumbnails: ' + str(ex)) + self._handleError(u'Error deleting book thumbnails: ' + str(ex)) @property def name(self): diff --git a/cps/templates/author.html b/cps/templates/author.html index 3a4e6c57..2ff1ce7a 100644 --- a/cps/templates/author.html +++ b/cps/templates/author.html @@ -37,8 +37,7 @@