From b670fc6ce467eb80b154eb5ca039dec53bcf4ad2 Mon Sep 17 00:00:00 2001 From: crocodilestick <105450872+crocodilestick@users.noreply.github.com> Date: Sun, 14 Sep 2025 15:29:37 +0200 Subject: [PATCH] Improve CWA thumbnail system: always-active with enhanced UI and migration Enable thumbnail generation by default with always-active operation Add comprehensive progress tracking and CWA-style notifications for manual refresh Implement flat directory structure with deterministic WebP naming (book_ID_rRESOLUTION.webp) Create automatic migration system for legacy UUID/JPEG thumbnails Enhance on-demand generation for missing thumbnails during requests Fix notification styling consistency across all alert states (success/error/warning) Update admin UI to clarify scheduled vs manual thumbnail operations Persist thumbnail cache in /config/thumbnails for container stability This comprehensive enhancement transforms the thumbnail system from an optional, configuration-dependent feature into a robust, always-available core functionality with modern WebP compression, deterministic naming, and seamless migration for existing users. --- CONTRIBUTORS | 2 +- cps/admin.py | 35 +++++-- cps/config_sql.py | 3 +- cps/editbooks.py | 5 +- cps/fs.py | 36 +++++-- cps/helper.py | 76 ++++++++++++-- cps/schedule.py | 8 ++ cps/static/css/caliBlur.css | 9 +- cps/static/css/caliBlur_override.css | 16 +++ cps/static/js/main.js | 140 ++++++++++++++++++++++++++ cps/tasks/thumbnail.py | 79 ++++++++++++--- cps/tasks/thumbnail_migration.py | 145 +++++++++++++++++++++++++++ cps/templates/admin.html | 6 +- cps/templates/schedule_edit.html | 3 +- cps/ub.py | 36 ++++++- 15 files changed, 548 insertions(+), 51 deletions(-) create mode 100644 cps/tasks/thumbnail_migration.py diff --git a/CONTRIBUTORS b/CONTRIBUTORS index 9777ec7..7dc2aab 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -1,7 +1,7 @@ CONTRIBUTORS This file is automatically generated. DO NOT EDIT MANUALLY. -Generated on: 2025-09-14T10:33:48.851982Z +Generated on: 2025-09-14T13:29:39.223642Z Upstream project: https://github.com/janeczku/calibre-web Fork project (Calibre-Web Automated, since 2024): https://github.com/crocodilestick/calibre-web-automated diff --git a/cps/admin.py b/cps/admin.py index 5f7d6d3..c051083 100644 --- a/cps/admin.py +++ b/cps/admin.py @@ -18,7 +18,7 @@ from datetime import time as datetime_time from functools import wraps from urllib.parse import urlparse -from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response +from flask import Blueprint, flash, redirect, url_for, abort, request, make_response, send_from_directory, g, Response, jsonify from markupsafe import Markup from .cw_login import current_user from flask_babel import gettext as _ @@ -203,11 +203,34 @@ def reconnect(): @user_login_required @admin_required def update_thumbnails(): - content = config.get_scheduled_task_settings() - if content['schedule_generate_book_covers']: - log.info("Update of Cover cache requested") - helper.update_thumbnail_cache() - return "" + # Always allow manual thumbnail cache updates + log.info("Update of Cover cache requested") + + try: + from .tasks.thumbnail import TaskGenerateCoverThumbnails + task_id = helper.update_thumbnail_cache() + + # Check if there are any books to process + books_with_covers = TaskGenerateCoverThumbnails.get_books_with_covers() + book_count = len(books_with_covers) + + if book_count > 0: + message = _('Thumbnail cache refresh started for {} book(s). This may take a few minutes.').format(book_count) + else: + message = _('No books with covers found to process.') + + return jsonify({ + 'success': True, + 'message': message, + 'book_count': book_count, + 'task_id': str(task_id) if task_id else None + }) + except Exception as e: + log.error(f"Error starting thumbnail refresh: {e}") + return jsonify({ + 'success': False, + 'message': _('Failed to start thumbnail refresh: {}').format(str(e)) + }) def cwa_get_package_versions() -> tuple[str, str, str, str]: diff --git a/cps/config_sql.py b/cps/config_sql.py index 577a52b..f2ce7d9 100644 --- a/cps/config_sql.py +++ b/cps/config_sql.py @@ -147,7 +147,8 @@ class _Settings(_Base): schedule_start_time = Column(Integer, default=4) schedule_duration = Column(Integer, default=10) - schedule_generate_book_covers = Column(Boolean, default=False) + # Controls scheduled thumbnail refresh only - thumbnails are always generated on-demand regardless + schedule_generate_book_covers = Column(Boolean, default=True) schedule_generate_series_covers = Column(Boolean, default=False) schedule_reconnect = Column(Boolean, default=False) schedule_metadata_backup = Column(Boolean, default=False) diff --git a/cps/editbooks.py b/cps/editbooks.py index 8193de7..9a976f7 100644 --- a/cps/editbooks.py +++ b/cps/editbooks.py @@ -693,7 +693,8 @@ def do_edit_book(book_id, upload_formats=None): if result: book.has_cover = 1 modify_date = True - helper.replace_cover_thumbnail_cache(book.id) + # Trigger thumbnail generation after successful cover fetch + helper.trigger_thumbnail_generation_for_book(book.id) else: edit_error = True flash(error, category="error") @@ -1551,7 +1552,7 @@ def upload_cover(cover_request, book): if not current_user.role_upload(): flash(_("User has no rights to upload cover"), category="error") return False - ret, message = helper.save_cover(requested_file, book.path) + ret, message = helper.save_cover_with_thumbnail_update(requested_file, book.path, book.id) if ret is True: helper.replace_cover_thumbnail_cache(book.id) return True diff --git a/cps/fs.py b/cps/fs.py index ee9260b..335a1ac 100755 --- a/cps/fs.py +++ b/cps/fs.py @@ -6,7 +6,7 @@ # See CONTRIBUTORS for full list of authors. from . import logger -from .constants import CACHE_DIR +from .constants import CACHE_DIR, CONFIG_DIR, CACHE_TYPE_THUMBNAILS from os import makedirs, remove from os.path import isdir, isfile, join from shutil import rmtree @@ -23,24 +23,36 @@ class FileSystem: return cls._instance def get_cache_dir(self, cache_type=None): - if not isdir(self._cache_dir): + # Use /config/thumbnails for thumbnail cache to persist across container rebuilds + if cache_type == CACHE_TYPE_THUMBNAILS: + cache_dir = join(CONFIG_DIR, 'thumbnails') + else: + cache_dir = self._cache_dir + + if not isdir(cache_dir): try: - makedirs(self._cache_dir) + makedirs(cache_dir) except OSError: - self.log.info(f'Failed to create path {self._cache_dir} (Permission denied).') + self.log.info(f'Failed to create path {cache_dir} (Permission denied).') raise - path = join(self._cache_dir, cache_type) - if cache_type and not isdir(path): + path = join(cache_dir, cache_type) if cache_type and cache_type != CACHE_TYPE_THUMBNAILS else cache_dir + if cache_type and cache_type != CACHE_TYPE_THUMBNAILS and not isdir(path): try: makedirs(path) except OSError: self.log.info(f'Failed to create path {path} (Permission denied).') raise - return path if cache_type else self._cache_dir + return path if cache_type else cache_dir def get_cache_file_dir(self, filename, cache_type=None): + # For thumbnails with deterministic naming, store directly in cache dir + # instead of creating subdirectories based on first 2 characters + if cache_type == CACHE_TYPE_THUMBNAILS: + return self.get_cache_dir(cache_type) + + # For other cache types, maintain subdirectory structure path = join(self.get_cache_dir(cache_type), filename[:2]) if not isdir(path): try: @@ -66,7 +78,12 @@ class FileSystem: self.log.info(f'Failed to delete path {self._cache_dir} (Permission denied).') raise - path = join(self._cache_dir, cache_type) + # Handle special case for thumbnails stored in /config/thumbnails + if cache_type == CACHE_TYPE_THUMBNAILS: + path = join(CONFIG_DIR, 'thumbnails') + else: + path = join(self._cache_dir, cache_type) + if cache_type and isdir(path): try: rmtree(path) @@ -75,6 +92,9 @@ class FileSystem: raise def delete_cache_file(self, filename, cache_type=None): + # Skip if no filename provided (defensive guard) + if not filename: + return path = self.get_cache_file_path(filename, cache_type) if isfile(path): try: diff --git a/cps/helper.py b/cps/helper.py index 4983650..a5580ee 100755 --- a/cps/helper.py +++ b/cps/helper.py @@ -768,6 +768,11 @@ def get_book_cover_with_uuid(book_uuid, resolution=None): def get_book_cover_internal(book, resolution=None): + """Serve book cover with improved thumbnail generation fallback. + + When a thumbnail is requested but missing, generate it synchronously + instead of falling back to the original cover.jpg. + """ if book and book.has_cover: # Send the book cover thumbnail if it exists in cache @@ -778,6 +783,30 @@ def get_book_cover_internal(book, resolution=None): if cache.get_cache_file_exists(thumbnail.filename, CACHE_TYPE_THUMBNAILS): return send_from_directory(cache.get_cache_file_dir(thumbnail.filename, CACHE_TYPE_THUMBNAILS), thumbnail.filename) + + # Try to generate missing thumbnail on-demand + try: + from .tasks.thumbnail import TaskGenerateCoverThumbnails + from . import use_IM + + # Only generate if ImageMagick is available + if use_IM: + # Create thumbnail generation task for this specific book + thumbnail_task = TaskGenerateCoverThumbnails(book_id=book.id) + # Generate the specific resolution needed + generated = thumbnail_task.create_book_cover_thumbnails(book) + + if generated > 0: + # Try to serve the newly generated thumbnail + thumbnail = get_book_cover_thumbnail(book, resolution) + if thumbnail: + cache = fs.FileSystem() + if cache.get_cache_file_exists(thumbnail.filename, CACHE_TYPE_THUMBNAILS): + return send_from_directory(cache.get_cache_file_dir(thumbnail.filename, CACHE_TYPE_THUMBNAILS), + thumbnail.filename) + except Exception as ex: + # Log the error but don't fail completely + log.debug(f'Failed to generate thumbnail on-demand for book {book.id}: {ex}') # Send the book cover from Google Drive if configured if config.config_use_google_drive: @@ -954,6 +983,32 @@ def save_cover(img, book_path): return save_cover_from_filestorage(os.path.join(config.get_book_path(), book_path), "cover.jpg", img) +def trigger_thumbnail_generation_for_book(book_id): + """Trigger thumbnail generation for a book after cover changes.""" + try: + from .tasks.thumbnail import TaskGenerateCoverThumbnails + from . import use_IM, WorkerThread + + if use_IM: + # Queue thumbnail generation task + thumbnail_task = TaskGenerateCoverThumbnails(book_id=book_id, task_message="Generating thumbnails after cover update") + WorkerThread.add(current_user.name, thumbnail_task, hidden=True) + log.debug(f'Queued thumbnail generation for book {book_id}') + except Exception as ex: + log.debug(f'Failed to queue thumbnail generation for book {book_id}: {ex}') + + +def save_cover_with_thumbnail_update(img, book_path, book_id=None): + """Save cover and trigger thumbnail generation.""" + result, message = save_cover(img, book_path) + + # If cover save was successful and we have a book_id, generate thumbnails + if result and book_id: + trigger_thumbnail_generation_for_book(book_id) + + return result, message + + def do_download_file(book, book_format, client, data, headers): book_name = data.name download_name = filename = None @@ -1141,14 +1196,14 @@ def get_download_link(book_id, book_format, client): def clear_cover_thumbnail_cache(book_id): - if config.schedule_generate_book_covers: - WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True) + # Always allow clearing thumbnail cache + WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True) def replace_cover_thumbnail_cache(book_id): - if config.schedule_generate_book_covers: - WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True) - WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True) + # Always allow replacing thumbnail cache + WorkerThread.add(None, TaskClearCoverThumbnailCache(book_id), hidden=True) + WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True) def delete_thumbnail_cache(): @@ -1156,13 +1211,16 @@ def delete_thumbnail_cache(): def add_book_to_thumbnail_cache(book_id): - if config.schedule_generate_book_covers: - WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True) + # Always generate thumbnails for new books + WorkerThread.add(None, TaskGenerateCoverThumbnails(book_id), hidden=True) def update_thumbnail_cache(): - if config.schedule_generate_book_covers: - WorkerThread.add(None, TaskGenerateCoverThumbnails()) + # Always allow manual thumbnail cache updates + task = TaskGenerateCoverThumbnails() + WorkerThread.add(None, task) + # Return task ID for tracking + return task.id def set_all_metadata_dirty(): diff --git a/cps/schedule.py b/cps/schedule.py index 99ca9c0..2652ef5 100755 --- a/cps/schedule.py +++ b/cps/schedule.py @@ -12,6 +12,7 @@ from .services.background_scheduler import BackgroundScheduler, CronTrigger, use from .tasks.database import TaskReconnectDatabase from .tasks.clean import TaskClean from .tasks.thumbnail import TaskGenerateCoverThumbnails, TaskGenerateSeriesThumbnails, TaskClearCoverThumbnailCache +from .tasks.thumbnail_migration import check_and_migrate_thumbnails from .services.worker import WorkerThread from .tasks.metadata_backup import TaskBackupMetadata @@ -78,6 +79,13 @@ def register_startup_tasks(): start = config.schedule_start_time duration = config.schedule_duration + # Run thumbnail migration on startup (one-time operation) + try: + check_and_migrate_thumbnails() + except Exception as ex: + # Don't let migration failures stop the application + pass + # Run scheduled tasks immediately for development and testing # Ignore tasks that should currently be running, as these will be added when registering scheduled tasks if constants.APP_MODE in ['development', 'test'] and not should_task_be_running(start, duration): diff --git a/cps/static/css/caliBlur.css b/cps/static/css/caliBlur.css index c8bd041..f4a36bd 100644 --- a/cps/static/css/caliBlur.css +++ b/cps/static/css/caliBlur.css @@ -1216,7 +1216,9 @@ body > div.navbar.navbar-default.navbar-static-top > div > div.navbar-collapse.c body > div.navbar.navbar-default.navbar-static-top > div > div.navbar-collapse.collapse > ul > li > a { height: 60px; padding: 20px 10px; - color: hsla(0, 0%, 100%, .7) + color: hsla(0, 0%, 100%, .7); + display: flex; + align-items: center; } body > div.navbar.navbar-default.navbar-static-top > div > div.navbar-collapse.collapse > ul > li > #top_admin > .glyphicon-dashboard::before { @@ -2391,7 +2393,10 @@ body > div.row-fluid { animation: none } -.alert-info.refresh-cwa { +.alert-info.refresh-cwa, +.alert-success.refresh-cwa, +.alert-warning.refresh-cwa, +.alert-danger.refresh-cwa { position: fixed; top: auto; bottom: 20px; diff --git a/cps/static/css/caliBlur_override.css b/cps/static/css/caliBlur_override.css index f94e7a5..8ae481d 100755 --- a/cps/static/css/caliBlur_override.css +++ b/cps/static/css/caliBlur_override.css @@ -374,4 +374,20 @@ button.close > span { padding-top: 4rem !important; max-width: none !important; width: 36rem !important; +} + +div#GeneralChangeModal > div > div.modal-content { + top: 200px; + width: max-content; + max-width: 400px; + padding: 2rem; + justify-self: center; +} + +.modal-content > .modal-header.bg-info.text-center { + display: none; +} + +a#advanced_search > span.hidden-sm { + margin-left: 4px; } \ No newline at end of file diff --git a/cps/static/js/main.js b/cps/static/js/main.js index 174eea3..7ad5cfc 100755 --- a/cps/static/js/main.js +++ b/cps/static/js/main.js @@ -508,15 +508,155 @@ $(function() { }); $("#admin_refresh_cover_cache").click(function() { confirmDialog("admin_refresh_cover_cache", "GeneralChangeModal", 0, function () { + // Show loading state + $("#admin_refresh_cover_cache").prop('disabled', true).text('Starting...'); + + // Remove any existing thumbnail notifications + $("#thumbnail_progress_notification").remove(); + $.ajax({ method:"post", contentType: "application/json; charset=utf-8", dataType: "json", url: getPath() + "/ajax/updateThumbnails", + success: function(response) { + if (response.success && response.book_count > 0 && response.task_id) { + // Show initial message with CWA-style notification + var notificationHtml = + '