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 */} )} - - {isOpen && - createPortal( -
-
- {children({ close })} -
-
, - document.body, - )} + {absolutePanel} + {fixedPanel} ); diff --git a/src/frontend/src/components/DropdownList.tsx b/src/frontend/src/components/DropdownList.tsx index a23f396b..981484d8 100644 --- a/src/frontend/src/components/DropdownList.tsx +++ b/src/frontend/src/components/DropdownList.tsx @@ -26,6 +26,7 @@ interface DropdownListProps { summaryFormatter?: (selected: DropdownListOption[], placeholder: string) => ReactNode; keepOpenOnSelect?: boolean; triggerChrome?: 'default' | 'minimal'; + positionStrategy?: 'absolute' | 'fixed'; renderTrigger?: (props: { isOpen: boolean; toggle: () => void }) => ReactNode; onOpenChange?: (isOpen: boolean) => void; } @@ -45,6 +46,7 @@ export const DropdownList = ({ summaryFormatter, keepOpenOnSelect, triggerChrome = 'default', + positionStrategy, renderTrigger, onOpenChange, }: DropdownListProps) => { @@ -114,6 +116,7 @@ export const DropdownList = ({ buttonClassName={buttonClassName} panelClassName={panelClassName} triggerChrome={triggerChrome} + positionStrategy={positionStrategy} renderTrigger={renderTrigger} onOpenChange={onOpenChange} > diff --git a/src/frontend/src/components/Header.tsx b/src/frontend/src/components/Header.tsx index 1ce7b101..9db3e4c6 100644 --- a/src/frontend/src/components/Header.tsx +++ b/src/frontend/src/components/Header.tsx @@ -646,7 +646,7 @@ export const Header = forwardRef( return (
diff --git a/src/frontend/src/components/OnBehalfConfirmationModal.tsx b/src/frontend/src/components/OnBehalfConfirmationModal.tsx index aedc4ecd..8cd8af2b 100644 --- a/src/frontend/src/components/OnBehalfConfirmationModal.tsx +++ b/src/frontend/src/components/OnBehalfConfirmationModal.tsx @@ -61,7 +61,7 @@ export const OnBehalfConfirmationModal = ({