music-assistant-server

37.6 KBPY
images.py
37.6 KB968 lines • python
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