Skip to content

Commit 87d4dba

Browse files
marcelveldtanatosun
authored andcommitted
Add API command to get the color palette for any image (music-assistant#4193)
1 parent d2c650c commit 87d4dba

6 files changed

Lines changed: 154 additions & 57 deletions

File tree

music_assistant/controllers/metadata.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
ItemMapping,
4848
MediaItemImage,
4949
MediaItemMetadata,
50+
MediaItemPalette,
5051
MediaItemType,
5152
Playlist,
5253
Podcast,
@@ -70,6 +71,7 @@
7071
update_current_task_progress_text,
7172
)
7273
from music_assistant.helpers.api import api_command
74+
from music_assistant.helpers.colors import get_palette, get_palette_for_url
7375
from music_assistant.helpers.compare import compare_strings
7476
from music_assistant.helpers.images import (
7577
cleanup_thumb_cache,
@@ -648,6 +650,25 @@ def get_image_url(
648650
return f"{base_url}/imageproxy/{image_id}?size={size}&fmt={image_format}"
649651
return image.path
650652

653+
@api_command("metadata/get_image_palette")
654+
async def get_image_palette(self, image: MediaItemImage | str) -> MediaItemPalette | None:
655+
"""
656+
Get the color palette extracted from an image.
657+
658+
The palette follows the Sendspin color@v1 spec (primary, accent, on_dark,
659+
on_light, background_dark and background_light). Results are cached, so
660+
repeated requests for the same image are cheap.
661+
662+
:param image: A MediaItemImage to read colors from, or an image URL (either a
663+
direct URL or an imageproxy URL as produced by `get_image_url`).
664+
"""
665+
if not isinstance(image, MediaItemImage):
666+
return await get_palette_for_url(self.mass, image)
667+
try:
668+
return await get_palette(self.mass, image.path, image.provider)
669+
except (FileNotFoundError, OSError):
670+
return None
671+
651672
async def get_thumbnail(
652673
self,
653674
path: str,

music_assistant/controllers/players/controller.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@
100100
get_sendspin_player_id,
101101
)
102102
from music_assistant.helpers.api import api_command
103-
from music_assistant.helpers.colors import get_palette_for_url, peek_palette_for_url
103+
from music_assistant.helpers.colors import get_palette_for_url
104104
from music_assistant.helpers.tags import async_parse_tags
105105
from music_assistant.helpers.util import (
106106
TaskManager,
@@ -1668,25 +1668,30 @@ def _schedule_palette_fetch(
16681668
:param trigger_update: When True, re-emit player state once palette is ready
16691669
(current track). When False, only warm the cache (prefetch).
16701670
"""
1671-
if not image_url or peek_palette_for_url(image_url) is not None:
1671+
if not image_url:
16721672
return
1673+
# Key the task on the image (not just the player) so a track change always
1674+
# schedules a fetch for the new image instead of being dropped by an in-flight
1675+
# fetch for the previous one; repeated schedules for the same image still dedupe.
16731676
slot = "current" if trigger_update else "next"
16741677
self.mass.create_task(
16751678
self._fetch_palette(player_id, image_url, trigger_update=trigger_update),
1676-
task_id=f"palette_fetch_{player_id}_{slot}",
1679+
task_id=f"palette_fetch_{player_id}_{slot}_{image_url}",
16771680
abort_existing=False,
16781681
)
16791682

16801683
async def _fetch_palette(self, player_id: str, image_url: str, *, trigger_update: bool) -> None:
16811684
palette = await get_palette_for_url(self.mass, image_url)
16821685
if palette is None or not trigger_update:
1683-
return
1686+
return # prefetch only warms the cache controller; nothing to attach
16841687
player = self.get_player(player_id)
16851688
if player is None:
16861689
return
16871690
current = player.state.current_media
16881691
if current is None or current.image_url != image_url:
16891692
return # media changed while fetching
1693+
# Carry the palette on player state so the (sync) serialization reads it back.
1694+
player.set_resolved_palette(image_url, palette)
16901695
# Avoid trigger_player_update so a concurrent state-change debounce
16911696
# doesn't cancel our timer via the shared player_update_state task_id.
16921697
self.mass.call_later(

music_assistant/helpers/colors.py

Lines changed: 26 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
chosen (and adjusted where needed) so that every spec-mandated contrast pair
77
clears the WCAG AA 4.5:1 threshold.
88
9-
A process-wide in-memory cache keyed on a hash of provider and image path
10-
avoids redundant extraction while the server is running.
9+
Extracted palettes are stored in the cache controller (sqlite), keyed on a
10+
hash of provider and image path, so results persist across restarts and are
11+
shared process-wide.
1112
"""
1213

1314
from __future__ import annotations
1415

1516
import asyncio
16-
from collections import OrderedDict
1717
from typing import TYPE_CHECKING
1818

1919
from modern_colorthief import get_palette as _mmcq_palette
@@ -30,7 +30,6 @@
3030
from music_assistant.mass import MusicAssistant
3131

3232

33-
_PALETTE_MEMORY_CACHE_MAX = 256
3433
_PALETTE_QUANTIZE_COLORS = 5
3534
_COLORTHIEF_QUALITY = 10
3635
# Minimum contrast ratio between colors (Sendspin color@v1 requires WCAG AA ≥ 4.5:1).
@@ -44,23 +43,13 @@
4443
_BACKGROUND_ADJUST_STEPS = 20
4544
_SIMILARITY_THRESHOLD = 60 # squared euclidean RGB distance for accent picking
4645

47-
_RGB = tuple[int, int, int]
48-
49-
_palette_memory_cache: OrderedDict[str, MediaItemPalette] = OrderedDict()
50-
51-
52-
def _get_from_memory_cache(key: str) -> MediaItemPalette | None:
53-
if key in _palette_memory_cache:
54-
_palette_memory_cache.move_to_end(key)
55-
return _palette_memory_cache[key]
56-
return None
46+
# Cache controller namespace for extracted palettes. Palettes are
47+
# content-addressed (hash of provider+path) and deterministic, so a long
48+
# expiration is safe: a palette only changes if the underlying image changes.
49+
_CACHE_PROVIDER = "palette"
50+
_CACHE_EXPIRATION = 90 * 24 * 3600 # 90 days
5751

58-
59-
def _put_in_memory_cache(key: str, palette: MediaItemPalette) -> None:
60-
_palette_memory_cache[key] = palette
61-
_palette_memory_cache.move_to_end(key)
62-
while len(_palette_memory_cache) > _PALETTE_MEMORY_CACHE_MAX:
63-
_palette_memory_cache.popitem(last=False)
52+
_RGB = tuple[int, int, int]
6453

6554

6655
def _relative_luminance(rgb: _RGB) -> float:
@@ -229,14 +218,24 @@ async def _extract_and_cache(
229218
) -> MediaItemPalette:
230219
img_data = await get_image_data(mass, path_or_url, provider)
231220
palette = await asyncio.to_thread(extract_palette, img_data)
232-
_put_in_memory_cache(key, palette)
221+
# Only persist a palette that actually yielded colors; an empty result is
222+
# usually a transient decode/download failure that should be retried rather
223+
# than cached for weeks.
224+
if palette.primary is not None:
225+
await mass.cache.set(
226+
key,
227+
palette.to_dict(),
228+
provider=_CACHE_PROVIDER,
229+
expiration=_CACHE_EXPIRATION,
230+
)
233231
return palette
234232

235233

236234
async def get_palette(
237235
mass: MusicAssistant, path_or_url: str, provider: str
238236
) -> MediaItemPalette | None:
239-
"""Get the color palette for an image, using a process-wide memory cache.
237+
"""
238+
Get the color palette for an image, backed by the cache controller.
240239
241240
:param mass: The MusicAssistant instance.
242241
:param path_or_url: Image path or URL (same format as get_image_data).
@@ -246,9 +245,13 @@ async def get_palette(
246245
return None
247246
key = create_thumb_hash(provider, path_or_url)
248247

249-
if cached := _get_from_memory_cache(key):
248+
cached: MediaItemPalette | None = await mass.cache.get(
249+
key, provider=_CACHE_PROVIDER, base_class=MediaItemPalette
250+
)
251+
if cached is not None:
250252
return cached
251253

254+
# Dedupe concurrent extraction (e.g. now-playing + prefetch) for the same image.
252255
task: asyncio.Task[MediaItemPalette] = mass.create_task(
253256
_extract_and_cache,
254257
mass,
@@ -261,27 +264,6 @@ async def get_palette(
261264
return await asyncio.shield(task)
262265

263266

264-
def peek_palette_for_url(image_url: str | None) -> MediaItemPalette | None:
265-
"""Return the cached palette for an image URL, or None.
266-
267-
Memory-only sync lookup. Use to attach a palette to a PlayerMedia without
268-
blocking when a prior async fetch has already populated the cache.
269-
"""
270-
if not image_url:
271-
return None
272-
# /imageproxy/<id>: `image_id` is by construction equal to
273-
# `create_thumb_hash(provider, path)` (see MetaDataController.compute_image_id),
274-
# which is also the key get_palette() stores under, so we can peek directly.
275-
if image_id := _extract_imageproxy_id(image_url):
276-
return _get_from_memory_cache(image_id)
277-
if extracted := _extract_imageproxy_params(image_url):
278-
path, provider = extracted
279-
else:
280-
path, provider = image_url, "builtin"
281-
key = create_thumb_hash(provider, path)
282-
return _get_from_memory_cache(key)
283-
284-
285267
async def get_palette_for_url(
286268
mass: MusicAssistant, image_url: str | None
287269
) -> MediaItemPalette | None:

music_assistant/models/player.py

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666

6767
if TYPE_CHECKING:
6868
from music_assistant_models.config_entries import ConfigEntry, ConfigValueType, PlayerConfig
69+
from music_assistant_models.media_items import MediaItemPalette
6970
from music_assistant_models.player_queue import PlayerQueue
7071

7172
from .player_provider import PlayerProvider
@@ -98,6 +99,11 @@ class Player(ABC):
9899
_attr_active_source: str | None = None
99100
_attr_active_sound_mode: str | None = None
100101
_attr_current_media: PlayerMedia | None = None
102+
# Palette for the image currently shown, resolved asynchronously from the
103+
# cache controller and carried here so the (synchronous) state serialization
104+
# can read it back without blocking. See set_resolved_palette.
105+
_attr_current_palette: MediaItemPalette | None = None
106+
_attr_current_palette_url: str | None = None
101107
_attr_needs_poll: bool = False
102108
_attr_poll_interval: int = 30
103109
_attr_hidden_by_default: bool = False
@@ -1387,6 +1393,22 @@ def set_current_media( # noqa: PLR0913
13871393
if custom_data:
13881394
self._attr_current_media.custom_data = custom_data
13891395

1396+
@final
1397+
def set_resolved_palette(self, image_url: str, palette: MediaItemPalette) -> None:
1398+
"""
1399+
Store the resolved color palette for the currently shown image.
1400+
1401+
The palette is resolved asynchronously (from the cache controller) by the
1402+
PlayerController; it is carried on the player here so the synchronous state
1403+
serialization can attach it without blocking. May only be called by the
1404+
PlayerController.
1405+
1406+
:param image_url: Image URL the palette was extracted from.
1407+
:param palette: The extracted color palette.
1408+
"""
1409+
self._attr_current_palette_url = image_url
1410+
self._attr_current_palette = palette
1411+
13901412
@final
13911413
def set_config(self, config: PlayerConfig) -> None:
13921414
"""
@@ -1764,9 +1786,6 @@ def __final_active_group(self) -> str | None:
17641786
@final
17651787
def __final_current_media(self) -> PlayerMedia | None:
17661788
"""Return the FINAL current media for the player."""
1767-
# Lazy import to avoid helpers.colors -> helpers.images -> models.plugin cycle.
1768-
from music_assistant.helpers.colors import peek_palette_for_url # noqa: PLC0415
1769-
17701789
# if the player is grouped/synced, use the current_media of the group/parent player
17711790
if parent_player_id := (self.__final_active_group or self.__final_synced_to):
17721791
if parent_player_id != self.player_id and (
@@ -1804,7 +1823,7 @@ def __final_current_media(self) -> PlayerMedia | None:
18041823
artist=stream_metadata.artist,
18051824
album=stream_metadata.album or stream_metadata.description or current_item.name,
18061825
image_url=image_url,
1807-
palette=peek_palette_for_url(image_url),
1826+
palette=self._resolved_palette(image_url),
18081827
duration=stream_metadata.duration or current_item.duration,
18091828
source_id=active_queue.queue_id,
18101829
queue_item_id=current_item.queue_item_id,
@@ -1836,7 +1855,7 @@ def __final_current_media(self) -> PlayerMedia | None:
18361855
artist=getattr(media_item, "artist_str", None),
18371856
album=album.name if album else podcast.name if podcast else description,
18381857
image_url=image_url,
1839-
palette=peek_palette_for_url(image_url),
1858+
palette=self._resolved_palette(image_url),
18401859
duration=media_item.duration,
18411860
source_id=active_queue.queue_id,
18421861
queue_item_id=current_item.queue_item_id,
@@ -1850,7 +1869,7 @@ def __final_current_media(self) -> PlayerMedia | None:
18501869
media_type=current_item.media_type,
18511870
title=current_item.name,
18521871
image_url=item_image_url,
1853-
palette=peek_palette_for_url(item_image_url),
1872+
palette=self._resolved_palette(item_image_url),
18541873
duration=current_item.duration,
18551874
source_id=active_queue.queue_id,
18561875
queue_item_id=current_item.queue_item_id,
@@ -1870,7 +1889,7 @@ def __final_current_media(self) -> PlayerMedia | None:
18701889
artist=self.current_media.artist,
18711890
album=self.current_media.album,
18721891
image_url=image_url,
1873-
palette=peek_palette_for_url(image_url),
1892+
palette=self._resolved_palette(image_url),
18741893
duration=self.current_media.duration,
18751894
source_id=self.current_media.source_id or active_source,
18761895
queue_item_id=self.current_media.queue_item_id,
@@ -1882,6 +1901,12 @@ def __final_current_media(self) -> PlayerMedia | None:
18821901
)
18831902
return None
18841903

1904+
def _resolved_palette(self, image_url: str | None) -> MediaItemPalette | None:
1905+
"""Return the carried palette if it matches image_url, else None."""
1906+
if image_url and image_url == self._attr_current_palette_url:
1907+
return self._attr_current_palette
1908+
return None
1909+
18851910
@cached_property
18861911
@final
18871912
def __final_source_list(self) -> UniqueList[PlayerSource]:

tests/core/test_image_proxy.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ async def test_image_id_matches_thumb_and_palette_cache_key(
7676
7777
The thumbnail and color-palette caches both key off
7878
`create_thumb_hash(provider, path)`. If `compute_image_id` ever diverged
79-
from that, `peek_palette_for_url` for /imageproxy/<id> URLs would silently
80-
stop hitting and the on-disk thumbnail cache would split into two buckets.
79+
from that, palette lookups for /imageproxy/<id> URLs would miss and the
80+
on-disk thumbnail cache would split into two buckets.
8181
"""
8282
image_id = metadata_controller.compute_image_id("filesystem", "/local/cover.jpg")
8383
assert image_id == create_thumb_hash("filesystem", "/local/cover.jpg")

tests/core/test_metadata.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Tests for the MetaDataController public API."""
2+
3+
from __future__ import annotations
4+
5+
from typing import TYPE_CHECKING
6+
7+
import pytest
8+
from music_assistant_models.enums import ImageType
9+
from music_assistant_models.media_items import MediaItemImage, MediaItemPalette
10+
11+
from music_assistant.controllers.metadata import MetaDataController
12+
13+
if TYPE_CHECKING:
14+
from music_assistant.mass import MusicAssistant
15+
16+
17+
@pytest.fixture
18+
async def metadata_controller(mass_minimal: MusicAssistant) -> MetaDataController:
19+
"""Construct a MetaDataController with the minimal MA fixture."""
20+
await mass_minimal.cache._setup_database()
21+
controller = MetaDataController(mass_minimal)
22+
mass_minimal.metadata = controller
23+
return controller
24+
25+
26+
async def test_get_image_palette_dispatches_by_input_type(
27+
metadata_controller: MetaDataController, monkeypatch: pytest.MonkeyPatch
28+
) -> None:
29+
"""A MediaItemImage resolves by (path, provider); a plain URL goes via get_palette_for_url."""
30+
palette = MediaItemPalette(primary=(1, 2, 3))
31+
calls: dict[str, object] = {}
32+
33+
async def fake_get_palette(_mass: object, path: str, provider: str) -> MediaItemPalette:
34+
calls["get_palette"] = (path, provider)
35+
return palette
36+
37+
async def fake_get_palette_for_url(_mass: object, url: str) -> MediaItemPalette:
38+
calls["get_palette_for_url"] = url
39+
return palette
40+
41+
monkeypatch.setattr("music_assistant.controllers.metadata.get_palette", fake_get_palette)
42+
monkeypatch.setattr(
43+
"music_assistant.controllers.metadata.get_palette_for_url", fake_get_palette_for_url
44+
)
45+
46+
image = MediaItemImage(type=ImageType.THUMB, path="cover.jpg", provider="spotify")
47+
assert await metadata_controller.get_image_palette(image) is palette
48+
assert calls["get_palette"] == ("cover.jpg", "spotify")
49+
50+
assert await metadata_controller.get_image_palette("https://example/cover.jpg") is palette
51+
assert calls["get_palette_for_url"] == "https://example/cover.jpg"
52+
53+
54+
async def test_get_image_palette_returns_none_for_unreadable_image(
55+
metadata_controller: MetaDataController, monkeypatch: pytest.MonkeyPatch
56+
) -> None:
57+
"""An image whose data can't be read yields None instead of raising."""
58+
59+
async def boom(_mass: object, path: str, _provider: str) -> MediaItemPalette:
60+
raise FileNotFoundError(path)
61+
62+
monkeypatch.setattr("music_assistant.controllers.metadata.get_palette", boom)
63+
image = MediaItemImage(type=ImageType.THUMB, path="missing.jpg", provider="filesystem")
64+
assert await metadata_controller.get_image_palette(image) is None

0 commit comments

Comments
 (0)