mirror of
https://github.com/home-assistant/core.git
synced 2025-07-16 09:47:13 +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 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:
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
"@rajlaud"
|
"@rajlaud"
|
||||||
],
|
],
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"pysqueezebox==0.5.4"
|
"pysqueezebox==0.5.5"
|
||||||
],
|
],
|
||||||
"config_flow": true
|
"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, [])
|
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)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user