Files
crocodilestick e2b9fc8d7d Optimize thumbnail generation for large libraries
- Move thumbnail generation to background queue to prevent UI blocking
- Add pending set tracking to prevent duplicate task creation
- Implement try/finally blocks to guarantee cleanup on all exit paths
- Protect all WorkerThread.add() calls with exception handling
- Fix migration to preserve existing thumbnails (lazy regeneration)
- Ensure cover changes force thumbnail regeneration via replace_cover_thumbnail_cache()
- Add comprehensive error handling for queue failures and task cancellation
- Replace generic_cover.jpg with generic_cover.svg throughout codebase

This resolves performance issues reported in Discord where users with large libraries
experienced 20-60 second page load times during thumbnail generation. Pages now load
instantly with thumbnails generating asynchronously in the background.

Fixes memory leaks from stuck pending set entries and ensures proper state management
across book deletion, cover updates, and task failures.
2026-01-05 16:42:05 +01:00

167 lines
6.0 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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 concurrent.futures
import importlib
import inspect
import json
import os
import sys
from flask import Blueprint, request, url_for, make_response, jsonify, copy_current_request_context
from .cw_login import current_user
from flask_babel import get_locale
from sqlalchemy.exc import InvalidRequestError, OperationalError
from sqlalchemy.orm.attributes import flag_modified
from cps.services.Metadata import Metadata
from . import constants, logger, ub, web_server
from .usermanagement import user_login_required
meta = Blueprint("metadata", __name__)
log = logger.create()
try:
from dataclasses import asdict
except ImportError:
log.info('*** "dataclasses" is needed for calibre-web automated to run. Please install it using pip: "pip install dataclasses" ***')
print('*** "dataclasses" is needed for calibre-web automated to run. Please install it using pip: "pip install dataclasses" ***')
web_server.stop(True)
sys.exit(6)
new_list = list()
meta_dir = os.path.join(constants.BASE_DIR, "cps", "metadata_provider")
modules = os.listdir(os.path.join(constants.BASE_DIR, "cps", "metadata_provider"))
for f in modules:
if os.path.isfile(os.path.join(meta_dir, f)) and not f.endswith("__init__.py"):
a = os.path.basename(f)[:-3]
try:
importlib.import_module("cps.metadata_provider." + a)
new_list.append(a)
except (IndentationError, SyntaxError) as e:
log.error("Syntax error for metadata source: {} - {}".format(a, e))
except ImportError as e:
log.debug("Import error for metadata source: {} - {}".format(a, e))
def list_classes(provider_list):
classes = list()
for element in provider_list:
for name, obj in inspect.getmembers(
sys.modules["cps.metadata_provider." + element]
):
if (
inspect.isclass(obj)
and name != "Metadata"
and issubclass(obj, Metadata)
):
classes.append(obj())
return classes
cl = list_classes(new_list)
# Alphabetises the list of Metadata providers
cl.sort(key=lambda x: x.__class__.__name__)
# Helper to load global provider enablement map from CWA settings
def _get_global_provider_enabled_map() -> dict:
try:
# Import here to avoid circular import issues and keep startup fast
sys.path.insert(1, '/app/calibre-web-automated/scripts/')
from cwa_db import CWA_DB # type: ignore
cwa_db = CWA_DB()
settings = cwa_db.get_cwa_settings()
if not settings:
log.warning("Could not get CWA settings for provider enabled map")
return {}
from cps.cwa_functions import parse_metadata_providers_enabled
return parse_metadata_providers_enabled(
settings.get('metadata_providers_enabled', '{}')
)
except Exception as e:
# On any failure, treat as all enabled (empty dict = all default to enabled)
log.warning(f"Error loading provider enabled map: {e}")
return {}
# Remove redundant return
@meta.route("/metadata/provider")
@user_login_required
def metadata_provider():
active = current_user.view_settings.get("metadata", {})
global_enabled = _get_global_provider_enabled_map()
provider = list()
for c in cl:
ac = active.get(c.__id__, True)
provider.append(
{
"name": c.__name__,
"active": ac,
"initial": ac,
"id": c.__id__,
"globally_enabled": bool(global_enabled.get(c.__id__, True)),
}
)
return make_response(jsonify(provider))
@meta.route("/metadata/provider", methods=["POST"])
@meta.route("/metadata/provider/<prov_name>", methods=["POST"])
@user_login_required
def metadata_change_active_provider(prov_name):
new_state = request.get_json()
active = current_user.view_settings.get("metadata", {})
active[new_state["id"]] = new_state["value"]
current_user.view_settings["metadata"] = active
try:
try:
flag_modified(current_user, "view_settings")
except AttributeError:
pass
ub.session.commit()
except (InvalidRequestError, OperationalError):
log.error("Invalid request received: {}".format(request))
return "Invalid request", 400
if "initial" in new_state and prov_name:
data = []
provider = next((c for c in cl if c.__id__ == prov_name), None)
# Respect global disablement for preview search as well
global_enabled = _get_global_provider_enabled_map()
if provider is not None:
if bool(global_enabled.get(provider.__id__, True)):
data = provider.search(new_state.get("query", ""))
else:
data = []
return make_response(jsonify([asdict(x) for x in data]))
return ""
@meta.route("/metadata/search", methods=["POST"])
@user_login_required
def metadata_search():
query = request.form.to_dict().get("query")
data = list()
active = current_user.view_settings.get("metadata", {})
locale = get_locale()
global_enabled = _get_global_provider_enabled_map()
if query:
static_cover = url_for("static", filename="generic_cover.svg")
# ret = cl[0].search(query, static_cover, locale)
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
meta = {
executor.submit(copy_current_request_context(c.search), query, static_cover, locale): c
for c in cl
if active.get(c.__id__, True) and bool(global_enabled.get(c.__id__, True))
}
for future in concurrent.futures.as_completed(meta):
data.extend([asdict(x) for x in future.result() if x])
return make_response(jsonify(data))