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 = + '
' + + '
' + + '' + response.message + '' + + '
Starting...' + + '' + + '
' + + '
'; + + // Insert after the navbar + $(".navbar").after(notificationHtml); + + // Start polling for completion + pollTaskCompletion(response.task_id); + } else { + // Re-enable button immediately if no work to do + $("#admin_refresh_cover_cache").prop('disabled', false).text('Refresh Thumbnail Cache'); + + // Show simple notification for no work case + var alertClass = response.book_count > 0 ? 'alert-info' : 'alert-warning'; + var notificationHtml = + '
' + + '
' + + response.message + + '' + + '
' + + '
'; + + $(".navbar").after(notificationHtml); + } + }, + error: function(xhr, status, error) { + // Re-enable button + $("#admin_refresh_cover_cache").prop('disabled', false).text('Refresh Thumbnail Cache'); + + // Show error message + var errorMsg = xhr.responseJSON && xhr.responseJSON.message ? + xhr.responseJSON.message : + 'Failed to start thumbnail cache refresh: ' + error; + + var notificationHtml = + '
' + + '
' + + errorMsg + + '' + + '
' + + '
'; + + $(".navbar").after(notificationHtml); + } }); }); }); + // Function to poll task completion + function pollTaskCompletion(taskId) { + var pollInterval = setInterval(function() { + $.ajax({ + method: "get", + url: getPath() + "/ajax/emailstat", + dataType: "json", + success: function(tasks) { + var thumbnailTask = tasks.find(function(task) { + return task.task_id === taskId; + }); + + if (thumbnailTask) { + // Update progress + var progressText = thumbnailTask.progress + " - " + thumbnailTask.status; + $("#thumbnail_progress_status").text(progressText); + + // Check if task is finished + if (thumbnailTask.stat === 3) { // STAT_FINISH_SUCCESS + clearInterval(pollInterval); + $("#admin_refresh_cover_cache").prop('disabled', false).text('Refresh Thumbnail Cache'); + + // Update notification to success style but keep refresh-cwa class + $("#thumbnail_progress_notification") + .removeClass('alert-info alert-danger alert-warning alert-success') + .addClass('alert-success refresh-cwa'); + $("#thumbnail_message").text('✅ Thumbnail cache refresh completed successfully!'); + $("#thumbnail_progress_status").text('All thumbnails generated'); + + } else if (thumbnailTask.stat === 1) { // STAT_FAIL + clearInterval(pollInterval); + $("#admin_refresh_cover_cache").prop('disabled', false).text('Refresh Thumbnail Cache'); + + // Update notification to error style but keep refresh-cwa class + $("#thumbnail_progress_notification") + .removeClass('alert-info alert-success alert-warning alert-danger') + .addClass('alert-danger refresh-cwa'); + $("#thumbnail_message").text('❌ Thumbnail cache refresh failed'); + $("#thumbnail_progress_status").text('Error: ' + (thumbnailTask.error || 'Unknown error')); + } + } else { + // Task not found - might be completed and cleaned up + clearInterval(pollInterval); + $("#admin_refresh_cover_cache").prop('disabled', false).text('Refresh Thumbnail Cache'); + + // Update notification to success style but keep refresh-cwa class + $("#thumbnail_progress_notification") + .removeClass('alert-info alert-danger alert-warning alert-success') + .addClass('alert-success refresh-cwa'); + $("#thumbnail_message").text('✅ Thumbnail cache refresh completed!'); + $("#thumbnail_progress_status").text('Task finished'); + } + }, + error: function() { + // If we can't check status, assume completion after some time + clearInterval(pollInterval); + $("#admin_refresh_cover_cache").prop('disabled', false).text('Refresh Thumbnail Cache'); + + // Update with warning about status check failure but keep refresh-cwa class + $("#thumbnail_progress_notification") + .removeClass('alert-info alert-danger alert-success alert-warning') + .addClass('alert-warning refresh-cwa'); + $("#thumbnail_message").text('⚠️ Status check failed'); + $("#thumbnail_progress_status").text('Task may have completed - check manually'); + } + }); + }, 2000); // Poll every 2 seconds + + // Stop polling after 10 minutes to prevent infinite polling + setTimeout(function() { + clearInterval(pollInterval); + $("#admin_refresh_cover_cache").prop('disabled', false).text('Refresh Thumbnail Cache'); + }, 600000); + } + $("#restart_database").click(function() { $("#DialogHeader").addClass("hidden"); $("#DialogFinished").addClass("hidden"); diff --git a/cps/tasks/thumbnail.py b/cps/tasks/thumbnail.py index 240234c..d710861 100755 --- a/cps/tasks/thumbnail.py +++ b/cps/tasks/thumbnail.py @@ -126,22 +126,51 @@ class TaskGenerateCoverThumbnails(CalibreTask): generated += 1 self.create_book_cover_single_thumbnail(book, resolution) - # Replace outdated or missing thumbnails + # Replace outdated, legacy, format-mismatch, or missing thumbnails for thumbnail in book_cover_thumbnails: - if book.last_modified.replace(tzinfo=None) > thumbnail.generated_at: - generated += 1 - self.update_book_cover_thumbnail(book, thumbnail) + try: + legacy_naming = not (thumbnail.filename.startswith('book_') or thumbnail.filename.startswith('series_')) + wrong_format = (thumbnail.format.lower() != 'webp') + file_missing = not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS) + source_newer = book.last_modified.replace(tzinfo=None) > thumbnail.generated_at - elif not self.cache.get_cache_file_exists(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS): - generated += 1 - self.update_book_cover_thumbnail(book, thumbnail) + # If any legacy condition matched, migrate: delete old file & regenerate with deterministic name + if legacy_naming or wrong_format: + # update db fields to new format; filename is property default only on insert so we recreate row + # Simplest safe path: delete & recreate row to ensure deterministic filename logic executes + old_id = thumbnail.id + old_filename = thumbnail.filename + self.app_db_session.delete(thumbnail) + self.app_db_session.commit() + new_thumb = ub.Thumbnail() + new_thumb.type = constants.THUMBNAIL_TYPE_COVER + new_thumb.entity_id = book.id + new_thumb.format = 'webp' + new_thumb.resolution = thumbnail.resolution + self.app_db_session.add(new_thumb) + self.app_db_session.commit() + # remove old file if still present + try: + self.cache.delete_cache_file(old_filename, constants.CACHE_TYPE_THUMBNAILS) + except Exception: + pass + self.generate_book_thumbnail(book, new_thumb) + generated += 1 + continue + + if source_newer or file_missing: + generated += 1 + self.update_book_cover_thumbnail(book, thumbnail) + except Exception as ex: + self.log.debug(f"Thumbnail migration/update issue for book {book.id}: {ex}") return generated def create_book_cover_single_thumbnail(self, book, resolution): thumbnail = ub.Thumbnail() thumbnail.type = constants.THUMBNAIL_TYPE_COVER thumbnail.entity_id = book.id - thumbnail.format = 'jpeg' + # Store thumbnails as WebP for better compression + thumbnail.format = 'webp' thumbnail.resolution = resolution self.app_db_session.add(thumbnail) @@ -184,11 +213,19 @@ class TaskGenerateCoverThumbnails(CalibreTask): width = get_resize_width(thumbnail.resolution, img.width, img.height) img.resize(width=width, height=height, filter='lanczos') img.format = thumbnail.format + try: + img.compression_quality = 82 + except Exception: + pass img.save(filename=filename) else: - stream.seek(0) - with open(filename, 'wb') as fd: - copyfileobj(stream, fd) + # Even if no resizing needed, convert to WebP format + img.format = thumbnail.format + try: + img.compression_quality = 82 + except Exception: + pass + img.save(filename=filename) except Exception as ex: @@ -210,10 +247,19 @@ class TaskGenerateCoverThumbnails(CalibreTask): width = get_resize_width(thumbnail.resolution, img.width, img.height) img.resize(width=width, height=height, filter='lanczos') img.format = thumbnail.format + try: + img.compression_quality = 82 + except Exception: + pass img.save(filename=filename) else: - # take cover as is - copyfile(book_cover_filepath, filename) + # Even if no resizing needed, convert to WebP format + img.format = thumbnail.format + try: + img.compression_quality = 82 + except Exception: + pass + img.save(filename=filename) @property def name(self): @@ -324,7 +370,8 @@ class TaskGenerateSeriesThumbnails(CalibreTask): thumbnail = ub.Thumbnail() thumbnail.type = constants.THUMBNAIL_TYPE_SERIES thumbnail.entity_id = series.id - thumbnail.format = 'jpeg' + # Store series thumbnails as WebP as well + thumbnail.format = 'webp' thumbnail.resolution = resolution self.app_db_session.add(thumbnail) @@ -426,6 +473,10 @@ class TaskGenerateSeriesThumbnails(CalibreTask): canvas.format = thumbnail.format filename = self.cache.get_cache_file_path(thumbnail.filename, constants.CACHE_TYPE_THUMBNAILS) + try: + canvas.compression_quality = 80 + except Exception: + pass canvas.save(filename=filename) @property diff --git a/cps/tasks/thumbnail_migration.py b/cps/tasks/thumbnail_migration.py new file mode 100644 index 0000000..a3fe46f --- /dev/null +++ b/cps/tasks/thumbnail_migration.py @@ -0,0 +1,145 @@ +# -*- coding: utf-8 -*- +# Calibre-Web Automated – fork of Calibre-Web +# Copyright (C) 2018-2025 Calibre-Web contributors +# Copyright (C) 2024-2025 Calibre-Web Automated contributors +# SPDX-License-Identifier: GPL-3.0-or-later +# See CONTRIBUTORS for full list of authors. + +import os +import shutil +from .. import logger, ub, fs, config_sql +from ..constants import CACHE_TYPE_THUMBNAILS + +log = logger.create() + +MIGRATION_VERSION_KEY = "thumbnail_flat_structure_migration" +MIGRATION_VERSION = "v1.0" + +def get_migration_status(): + """Check if the thumbnail migration has already been completed.""" + try: + session = ub.get_new_session_instance() + try: + # Check if migration marker exists in settings + setting = session.query(ub.Settings).filter( + ub.Settings.mail_server == MIGRATION_VERSION_KEY + ).first() + return setting.mail_server_type == MIGRATION_VERSION if setting else False + finally: + session.close() + except Exception: + return False + +def set_migration_completed(): + """Mark the thumbnail migration as completed.""" + try: + session = ub.get_new_session_instance() + try: + # Store migration marker in settings table + setting = session.query(ub.Settings).filter( + ub.Settings.mail_server == MIGRATION_VERSION_KEY + ).first() + + if not setting: + setting = ub.Settings() + setting.mail_server = MIGRATION_VERSION_KEY + session.add(setting) + + setting.mail_server_type = MIGRATION_VERSION + session.commit() + except Exception as ex: + log.error(f"Failed to mark migration as completed: {ex}") + session.rollback() + finally: + session.close() + except Exception as ex: + log.error(f"Failed to access database for migration marker: {ex}") + +def migrate_thumbnail_structure(): + """ + One-time migration for existing CWA installations to move from + subdirectory-based thumbnail storage to flat directory structure. + + This will: + 1. Clear all existing thumbnail database entries + 2. Remove old subdirectory structure + 3. Trigger regeneration of all thumbnails in new format + """ + try: + cache = fs.FileSystem() + thumbnails_dir = cache.get_cache_dir(CACHE_TYPE_THUMBNAILS) + + # Check if migration is needed (look for old subdirectories) + migration_needed = False + subdirs_found = [] + + if os.path.exists(thumbnails_dir): + for item in os.listdir(thumbnails_dir): + item_path = os.path.join(thumbnails_dir, item) + # Look for hex subdirectories (00, 01, ..., ff, bo, etc.) + if (os.path.isdir(item_path) and + len(item) == 2 and + item not in ['.', '..']): + subdirs_found.append(item) + migration_needed = True + + if not migration_needed: + log.info("Thumbnail migration: No old subdirectories found, skipping migration") + return + + log.info(f"Thumbnail migration: Found {len(subdirs_found)} old subdirectories, starting migration") + + # Clear all thumbnail database entries + session = ub.get_new_session_instance() + try: + deleted_count = session.query(ub.Thumbnail).delete() + session.commit() + log.info(f"Thumbnail migration: Cleared {deleted_count} old database entries") + except Exception as ex: + log.error(f"Thumbnail migration: Failed to clear database entries: {ex}") + session.rollback() + finally: + session.close() + + # Remove old files and subdirectories + files_removed = 0 + dirs_removed = 0 + + for subdir in subdirs_found: + subdir_path = os.path.join(thumbnails_dir, subdir) + try: + if os.path.exists(subdir_path): + # Count files before removal + for root, dirs, files in os.walk(subdir_path): + files_removed += len(files) + + # Remove the entire subdirectory + shutil.rmtree(subdir_path) + dirs_removed += 1 + log.debug(f"Thumbnail migration: Removed subdirectory {subdir}") + except Exception as ex: + log.warning(f"Thumbnail migration: Failed to remove {subdir_path}: {ex}") + + log.info(f"Thumbnail migration: Removed {files_removed} old files and {dirs_removed} subdirectories") + log.info("Thumbnail migration: Complete. Thumbnails will be regenerated automatically as needed.") + + # Mark migration as completed + set_migration_completed() + + except Exception as ex: + log.error(f"Thumbnail migration: Failed with error: {ex}") + +def check_and_migrate_thumbnails(): + """ + Check if thumbnail migration is needed and run it if so. + This should be called during application startup. + """ + try: + # Skip if already migrated + if get_migration_status(): + log.debug("Thumbnail migration: Already completed, skipping") + return + + migrate_thumbnail_structure() + except Exception as ex: + log.error(f"Thumbnail migration check failed: {ex}") \ No newline at end of file diff --git a/cps/templates/admin.html b/cps/templates/admin.html index 44d8aeb..0738162 100644 --- a/cps/templates/admin.html +++ b/cps/templates/admin.html @@ -178,7 +178,7 @@
{{schedule_duration}}
-
{{_('Generate Thumbnails')}}
+
{{_('Scheduled Thumbnail Refresh')}}
{{ display_bool_setting(config.schedule_generate_book_covers) }}