Fix stale WiiM artwork when media image URLs are reused (#179461)

Co-authored-by: Tao Jiang <tao.jiang@linkplay.com>
This commit is contained in:
Linkplay2020
2026-08-18 23:12:05 +02:00
committed by GitHub
co-authored by Tao Jiang
parent cb9ad3aa6e
commit 1e2984c987
2 changed files with 123 additions and 1 deletions
@@ -2,6 +2,8 @@
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
from hashlib import sha256
import json
from typing import Any, Concatenate, override
from async_upnp_client.client import UpnpService, UpnpStateVariable
@@ -219,12 +221,52 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
self._attr_media_artist = None
self._attr_media_album_name = None
self._attr_media_image_url = None
self._attr_media_image_hash = None
self._attr_media_content_id = None
self._attr_media_content_type = None
self._attr_media_duration = None
self._attr_media_position = None
self._attr_media_position_updated_at = None
@callback
def _set_media_image_hash(
self,
*,
image_url: str | None,
media_uri: str | None,
title: str | None,
artist: str | None,
album: str | None,
) -> None:
"""Set a cache-busting media image hash for Home Assistant.
Some WiiM sources reuse the same artwork URL across tracks, so the
default HA URL-based hash is not sufficient to invalidate the image cache.
"""
if not image_url:
self._attr_media_image_hash = None
return
digest_source = json.dumps(
[image_url, media_uri, title, artist, album],
ensure_ascii=False,
separators=(",", ":"),
)
self._attr_media_image_hash = sha256(
digest_source.encode("utf-8"), usedforsecurity=False
).hexdigest()
@override
async def async_get_media_image(self) -> tuple[bytes | None, str | None]:
"""Fetch the media image using a track-aware cache key."""
if (url := self.media_image_url) is None:
return None, None
if (image_hash := self.media_image_hash) is not None:
url = f"{url.partition('#')[0]}#{image_hash}"
return await self._async_fetch_image_from_cache(url)
@callback
def _get_command_target_device(self, action_name: str) -> WiimDevice:
"""Return the device that should receive a grouped playback command."""
@@ -342,6 +384,13 @@ class WiimMediaPlayerEntity(WiimBaseEntity, MediaPlayerEntity):
self._attr_media_artist = media.artist
self._attr_media_album_name = media.album
self._attr_media_image_url = media.image_url
self._set_media_image_hash(
image_url=media.image_url,
media_uri=media.uri,
title=media.title,
artist=media.artist,
album=media.album,
)
self._attr_media_content_id = media.uri
self._attr_media_content_type = MediaType.MUSIC
self._attr_media_duration = media.duration
+74 -1
View File
@@ -19,6 +19,7 @@ from wiim.models import (
from wiim.wiim_device import WiimDevice
from homeassistant.components.media_player import (
ATTR_ENTITY_PICTURE_LOCAL,
ATTR_GROUP_MEMBERS,
ATTR_INPUT_SOURCE,
ATTR_MEDIA_ALBUM_NAME,
@@ -57,13 +58,20 @@ from homeassistant.components.media_player import (
)
import homeassistant.components.wiim as wiim_component
from homeassistant.components.wiim.const import DOMAIN
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, STATE_UNAVAILABLE
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE,
CONF_HOST,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from . import fire_general_update, fire_transport_update, setup_integration
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
MEDIA_PLAYER_ENTITY_ID = "media_player.test_wiim_device"
@@ -1313,3 +1321,68 @@ async def test_join_service_invalid_member_uses_translation(
assert exc_info.value.translation_key == "invalid_grouping_entity"
assert exc_info.value.translation_placeholders == {"entity_id": invalid_entity_id}
mock_wiim_controller.async_join_group.assert_not_awaited()
@pytest.mark.usefixtures("mock_wiim_controller")
async def test_media_image_hash_changes_for_same_local_artwork_url(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_wiim_device: MagicMock,
aioclient_mock: AiohttpClientMocker,
hass_client: ClientSessionGenerator,
) -> None:
"""Test the media proxy returns new artwork when its URL is reused."""
await setup_integration(hass, mock_config_entry)
image_url = "https://192.168.1.100/changing-album-art.jpg"
client = await hass_client()
mock_wiim_device.current_media = WiimMediaMetadata(
title="First Song",
artist="Artist",
album="Album",
uri="http://example.com/first.flac",
image_url=image_url,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.attributes[ATTR_ENTITY_PICTURE] == image_url
first_local_image = state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
aioclient_mock.get(
image_url,
content=b"first-image",
headers={"Content-Type": "image/jpeg"},
)
media_response = await client.get(first_local_image)
assert media_response.status == 200
first_image = await media_response.read()
assert first_image == b"first-image"
mock_wiim_device.current_media = WiimMediaMetadata(
title="Second Song",
artist="Artist",
album="Album",
uri="http://example.com/second.flac",
image_url=image_url,
)
await fire_general_update(hass, mock_wiim_device)
state = hass.states.get(MEDIA_PLAYER_ENTITY_ID)
assert state is not None
assert state.attributes[ATTR_ENTITY_PICTURE] == image_url
second_local_image = state.attributes[ATTR_ENTITY_PICTURE_LOCAL]
assert second_local_image != first_local_image
aioclient_mock.clear_requests()
aioclient_mock.get(
image_url,
content=b"second-image",
headers={"Content-Type": "image/jpeg"},
)
media_response = await client.get(second_local_image)
assert media_response.status == 200
second_image = await media_response.read()
assert second_image == b"second-image"
assert second_image != first_image