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 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)

View File

@ -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,
)
)

View File

@ -6,7 +6,7 @@
"@rajlaud"
],
"requirements": [
"pysqueezebox==0.5.4"
"pysqueezebox==0.5.5"
],
"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, [])
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)

View File

@ -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

View File

@ -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