/
/
/
1"""
2Image handling for the Metadata Controller.
3
4Provides the ImageProxyMixin, mixed into the MetaDataController, which resolves
5media images to (proxied) URLs, renders and caches thumbnails, serves the
6``/imageproxy`` HTTP endpoint, extracts colour palettes and builds playlist
7collage images.
8"""
9
10from __future__ import annotations
11
12import os
13import random
14import threading
15import time
16from base64 import b64encode
17from typing import TYPE_CHECKING, cast
18
19import aiofiles
20from aiohttp import web
21from music_assistant_models.auth import Scope
22from music_assistant_models.enums import ImageType
23from music_assistant_models.errors import MediaNotFoundError, ProviderUnavailableError
24from music_assistant_models.media_items import (
25 Album,
26 BrowseFolder,
27 ItemMapping,
28 MediaItemImage,
29 MediaItemPalette,
30 MediaItemType,
31 Track,
32)
33
34from music_assistant.constants import VERBOSE_LOG_LEVEL
35from music_assistant.helpers.api import api_command
36from music_assistant.helpers.colors import get_palette, invalidate_cached_palette
37from music_assistant.helpers.images import (
38 create_collage,
39 create_thumb_hash,
40 detect_image_content_format,
41 get_image_data,
42 get_image_thumb,
43 invalidate_cached_image,
44)
45from music_assistant.helpers.security import is_safe_path
46
47from .constants import (
48 _ALLOWED_IMAGEPROXY_SIZES,
49 _ALLOWED_IMAGEPROXY_SIZES_STR,
50 _IMAGE_ID_CACHE_TTL,
51 _IMAGE_ID_LRU_MAX,
52 _IMAGEPROXY_CONTENT_TYPES,
53 _IMAGEPROXY_PATH_PREFIX,
54 CACHE_CATEGORY_IMAGE_IDS,
55)
56from .helpers import (
57 _detect_image_format,
58 _normalize_imageproxy_format,
59)
60
61if TYPE_CHECKING:
62 import logging
63 from collections import OrderedDict
64
65 from music_assistant import MusicAssistant
66 from music_assistant.controllers.cache import CacheController
67
68
69class ImageProxyMixin:
70 """
71 Image/imageproxy functionality for the MetaDataController.
72
73 Expects to be mixed with a class providing ``mass``, ``cache``, ``logger``,
74 ``domain``, the ``_collage_images_dir`` set during setup and the image-id
75 LRU bookkeeping attributes initialised in ``__init__``.
76 """
77
78 if TYPE_CHECKING:
79 mass: MusicAssistant
80 cache: CacheController
81 logger: logging.Logger
82 domain: str
83 _collage_images_dir: str
84 _image_id_forward: dict[tuple[str, str], str]
85 _image_id_lru: OrderedDict[str, tuple[str, str]]
86 _image_id_persisted: dict[str, float]
87 _image_id_lock: threading.Lock
88
89 def compute_image_id(self, provider: str, path: str) -> str:
90 """
91 Return the opaque imageproxy image id for the given image.
92
93 The id is deterministic: the same (provider, path) pair always
94 yields the same id, across processes and restarts. Calling this
95 also ensures the id is resolvable back to (provider, path) by
96 a subsequent imageproxy request.
97
98 Safe to call from any thread.
99
100 :param provider: Provider id that owns / can resolve the image.
101 :param path: Image path or URL as the provider knows it.
102 """
103 # fast path: a bare dict read is atomic, so no hashing or locking is
104 # needed for an image that was serialized before. This runs for every
105 # image occurrence in every outbound message, so it must stay cheap.
106 image_key = (provider, path)
107 if (image_id := self._image_id_forward.get(image_key)) is not None:
108 return image_id
109 image_id = create_thumb_hash(provider, path)
110 now = time.time()
111 with self._image_id_lock:
112 self._image_id_forward[image_key] = image_id
113 while len(self._image_id_forward) > _IMAGE_ID_LRU_MAX:
114 del self._image_id_forward[next(iter(self._image_id_forward))]
115 self._image_id_lru[image_id] = image_key
116 self._image_id_lru.move_to_end(image_id)
117 while len(self._image_id_lru) > _IMAGE_ID_LRU_MAX:
118 self._image_id_lru.popitem(last=False)
119 # skip the persist when this process already stored the mapping
120 # recently; re-persist once the stored row has burned through half
121 # its TTL so long-lived ids remain resolvable across restarts
122 persisted_at = self._image_id_persisted.get(image_id)
123 if persisted_at is not None and now - persisted_at < _IMAGE_ID_CACHE_TTL / 2:
124 return image_id
125 # mark optimistically at schedule time to dedupe concurrent bursts;
126 # _persist_image_id drops the marker again if storing fails
127 self._image_id_persisted[image_id] = now
128 while len(self._image_id_persisted) > _IMAGE_ID_LRU_MAX:
129 del self._image_id_persisted[next(iter(self._image_id_persisted))]
130 # the to_dict hook calls us from the executor when running under
131 # _send_message; only call create_task directly when we know we are
132 # on the loop thread, otherwise hop across via call_soon_threadsafe
133 coro = self._persist_image_id(image_id, provider, path)
134 if threading.get_ident() == self.mass.loop_thread_id:
135 self.mass.create_task(coro)
136 else:
137 self.mass.loop.call_soon_threadsafe(self.mass.create_task, coro)
138 return image_id
139
140 async def resolve_image_id(self, image_id: str) -> tuple[str, str] | None:
141 """
142 Return the (provider, path) tuple for a previously registered image id.
143
144 :param image_id: The opaque id as produced by `compute_image_id`.
145 """
146 with self._image_id_lock:
147 if cached := self._image_id_lru.get(image_id):
148 self._image_id_lru.move_to_end(image_id)
149 return cached
150 cached_db = await self.cache.get(
151 key=image_id,
152 category=CACHE_CATEGORY_IMAGE_IDS,
153 provider=self.domain,
154 )
155 if isinstance(cached_db, dict):
156 provider = cached_db.get("provider")
157 path = cached_db.get("path")
158 if isinstance(provider, str) and isinstance(path, str):
159 result = (provider, path)
160 with self._image_id_lock:
161 self._image_id_lru[image_id] = result
162 while len(self._image_id_lru) > _IMAGE_ID_LRU_MAX:
163 self._image_id_lru.popitem(last=False)
164 self._image_id_forward[result] = image_id
165 while len(self._image_id_forward) > _IMAGE_ID_LRU_MAX:
166 del self._image_id_forward[next(iter(self._image_id_forward))]
167 return result
168 return None
169
170 async def get_image_data_for_item(
171 self,
172 media_item: MediaItemType,
173 img_type: ImageType = ImageType.THUMB,
174 size: int = 0,
175 ) -> bytes | None:
176 """Get image data for given MedaItem."""
177 img_path = await self.get_image_url_for_item(
178 media_item=media_item,
179 img_type=img_type,
180 )
181 if not img_path:
182 return None
183 try:
184 thumbnail = await self.get_thumbnail(img_path, provider="builtin", size=size)
185 except MediaNotFoundError:
186 return None
187
188 return cast("bytes", thumbnail)
189
190 async def get_image_url_for_item(
191 self,
192 media_item: MediaItemType | ItemMapping,
193 img_type: ImageType = ImageType.THUMB,
194 resolve: bool = True,
195 ) -> str | None:
196 """Get url to image for given media media_item."""
197 if not media_item:
198 return None
199
200 if isinstance(media_item, ItemMapping):
201 # Check if the ItemMapping already has an image - avoid expensive API call
202 if media_item.image and media_item.image.type == img_type:
203 if media_item.image.remotely_accessible and resolve:
204 return self.get_image_url(media_item.image)
205 if not media_item.image.remotely_accessible:
206 return media_item.image.path
207
208 # Only retrieve full item if we don't have the image we need
209 if not media_item.uri:
210 return None
211 retrieved_item = await self.mass.music.get_item_by_uri(media_item.uri)
212 if isinstance(retrieved_item, BrowseFolder):
213 return None # can not happen, but guard for type checker
214 media_item = retrieved_item
215
216 if media_item and media_item.metadata.images:
217 for img in media_item.metadata.images:
218 if img.type != img_type:
219 continue
220 if not img.remotely_accessible and not resolve:
221 # ignore image if its not remotely accessible and we don't allow resolving
222 continue
223 return self.get_image_url(img, prefer_proxy=not img.remotely_accessible)
224
225 # retry with track's album
226 if isinstance(media_item, Track) and media_item.album:
227 return await self.get_image_url_for_item(media_item.album, img_type, resolve)
228
229 # try artist instead for albums
230 if isinstance(media_item, Album) and media_item.artists:
231 return await self.get_image_url_for_item(media_item.artists[0], img_type, resolve)
232
233 # last resort: track artist(s)
234 if isinstance(media_item, Track) and media_item.artists:
235 for artist in media_item.artists:
236 return await self.get_image_url_for_item(artist, img_type, resolve)
237
238 return None
239
240 def get_image_url(
241 self,
242 image: MediaItemImage,
243 size: int = 0,
244 prefer_proxy: bool = False,
245 image_format: str | None = None,
246 prefer_stream_server: bool = False,
247 ) -> str:
248 """Get (proxied) URL for MediaItemImage."""
249 if image_format is None:
250 image_format = _detect_image_format(image.path)
251 if image_format == "svg":
252 # SVGs don't need resizing
253 size = 0
254 if not image.remotely_accessible or prefer_proxy or size:
255 # short opaque id form; same id as the thumbnail cache key
256 image_id = self.compute_image_id(image.provider, image.path)
257 base_url = (
258 self.mass.streams.base_url if prefer_stream_server else self.mass.webserver.base_url
259 )
260 return f"{base_url}/imageproxy/{image_id}?size={size}&fmt={image_format}"
261 return image.path
262
263 @api_command("metadata/get_image_palette", required_scope=Scope.LIBRARY_READ)
264 async def get_image_palette(self, image_id: str) -> MediaItemPalette | None:
265 """
266 Get the color palette extracted from a (proxied) image.
267
268 The palette follows the Sendspin color@v1 spec (primary, accent, on_dark,
269 on_light, background_dark and background_light). Results are cached, so
270 repeated requests for the same image are cheap.
271
272 :param image_id: The opaque imageproxy image id (the ``proxy_id`` field on a
273 ``MediaItemImage``). Resolved to the image registered for that id; an
274 unknown id yields None.
275 """
276 resolved = await self.resolve_image_id(image_id)
277 if resolved is None:
278 return None
279 provider, path = resolved
280 try:
281 return await get_palette(self.mass, path, provider)
282 except MediaNotFoundError, OSError:
283 return None
284
285 async def invalidate_image_cache(self, provider: str, path: str) -> None:
286 """
287 Drop every cached artifact for an image so the next request re-fetches it.
288
289 Removes the cached source bytes, all thumbnail size/format variants
290 (memory + disk) and the extracted color palette. Call this when the
291 image content behind an unchanged (provider, path) identity has
292 changed, e.g. a local file whose (embedded) artwork was replaced.
293
294 :param provider: Provider (instance) id that owns / can resolve the image.
295 :param path: Image path or URL exactly as referenced by media items.
296 """
297 await invalidate_cached_image(self.mass, provider, path)
298 await invalidate_cached_palette(self.mass, provider, path)
299
300 async def get_thumbnail(
301 self,
302 path: str,
303 provider: str,
304 size: int | None = None,
305 base64: bool = False,
306 image_format: str | None = None,
307 flatten_transparency: bool = False,
308 ) -> bytes | str:
309 """Get/create thumbnail image for path (image url or local path)."""
310 if image_format is None:
311 image_format = _detect_image_format(path)
312 try:
313 thumbnail_bytes, content_format = await self._resolve_thumbnail(
314 path, provider, size, image_format, flatten_transparency
315 )
316 except (MediaNotFoundError, ProviderUnavailableError, OSError) as err:
317 # normalize a missing/unreadable image into one typed error so callers
318 # (and not just the HTTP imageproxy handler) can handle it uniformly.
319 # an unavailable provider is included: to a caller it is equally unreadable,
320 # and leaking it would abort sends that only meant to skip the artwork
321 raise MediaNotFoundError(f"Image not found or unreadable: {path}") from err
322 if base64:
323 enc_image = b64encode(thumbnail_bytes).decode()
324 return f"data:{_IMAGEPROXY_CONTENT_TYPES[content_format]};base64,{enc_image}"
325 return thumbnail_bytes
326
327 async def handle_imageproxy(self, request: web.Request) -> web.Response:
328 """
329 Serve an image for a `/imageproxy/<image_id>?size=&fmt=` request.
330
331 This is the canonical imageproxy endpoint: clients build the URL by
332 taking the `proxy_id` from a `MediaItemImage` and appending it as a
333 single path segment, optionally with `size` and `fmt` query parameters.
334 """
335 # require exactly /imageproxy/<id> (optionally with a trailing slash);
336 # extra path segments such as /imageproxy/foo/<id> must not validate
337 if not request.path.startswith(_IMAGEPROXY_PATH_PREFIX):
338 return web.Response(status=400)
339 image_id = request.path[len(_IMAGEPROXY_PATH_PREFIX) :].rstrip("/").lower()
340 if len(image_id) != 64 or any(c not in "0123456789abcdef" for c in image_id):
341 return web.Response(status=400, text="Invalid image id")
342 try:
343 size = int(request.query.get("size", "0"))
344 except ValueError:
345 return web.Response(
346 status=400,
347 text=f"Invalid size parameter: must be one of {_ALLOWED_IMAGEPROXY_SIZES_STR}.",
348 )
349 if size not in _ALLOWED_IMAGEPROXY_SIZES:
350 return web.Response(
351 status=400,
352 text=f"Unsupported size {size}: must be one of {_ALLOWED_IMAGEPROXY_SIZES_STR} "
353 "(0 = original size).",
354 )
355 resolved = await self.resolve_image_id(image_id)
356 if resolved is None:
357 return web.Response(status=404)
358 provider, path = resolved
359 image_format = _normalize_imageproxy_format(
360 request.query.get("fmt")
361 ) or _detect_image_format(path)
362 return await self._serve_thumbnail(path, provider, size, image_format)
363
364 async def create_collage_image(
365 self,
366 images: list[MediaItemImage],
367 filename: str,
368 fanart: bool = False,
369 ) -> MediaItemImage | None:
370 """Create collage thumb/fanart image for (in-library) playlist."""
371 if (len(images) < 8 and fanart) or len(images) < 3:
372 # require at least some images otherwise this does not make a lot of sense
373 return None
374 # limit to 50 images to prevent we're going OOM
375 if len(images) > 50:
376 images = random.sample(images, 50)
377 else:
378 random.shuffle(images)
379 try:
380 # create collage thumb from playlist tracks
381 # if playlist has no default image (e.g. a local playlist)
382 dimensions = (2500, 1750) if fanart else (1500, 1500)
383 img_data = await create_collage(self.mass, images, dimensions)
384 # always overwrite existing path
385 file_path = os.path.join(self._collage_images_dir, filename)
386 async with aiofiles.open(file_path, "wb") as _file:
387 await _file.write(img_data)
388 del img_data
389 return MediaItemImage(
390 type=ImageType.FANART if fanart else ImageType.THUMB,
391 path=f"/collage/{filename}",
392 provider="builtin",
393 remotely_accessible=False,
394 )
395 except Exception as err:
396 self.logger.warning(
397 "Error while creating playlist image: %s",
398 str(err),
399 exc_info=err if self.logger.isEnabledFor(10) else None,
400 )
401 return None
402
403 async def _resolve_thumbnail(
404 self,
405 path: str,
406 provider: str,
407 size: int | None,
408 image_format: str,
409 flatten_transparency: bool,
410 ) -> tuple[bytes, str]:
411 """
412 Fetch image bytes and return them with their resolved content format.
413
414 The served format can differ from the requested one (SVG is passed
415 through unchanged and transparent sources may be kept as PNG), so the
416 actual format is determined once here and reused by every caller.
417
418 :param path: Image url or local path.
419 :param provider: Provider identifier for the image source.
420 :param size: Target thumbnail size (square), or None for original.
421 :param image_format: Requested output format (jpg/jpeg/png/svg).
422 :param flatten_transparency: Composite alpha onto white and keep JPEG when True.
423 """
424 if provider == "builtin" and path.startswith("/collage/"):
425 # special case for collage images
426 collage_rel = path.rsplit("/collage/", maxsplit=1)[-1]
427 if not is_safe_path(collage_rel):
428 raise FileNotFoundError("Invalid collage path")
429 path = os.path.join(self._collage_images_dir, collage_rel)
430 if image_format == "svg":
431 return await get_image_data(self.mass, path, provider), "svg"
432 thumbnail_bytes = await get_image_thumb(
433 self.mass,
434 path,
435 size=size,
436 provider=provider,
437 image_format=image_format,
438 flatten_transparency=flatten_transparency,
439 )
440 return thumbnail_bytes, detect_image_content_format(thumbnail_bytes) or image_format
441
442 async def _serve_thumbnail(
443 self, path: str, provider: str, size: int, image_format: str
444 ) -> web.Response:
445 """Fetch (or render+cache) the thumbnail and produce an HTTP response."""
446 # `fmt=jpeg` is the explicit player-media request: players are sent a
447 # JPEG for maximum compatibility, and since JPEG has no alpha channel we
448 # composite transparency onto white. The auto-detected `fmt=jpg`/`png`
449 # default (app/UI) instead keeps transparency as PNG.
450 flatten_transparency = image_format == "jpeg"
451 try:
452 image_data, content_format = await self._resolve_thumbnail(
453 path, provider, size, image_format, flatten_transparency
454 )
455 except Exception as err:
456 # broadly catch all exceptions here to ensure we dont crash the request handler
457 if isinstance(err, (MediaNotFoundError, FileNotFoundError)):
458 self.logger.log(VERBOSE_LOG_LEVEL, "Image not found: %s", path)
459 else:
460 self.logger.warning(
461 "Error while fetching image %s: %s",
462 path,
463 str(err),
464 exc_info=err if self.logger.isEnabledFor(10) else None,
465 )
466 return web.Response(status=404)
467 response_headers = {
468 "Cache-Control": "max-age=31536000",
469 "Access-Control-Allow-Origin": "*",
470 }
471 if content_format == "svg":
472 # Sniffed SVGs from attacker-influenceable sources (radio favicons) are
473 # served same-origin; without a CSP an embedded <script> would run.
474 response_headers["Content-Security-Policy"] = (
475 "default-src 'none'; style-src 'unsafe-inline'; sandbox"
476 )
477 response_headers["X-Content-Type-Options"] = "nosniff"
478 return web.Response(
479 body=image_data,
480 headers=response_headers,
481 content_type=_IMAGEPROXY_CONTENT_TYPES[content_format],
482 )
483
484 async def _persist_image_id(self, image_id: str, provider: str, path: str) -> None:
485 """Store an image-id mapping so a later imageproxy request can resolve it."""
486 try:
487 # the mapping is usually already stored by a previous process run;
488 # probing the expiration first turns the write storm while browsing
489 # after a restart into (much cheaper) reads. Only rewrite when the
490 # stored row is absent or has burned through half its TTL.
491 expires = await self.cache.get_expiration(
492 key=image_id,
493 category=CACHE_CATEGORY_IMAGE_IDS,
494 provider=self.domain,
495 )
496 if expires is not None and expires - time.time() > _IMAGE_ID_CACHE_TTL / 2:
497 return
498 await self.cache.set(
499 key=image_id,
500 data={"provider": provider, "path": path},
501 category=CACHE_CATEGORY_IMAGE_IDS,
502 provider=self.domain,
503 expiration=_IMAGE_ID_CACHE_TTL,
504 persistent=True,
505 )
506 except Exception:
507 # drop the optimistic marker so a later encounter retries the persist
508 with self._image_id_lock:
509 self._image_id_persisted.pop(image_id, None)
510 raise
511