mirror of
https://github.com/home-assistant/core.git
synced 2025-07-15 17:27:10 +00:00
Add proxy support for browse media images (#42193)
Co-authored-by: Chris Talkington <chris@talkingtontech.com>
This commit is contained in:
parent
94db07ca8c
commit
55a6d37f2c
@ -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)
|
||||
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -6,7 +6,7 @@
|
||||
"@rajlaud"
|
||||
],
|
||||
"requirements": [
|
||||
"pysqueezebox==0.5.4"
|
||||
"pysqueezebox==0.5.5"
|
||||
],
|
||||
"config_flow": true
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user