Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ dependencies = [
"transmission-rpc",
"authlib>=1.6.6,<1.7",
"apprise>=1.9.0",
"Pillow>=11.0.0",
]

[project.optional-dependencies]
Expand Down
161 changes: 161 additions & 0 deletions shelfmark/core/image_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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."""

Expand Down
90 changes: 67 additions & 23 deletions shelfmark/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/frontend/src/components/BookTargetDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ const BookTargetDropdownSession = ({
multiple
showCheckboxes
keepOpenOnSelect
positionStrategy={variant === 'icon' ? 'fixed' : 'absolute'}
summaryFormatter={(selectedOptions) => renderSummary(selectedOptions)}
renderTrigger={customTrigger}
onOpenChange={onOpenChange}
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/src/components/ConfigSetupBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ export const ConfigSetupBanner = ({
{/* Backdrop */}
<button
type="button"
className={`absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
className={`absolute inset-0 bg-black/60 transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
aria-label="Close settings setup dialog"
/>
Expand Down
12 changes: 10 additions & 2 deletions src/frontend/src/components/DetailsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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]) => {
Expand Down Expand Up @@ -201,14 +206,17 @@ export const DetailsModal = ({
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-6">
<div className="flex flex-col gap-6 lg:min-h-0 lg:flex-row lg:items-stretch lg:gap-8">
<div className="flex w-full justify-center lg:w-auto lg:flex-none lg:justify-start lg:self-stretch lg:pr-4">
{book.preview ? (
{optimizedPreview ? (
<div
className="flex w-full items-center justify-center lg:h-full lg:max-w-none"
style={{ maxHeight: artworkMaxHeight, maxWidth: artworkMaxWidth }}
>
<img
src={book.preview}
src={optimizedPreview}
alt="Book cover"
width={isSquareCover ? 640 : 480}
height={isSquareCover ? 640 : 720}
decoding="async"
className="h-auto max-h-full w-auto max-w-full rounded-xl object-contain shadow-lg"
style={{ maxHeight: '100%', maxWidth: '100%' }}
/>
Expand Down
Loading
Loading