diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 410d69c22c3..4fb0cdae33e 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -6,7 +6,7 @@ from datetime import timedelta import functools as ft import hashlib import logging -from random import SystemRandom +import secrets from typing import List, Optional from urllib.parse import urlparse @@ -15,6 +15,7 @@ from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE from aiohttp.typedefs import LooseHeaders import async_timeout import voluptuous as vol +from yarl import URL from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView @@ -119,7 +120,6 @@ from .errors import BrowseError # mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) -_RND = SystemRandom() ENTITY_ID_FORMAT = DOMAIN + ".{}" @@ -365,9 +365,7 @@ class MediaPlayerEntity(Entity): def access_token(self) -> str: """Access token for this media player.""" if self._access_token is None: - self._access_token = hashlib.sha256( - _RND.getrandbits(256).to_bytes(32, "little") - ).hexdigest() + self._access_token = secrets.token_hex(32) return self._access_token @property @@ -433,7 +431,17 @@ class MediaPlayerEntity(Entity): if url is None: return None, None - return await _async_fetch_image(self.hass, url) + return await self._async_fetch_image_from_cache(url) + + async def async_get_browse_image( + self, media_content_type, media_content_id, media_image_id=None + ): + """ + Optionally fetch internally accessible image for media browser. + + Must be implemented by integration. + """ + return None, None @property def media_title(self): @@ -848,27 +856,37 @@ class MediaPlayerEntity(Entity): """ raise NotImplementedError() + async def _async_fetch_image_from_cache(self, url): + """Fetch image. -async def _async_fetch_image(hass, url): - """Fetch image. + Images are cached in memory (the images are typically 10-100kB in size). + """ + cache_images = ENTITY_IMAGE_CACHE[CACHE_IMAGES] + cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE] - Images are cached in memory (the images are typically 10-100kB in size). - """ - cache_images = ENTITY_IMAGE_CACHE[CACHE_IMAGES] - cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE] + if urlparse(url).hostname is None: + url = f"{get_url(self.hass)}{url}" - if urlparse(url).hostname is None: - url = f"{get_url(hass)}{url}" + if url not in cache_images: + cache_images[url] = {CACHE_LOCK: asyncio.Lock()} - if url not in cache_images: - cache_images[url] = {CACHE_LOCK: asyncio.Lock()} + async with cache_images[url][CACHE_LOCK]: + if CACHE_CONTENT in cache_images[url]: + return cache_images[url][CACHE_CONTENT] - async with cache_images[url][CACHE_LOCK]: - if CACHE_CONTENT in cache_images[url]: - return cache_images[url][CACHE_CONTENT] + (content, content_type) = await self._async_fetch_image(url) + async with cache_images[url][CACHE_LOCK]: + cache_images[url][CACHE_CONTENT] = content, content_type + while len(cache_images) > cache_maxsize: + cache_images.popitem(last=False) + + return content, content_type + + async def _async_fetch_image(self, url): + """Retrieve an image.""" content, content_type = (None, None) - websession = async_get_clientsession(hass) + websession = async_get_clientsession(self.hass) try: with async_timeout.timeout(10): response = await websession.get(url) @@ -878,16 +896,30 @@ async def _async_fetch_image(hass, url): content_type = response.headers.get(CONTENT_TYPE) if content_type: content_type = content_type.split(";")[0] - cache_images[url][CACHE_CONTENT] = content, content_type except asyncio.TimeoutError: pass - while len(cache_images) > cache_maxsize: - cache_images.popitem(last=False) - return content, content_type + def get_browse_image_url( + self, media_content_type, media_content_id, media_image_id=None + ): + """Generate an url for a media browser image.""" + url_path = ( + f"/api/media_player_proxy/{self.entity_id}/browse_media" + f"/{media_content_type}/{media_content_id}" + ) + url = str( + URL(url_path).with_query( + { + "token": self.access_token, + "media_image_id": media_image_id, + } + ) + ) + return url + class MediaPlayerImageView(HomeAssistantView): """Media player view to serve an image.""" @@ -895,12 +927,21 @@ class MediaPlayerImageView(HomeAssistantView): requires_auth = False url = "/api/media_player_proxy/{entity_id}" name = "api:media_player:image" + extra_urls = [ + url + "/browse_media/{media_content_type}/{media_content_id}", + ] def __init__(self, component): """Initialize a media player view.""" self.component = component - async def get(self, request: web.Request, entity_id: str) -> web.Response: + async def get( + self, + request: web.Request, + entity_id: str, + media_content_type: Optional[str] = None, + media_content_id: Optional[str] = None, + ) -> web.Response: """Start a get request.""" player = self.component.get_entity(entity_id) if player is None: @@ -915,7 +956,13 @@ class MediaPlayerImageView(HomeAssistantView): if not authenticated: return web.Response(status=HTTP_UNAUTHORIZED) - data, content_type = await player.async_get_media_image() + if media_content_type and media_content_id: + media_image_id = request.query.get("media_image_id") + data, content_type = await player.async_get_browse_image( + media_content_type, media_content_id, media_image_id + ) + else: + data, content_type = await player.async_get_media_image() if data is None: return web.Response(status=HTTP_INTERNAL_SERVER_ERROR) diff --git a/homeassistant/components/squeezebox/browse_media.py b/homeassistant/components/squeezebox/browse_media.py index bab24e39cbc..124b8158fbf 100644 --- a/homeassistant/components/squeezebox/browse_media.py +++ b/homeassistant/components/squeezebox/browse_media.py @@ -47,10 +47,7 @@ CONTENT_TYPE_MEDIA_CLASS = { MEDIA_TYPE_ARTIST: {"item": MEDIA_CLASS_ARTIST, "children": MEDIA_CLASS_ALBUM}, MEDIA_TYPE_TRACK: {"item": MEDIA_CLASS_TRACK, "children": None}, MEDIA_TYPE_GENRE: {"item": MEDIA_CLASS_GENRE, "children": MEDIA_CLASS_ARTIST}, - MEDIA_TYPE_PLAYLIST: { - "item": MEDIA_CLASS_PLAYLIST, - "children": MEDIA_CLASS_TRACK, - }, + MEDIA_TYPE_PLAYLIST: {"item": MEDIA_CLASS_PLAYLIST, "children": MEDIA_CLASS_TRACK}, } CONTENT_TYPE_TO_CHILD_TYPE = { @@ -68,7 +65,7 @@ CONTENT_TYPE_TO_CHILD_TYPE = { BROWSE_LIMIT = 1000 -async def build_item_response(player, payload): +async def build_item_response(entity, player, payload): """Create response payload for search described by payload.""" search_id = payload["search_id"] search_type = payload["search_type"] @@ -94,15 +91,24 @@ async def build_item_response(player, payload): children = [] for item in result["items"]: + item_id = str(item["id"]) + item_thumbnail = None + + artwork_track_id = item.get("artwork_track_id") + if artwork_track_id: + item_thumbnail = entity.get_browse_image_url( + item_type, item_id, artwork_track_id + ) + children.append( BrowseMedia( title=item["title"], media_class=child_media_class["item"], - media_content_id=str(item["id"]), + media_content_id=item_id, media_content_type=item_type, can_play=True, can_expand=child_media_class["children"] is not None, - thumbnail=item.get("image_url"), + thumbnail=item_thumbnail, ) ) diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index a0d0d1ef50e..5c384e6ae1c 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -6,7 +6,7 @@ "@rajlaud" ], "requirements": [ - "pysqueezebox==0.5.4" + "pysqueezebox==0.5.5" ], "config_flow": true } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 9a7baffb3f8..b87695dd159 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -168,8 +168,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): known_players = hass.data[DOMAIN].setdefault(KNOWN_PLAYERS, []) + session = async_get_clientsession(hass) _LOGGER.debug("Creating LMS object for %s", host) - lms = Server(async_get_clientsession(hass), host, port, username, password) + lms = Server(session, host, port, username, password) async def _discovery(now=None): """Discover squeezebox players by polling server.""" @@ -587,4 +588,17 @@ class SqueezeBoxEntity(MediaPlayerEntity): "search_id": media_content_id, } - return await build_item_response(self._player, payload) + return await build_item_response(self, self._player, payload) + + async def async_get_browse_image( + self, media_content_type, media_content_id, media_image_id=None + ): + """Get album art from Squeezebox server.""" + if media_image_id: + image_url = self._player.generate_image_url_from_track_id(media_image_id) + result = await self._async_fetch_image(image_url) + if result == (None, None): + _LOGGER.debug("Error retrieving proxied album art from %s", image_url) + return result + + return (None, None) diff --git a/requirements_all.txt b/requirements_all.txt index e3fb68c8bfe..fd57e486de7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1701,7 +1701,7 @@ pysonos==0.0.35 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.5.4 +pysqueezebox==0.5.5 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3106b623ae6..92e69a6a5b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -848,7 +848,7 @@ pysonos==0.0.35 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.5.4 +pysqueezebox==0.5.5 # homeassistant.components.syncthru pysyncthru==0.7.0