diff --git a/pyproject.toml b/pyproject.toml index b152a7a..fbc7ea2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,6 @@ dependencies = [ "transmission-rpc", "authlib>=1.7.0,<1.8", "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 d9b481c..1aeb0fd 100644 --- a/shelfmark/core/image_cache.py +++ b/shelfmark/core/image_cache.py @@ -49,9 +49,6 @@ TRANSIENT_CACHE_TTL = 60 _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: @@ -75,164 +72,6 @@ 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 777fbea..6683711 100644 --- a/shelfmark/main.py +++ b/shelfmark/main.py @@ -1597,9 +1597,6 @@ 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. @@ -1609,84 +1606,43 @@ 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 ( - build_variant_cache_id, - create_image_variant, - get_image_cache, - normalize_variant_dimension, - normalize_variant_format, - ) + from shelfmark.core.image_cache import get_image_cache # 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 - ) - - 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"] = 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) + cached = cache.get(cover_id) if cached: image_data, content_type = cached - return make_cover_response(image_data, content_type, cache_status="HIT") + 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" + return response # Cache miss - get URL from query parameter encoded_url = request.args.get("url") - original: tuple[bytes, str] | None = cache.get(cover_id) if variant_cache_id else None + if not encoded_url: + return jsonify({"error": "Cover URL not provided"}), 404 - 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 - 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 - # 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") + 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" 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/DetailsModal.tsx b/src/frontend/src/components/DetailsModal.tsx index 305e951..ad5b4af 100644 --- a/src/frontend/src/components/DetailsModal.tsx +++ b/src/frontend/src/components/DetailsModal.tsx @@ -7,7 +7,6 @@ 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'; @@ -137,10 +136,6 @@ 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]) => { @@ -206,17 +201,14 @@ export const DetailsModal = ({
- {optimizedPreview ? ( + {book.preview ? (
Book cover diff --git a/src/frontend/src/components/ReleaseModal.tsx b/src/frontend/src/components/ReleaseModal.tsx index df61021..c39dd7e 100644 --- a/src/frontend/src/components/ReleaseModal.tsx +++ b/src/frontend/src/components/ReleaseModal.tsx @@ -22,7 +22,6 @@ import type { import { isMetadataBook } from '../types'; import { bookSupportsTargets } from '../utils/bookTargetLoader'; import { getColorStyleFromHint } from '../utils/colorMaps'; -import { getSizedCoverUrl } from '../utils/covers'; import { LANGUAGE_OPTION_DEFAULT, getLanguageFilterValues, @@ -211,9 +210,8 @@ function StarRating({ rating, maxRating = 5 }: { rating: number; maxRating?: num const ReleaseThumbnail = ({ preview, title }: { preview?: string; title?: string }) => { const [imageLoaded, setImageLoaded] = useState(false); const [imageError, setImageError] = useState(false); - const optimizedPreview = getSizedCoverUrl(preview, { width: 32, height: 48 }); - if (!optimizedPreview || imageError) { + if (!preview || imageError) { return (
)} {title setImageLoaded(true)} onError={() => setImageError(true)} style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }} @@ -1242,10 +1237,6 @@ const ReleaseModalSession = ({ } else if (book.series_name) { coverSizeClassName = 'h-[144px] w-24'; } - const modalPreview = getSizedCoverUrl(book.preview, { - width: book.cover_aspect === 'square' ? 144 : 96, - height: 144, - }); let combinedFooterEbookMode = combinedEbookMode; if (combinedPhase === 'ebook') { @@ -1334,14 +1325,13 @@ const ReleaseModalSession = ({ {/* Mobile: static thumbnail always visible */} {!isRequestMode && (
- {modalPreview ? ( + {book.preview ? ( - {modalPreview ? ( + {book.preview ? ( - {modalPreview ? ( + {book.preview ? ( Book cover ) : ( diff --git a/src/frontend/src/components/RequestConfirmationModal.tsx b/src/frontend/src/components/RequestConfirmationModal.tsx index afb8576..3315a78 100644 --- a/src/frontend/src/components/RequestConfirmationModal.tsx +++ b/src/frontend/src/components/RequestConfirmationModal.tsx @@ -5,7 +5,6 @@ import { useEscapeKey } from '../hooks/useEscapeKey'; import { useMountEffect } from '../hooks/useMountEffect'; import { getMetadataBookInfo } from '../services/api'; import type { CreateRequestPayload } from '../types'; -import { getSizedCoverUrl } from '../utils/covers'; import type { RequestConfirmationPreview } from '../utils/requestConfirmation'; import { applyRequestNoteToPayload, @@ -176,7 +175,6 @@ function RequestConfirmationModalSession({ const titleId = 'request-confirmation-modal-title'; const confirmDisabled = isSubmitting || (allowNotes && note.length > MAX_REQUEST_NOTE_LENGTH); - const previewImage = getSizedCoverUrl(preview.preview, { width: 64, height: 96 }); const submit = async () => { if (confirmDisabled) { @@ -242,15 +240,11 @@ function RequestConfirmationModalSession({
- {previewImage ? ( + {preview.preview ? ( {`${preview.title} ) : (
diff --git a/src/frontend/src/components/activity/ActivityCard.tsx b/src/frontend/src/components/activity/ActivityCard.tsx index c90ea1c..98fb328 100644 --- a/src/frontend/src/components/activity/ActivityCard.tsx +++ b/src/frontend/src/components/activity/ActivityCard.tsx @@ -3,7 +3,6 @@ import { useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { RequestRecord } from '../../types'; import { withBasePath } from '../../utils/basePath'; -import { getSizedCoverUrl } from '../../utils/covers'; import { Tooltip } from '../shared/Tooltip'; import type { ActivityCardAction } from './activityCardModel'; import { buildActivityCardModel } from './activityCardModel'; @@ -500,7 +499,6 @@ export const ActivityCard = ({ const titleLineRef = useRef(null); const [badgeOverflow, setBadgeOverflow] = useState>({}); const [titleOverflow, setTitleOverflow] = useState(false); - const previewImage = getSizedCoverUrl(item.preview, { width: 48, height: 72 }); useLayoutEffect(() => { const measureBadgeOverflow = () => { @@ -711,15 +709,11 @@ export const ActivityCard = ({
{/* Artwork */}
- {previewImage ? ( + {item.preview ? ( {`${item.title} ) : ( diff --git a/src/frontend/src/components/resultsViews/CardView.tsx b/src/frontend/src/components/resultsViews/CardView.tsx index 6731139..123fdcf 100644 --- a/src/frontend/src/components/resultsViews/CardView.tsx +++ b/src/frontend/src/components/resultsViews/CardView.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { useSearchMode } from '../../contexts/SearchModeContext'; import type { Book, ButtonStateInfo } from '../../types'; import { bookSupportsTargets } from '../../utils/bookTargetLoader'; -import { getSizedCoverUrl } from '../../utils/covers'; import { BookActionButton } from '../BookActionButton'; import { BookTargetDropdown } from '../BookTargetDropdown'; import { DisplayFieldBadges } from '../shared'; @@ -42,11 +41,6 @@ export const CardView = ({ const [dropdownOpen, setDropdownOpen] = useState(false); const targetProvider = book.provider; const targetBookId = book.provider_id; - const isSquareCover = book.cover_aspect === 'square'; - const optimizedPreview = getSizedCoverUrl(book.preview, { - width: 292, - height: isSquareCover ? 292 : 438, - }); let zIndex: number | undefined; if (dropdownOpen) { zIndex = 20; @@ -103,7 +97,7 @@ export const CardView = ({ #{book.series_position}
)} - {optimizedPreview && !imageError ? ( + {book.preview && !imageError ? ( <> {!imageLoaded && (
@@ -111,13 +105,10 @@ export const CardView = ({
)} {book.title field.icon === 'microphone'); - const isSquareCover = book.cover_aspect === 'square'; - const optimizedPreview = getSizedCoverUrl(book.preview, { - width: 120, - height: isSquareCover ? 120 : 180, - }); let zIndex: number | undefined; if (dropdownOpen) { zIndex = 20; @@ -103,7 +97,7 @@ export const CompactView = ({ #{book.series_position}
)} - {optimizedPreview && !imageError ? ( + {book.preview && !imageError ? ( <> {!imageLoaded && (
@@ -111,13 +105,10 @@ export const CompactView = ({
)} {book.title )} {title setImageLoaded(true)} onError={() => setImageError(true)} style={{ opacity: imageLoaded ? 1 : 0, transition: 'opacity 0.2s ease-in-out' }} diff --git a/src/frontend/src/tests/covers.test.ts b/src/frontend/src/tests/covers.test.ts deleted file mode 100644 index a67cfd6..0000000 --- a/src/frontend/src/tests/covers.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { getSizedCoverUrl } from '../utils/covers'; - -describe('getSizedCoverUrl', () => { - it('adds size and format params to local cover proxy URLs', () => { - expect( - getSizedCoverUrl('/api/covers/book-1?url=abc', { - width: 120, - height: 180, - }), - ).toBe('/api/covers/book-1?url=abc&w=120&h=180&format=webp'); - }); - - it('leaves external preview URLs alone', () => { - expect( - getSizedCoverUrl('https://covers.example.com/book.jpg', { - width: 120, - height: 180, - }), - ).toBe('https://covers.example.com/book.jpg'); - }); - - it('preserves absolute proxy URLs', () => { - expect( - getSizedCoverUrl('https://bookrequest.example.com/api/covers/book-1?url=abc', { - width: 56, - height: 56, - format: 'png', - }), - ).toBe('https://bookrequest.example.com/api/covers/book-1?url=abc&w=56&h=56&format=png'); - }); -}); diff --git a/src/frontend/src/utils/covers.ts b/src/frontend/src/utils/covers.ts deleted file mode 100644 index f2438e1..0000000 --- a/src/frontend/src/utils/covers.ts +++ /dev/null @@ -1,66 +0,0 @@ -const COVER_PROXY_PATH = '/api/covers/'; -const LOCAL_URL_BASE = 'http://shelfmark.local'; -const DEFAULT_COVER_FORMAT = 'webp'; -const MAX_COVER_DIMENSION = 1024; - -type CoverFormat = 'jpeg' | 'png' | 'webp'; - -interface SizedCoverUrlOptions { - width?: number; - height?: number; - format?: CoverFormat; -} - -const normalizeDimension = (value?: number) => { - if (typeof value !== 'number' || !Number.isFinite(value)) { - return undefined; - } - - const rounded = Math.round(value); - if (rounded <= 0) { - return undefined; - } - - return Math.min(rounded, MAX_COVER_DIMENSION); -}; - -export const getSizedCoverUrl = ( - preview?: string, - { width, height, format = DEFAULT_COVER_FORMAT }: SizedCoverUrlOptions = {}, -) => { - if (!preview) { - return preview; - } - - const isRelativeUrl = preview.startsWith('/'); - - let url: URL; - try { - url = new URL(preview, LOCAL_URL_BASE); - } catch { - return preview; - } - - if (!url.pathname.includes(COVER_PROXY_PATH)) { - return preview; - } - - const normalizedWidth = normalizeDimension(width); - const normalizedHeight = normalizeDimension(height); - - if (normalizedWidth !== undefined) { - url.searchParams.set('w', String(normalizedWidth)); - } - - if (normalizedHeight !== undefined) { - url.searchParams.set('h', String(normalizedHeight)); - } - - if (format) { - url.searchParams.set('format', format); - } - - const search = url.searchParams.toString(); - const relativeUrl = `${url.pathname}${search ? `?${search}` : ''}${url.hash}`; - return isRelativeUrl ? relativeUrl : url.toString(); -}; diff --git a/tests/core/test_image_cache.py b/tests/core/test_image_cache.py index 3d147dc..27783f0 100644 --- a/tests/core/test_image_cache.py +++ b/tests/core/test_image_cache.py @@ -1,25 +1,8 @@ """Tests for targeted image cache safety and fetch fallbacks.""" -from io import BytesIO - import requests -from PIL import Image -from shelfmark.core.image_cache import ( - ImageCacheService, - build_variant_cache_id, - create_image_variant, - normalize_variant_dimension, - normalize_variant_format, -) - - -def _make_image_bytes( - *, width: int = 400, height: int = 600, image_format: str = "JPEG", color: str = "navy" -) -> bytes: - buffer = BytesIO() - Image.new("RGB", (width, height), color=color).save(buffer, format=image_format) - return buffer.getvalue() +from shelfmark.core.image_cache import ImageCacheService def test_is_safe_url_rejects_invalid_ipv6_url() -> None: @@ -37,58 +20,3 @@ def test_fetch_and_cache_returns_none_on_request_exception(tmp_path, monkeypatch assert cache.fetch_and_cache("cover-1", "https://example.com/cover.jpg") is None assert "cover-1" not in cache._index - - -def test_create_image_variant_resizes_and_transcodes_to_webp() -> None: - variant = create_image_variant( - _make_image_bytes(), - width=120, - height=180, - image_format="webp", - ) - - assert variant is not None - variant_bytes, content_type = variant - assert content_type == "image/webp" - - with Image.open(BytesIO(variant_bytes)) as image: - assert image.size == (120, 180) - - -def test_create_image_variant_preserves_aspect_ratio_for_single_dimension() -> None: - variant = create_image_variant( - _make_image_bytes(), - width=120, - image_format="jpeg", - ) - - assert variant is not None - variant_bytes, content_type = variant - assert content_type == "image/jpeg" - - with Image.open(BytesIO(variant_bytes)) as image: - assert image.size == (120, 180) - - -def test_create_image_variant_returns_none_when_no_change_needed() -> None: - image_bytes = _make_image_bytes(image_format="WEBP") - - assert create_image_variant(image_bytes, image_format="webp") is None - - -def test_variant_helpers_normalize_requested_variant_values() -> None: - assert normalize_variant_dimension("240") == 240 - assert normalize_variant_dimension("0") is None - assert normalize_variant_dimension("99999") == 1024 - assert normalize_variant_format("jpg") == "jpeg" - assert normalize_variant_format("weBp") == "webp" - assert normalize_variant_format("gif") is None - assert ( - build_variant_cache_id( - "cover-123", - width=120, - height=180, - image_format="webp", - ) - == "cover-123__w120_h180_fwebp" - ) diff --git a/uv.lock b/uv.lock index fb204c5..431e3c7 100644 --- a/uv.lock +++ b/uv.lock @@ -718,39 +718,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/fe/53ac0cd932db5dcaf55961bc7cb7afdca8d80d8cc7406ed661f0c7dc111a/pdbp-1.8.2-py3-none-any.whl", hash = "sha256:d4fd05e177636b5ccd0b2e03e378cec57afc06149e5fd975de6f8ddb3d0109a8", size = 21969, upload-time = "2026-01-14T03:10:27.062Z" }, ] -[[package]] -name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, -] - [[package]] name = "pip" version = "26.0.1" @@ -1373,7 +1340,6 @@ dependencies = [ { name = "gevent" }, { name = "gevent-websocket" }, { name = "gunicorn" }, - { name = "pillow" }, { name = "psutil" }, { name = "python-socketio" }, { name = "qbittorrent-api" }, @@ -1416,7 +1382,6 @@ requires-dist = [ { name = "gevent" }, { name = "gevent-websocket" }, { name = "gunicorn" }, - { name = "pillow", specifier = ">=11.0.0" }, { name = "psutil" }, { name = "pyautogui", marker = "extra == 'browser'" }, { name = "python-socketio" },