Reduce FFmpeg cover-art storms (#3109)
authorŁukasz Wawrzyk <lukasz.wawrzyk@gmail.com>
Thu, 19 Feb 2026 09:03:14 +0000 (10:03 +0100)
committerGitHub <noreply@github.com>
Thu, 19 Feb 2026 09:03:14 +0000 (10:03 +0100)
* fix(images): add in-memory cache and concurrency limit for image data

Add a TTL-based LRU memory cache (256 entries, 15min TTL) to
get_image_data() to avoid redundant fetches of the same image.
Deduplicate concurrent in-flight requests via task_id and limit
embedded image extractions to 2 concurrent ffmpeg processes
using a semaphore.

Includes unit tests for cache hits, TTL expiry, eviction, and
request deduplication.

* Revert "fix(images): add in-memory cache and concurrency limit for image data"

This reverts commit a676a2484d24df1de4d2631118f1f0315c503fc8.

* feat(images): add on-disk thumbnail cache to avoid repeated ffmpeg extractions

Implement a two-tier caching system for image thumbnails:

- On-disk cache: thumbnails stored as {sha256(provider/path)}_{size}.{ext}
  in a "thumbnails" subfolder of the cache directory, surviving restarts.
- In-memory FIFO cache: last 50 thumbnails for instant access on hot paths.
- In-flight deduplication: concurrent requests for the same thumbnail share
  a single generation task via create_task, preventing ffmpeg bursts.

This eliminates repeated ffmpeg spawns for embedded cover art extraction,
which caused high CPU usage and playback interruptions especially on
lower-powered hardware and network-mounted music libraries (e.g. SMB).

music_assistant/helpers/images.py

index dca001a5c265d0c3130b7e32fb70f48cdec7446d..90e5058ea7cb8da7df20fb578d854d5a1d9a334f 100644 (file)
@@ -3,10 +3,12 @@
 from __future__ import annotations
 
 import asyncio
+import hashlib
 import itertools
 import os
 import random
 from base64 import b64decode
+from collections import OrderedDict
 from collections.abc import Iterable
 from io import BytesIO
 from typing import TYPE_CHECKING, cast
@@ -28,6 +30,43 @@ if TYPE_CHECKING:
     from music_assistant.mass import MusicAssistant
 
 
+# Thumbnail cache: on-disk (persistent) + small in-memory FIFO (hot path)
+_THUMB_CACHE_DIR = "thumbnails"
+_THUMB_MEMORY_CACHE_MAX = 50
+
+_thumb_memory_cache: OrderedDict[str, bytes] = OrderedDict()
+
+
+def _create_thumb_hash(provider: str, path_or_url: str) -> str:
+    """Create a safe filesystem hash from provider and image path."""
+    raw = f"{provider}/{path_or_url}"
+    return hashlib.sha256(raw.encode(), usedforsecurity=False).hexdigest()
+
+
+def _thumb_cache_filename(thumb_hash: str, size: int | None, image_format: str) -> str:
+    """Build the cache filename for a thumbnail."""
+    ext = image_format.lower()
+    if ext == "jpeg":
+        ext = "jpg"
+    return f"{thumb_hash}_{size or 0}.{ext}"
+
+
+def _get_from_memory_cache(key: str) -> bytes | None:
+    """Retrieve thumbnail from in-memory FIFO cache."""
+    if key in _thumb_memory_cache:
+        _thumb_memory_cache.move_to_end(key)
+        return _thumb_memory_cache[key]
+    return None
+
+
+def _put_in_memory_cache(key: str, data: bytes) -> None:
+    """Store thumbnail in in-memory FIFO cache."""
+    _thumb_memory_cache[key] = data
+    _thumb_memory_cache.move_to_end(key)
+    while len(_thumb_memory_cache) > _THUMB_MEMORY_CACHE_MAX:
+        _thumb_memory_cache.popitem(last=False)
+
+
 async def get_image_data(mass: MusicAssistant, path_or_url: str, provider: str) -> bytes:
     """Create thumbnail from image url."""
     # TODO: add local cache here !
@@ -67,41 +106,107 @@ async def get_image_thumb(
     provider: str,
     image_format: str = "PNG",
 ) -> bytes:
-    """Get (optimized) PNG thumbnail from image url."""
+    """Get (optimized) thumbnail from image url.
+
+    Uses a two-tier cache (in-memory FIFO + on-disk) keyed by a hash of
+    provider + path so that repeated requests never trigger ffmpeg or
+    PIL processing again.  Concurrent requests for the same thumbnail
+    are de-duplicated via create_task.
+
+    :param mass: The MusicAssistant instance.
+    :param path_or_url: Path or URL to the source image.
+    :param size: Target thumbnail size (square), or None for original.
+    :param provider: Provider identifier for the image source.
+    :param image_format: Output format (PNG or JPEG/JPG).
+    """
+    image_format = image_format.upper()
+    if image_format == "JPG":
+        image_format = "JPEG"
+
+    thumb_hash = _create_thumb_hash(provider, path_or_url)
+    cache_filename = _thumb_cache_filename(thumb_hash, size, image_format)
+
+    # 1. Check in-memory FIFO cache
+    if cached := _get_from_memory_cache(cache_filename):
+        return cached
+
+    # 2. Check on-disk cache
+    thumb_dir = os.path.join(mass.cache_path, _THUMB_CACHE_DIR)
+    cache_filepath = os.path.join(thumb_dir, cache_filename)
+    if await asyncio.to_thread(os.path.isfile, cache_filepath):
+        async with aiofiles.open(cache_filepath, "rb") as f:
+            thumb_data = cast("bytes", await f.read())
+        _put_in_memory_cache(cache_filename, thumb_data)
+        return thumb_data
+
+    # 3. Generate thumbnail (de-duplicated across concurrent requests)
+    task: asyncio.Task[bytes] = mass.create_task(
+        _generate_and_cache_thumb,
+        mass,
+        path_or_url,
+        size,
+        provider,
+        image_format,
+        cache_filepath,
+        task_id=f"thumb.{cache_filename}",
+        abort_existing=False,
+    )
+    thumb_data = await asyncio.shield(task)
+    _put_in_memory_cache(cache_filename, thumb_data)
+    return thumb_data
+
+
+async def _generate_and_cache_thumb(
+    mass: MusicAssistant,
+    path_or_url: str,
+    size: int | None,
+    provider: str,
+    image_format: str,
+    cache_filepath: str,
+) -> bytes:
+    """Generate a thumbnail, persist it on disk, and return the bytes.
+
+    :param mass: The MusicAssistant instance.
+    :param path_or_url: Path or URL to the source image.
+    :param size: Target thumbnail size (square), or None for original.
+    :param provider: Provider identifier for the image source.
+    :param image_format: Normalized output format (PNG or JPEG).
+    :param cache_filepath: Absolute path where the thumbnail will be stored.
+    """
     img_data = await get_image_data(mass, path_or_url, provider)
     if not img_data or not isinstance(img_data, bytes):
         raise FileNotFoundError(f"Image not found: {path_or_url}")
 
     if not size and image_format.encode() in img_data:
-        return img_data
+        thumb_data = img_data
+    else:
 
-    image_format = image_format.upper()
-    if image_format == "JPG":
-        image_format = "JPEG"
+        def _create_image() -> bytes:
+            data = BytesIO()
+            try:
+                img = Image.open(BytesIO(img_data))
+            except UnidentifiedImageError:
+                raise FileNotFoundError(f"Invalid image: {path_or_url}")
+            if size:
+                img.thumbnail((size, size), Image.Resampling.LANCZOS)
+            mode = "RGBA" if image_format == "PNG" else "RGB"
+            if image_format == "JPEG":
+                img.convert(mode).save(data, image_format, quality=95, optimize=False)
+            else:
+                img.convert(mode).save(data, image_format, optimize=False)
+            return data.getvalue()
 
-    def _create_image() -> bytes:
-        data = BytesIO()
-        try:
-            img = Image.open(BytesIO(img_data))
-        except UnidentifiedImageError:
-            raise FileNotFoundError(f"Invalid image: {path_or_url}")
-        if size:
-            # Use LANCZOS for high quality downsampling
-            img.thumbnail((size, size), Image.Resampling.LANCZOS)
-
-        mode = "RGBA" if image_format == "PNG" else "RGB"
-
-        # Save with high quality settings
-        if image_format == "JPEG":
-            # For JPEG, use quality=95 for better quality
-            img.convert(mode).save(data, image_format, quality=95, optimize=False)
-        else:
-            # For PNG, disable optimize to preserve quality
-            img.convert(mode).save(data, image_format, optimize=False)
-        return data.getvalue()
+        thumb_data = await asyncio.to_thread(_create_image)
 
-    image_format = image_format.upper()
-    return await asyncio.to_thread(_create_image)
+    # Persist to disk cache (best-effort, don't fail on I/O errors)
+    try:
+        await asyncio.to_thread(os.makedirs, os.path.dirname(cache_filepath), exist_ok=True)
+        async with aiofiles.open(cache_filepath, "wb") as f:
+            await f.write(thumb_data)
+    except OSError:
+        pass
+
+    return thumb_data
 
 
 async def create_collage(