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:
crocodilestick
2025-09-14 15:29:37 +02:00
parent cefaec9a5a
commit b670fc6ce4
15 changed files with 548 additions and 51 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+28 -8
View File
@@ -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
View File
@@ -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():
+8
View File
@@ -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):
+7 -2
View File
@@ -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;
+16
View File
@@ -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;
}
+140
View File
@@ -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">&times;</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">&times;</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">&times;</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
View File
@@ -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
+145
View File
@@ -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}")
+2 -4
View File
@@ -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 %}
+2 -1
View File
@@ -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 %}>
+33 -3
View File
@@ -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):