Add proxy support for browse media images (#42193)

Co-authored-by: Chris Talkington <chris@talkingtontech.com>
This commit is contained in:
rajlaud 2020-11-10 16:51:58 -06:00 committed by GitHub
parent 94db07ca8c
commit 55a6d37f2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 105 additions and 38 deletions

View File

@ -6,7 +6,7 @@ from datetime import timedelta
import functools as ft import functools as ft
import hashlib import hashlib
import logging import logging
from random import SystemRandom import secrets
from typing import List, Optional from typing import List, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
@ -15,6 +15,7 @@ from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
from aiohttp.typedefs import LooseHeaders from aiohttp.typedefs import LooseHeaders
import async_timeout import async_timeout
import voluptuous as vol import voluptuous as vol
from yarl import URL
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
@ -119,7 +120,6 @@ from .errors import BrowseError
# mypy: allow-untyped-defs, no-check-untyped-defs # mypy: allow-untyped-defs, no-check-untyped-defs
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
_RND = SystemRandom()
ENTITY_ID_FORMAT = DOMAIN + ".{}" ENTITY_ID_FORMAT = DOMAIN + ".{}"
@ -365,9 +365,7 @@ class MediaPlayerEntity(Entity):
def access_token(self) -> str: def access_token(self) -> str:
"""Access token for this media player.""" """Access token for this media player."""
if self._access_token is None: if self._access_token is None:
self._access_token = hashlib.sha256( self._access_token = secrets.token_hex(32)
_RND.getrandbits(256).to_bytes(32, "little")
).hexdigest()
return self._access_token return self._access_token
@property @property
@ -433,7 +431,17 @@ class MediaPlayerEntity(Entity):
if url is None: if url is None:
return None, 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 @property
def media_title(self): def media_title(self):
@ -848,8 +856,7 @@ class MediaPlayerEntity(Entity):
""" """
raise NotImplementedError() raise NotImplementedError()
async def _async_fetch_image_from_cache(self, url):
async def _async_fetch_image(hass, url):
"""Fetch image. """Fetch image.
Images are cached in memory (the images are typically 10-100kB in size). Images are cached in memory (the images are typically 10-100kB in size).
@ -858,7 +865,7 @@ async def _async_fetch_image(hass, url):
cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE] cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE]
if urlparse(url).hostname is None: if urlparse(url).hostname is None:
url = f"{get_url(hass)}{url}" url = f"{get_url(self.hass)}{url}"
if url not in cache_images: if url not in cache_images:
cache_images[url] = {CACHE_LOCK: asyncio.Lock()} cache_images[url] = {CACHE_LOCK: asyncio.Lock()}
@ -867,8 +874,19 @@ async def _async_fetch_image(hass, url):
if CACHE_CONTENT in cache_images[url]: if CACHE_CONTENT in cache_images[url]:
return cache_images[url][CACHE_CONTENT] 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) content, content_type = (None, None)
websession = async_get_clientsession(hass) websession = async_get_clientsession(self.hass)
try: try:
with async_timeout.timeout(10): with async_timeout.timeout(10):
response = await websession.get(url) response = await websession.get(url)
@ -878,16 +896,30 @@ async def _async_fetch_image(hass, url):
content_type = response.headers.get(CONTENT_TYPE) content_type = response.headers.get(CONTENT_TYPE)
if content_type: if content_type:
content_type = content_type.split(";")[0] content_type = content_type.split(";")[0]
cache_images[url][CACHE_CONTENT] = content, content_type
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
while len(cache_images) > cache_maxsize:
cache_images.popitem(last=False)
return content, content_type 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): class MediaPlayerImageView(HomeAssistantView):
"""Media player view to serve an image.""" """Media player view to serve an image."""
@ -895,12 +927,21 @@ class MediaPlayerImageView(HomeAssistantView):
requires_auth = False requires_auth = False
url = "/api/media_player_proxy/{entity_id}" url = "/api/media_player_proxy/{entity_id}"
name = "api:media_player:image" name = "api:media_player:image"
extra_urls = [
url + "/browse_media/{media_content_type}/{media_content_id}",
]
def __init__(self, component): def __init__(self, component):
"""Initialize a media player view.""" """Initialize a media player view."""
self.component = component 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.""" """Start a get request."""
player = self.component.get_entity(entity_id) player = self.component.get_entity(entity_id)
if player is None: if player is None:
@ -915,6 +956,12 @@ class MediaPlayerImageView(HomeAssistantView):
if not authenticated: if not authenticated:
return web.Response(status=HTTP_UNAUTHORIZED) return web.Response(status=HTTP_UNAUTHORIZED)
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() data, content_type = await player.async_get_media_image()
if data is None: if data is None:

View File

@ -47,10 +47,7 @@ CONTENT_TYPE_MEDIA_CLASS = {
MEDIA_TYPE_ARTIST: {"item": MEDIA_CLASS_ARTIST, "children": MEDIA_CLASS_ALBUM}, MEDIA_TYPE_ARTIST: {"item": MEDIA_CLASS_ARTIST, "children": MEDIA_CLASS_ALBUM},
MEDIA_TYPE_TRACK: {"item": MEDIA_CLASS_TRACK, "children": None}, MEDIA_TYPE_TRACK: {"item": MEDIA_CLASS_TRACK, "children": None},
MEDIA_TYPE_GENRE: {"item": MEDIA_CLASS_GENRE, "children": MEDIA_CLASS_ARTIST}, MEDIA_TYPE_GENRE: {"item": MEDIA_CLASS_GENRE, "children": MEDIA_CLASS_ARTIST},
MEDIA_TYPE_PLAYLIST: { MEDIA_TYPE_PLAYLIST: {"item": MEDIA_CLASS_PLAYLIST, "children": MEDIA_CLASS_TRACK},
"item": MEDIA_CLASS_PLAYLIST,
"children": MEDIA_CLASS_TRACK,
},
} }
CONTENT_TYPE_TO_CHILD_TYPE = { CONTENT_TYPE_TO_CHILD_TYPE = {
@ -68,7 +65,7 @@ CONTENT_TYPE_TO_CHILD_TYPE = {
BROWSE_LIMIT = 1000 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.""" """Create response payload for search described by payload."""
search_id = payload["search_id"] search_id = payload["search_id"]
search_type = payload["search_type"] search_type = payload["search_type"]
@ -94,15 +91,24 @@ async def build_item_response(player, payload):
children = [] children = []
for item in result["items"]: 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( children.append(
BrowseMedia( BrowseMedia(
title=item["title"], title=item["title"],
media_class=child_media_class["item"], media_class=child_media_class["item"],
media_content_id=str(item["id"]), media_content_id=item_id,
media_content_type=item_type, media_content_type=item_type,
can_play=True, can_play=True,
can_expand=child_media_class["children"] is not None, can_expand=child_media_class["children"] is not None,
thumbnail=item.get("image_url"), thumbnail=item_thumbnail,
) )
) )

