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.
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
+29
-6
@@ -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]:
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
+67
-9
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 =
|
||||
'<div class="row-fluid text-center">' +
|
||||
'<div id="thumbnail_progress_notification" class="alert alert-info refresh-cwa">' +
|
||||
'<span id="thumbnail_message">' + response.message + '</span>' +
|
||||
'<br><span id="thumbnail_progress_status">Starting...</span>' +
|
||||
'<button type="button" class="close" data-dismiss="alert" aria-label="Close">' +
|
||||
'<span aria-hidden="true">×</span>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
// 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 =
|
||||
'<div class="row-fluid text-center">' +
|
||||
'<div id="thumbnail_progress_notification" class="alert ' + alertClass + ' refresh-cwa">' +
|
||||
response.message +
|
||||
'<button type="button" class="close" data-dismiss="alert" aria-label="Close">' +
|
||||
'<span aria-hidden="true">×</span>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
$(".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 =
|
||||
'<div class="row-fluid text-center">' +
|
||||
'<div id="thumbnail_progress_notification" class="alert alert-danger">' +
|
||||
errorMsg +
|
||||
'<button type="button" class="close" data-dismiss="alert" aria-label="Close">' +
|
||||
'<span aria-hidden="true">×</span>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
|
||||
$(".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");
|
||||
|
||||
+65
-14
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
@@ -178,7 +178,7 @@
|
||||
<div class="col-xs-6 col-sm-3">{{schedule_duration}}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-6 col-sm-3">{{_('Generate Thumbnails')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{_('Scheduled Thumbnail Refresh')}}</div>
|
||||
<div class="col-xs-6 col-sm-3">{{ display_bool_setting(config.schedule_generate_book_covers) }}</div>
|
||||
</div>
|
||||
<!--div class="row">
|
||||
@@ -196,9 +196,7 @@
|
||||
|
||||
</div>
|
||||
<a class="btn btn-default scheduledtasks" id="admin_edit_scheduled_tasks" href="{{url_for('admin.edit_scheduledtasks')}}">{{_('Edit Scheduled Tasks Settings')}}</a>
|
||||
{% if config.schedule_generate_book_covers %}
|
||||
<a class="btn btn-default" id="admin_refresh_cover_cache">{{_('Refresh Thumbnail Cache')}}</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-default" id="admin_refresh_cover_cache">{{_('Refresh Thumbnail Cache')}}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="checkbox" id="schedule_generate_book_covers" name="schedule_generate_book_covers" {% if config.schedule_generate_book_covers %}checked{% endif %}>
|
||||
<label for="schedule_generate_book_covers">{{_('Generate Thumbnails')}}</label>
|
||||
<label for="schedule_generate_book_covers">{{_('Scheduled Thumbnail Refresh')}}</label>
|
||||
<small class="form-text text-muted">{{_('Automatically refresh all thumbnails during scheduled maintenance. Thumbnails are always generated on-demand regardless of this setting.')}}</small>
|
||||
</div>
|
||||
<!--div class="form-group">
|
||||
<input type="checkbox" id="schedule_generate_series_covers" name="schedule_generate_series_covers" {% if config.schedule_generate_series_covers %}checked{% endif %}>
|
||||
|
||||
@@ -569,11 +569,41 @@ class RemoteAuthToken(Base):
|
||||
|
||||
|
||||
def filename(context):
|
||||
file_format = context.get_current_parameters()['format']
|
||||
"""Generate deterministic filename for thumbnails.
|
||||
|
||||
Prefer the pattern:
|
||||
cover thumbnails: book_<entity_id>_r<resolution>.<ext>
|
||||
series thumbnails: series_<entity_id>_r<resolution>.<ext>
|
||||
|
||||
Fallback to legacy uuid-based naming if required fields are missing.
|
||||
This keeps previously generated files valid while making new ones easier
|
||||
to reason about and purge selectively.
|
||||
"""
|
||||
params = context.get_current_parameters()
|
||||
file_format = params.get('format', 'jpeg')
|
||||
entity_id = params.get('entity_id')
|
||||
resolution = params.get('resolution')
|
||||
thumb_type = params.get('type') # cover or series
|
||||
uuid_val = params.get('uuid')
|
||||
|
||||
# map format 'jpeg' -> extension jpg
|
||||
if file_format == 'jpeg':
|
||||
return context.get_current_parameters()['uuid'] + '.jpg'
|
||||
ext = 'jpg'
|
||||
else:
|
||||
return context.get_current_parameters()['uuid'] + '.' + file_format
|
||||
ext = file_format
|
||||
|
||||
try:
|
||||
if entity_id is not None and resolution is not None and thumb_type is not None:
|
||||
if thumb_type == constants.THUMBNAIL_TYPE_COVER:
|
||||
return f"book_{entity_id}_r{resolution}.{ext}"
|
||||
elif thumb_type == constants.THUMBNAIL_TYPE_SERIES:
|
||||
return f"series_{entity_id}_r{resolution}.{ext}"
|
||||
except Exception:
|
||||
# fall back to uuid naming if anything unexpected occurs
|
||||
pass
|
||||
|
||||
# legacy fallback
|
||||
return f"{uuid_val}.{ext}" if uuid_val else f"legacy_unknown.{ext}"
|
||||
|
||||
|
||||
class Thumbnail(Base):
|
||||
|
||||
Reference in New Issue
Block a user