From 3ae5173db8ae0d294d66f6c51ae9d77bf36130f7 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:49:31 +0100 Subject: [PATCH] Dropdown fixes --- pyproject.toml | 1 + shelfmark/core/image_cache.py | 161 +++++++++++++ shelfmark/main.py | 90 ++++++-- .../src/components/BookTargetDropdown.tsx | 1 + .../src/components/ConfigSetupBanner.tsx | 2 +- src/frontend/src/components/DetailsModal.tsx | 12 +- src/frontend/src/components/Dropdown.tsx | 216 +++++++++++------- src/frontend/src/components/DropdownList.tsx | 3 + src/frontend/src/components/Header.tsx | 2 +- .../components/OnBehalfConfirmationModal.tsx | 2 +- .../src/components/OnboardingModal.tsx | 6 +- src/frontend/src/components/ReleaseModal.tsx | 34 ++- .../components/RequestConfirmationModal.tsx | 12 +- .../src/components/activity/ActivityCard.tsx | 10 +- .../src/components/resultsViews/CardView.tsx | 20 +- .../components/resultsViews/CompactView.tsx | 20 +- .../src/components/resultsViews/ListView.tsx | 14 +- .../components/settings/SelfSettingsModal.tsx | 2 +- .../src/components/settings/SettingsModal.tsx | 6 +- src/frontend/src/styles.css | 12 +- src/frontend/src/tests/covers.test.ts | 33 +++ src/frontend/src/utils/covers.ts | 66 ++++++ tests/core/test_image_cache.py | 74 +++++- uv.lock | 35 +++ 24 files changed, 682 insertions(+), 152 deletions(-) create mode 100644 src/frontend/src/tests/covers.test.ts create mode 100644 src/frontend/src/utils/covers.ts diff --git a/pyproject.toml b/pyproject.toml index 502d9e23..4eb07be8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "transmission-rpc", "authlib>=1.6.6,<1.7", "apprise>=1.9.0", + "Pillow>=11.0.0", ] [project.optional-dependencies] diff --git a/shelfmark/core/image_cache.py b/shelfmark/core/image_cache.py index 1aeb0fdb..d9b481c7 100644 --- a/shelfmark/core/image_cache.py +++ b/shelfmark/core/image_cache.py @@ -49,6 +49,9 @@ _MIN_WEBP_HEADER_LENGTH = 12 HTTP_NOT_FOUND = HTTPStatus.NOT_FOUND +MAX_VARIANT_DIMENSION = 1024 +WEBP_DEFAULT_QUALITY = 80 +JPEG_DEFAULT_QUALITY = 85 def _detect_image_type(data: bytes) -> tuple[str, str] | None: @@ -72,6 +75,164 @@ def _detect_image_type(data: bytes) -> tuple[str, str] | None: return None +def normalize_variant_dimension(value: object) -> int | None: + """Normalize a requested variant dimension, clamping to a safe upper bound.""" + dimension = coerce_int(value, 0) + if dimension <= 0: + return None + return min(dimension, MAX_VARIANT_DIMENSION) + + +def normalize_variant_format(value: object) -> str | None: + """Normalize a requested output image format.""" + if not isinstance(value, str): + return None + + normalized = value.strip().lower() + if normalized in {"jpg", "jpeg"}: + return "jpeg" + if normalized in {"png", "webp"}: + return normalized + return None + + +def build_variant_cache_id( + cache_id: str, + *, + width: int | None, + height: int | None, + image_format: str | None, +) -> str: + """Build a cache key for a derived cover variant.""" + width_token = str(width) if width is not None else "auto" + height_token = str(height) if height is not None else "auto" + format_token = image_format or "original" + return f"{cache_id}__w{width_token}_h{height_token}_f{format_token}" + + +def _calculate_variant_size( + *, + source_width: int, + source_height: int, + width: int | None, + height: int | None, +) -> tuple[int, int]: + """Calculate the output size while preserving aspect ratio and avoiding upscaling.""" + if width is None and height is None: + return source_width, source_height + + width_ratio = (width / source_width) if width is not None else None + height_ratio = (height / source_height) if height is not None else None + + if width_ratio is not None and height_ratio is not None: + scale = min(width_ratio, height_ratio, 1.0) + elif width_ratio is not None: + scale = min(width_ratio, 1.0) + elif height_ratio is not None: + scale = min(height_ratio, 1.0) + else: + scale = 1.0 + + return ( + max(1, round(source_width * scale)), + max(1, round(source_height * scale)), + ) + + +def _normalize_source_format(image_data: bytes) -> str | None: + """Return the normalized detected source image format.""" + detected = _detect_image_type(image_data) + if not detected: + return None + + content_type, _ext = detected + if content_type == "image/jpeg": + return "jpeg" + if content_type == "image/png": + return "png" + if content_type == "image/webp": + return "webp" + return None + + +def create_image_variant( + image_data: bytes, + *, + width: int | None = None, + height: int | None = None, + image_format: str | None = None, +) -> tuple[bytes, str] | None: + """Create a resized and/or transcoded image variant. + + Returns None when no variant is needed or the image cannot be safely transformed. + """ + requested_format = normalize_variant_format(image_format) + if width is None and height is None and requested_format is None: + return None + + source_format = _normalize_source_format(image_data) + + try: + from PIL import Image, ImageOps, UnidentifiedImageError + except ImportError: + logger.warning("Pillow is not installed; serving original cover image") + return None + + try: + with Image.open(BytesIO(image_data)) as source_image: + if getattr(source_image, "is_animated", False): + return None + + image = ImageOps.exif_transpose(source_image) + source_width, source_height = image.size + output_width, output_height = _calculate_variant_size( + source_width=source_width, + source_height=source_height, + width=width, + height=height, + ) + + needs_resize = (output_width, output_height) != (source_width, source_height) + output_format = requested_format or source_format + + if not needs_resize and output_format == source_format: + return None + + if needs_resize: + image = image.resize((output_width, output_height), Image.Resampling.LANCZOS) + + if output_format == "jpeg": + if image.mode not in {"RGB", "L"}: + image = image.convert("RGB") + content_type = "image/jpeg" + save_kwargs: dict[str, Any] = { + "format": "JPEG", + "quality": JPEG_DEFAULT_QUALITY, + "optimize": True, + } + elif output_format == "png": + if image.mode not in {"1", "L", "LA", "P", "PA", "RGB", "RGBA"}: + image = image.convert("RGBA") + content_type = "image/png" + save_kwargs = {"format": "PNG", "optimize": True} + else: + if image.mode not in {"RGB", "RGBA"}: + image = image.convert("RGBA" if "A" in image.getbands() else "RGB") + content_type = "image/webp" + save_kwargs = { + "format": "WEBP", + "quality": WEBP_DEFAULT_QUALITY, + "method": 6, + } + + output = BytesIO() + image.save(output, **save_kwargs) + return output.getvalue(), content_type + except (OSError, UnidentifiedImageError, ValueError) as exc: + logger.warning("Failed to derive image variant: %s", exc) + return None + + class ImageCacheService: """Persistent image cache with LRU eviction and TTL support.""" diff --git a/shelfmark/main.py b/shelfmark/main.py index 66837114..777fbea2 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1597,6 +1597,9 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]: Query Parameters: url (str): Base64-encoded original image URL (required on first request) + w (int): Optional max width for a derived image variant + h (int): Optional max height for a derived image variant + format (str): Optional output format for a derived image variant (webp/png/jpeg) Returns: flask.Response: Binary image data with appropriate Content-Type, or 404. @@ -1606,43 +1609,84 @@ def api_cover(cover_id: str) -> Response | tuple[Response, int]: import base64 from shelfmark.config.env import is_covers_cache_enabled - from shelfmark.core.image_cache import get_image_cache + from shelfmark.core.image_cache import ( + build_variant_cache_id, + create_image_variant, + get_image_cache, + normalize_variant_dimension, + normalize_variant_format, + ) # Check if caching is enabled if not is_covers_cache_enabled(): return jsonify({"error": "Cover caching is disabled"}), 404 cache = get_image_cache() + width = normalize_variant_dimension(request.args.get("w")) + height = normalize_variant_dimension(request.args.get("h")) + image_format = normalize_variant_format(request.args.get("format")) + variant_cache_id = ( + build_variant_cache_id( + cover_id, + width=width, + height=height, + image_format=image_format, + ) + if width is not None or height is not None or image_format is not None + else None + ) - # Try to get from cache first - cached = cache.get(cover_id) - if cached: - image_data, content_type = cached + def make_cover_response( + image_data: bytes, + content_type: str, + *, + cache_status: str, + ) -> Response: response = app.response_class(response=image_data, status=200, mimetype=content_type) response.headers["Cache-Control"] = "public, max-age=86400" - response.headers["X-Cache"] = "HIT" + response.headers["X-Cache"] = cache_status return response + # Try to get from cache first + cache_lookup_id = variant_cache_id or cover_id + cached = cache.get(cache_lookup_id) + if cached: + image_data, content_type = cached + return make_cover_response(image_data, content_type, cache_status="HIT") + # Cache miss - get URL from query parameter encoded_url = request.args.get("url") - if not encoded_url: - return jsonify({"error": "Cover URL not provided"}), 404 + original: tuple[bytes, str] | None = cache.get(cover_id) if variant_cache_id else None - try: - original_url = base64.urlsafe_b64decode(encoded_url).decode() - except (binascii.Error, UnicodeDecodeError) as e: - logger.warning("Failed to decode cover URL: %s", e) - return jsonify({"error": "Invalid cover URL encoding"}), 400 - - # Fetch and cache the image - result = cache.fetch_and_cache(cover_id, original_url) - if not result: - return jsonify({"error": "Failed to fetch cover image"}), 404 - - image_data, content_type = result - response = app.response_class(response=image_data, status=200, mimetype=content_type) - response.headers["Cache-Control"] = "public, max-age=86400" - response.headers["X-Cache"] = "MISS" + if original is None: + if not encoded_url: + return jsonify({"error": "Cover URL not provided"}), 404 + + try: + original_url = base64.urlsafe_b64decode(encoded_url).decode() + except (binascii.Error, UnicodeDecodeError) as e: + logger.warning("Failed to decode cover URL: %s", e) + return jsonify({"error": "Invalid cover URL encoding"}), 400 + + # Fetch and cache the original image + original = cache.fetch_and_cache(cover_id, original_url) + if not original: + return jsonify({"error": "Failed to fetch cover image"}), 404 + + image_data, content_type = original + + if variant_cache_id: + variant = create_image_variant( + image_data, + width=width, + height=height, + image_format=image_format, + ) + if variant: + image_data, content_type = variant + cache.put(variant_cache_id, image_data, content_type) + + response = make_cover_response(image_data, content_type, cache_status="MISS") except _IMPORT_OPERATIONAL_ERRORS as e: logger.error_trace(f"Cover fetch error: {e}") return jsonify({"error": str(e)}), 500 diff --git a/src/frontend/src/components/BookTargetDropdown.tsx b/src/frontend/src/components/BookTargetDropdown.tsx index e5f32ab8..64d7bd8b 100644 --- a/src/frontend/src/components/BookTargetDropdown.tsx +++ b/src/frontend/src/components/BookTargetDropdown.tsx @@ -296,6 +296,7 @@ const BookTargetDropdownSession = ({ multiple showCheckboxes keepOpenOnSelect + positionStrategy={variant === 'icon' ? 'fixed' : 'absolute'} summaryFormatter={(selectedOptions) => renderSummary(selectedOptions)} renderTrigger={customTrigger} onOpenChange={onOpenChange} diff --git a/src/frontend/src/components/ConfigSetupBanner.tsx b/src/frontend/src/components/ConfigSetupBanner.tsx index 697c380f..523e2748 100644 --- a/src/frontend/src/components/ConfigSetupBanner.tsx +++ b/src/frontend/src/components/ConfigSetupBanner.tsx @@ -72,7 +72,7 @@ export const ConfigSetupBanner = ({ {/* Backdrop */} diff --git a/src/frontend/src/components/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index ad5b4af2..305e9518 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -7,6 +7,7 @@ import { useMountEffect } from '../hooks/useMountEffect'; import type { Book, ButtonStateInfo } from '../types'; import { isMetadataBook } from '../types'; import { bookSupportsTargets } from '../utils/bookTargetLoader'; +import { getSizedCoverUrl } from '../utils/covers'; import { isUserCancelledError } from '../utils/errors'; import { BookTargetDropdown } from './BookTargetDropdown'; @@ -136,6 +137,10 @@ export const DetailsModal = ({ const artworkMaxWidth = isSquareCover ? 'min(45vw, 400px, calc(90vh - 220px))' : 'min(45vw, 520px, calc((90vh - 220px) / 1.6))'; + const optimizedPreview = getSizedCoverUrl(book.preview, { + width: isSquareCover ? 640 : 480, + height: isSquareCover ? 640 : 720, + }); const additionalInfo = book.info && Object.keys(book.info).length > 0 ? Object.entries(book.info).filter(([key]) => { @@ -201,14 +206,17 @@ export const DetailsModal = ({