View File

@ -6,7 +6,7 @@
"@rajlaud" "@rajlaud"
], ],
"requirements": [ "requirements": [
"pysqueezebox==0.5.4" "pysqueezebox==0.5.5"
], ],
"config_flow": true "config_flow": true
} }

View File

@ -168,8 +168,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
known_players = hass.data[DOMAIN].setdefault(KNOWN_PLAYERS, []) known_players = hass.data[DOMAIN].setdefault(KNOWN_PLAYERS, [])
session = async_get_clientsession(hass)
_LOGGER.debug("Creating LMS object for %s", host) _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): async def _discovery(now=None):
"""Discover squeezebox players by polling server.""" """Discover squeezebox players by polling server."""
@ -587,4 +588,17 @@ class SqueezeBoxEntity(MediaPlayerEntity):
"search_id": media_content_id, "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)

View File

@ -1701,7 +1701,7 @@ pysonos==0.0.35
pyspcwebgw==0.4.0 pyspcwebgw==0.4.0
# homeassistant.components.squeezebox # homeassistant.components.squeezebox
pysqueezebox==0.5.4 pysqueezebox==0.5.5
# homeassistant.components.stiebel_eltron # homeassistant.components.stiebel_eltron
pystiebeleltron==0.0.1.dev2 pystiebeleltron==0.0.1.dev2

View File

@ -848,7 +848,7 @@ pysonos==0.0.35
pyspcwebgw==0.4.0 pyspcwebgw==0.4.0
# homeassistant.components.squeezebox # homeassistant.components.squeezebox
pysqueezebox==0.5.4 pysqueezebox==0.5.5
# homeassistant.components.syncthru # homeassistant.components.syncthru
pysyncthru==0.7.0 pysyncthru==0.7.0