/
/
/
1"""Utilities for image manipulation and retrieval."""
2
3from __future__ import annotations
4
5import asyncio
6import contextlib
7import hashlib
8import itertools
9import logging
10import os
11import random
12import re
13import tempfile
14import time
15import urllib.parse
16from base64 import b64decode
17from collections import OrderedDict
18from collections.abc import Iterable
19from io import BytesIO
20from pathlib import Path
21from typing import TYPE_CHECKING, cast
22
23import aiofiles
24import aiofiles.os
25from aiohttp.client_exceptions import ClientError
26from music_assistant_models.enums import ProviderIconVariant
27from music_assistant_models.errors import (
28 MediaNotFoundError,
29 MusicAssistantError,
30 ProviderUnavailableError,
31)
32from PIL import Image, UnidentifiedImageError
33
34from music_assistant.constants import APPLICATION_NAME
35from music_assistant.helpers.security import is_safe_path
36from music_assistant.helpers.tags import get_embedded_image
37from music_assistant.helpers.util import join_task
38from music_assistant.models.metadata_provider import MetadataProvider
39from music_assistant.models.music_provider import MusicProvider
40from music_assistant.models.player_provider import PlayerProvider
41from music_assistant.models.plugin import PluginProvider
42
43if TYPE_CHECKING:
44 from music_assistant_models.media_items import MediaItemImage
45 from PIL.Image import Image as ImageClass
46
47 from music_assistant.mass import MusicAssistant
48
49
50LOGGER = logging.getLogger(__name__)
51
52# Thumbnail cache: on-disk (persistent) + small in-memory FIFO (hot path)
53_THUMB_CACHE_DIR = "thumbnails"
54_THUMB_MEMORY_CACHE_MAX = 50
55_ALLOWED_THUMB_FORMATS: frozenset[str] = frozenset({"PNG", "JPEG"})
56
57# Bump on encoding-rule changes so stale entries (e.g. old black-bg JPEGs for
58# transparent logos) aren't served from a colliding filename after upgrade.
59_THUMB_CACHE_VERSION = 2
60
61# By construction the filename is `<sha256>_<int>_v<int>[_flat].(jpg|png)`; the
62# regex is an explicit sanitizer that also lets CodeQL prove the value is safe
63# to join into a filesystem path. The `_flat` marker separates the flattened
64# and transparency-preserving cache variants.
65_THUMB_FILENAME_RE = re.compile(r"^[0-9a-f]{64}_\d+_v\d+(?:_flat)?\.(?:jpg|png)$")
66
67_thumb_memory_cache: OrderedDict[str, bytes] = OrderedDict()
68
69# Source-image cache: the raw origin bytes (remote download, local file read or
70# ffmpeg-extracted embedded art) that every derived artifact (thumb sizes/formats,
71# color palette, collage tiles) is generated from. Without it, first display of a
72# single item fetches the same source several times within seconds. The memory
73# tier is byte-budgeted (originals can be multi-MB); sources also get an on-disk
74# `<hash>_src` entry in the thumbnail cache dir so multi-variant generation
75# after a restart doesn't re-fetch either.
76_SOURCE_CACHE_SUFFIX = "_src"
77# remote urls can serve new content behind a stable url, so keep the TTL modest;
78# local files don't rely on it as invalidate_cached_image busts them on change
79_SOURCE_CACHE_TTL = 3600
80_SOURCE_MEMORY_MAX_BYTES = 32 * 1024 * 1024
81# a single huge original would evict everything else, so cap the entry size
82_SOURCE_MEMORY_ENTRY_MAX_BYTES = 8 * 1024 * 1024
83
84_MAX_IMAGEPROXY_RECURSION_DEPTH = 5
85
86# Leading magic bytes used to sniff raster image formats from their content.
87_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
88_JPEG_MAGIC = b"\xff\xd8\xff"
89
90
91def is_svg_data(data: bytes) -> bool:
92 """Return True when the given bytes appear to be an SVG image."""
93 if not data:
94 return False
95 # the root <svg> may be preceded by an xml declaration, doctype or comment
96 sample = data[:1024].lstrip()
97 if not sample[:64].lower().startswith((b"<?xml", b"<svg", b"<!--", b"<!doctype")):
98 return False
99 return b"<svg" in sample.lower()
100
101
102def detect_image_content_format(data: bytes) -> str | None:
103 """
104 Return the sniffed image format (`png`, `jpg` or `svg`), or None if unknown.
105
106 :param data: Raw image bytes to inspect.
107 """
108 if not data:
109 return None
110 if data.startswith(_PNG_MAGIC):
111 return "png"
112 if data.startswith(_JPEG_MAGIC):
113 return "jpg"
114 if is_svg_data(data):
115 return "svg"
116 return None
117
118
119def create_thumb_hash(provider: str, path_or_url: str) -> str:
120 """Create a safe filesystem hash from provider and image path."""
121 raw = f"{provider}/{path_or_url}"
122 return hashlib.sha256(raw.encode(), usedforsecurity=False).hexdigest()
123
124
125def _thumb_cache_filename(
126 thumb_hash: str,
127 size: int | None,
128 image_format: str,
129 flatten_transparency: bool = False,
130) -> str:
131 """Build the cache filename for a thumbnail."""
132 ext = image_format.lower()
133 if ext == "jpeg":
134 ext = "jpg"
135 suffix = "_flat" if flatten_transparency else ""
136 return f"{thumb_hash}_{size or 0}_v{_THUMB_CACHE_VERSION}{suffix}.{ext}"
137
138
139def _get_from_memory_cache(key: str) -> bytes | None:
140 """Retrieve thumbnail from in-memory FIFO cache."""
141 if key in _thumb_memory_cache:
142 _thumb_memory_cache.move_to_end(key)
143 return _thumb_memory_cache[key]
144 return None
145
146
147def _put_in_memory_cache(key: str, data: bytes) -> None:
148 """Store thumbnail in in-memory FIFO cache."""
149 _thumb_memory_cache[key] = data
150 _thumb_memory_cache.move_to_end(key)
151 while len(_thumb_memory_cache) > _THUMB_MEMORY_CACHE_MAX:
152 _thumb_memory_cache.popitem(last=False)
153
154
155class _SourceMemoryCache:
156 """In-memory byte-budgeted LRU tier of the source-image cache."""
157
158 def __init__(self) -> None:
159 self.entries: OrderedDict[str, tuple[bytes, float]] = OrderedDict()
160 self.total_bytes = 0
161
162 def get(self, key: str) -> bytes | None:
163 """Return cached source bytes for key, or None on miss/expiry."""
164 entry = self.entries.get(key)
165 if entry is None:
166 return None
167 data, stored_at = entry
168 if time.monotonic() - stored_at > _SOURCE_CACHE_TTL:
169 self.pop(key)
170 return None
171 self.entries.move_to_end(key)
172 return data
173
174 def put(self, key: str, data: bytes) -> None:
175 """Store source bytes for key, evicting oldest entries over the byte budget."""
176 if len(data) > _SOURCE_MEMORY_ENTRY_MAX_BYTES:
177 return
178 self.pop(key)
179 self.entries[key] = (data, time.monotonic())
180 self.total_bytes += len(data)
181 while self.total_bytes > _SOURCE_MEMORY_MAX_BYTES and self.entries:
182 _, (evicted, _stored_at) = self.entries.popitem(last=False)
183 self.total_bytes -= len(evicted)
184
185 def pop(self, key: str) -> None:
186 """Remove the entry for key (if present)."""
187 if entry := self.entries.pop(key, None):
188 self.total_bytes -= len(entry[0])
189
190 def clear(self) -> None:
191 """Remove all entries."""
192 self.entries.clear()
193 self.total_bytes = 0
194
195
196_source_memory_cache = _SourceMemoryCache()
197
198# Negative cache for sources that recently failed to fetch. Without it, a
199# persistently failing origin (e.g. an artwork URL that keeps returning 404)
200# is re-fetched â with a fresh error logged each time â for every thumbnail,
201# palette or metadata request that references it, and each consumer waits for
202# the full network round-trip just to fail again.
203_FAILED_SOURCE_TTL = 300
204_FAILED_SOURCE_MAX_ENTRIES = 256
205_failed_sources: OrderedDict[str, tuple[float, str]] = OrderedDict()
206
207
208def _get_failed_source(cache_key: str) -> str | None:
209 """Return the failure message for a recently failed source, or None."""
210 entry = _failed_sources.get(cache_key)
211 if entry is None:
212 return None
213 expires_at, message = entry
214 if time.monotonic() >= expires_at:
215 _failed_sources.pop(cache_key, None)
216 return None
217 return message
218
219
220def _store_failed_source(cache_key: str, message: str) -> None:
221 """Remember a failed source fetch so it is not retried for a short while."""
222 _failed_sources[cache_key] = (time.monotonic() + _FAILED_SOURCE_TTL, message)
223 _failed_sources.move_to_end(cache_key)
224 while len(_failed_sources) > _FAILED_SOURCE_MAX_ENTRIES:
225 _failed_sources.popitem(last=False)
226
227
228def _has_alpha(img: ImageClass) -> bool:
229 """Return True if the image actually uses transparency."""
230 if img.mode == "P":
231 return "transparency" in img.info
232 if img.mode in ("RGBA", "LA", "PA"):
233 # an alpha channel may still be fully opaque; only treat it as
234 # transparent when some pixel is not fully opaque
235 # single-band getextrema() returns a (min, max) numeric tuple
236 return cast("tuple[float, float]", img.getchannel("A").getextrema())[0] < 255
237 return False
238
239
240_IMAGEPROXY_V2_PREFIX = "/imageproxy/"
241
242
243def _extract_imageproxy_id(url: str) -> str | None:
244 """
245 Return the 64-hex image_id from a /imageproxy/<id> URL, or None.
246
247 The path must match the canonical shape `/imageproxy/<id>` (optionally
248 with a single trailing slash) â extra segments are rejected so this
249 helper agrees with what `MetaDataController.handle_imageproxy` accepts.
250 """
251 # bail out early on anything that obviously can't be a v2 imageproxy URL
252 # (non-strings such as MagicMock from tests; urlparse would TypeError)
253 if not isinstance(url, str) or _IMAGEPROXY_V2_PREFIX not in url:
254 return None
255 parsed = urllib.parse.urlparse(url)
256 if not parsed.path.startswith(_IMAGEPROXY_V2_PREFIX):
257 return None
258 remainder = parsed.path[len(_IMAGEPROXY_V2_PREFIX) :].rstrip("/").lower()
259 if len(remainder) != 64 or any(c not in "0123456789abcdef" for c in remainder):
260 return None
261 return remainder
262
263
264def player_image_url(mass: MusicAssistant, url: str | None) -> str | None:
265 """
266 Rewrite an imageproxy URL for consumption by a (physical) player.
267
268 :param mass: The MusicAssistant instance.
269 :param url: Image URL as produced for frontend/API consumers.
270 """
271 if not url:
272 return url
273 webserver_base = mass.webserver.base_url
274 if webserver_base and url.startswith(f"{webserver_base}/imageproxy"):
275 # players may not be able to reach the webserver, so serve from the streams
276 # server, and force jpeg (= flatten transparency) as players such as legacy
277 # AirPlay receivers cannot be assumed to handle PNG alpha
278 url = mass.streams.base_url + url[len(webserver_base) :]
279 parsed = urllib.parse.urlparse(url)
280 query = urllib.parse.parse_qs(parsed.query)
281 query["fmt"] = ["jpeg"]
282 return parsed._replace(query=urllib.parse.urlencode(query, doseq=True)).geturl()
283 return url
284
285
286async def get_image_data(
287 mass: MusicAssistant, path_or_url: str, provider: str, *, _depth: int = 0
288) -> bytes:
289 """
290 Retrieve image data from a path or URL.
291
292 Source bytes are cached (in memory and on disk) so that deriving multiple
293 artifacts from one image (thumb sizes/formats, palette, collage tiles)
294 only fetches the origin once. Concurrent requests for the same source
295 share a single fetch.
296
297 :param mass: The MusicAssistant instance.
298 :param path_or_url: The image path, URL, or base64 data URI.
299 :param provider: The provider ID that can resolve the image.
300 :param _depth: Internal recursion depth counter (do not set manually).
301 """
302 if _depth >= _MAX_IMAGEPROXY_RECURSION_DEPTH:
303 msg = f"Maximum recursion depth exceeded when fetching image: {path_or_url}"
304 raise FileNotFoundError(msg)
305 # base64 data URIs carry their content inline; just decode them
306 if path_or_url.startswith("data:image"):
307 return b64decode(path_or_url.rsplit(",", maxsplit=1)[-1])
308 # imageproxy URLs pointing at our own server are resolved to their underlying
309 # (provider, path) before anything is cached: an alias-keyed cache entry
310 # would keep serving after the underlying image was invalidated
311 if path_or_url.startswith("http") and (
312 resolved := await _resolve_own_imageproxy_url(mass, path_or_url)
313 ):
314 extracted_provider, extracted_path = resolved
315 return await get_image_data(mass, extracted_path, extracted_provider, _depth=_depth + 1)
316 cache_key = create_thumb_hash(provider, path_or_url)
317 if (cached := _source_memory_cache.get(cache_key)) is not None:
318 return cached
319 # fail fast on sources that just failed instead of hammering the origin
320 if (failure := _get_failed_source(cache_key)) is not None:
321 raise FileNotFoundError(failure)
322 # fetch de-duplicated across concurrent requests for the same source
323 task: asyncio.Task[bytes] = mass.create_task(
324 _fetch_and_cache_source_image,
325 mass,
326 path_or_url,
327 provider,
328 cache_key,
329 _depth,
330 task_id=f"imgsrc.{cache_key}",
331 abort_existing=False,
332 # the failure reaches every waiter below; a fetch failure is reported here anyway,
333 # so a warning naming the task on top of that says nothing new
334 log_exceptions=False,
335 )
336 return await join_task(task)
337
338
339async def _resolve_own_imageproxy_url(mass: MusicAssistant, url: str) -> tuple[str, str] | None:
340 """
341 Resolve an imageproxy URL pointing at our own server to its (provider, path).
342
343 Returns None when the URL does not point at this server at all; raises
344 FileNotFoundError for own-server URLs that carry an invalid or unknown id.
345
346 :param mass: The MusicAssistant instance.
347 :param url: The (http/https) URL to inspect.
348 """
349 parsed_url = urllib.parse.urlparse(url)
350 url_origin = f"{parsed_url.scheme}://{parsed_url.netloc}"
351 server_origins = {
352 f"{p.scheme}://{p.netloc}"
353 for b in (mass.webserver.base_url, mass.streams.base_url)
354 if (p := urllib.parse.urlparse(b)).netloc
355 }
356 if url_origin not in server_origins:
357 return None
358 # opaque-id form: /imageproxy/<image_id>?size=...&fmt=...
359 if image_id := _extract_imageproxy_id(url):
360 resolved = await mass.metadata.resolve_image_id(image_id)
361 if resolved is None:
362 msg = f"Unknown image id in URL: {url}"
363 raise FileNotFoundError(msg)
364 return resolved
365 msg = f"Invalid imageproxy URL: {url}"
366 raise FileNotFoundError(msg)
367
368
369def _source_cache_filepath(mass: MusicAssistant, cache_key: str) -> str:
370 """Return the on-disk path for a cached source image, validating containment."""
371 thumb_dir = os.path.realpath(os.path.join(mass.cache_path, _THUMB_CACHE_DIR))
372 filepath = os.path.realpath(os.path.join(thumb_dir, f"{cache_key}{_SOURCE_CACHE_SUFFIX}"))
373 if not filepath.startswith(thumb_dir + os.sep):
374 msg = f"Cache path escapes thumbnail directory: {filepath}"
375 raise OSError(msg)
376 return filepath
377
378
379async def _fetch_and_cache_source_image(
380 mass: MusicAssistant, path_or_url: str, provider: str, cache_key: str, depth: int
381) -> bytes:
382 """
383 Fetch source image bytes, store them in the source cache tiers and return them.
384
385 :param mass: The MusicAssistant instance.
386 :param path_or_url: The image path or URL.
387 :param provider: The provider ID that can resolve the image.
388 :param cache_key: Source cache key (create_thumb_hash of provider + path).
389 :param depth: Recursion depth of the originating get_image_data call.
390 """
391 filepath = _source_cache_filepath(mass, cache_key)
392
393 def _read_disk_entry() -> bytes | None:
394 # remote urls only count as fresh within the TTL (a CDN can serve new
395 # content behind a stable url); local files rely on invalidation instead
396 try:
397 if not Path(filepath).is_file():
398 return None
399 if (
400 path_or_url.startswith("http")
401 and time.time() - Path(filepath).stat().st_mtime > _SOURCE_CACHE_TTL
402 ):
403 return None
404 with open(filepath, "rb") as _file:
405 return _file.read()
406 except OSError:
407 return None
408
409 if disk_data := await asyncio.to_thread(_read_disk_entry):
410 _source_memory_cache.put(cache_key, disk_data)
411 return disk_data
412
413 try:
414 img_data, disk_cacheable = await _fetch_source_image(mass, path_or_url, provider, depth)
415 except (FileNotFoundError, MediaNotFoundError) as err:
416 # remember the failure briefly and log it once, concisely: every
417 # thumbnail/palette/metadata request for this source would otherwise
418 # retry the origin and log the same error over and over
419 # a provider signals a missing source with MediaNotFoundError, which is not an
420 # OSError and would otherwise bypass this negative cache entirely
421 _store_failed_source(cache_key, str(err))
422 LOGGER.warning("%s (not retrying for %s seconds)", err, _FAILED_SOURCE_TTL)
423 raise
424 _failed_sources.pop(cache_key, None)
425 _source_memory_cache.put(cache_key, img_data)
426 if disk_cacheable:
427 # persist to disk cache (best-effort, don't fail on I/O errors)
428 try:
429 await asyncio.to_thread(os.makedirs, os.path.dirname(filepath), exist_ok=True)
430 async with aiofiles.open(filepath, "wb") as _file:
431 await _file.write(img_data)
432 except OSError:
433 pass
434 return img_data
435
436
437async def _fetch_source_image(
438 mass: MusicAssistant, path_or_url: str, provider: str, depth: int
439) -> tuple[bytes, bool]:
440 """
441 Fetch image bytes from their origin.
442
443 Returns the raw bytes plus whether they may be persisted on disk under this
444 (provider, path) cache key. That is True for every origin except results
445 resolved under a different key (imageproxy URLs pointing at our own server):
446 an alias-keyed copy would keep being served after the underlying image is
447 invalidated.
448
449 :param mass: The MusicAssistant instance.
450 :param path_or_url: The image path or URL.
451 :param provider: The provider ID that can resolve the image.
452 :param depth: Recursion depth of the originating get_image_data call.
453 """
454 if prov := mass.get_provider(provider):
455 assert isinstance(prov, MusicProvider | MetadataProvider | PlayerProvider | PluginProvider)
456 if resolved_image := await prov.resolve_image(path_or_url):
457 if isinstance(resolved_image, bytes):
458 return resolved_image, True
459 if isinstance(resolved_image, str):
460 path_or_url = resolved_image
461 elif (
462 not path_or_url.startswith(("http", "data:image"))
463 and not Path(path_or_url).is_absolute()
464 and mass.get_provider(provider, return_unavailable=True)
465 ):
466 # a relative path means only the provider can say what it is relative to, so a
467 # registered provider that is momentarily down leaves nothing to try: the routes
468 # below would probe a path relative to nothing (spawning ffmpeg per request for
469 # the whole outage) and reporting it as missing would cache that verdict. An
470 # unknown provider does fall through - it is gone for good, so missing is honest.
471 msg = f"{provider} is not available to resolve image {path_or_url}"
472 raise ProviderUnavailableError(msg)
473 # handle HTTP location
474 if path_or_url.startswith("http"):
475 # handle imageproxy URLs pointing to our own server
476 if resolved := await _resolve_own_imageproxy_url(mass, path_or_url):
477 extracted_provider, extracted_path = resolved
478 # route through the public entrypoint so the result is cached under
479 # the resolved key; don't persist it under this alias key as well
480 return (
481 await get_image_data(mass, extracted_path, extracted_provider, _depth=depth + 1),
482 False,
483 )
484 try:
485 return await _fetch_remote_image(mass, path_or_url), True
486 except ClientError as err:
487 msg = f"Failed to fetch image from {path_or_url}: {err}"
488 raise FileNotFoundError(msg) from err
489 # handle base64 embedded images
490 if path_or_url.startswith("data:image"):
491 return b64decode(path_or_url.split(",")[-1]), True
492 # handle FILE location (of type image)
493 if path_or_url.endswith(("jpg", "JPG", "png", "PNG", "jpeg", "svg", "SVG")) and is_safe_path(
494 path_or_url
495 ):
496 if await asyncio.to_thread(os.path.isfile, path_or_url):
497 async with aiofiles.open(path_or_url, "rb") as _file:
498 return cast("bytes", await _file.read()), True
499 # use ffmpeg for embedded images
500 if is_safe_path(path_or_url) and (img_data := await get_embedded_image(path_or_url)):
501 return img_data, True
502 msg = f"Image not found: {path_or_url}"
503 raise FileNotFoundError(msg)
504
505
506async def _fetch_remote_image(mass: MusicAssistant, url: str) -> bytes:
507 """
508 Fetch raw image bytes over HTTP.
509
510 :param mass: The MusicAssistant instance.
511 :param url: The (http/https) image URL to fetch.
512 """
513 # Bot-protected CDNs (e.g. Akamai) reject our normal self-identifying User-Agent,
514 # and even regular browser User-Agents, while still serving well-known fetch tools.
515 # We keep identifying as Music Assistant but carry a Wget compatibility token, which
516 # such CDNs allowlist, so artwork is served on the first (and only) request.
517 user_agent = f"{APPLICATION_NAME}/{mass.version} (Wget/1.24.5; +https://music-assistant.io)"
518 async with mass.http_session_no_ssl.get(
519 url, raise_for_status=True, headers={"User-Agent": user_agent}
520 ) as resp:
521 return await resp.read()
522
523
524async def get_image_thumb(
525 mass: MusicAssistant,
526 path_or_url: str,
527 size: int | None,
528 provider: str,
529 image_format: str = "PNG",
530 flatten_transparency: bool = False,
531) -> bytes:
532 """
533 Get (optimized) thumbnail from image url.
534
535 Uses a two-tier cache (in-memory FIFO + on-disk) keyed by a hash of
536 provider + path so that repeated requests never trigger ffmpeg or
537 PIL processing again. Concurrent requests for the same thumbnail
538 are de-duplicated via create_task.
539
540 :param mass: The MusicAssistant instance.
541 :param path_or_url: Path or URL to the source image.
542 :param size: Target thumbnail size (square), or None for original.
543 :param provider: Provider identifier for the image source.
544 :param image_format: Output format (PNG or JPEG/JPG).
545 :param flatten_transparency: When True, alpha is composited onto white and
546 kept as JPEG; when False, transparent sources are emitted as PNG.
547 """
548 thumb_data, _cache_filepath = await _get_image_thumb(
549 mass,
550 path_or_url,
551 size,
552 provider,
553 image_format,
554 flatten_transparency,
555 )
556 return thumb_data
557
558
559async def get_image_thumb_path(
560 mass: MusicAssistant,
561 path_or_url: str,
562 size: int | None,
563 provider: str,
564 image_format: str = "PNG",
565 flatten_transparency: bool = False,
566) -> str:
567 """
568 Get the absolute on-disk cache path for a thumbnail.
569
570 Unlike :func:`get_image_thumb`, cache persistence is required and any
571 filesystem error is raised to the caller.
572
573 :param mass: The MusicAssistant instance.
574 :param path_or_url: Path or URL to the source image.
575 :param size: Target thumbnail size (square), or None for original.
576 :param provider: Provider identifier for the image source.
577 :param image_format: Output format (PNG or JPEG/JPG).
578 :param flatten_transparency: When True, alpha is composited onto white and
579 kept as JPEG; when False, transparent sources are emitted as PNG.
580 """
581 thumb_data, cache_filepath = await _get_image_thumb(
582 mass,
583 path_or_url,
584 size,
585 provider,
586 image_format,
587 flatten_transparency,
588 )
589 await _ensure_thumb_on_disk(mass, cache_filepath, thumb_data)
590 return cache_filepath
591
592
593async def _get_image_thumb(
594 mass: MusicAssistant,
595 path_or_url: str,
596 size: int | None,
597 provider: str,
598 image_format: str,
599 flatten_transparency: bool,
600) -> tuple[bytes, str]:
601 """
602 Resolve thumbnail bytes and their validated cache path.
603
604 :param mass: The MusicAssistant instance.
605 :param path_or_url: Path or URL to the source image.
606 :param size: Target thumbnail size (square), or None for original.
607 :param provider: Provider identifier for the image source.
608 :param image_format: Output format (PNG or JPEG/JPG).
609 :param flatten_transparency: Whether to flatten alpha onto white for JPEG output.
610 """
611 image_format = image_format.upper()
612 if image_format == "JPG":
613 image_format = "JPEG"
614 if image_format not in _ALLOWED_THUMB_FORMATS:
615 msg = f"Unsupported thumbnail format: {image_format}"
616 raise ValueError(msg)
617
618 thumb_hash = create_thumb_hash(provider, path_or_url)
619 cache_filename = _thumb_cache_filename(thumb_hash, size, image_format, flatten_transparency)
620 if not _THUMB_FILENAME_RE.fullmatch(cache_filename):
621 # cache_filename is built from a sha256 + int + fixed extension, so this
622 # is unreachable in practice â it is here so a future change to either
623 # builder cannot silently let an unsafe value reach the filesystem path
624 msg = f"Refusing to use unexpected cache filename: {cache_filename!r}"
625 raise OSError(msg)
626
627 cache_filepath = _thumb_cache_filepath(mass, cache_filename)
628
629 # 1. Check in-memory FIFO cache
630 if (cached := _get_from_memory_cache(cache_filename)) is not None:
631 return cached, cache_filepath
632
633 # 2. Check on-disk cache
634 if await asyncio.to_thread(os.path.isfile, cache_filepath):
635 try:
636 async with aiofiles.open(cache_filepath, "rb") as f:
637 thumb_data = cast("bytes", await f.read())
638 except FileNotFoundError:
639 pass
640 else:
641 _put_in_memory_cache(cache_filename, thumb_data)
642 return thumb_data, cache_filepath
643
644 # 3. Generate thumbnail (de-duplicated across concurrent requests)
645 task: asyncio.Task[bytes] = mass.create_task(
646 _generate_and_cache_thumb,
647 mass,
648 path_or_url,
649 size,
650 provider,
651 image_format,
652 cache_filepath,
653 flatten_transparency,
654 task_id=f"thumb.{cache_filename}",
655 abort_existing=False,
656 # the failure reaches every waiter, which is where it belongs; a task that lost
657 # every waiter still leaves a debug line behind
658 log_exceptions=False,
659 )
660 thumb_data = await join_task(task)
661 _put_in_memory_cache(cache_filename, thumb_data)
662 return thumb_data, cache_filepath
663
664
665async def _generate_and_cache_thumb(
666 mass: MusicAssistant,
667 path_or_url: str,
668 size: int | None,
669 provider: str,
670 image_format: str,
671 cache_filepath: str,
672 flatten_transparency: bool = False,
673) -> bytes:
674 """
675 Generate a thumbnail, persist it on disk, and return the bytes.
676
677 :param mass: The MusicAssistant instance.
678 :param path_or_url: Path or URL to the source image.
679 :param size: Target thumbnail size (square), or None for original.
680 :param provider: Provider identifier for the image source.
681 :param image_format: Normalized output format (PNG or JPEG).
682 :param cache_filepath: Absolute path where the thumbnail will be stored.
683 :param flatten_transparency: When True, alpha is composited onto white and
684 kept as JPEG; when False, transparent sources are emitted as PNG.
685 """
686 img_data = await get_image_data(mass, path_or_url, provider)
687 if not img_data or not isinstance(img_data, bytes):
688 raise FileNotFoundError(f"Image not found: {path_or_url}")
689
690 if is_svg_data(img_data):
691 # Pillow can't decode SVG; pass it through unchanged.
692 thumb_data = img_data
693 elif not size and image_format.encode() in img_data:
694 thumb_data = img_data
695 else:
696
697 def _create_image() -> bytes:
698 data = BytesIO()
699 try:
700 img: ImageClass = Image.open(BytesIO(img_data))
701 except UnidentifiedImageError:
702 raise FileNotFoundError(f"Invalid image: {path_or_url}")
703 if size:
704 img.thumbnail((size, size), Image.Resampling.LANCZOS)
705 target_format = image_format
706 if target_format == "JPEG" and _has_alpha(img):
707 if flatten_transparency:
708 # composite onto white, else the alpha would flatten to black
709 background = Image.new("RGBA", img.size, (255, 255, 255, 255))
710 background.alpha_composite(img.convert("RGBA"))
711 img = background
712 else:
713 target_format = "PNG"
714 mode = "RGBA" if target_format == "PNG" else "RGB"
715 converted = img.convert(mode)
716 if target_format == "JPEG":
717 converted.save(data, target_format, quality=95, optimize=False)
718 else:
719 converted.save(data, target_format, optimize=False)
720 return data.getvalue()
721
722 thumb_data = await asyncio.to_thread(_create_image)
723
724 # Persist to disk cache (best-effort, don't fail on I/O errors).
725 with contextlib.suppress(OSError):
726 await _write_thumb_to_disk(mass, cache_filepath, thumb_data)
727
728 return thumb_data
729
730
731async def cleanup_thumb_cache(cache_path: str, max_size_bytes: int) -> int:
732 """
733 Remove oldest cached thumbnails when total size exceeds the limit.
734
735 :param cache_path: The base cache directory (mass.cache_path).
736 :param max_size_bytes: Maximum allowed total size in bytes.
737 :returns: Number of files removed.
738 """
739 thumb_dir = os.path.join(cache_path, _THUMB_CACHE_DIR)
740
741 def _cleanup() -> int:
742 if not Path(thumb_dir).is_dir():
743 return 0
744 entries = []
745 for entry in os.scandir(thumb_dir):
746 if entry.is_file():
747 stat = entry.stat()
748 entries.append((entry.path, stat.st_size, stat.st_mtime))
749 entries.sort(key=lambda e: e[2])
750 total_size = sum(e[1] for e in entries)
751 removed = 0
752 for filepath, file_size, _ in entries:
753 if total_size <= max_size_bytes:
754 break
755 try:
756 Path(filepath).unlink()
757 total_size -= file_size
758 removed += 1
759 except OSError:
760 pass
761 return removed
762
763 return await asyncio.to_thread(_cleanup)
764
765
766async def invalidate_cached_image(mass: MusicAssistant, provider: str, path_or_url: str) -> None:
767 """
768 Remove all cached artifacts (thumbnails + source bytes) for an image.
769
770 Used when the image content behind an unchanged (provider, path) identity
771 has changed, e.g. a local file whose (embedded) artwork was replaced.
772
773 :param mass: The MusicAssistant instance.
774 :param provider: Provider id exactly as used when the image was requested.
775 :param path_or_url: Image path or URL exactly as used when the image was requested.
776 """
777 thumb_hash = create_thumb_hash(provider, path_or_url)
778 prefix = f"{thumb_hash}_"
779 for key in [key for key in _thumb_memory_cache if key.startswith(prefix)]:
780 _thumb_memory_cache.pop(key, None)
781 _source_memory_cache.pop(thumb_hash)
782 _failed_sources.pop(thumb_hash, None)
783
784 thumb_dir = os.path.realpath(os.path.join(mass.cache_path, _THUMB_CACHE_DIR))
785
786 def _remove_disk_entries() -> None:
787 # covers every size/format/flatten thumb variant plus the `_src` entry
788 if not Path(thumb_dir).is_dir():
789 return
790 for entry in os.scandir(thumb_dir):
791 if entry.name.startswith(prefix) and entry.is_file():
792 with contextlib.suppress(OSError):
793 Path(entry.path).unlink()
794
795 await asyncio.to_thread(_remove_disk_entries)
796
797
798async def create_collage(
799 mass: MusicAssistant,
800 images: Iterable[MediaItemImage],
801 dimensions: tuple[int, int] = (1500, 1500),
802) -> bytes:
803 """Create a basic collage image from multiple image urls."""
804 image_size = 250
805
806 def _new_collage() -> ImageClass:
807 return Image.new("RGB", (dimensions[0], dimensions[1]), color=(255, 255, 255, 255))
808
809 collage = await asyncio.to_thread(_new_collage)
810
811 def _add_to_collage(img_data: bytes, coord_x: int, coord_y: int) -> None:
812 data = BytesIO(img_data)
813 photo = Image.open(data).convert("RGB")
814 photo = photo.resize((image_size, image_size))
815 collage.paste(photo, (coord_x, coord_y))
816 del data
817
818 # prevent duplicates with a set
819 images = list(set(images))
820 # warm the source cache with bounded concurrency and drop images that can't
821 # be fetched, so the (serial) tile loop below is served from cache
822 fetch_limiter = asyncio.Semaphore(8)
823
824 async def _warm_source_cache(img: MediaItemImage) -> MediaItemImage | None:
825 async with fetch_limiter:
826 try:
827 await get_image_data(mass, img.path, img.provider)
828 except FileNotFoundError, MusicAssistantError:
829 return None
830 return img
831
832 usable_images = [
833 img for img in await asyncio.gather(*map(_warm_source_cache, images)) if img is not None
834 ]
835 if not usable_images:
836 msg = "None of the collage images could be fetched"
837 raise FileNotFoundError(msg)
838 random.shuffle(usable_images)
839 iter_images = itertools.cycle(usable_images)
840
841 for x_co in range(0, dimensions[0], image_size):
842 for y_co in range(0, dimensions[1], image_size):
843 # try a few candidates per tile: a fetched image can still fail to decode
844 for _ in range(5):
845 img = next(iter_images)
846 try:
847 img_data = await get_image_data(mass, img.path, img.provider)
848 await asyncio.to_thread(_add_to_collage, img_data, x_co, y_co)
849 except FileNotFoundError, MusicAssistantError, UnidentifiedImageError:
850 continue
851 del img_data
852 break
853
854 def _save_collage() -> bytes:
855 final_data = BytesIO()
856 collage.convert("RGB").save(final_data, "JPEG", optimize=True)
857 return final_data.getvalue()
858
859 return await asyncio.to_thread(_save_collage)
860
861
862async def load_provider_icon(icon_path: str) -> tuple[str, bytes]:
863 """
864 Load a provider icon file and return its mime type and bytes.
865
866 :param icon_path: Path to an svg or (transparent) png icon file.
867 """
868 ext = icon_path.rsplit(".", maxsplit=1)[-1].lower()
869 if ext == "svg":
870 async with aiofiles.open(icon_path, encoding="utf-8") as svg_file:
871 xml_data = (await svg_file.read()).replace("\n", "").strip()
872 return "image/svg+xml", xml_data.encode("utf-8")
873 if ext == "png":
874 async with aiofiles.open(icon_path, "rb") as png_file:
875 return "image/png", await png_file.read()
876 msg = f"Unsupported provider icon format: {ext}"
877 raise ValueError(msg)
878
879
880async def detect_provider_icons(
881 provider_path: str,
882) -> dict[ProviderIconVariant, tuple[str, bytes]]:
883 """
884 Detect the provider icon variants present in a provider directory.
885
886 Svg is preferred over png when both exist for the same variant.
887
888 :param provider_path: Path to the provider directory to scan.
889 """
890 variant_files = {
891 ProviderIconVariant.DEFAULT: ("icon.svg", "icon.png"),
892 ProviderIconVariant.DARK: ("icon_dark.svg", "icon_dark.png"),
893 ProviderIconVariant.MONOCHROME: ("icon_monochrome.svg", "icon_monochrome.png"),
894 }
895 icons: dict[ProviderIconVariant, tuple[str, bytes]] = {}
896 for variant, filenames in variant_files.items():
897 for filename in filenames: # svg first -> preferred
898 icon_path = os.path.join(provider_path, filename)
899 if await aiofiles.os.path.isfile(icon_path):
900 icons[variant] = await load_provider_icon(icon_path)
901 break
902 return icons
903
904
905def _thumb_cache_filepath(mass: MusicAssistant, cache_filename: str) -> str:
906 """Return a validated absolute path for a thumbnail cache filename."""
907 thumb_dir = os.path.realpath(os.path.join(mass.cache_path, _THUMB_CACHE_DIR))
908 cache_filepath = os.path.realpath(os.path.join(thumb_dir, cache_filename))
909 if not cache_filepath.startswith(thumb_dir + os.sep):
910 msg = f"Cache path escapes thumbnail directory: {cache_filepath}"
911 raise OSError(msg)
912 return cache_filepath
913
914
915async def _ensure_thumb_on_disk(
916 mass: MusicAssistant, cache_filepath: str, thumb_data: bytes
917) -> None:
918 """
919 Ensure thumbnail bytes are fully persisted at their cache path.
920
921 :param mass: The MusicAssistant instance.
922 :param cache_filepath: Validated absolute thumbnail cache path.
923 :param thumb_data: Complete encoded thumbnail bytes.
924 """
925
926 def _is_complete() -> bool:
927 path = Path(cache_filepath)
928 try:
929 return path.is_file() and path.stat().st_size == len(thumb_data)
930 except OSError:
931 return False
932
933 if await asyncio.to_thread(_is_complete):
934 return
935 await _write_thumb_to_disk(mass, cache_filepath, thumb_data)
936 if not await asyncio.to_thread(_is_complete):
937 msg = f"Thumbnail cache file was not persisted: {cache_filepath}"
938 raise OSError(msg)
939
940
941async def _write_thumb_to_disk(
942 mass: MusicAssistant, cache_filepath: str, thumb_data: bytes
943) -> None:
944 """
945 Atomically persist thumbnail bytes to their validated cache path.
946
947 :param mass: The MusicAssistant instance.
948 :param cache_filepath: Validated absolute thumbnail cache path.
949 :param thumb_data: Complete encoded thumbnail bytes.
950 """
951 resolved = os.path.realpath(cache_filepath)
952 thumb_dir = os.path.realpath(os.path.join(mass.cache_path, _THUMB_CACHE_DIR))
953 if not resolved.startswith(thumb_dir + os.sep):
954 msg = f"Cache path escapes thumbnail directory: {resolved}"
955 raise OSError(msg)
956 await asyncio.to_thread(os.makedirs, thumb_dir, exist_ok=True)
957 file_descriptor, temp_filepath = await asyncio.to_thread(
958 tempfile.mkstemp, dir=thumb_dir, prefix=".thumb-"
959 )
960 await asyncio.to_thread(os.close, file_descriptor)
961 try:
962 async with aiofiles.open(temp_filepath, "wb") as temp_file:
963 await temp_file.write(thumb_data)
964 await asyncio.to_thread(os.replace, temp_filepath, resolved)
965 finally:
966 with contextlib.suppress(OSError):
967 await asyncio.to_thread(Path(temp_filepath).unlink)
968