"""Browse media features for media player.""" from __future__ import annotations from collections.abc import Sequence from dataclasses import dataclass, field from datetime import timedelta import logging from typing import Any from urllib.parse import quote import yarl from homeassistant.components.http.auth import async_sign_path from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.network import ( NoURLAvailableError, get_supervisor_network_url, get_url, is_hass_url, ) from .const import CONTENT_AUTH_EXPIRY_TIME, MediaClass, MediaType # Paths that we don't need to sign PATHS_WITHOUT_AUTH = ( "/api/tts_proxy/", "/api/esphome/ffmpeg_proxy/", "/api/assist_satellite/static/", ) @callback def async_process_play_media_url( hass: HomeAssistant, media_content_id: str, *, allow_relative_url: bool = False, for_supervisor_network: bool = False, ) -> str: """Update a media URL with authentication if it points at Home Assistant.""" parsed = yarl.URL(media_content_id) if parsed.scheme and parsed.scheme not in ("http", "https"): return media_content_id if parsed.is_absolute(): if not is_hass_url(hass, media_content_id): return media_content_id elif media_content_id[0] != "/": return media_content_id # https://github.com/pylint-dev/pylint/issues/3484 # pylint: disable-next=using-constant-test if parsed.query: logging.getLogger(__name__).debug( "Not signing path for content with query param" ) elif parsed.path.startswith(PATHS_WITHOUT_AUTH): # We don't sign this path if it doesn't need auth. Although signing itself can't # hurt, some devices are unable to handle long URLs and the auth signature might # push it over. pass else: signed_path = async_sign_path( hass, quote(parsed.path), timedelta(seconds=CONTENT_AUTH_EXPIRY_TIME), ) media_content_id = str(parsed.join(yarl.URL(signed_path))) # convert relative URL to absolute URL if not parsed.is_absolute() and not allow_relative_url: base_url = None if for_supervisor_network: base_url = get_supervisor_network_url(hass) if not base_url: try: base_url = get_url(hass) except NoURLAvailableError as err: msg = "Unable to determine Home Assistant URL to send to device" if ( hass.config.api and hass.config.api.use_ssl and (not hass.config.external_url or not hass.config.internal_url) ): msg += ". Configure internal and external URL in general settings." raise HomeAssistantError(msg) from err media_content_id = f"{base_url}{media_content_id}" return media_content_id class BrowseMedia: """Represent a browsable media file.""" def __init__( self, *, media_class: MediaClass | str, media_content_id: str, media_content_type: MediaType | str, title: str, can_play: bool, can_expand: bool, children: Sequence[BrowseMedia] | None = None, children_media_class: MediaClass | str | None = None, thumbnail: str | None = None, not_shown: int = 0, can_search: bool = False, ) -> None: """Initialize browse media item.""" self.media_class = media_class self.media_content_id = media_content_id self.media_content_type = media_content_type self.title = title self.can_play = can_play self.can_expand = can_expand self.children = children self.children_media_class = children_media_class self.thumbnail = thumbnail self.not_shown = not_shown self.can_search = can_search def as_dict(self, *, parent: bool = True) -> dict[str, Any]: """Convert Media class to browse media dictionary.""" if self.children_media_class is None and self.children: self.calculate_children_class() response: dict[str, Any] = { "title": self.title, "media_class": self.media_class, "media_content_type": self.media_content_type, "media_content_id": self.media_content_id, "children_media_class": self.children_media_class, "can_play": self.can_play, "can_expand": self.can_expand, "can_search": self.can_search, "thumbnail": self.thumbnail, } if not parent: return response response["not_shown"] = self.not_shown if self.children: response["children"] = [ child.as_dict(parent=False) for child in self.children ] else: response["children"] = [] return response def calculate_children_class(self) -> None: """Count the children media classes and calculate the correct class.""" self.children_media_class = MediaClass.DIRECTORY assert self.children is not None proposed_class = self.children[0].media_class if all(child.media_class == proposed_class for child in self.children): self.children_media_class = proposed_class def __repr__(self) -> str: """Return representation of browse media.""" return f"" @dataclass(kw_only=True, frozen=True) class SearchMedia: """Represent search results.""" version: int = field(default=1) result: list[BrowseMedia] def as_dict(self, *, parent: bool = True) -> dict[str, Any]: """Convert SearchMedia class to browse media dictionary.""" return { "result": [item.as_dict(parent=parent) for item in self.result], } @dataclass(kw_only=True, frozen=True) class SearchMediaQuery: """Represent a search media file.""" search_query: str media_content_type: MediaType | str | None = field(default=None) media_content_id: str | None = None media_filter_classes: list[MediaClass] | None = field(default=None)