async def async_play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: """Play media from a URL or file.""" # Handle media_source if media_source.is_media_source_id(media_id): sourced_media = await media_source.async_resolve_media( self.hass, media_id) media_type = MEDIA_TYPE_MUSIC media_id = sourced_media.url # Sign and prefix with URL if playing a relative URL if media_id[0] == "/": media_id = async_sign_path( self.hass, quote(media_id), timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), ) # prepend external URL hass_url = get_url(self.hass) media_id = f"{hass_url}{media_id}" if media_type != MEDIA_TYPE_MUSIC: LOGGER.error( "Invalid media type %s. Only %s is supported", media_type, MEDIA_TYPE_MUSIC, ) return await self._vlc.add(media_id) self._state = STATE_PLAYING
def async_process_play_media_url(hass: HomeAssistant, media_content_id: str, *, allow_relative_url: bool = False) -> str: """Update a media URL with authentication if it points at Home Assistant.""" if media_content_id[0] != "/" and not is_hass_url(hass, media_content_id): return media_content_id parsed = yarl.URL(media_content_id) if parsed.query: logging.getLogger(__name__).debug( "Not signing path for content with query param") 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 media_content_id[0] == "/" and not allow_relative_url: media_content_id = f"{get_url(hass)}{media_content_id}" return media_content_id
async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None: """Play media from a URL or file, launch an application, or tune to a channel.""" extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA) or {} # Handle media_source if media_source.is_media_source_id(media_id): sourced_media = await media_source.async_resolve_media( self.hass, media_id) media_type = MEDIA_TYPE_URL media_id = sourced_media.url # Sign and prefix with URL if playing a relative URL if media_id[0] == "/": media_id = async_sign_path( self.hass, quote(media_id), dt.timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), ) # prepend external URL hass_url = get_url(self.hass) media_id = f"{hass_url}{media_id}" if media_type not in PLAY_MEDIA_SUPPORTED_TYPES: _LOGGER.error( "Invalid media type %s. Only %s, %s, %s, and camera HLS streams are supported", media_type, MEDIA_TYPE_APP, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_URL, ) return if media_type == MEDIA_TYPE_APP: params = { param: extra[attr] for attr, param in ATTRS_TO_LAUNCH_PARAMS.items() if attr in extra } await self.coordinator.roku.launch(media_id, params) elif media_type == MEDIA_TYPE_CHANNEL: await self.coordinator.roku.tune(media_id) elif media_type == MEDIA_TYPE_URL: params = { param: extra[attr] for (attr, param) in ATTRS_TO_PLAY_VIDEO_PARAMS.items() if attr in extra } await self.coordinator.roku.play_on_roku(media_id, params) elif media_type == FORMAT_CONTENT_TYPE[HLS_PROVIDER]: params = { "MediaType": "hls", } await self.coordinator.roku.play_on_roku(media_id, params) await self.coordinator.async_request_refresh()
async def async_play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" # Handle media_source if media_source.is_media_source_id(media_id): sourced_media = await media_source.async_resolve_media(self.hass, media_id) media_type = sourced_media.mime_type media_id = sourced_media.url # If media ID is a relative URL, we serve it from HA. # Create a signed path. if media_id[0] == "/": # Sign URL with Home Assistant Cast User config_entries = self.hass.config_entries.async_entries(CAST_DOMAIN) user_id = config_entries[0].data["user_id"] user = await self.hass.auth.async_get_user(user_id) if user.refresh_tokens: refresh_token: RefreshToken = list(user.refresh_tokens.values())[0] media_id = async_sign_path( self.hass, refresh_token.id, media_id, timedelta(minutes=5), ) # prepend external URL hass_url = get_url(self.hass, prefer_external=True) media_id = f"{hass_url}{media_id}" await self.hass.async_add_executor_job( ft.partial(self.play_media, media_type, media_id, **kwargs) )
def websocket_sign_path( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): """Handle a sign path request.""" connection.send_message(websocket_api.result_message(msg['id'], { 'path': async_sign_path(hass, connection.refresh_token_id, msg['path'], timedelta(seconds=msg['expires'])) }))
async def async_play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" # Handle media_source if media_source.is_media_source_id(media_id): sourced_media = await media_source.async_resolve_media( self.hass, media_id) media_type = sourced_media.mime_type media_id = sourced_media.url # If media ID is a relative URL, we serve it from HA. # Create a signed path. if media_id[0] == "/": # Sign URL with Safegate Pro Cast User config_entry_id = self.registry_entry.config_entry_id config_entry = self.hass.config_entries.async_get_entry( config_entry_id) user_id = config_entry.data["user_id"] user = await self.hass.auth.async_get_user(user_id) if user.refresh_tokens: refresh_token: RefreshToken = list( user.refresh_tokens.values())[0] media_id = async_sign_path( self.hass, refresh_token.id, quote(media_id), timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), ) # prepend external URL hass_url = get_url(self.hass, prefer_external=True) media_id = f"{hass_url}{media_id}" await self.hass.async_add_executor_job( ft.partial(self.play_media, media_type, media_id, **kwargs))
async def async_play_media(self, media_type, media_id, **kwargs): """Play media from media_source.""" if media_source.is_media_source_id(media_id): sourced_media = await media_source.async_resolve_media(self.hass, media_id) media_type = sourced_media.mime_type media_id = sourced_media.url # If media ID is a relative URL, we serve it from HA. if media_id[0] == "/": user = await self.hass.auth.async_get_owner() if user.refresh_tokens: refresh_token: RefreshToken = list(user.refresh_tokens.values())[0] # Use kwargs so it works both before and after the change in Home Assistant 2022.2 media_id = async_sign_path( hass=self.hass, refresh_token_id=refresh_token.id, path=media_id, expiration=timedelta(minutes=5) ) # Prepend external URL. hass_url = get_url(self.hass, allow_internal=True) media_id = f"{hass_url}{media_id}" _LOGGER.info("Meural device %s: Playing media. Media type is %s, previewing image from %s", self.name, media_type, media_id) await self.local_meural.send_postcard(media_id, media_type) # Play gallery (playlist or album) by ID. elif media_type in ['playlist']: _LOGGER.info("Meural device %s: Playing media. Media type is %s, playing gallery %s", self.name, media_type, media_id) await self.local_meural.send_change_gallery(media_id) # "Preview image from URL. elif media_type in [ 'image/jpg', 'image/png', 'image/jpeg' ]: _LOGGER.info("Meural device %s: Playing media. Media type is %s, previewing image from %s", self.name, media_type, media_id) await self.local_meural.send_postcard(media_id, media_type) # Play item (artwork) by ID. Play locally if item is in currently displayed gallery. If not, play using Meural server.""" elif media_type in ['item']: if media_id.isdigit(): currentgallery_id = self._gallery_status["current_gallery"] currentitems = await self.local_meural.send_get_items_by_gallery(currentgallery_id) in_playlist = next((g["title"] for g in currentitems if g["id"] == media_id), None) if in_playlist is None: _LOGGER.info("Meural device %s: Playing media. Item %s is not in current gallery, trying to display via Meural server", self.name, media_id) try: await self.meural.device_load_item(self.meural_device_id, media_id) except: _LOGGER.error("Meural device %s: Playing media. Error while trying to display %s item %s via Meural server", self.name, media_type, media_id, exc_info=True) else: _LOGGER.info("Meural device %s: Playing media. Item %s is in current gallery %s, trying to display via local device", self.name, media_id, self._gallery_status["current_gallery_name"]) await self.local_meural.send_change_item(media_id) else: _LOGGER.error("Meural device %s: Playing media. ID %s is not an item", self.name, media_id) # This is an unsupported media type. else: _LOGGER.error("Meural device %s: Playing media. Does not support displaying this %s media with ID %s", self.name, media_type, media_id)
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 else: if media_content_id[0] != "/": raise ValueError("URL is relative, but does not start with a /") 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
async def test_auth_access_signed_path_with_refresh_token( hass, app, aiohttp_client, hass_access_token): """Test access with signed url.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( hass_access_token) signed_path = async_sign_path(hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id) req = await client.get(signed_path) assert req.status == HTTPStatus.OK data = await req.json() assert data["user_id"] == refresh_token.user.id # Use signature on other path req = await client.get("/another_path?{}".format( signed_path.split("?")[1])) assert req.status == HTTPStatus.UNAUTHORIZED # We only allow GET req = await client.post(signed_path) assert req.status == HTTPStatus.UNAUTHORIZED # Never valid as expired in the past. expired_signed_path = async_sign_path(hass, "/", timedelta(seconds=-5), refresh_token_id=refresh_token.id) req = await client.get(expired_signed_path) assert req.status == HTTPStatus.UNAUTHORIZED # refresh token gone should also invalidate signature await hass.auth.async_remove_refresh_token(refresh_token) req = await client.get(signed_path) assert req.status == HTTPStatus.UNAUTHORIZED
async def test_auth_access_signed_path( hass, app, aiohttp_client, hass_access_token): """Test access with signed url.""" app.router.add_post('/', mock_handler) app.router.add_get('/another_path', mock_handler) setup_auth(app, [], True, api_password=None) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( hass_access_token) signed_path = async_sign_path( hass, refresh_token.id, '/', timedelta(seconds=5) ) req = await client.get(signed_path) assert req.status == 200 data = await req.json() assert data['refresh_token_id'] == refresh_token.id assert data['user_id'] == refresh_token.user.id # Use signature on other path req = await client.get( '/another_path?{}'.format(signed_path.split('?')[1])) assert req.status == 401 # We only allow GET req = await client.post(signed_path) assert req.status == 401 # Never valid as expired in the past. expired_signed_path = async_sign_path( hass, refresh_token.id, '/', timedelta(seconds=-5) ) req = await client.get(expired_signed_path) assert req.status == 401 # refresh token gone should also invalidate signature await hass.auth.async_remove_refresh_token(refresh_token) req = await client.get(signed_path) assert req.status == 401
async def test_auth_access_signed_path_with_content_user(hass, app, aiohttp_client): """Test access signed url uses content user.""" await async_setup_auth(hass, app) signed_path = async_sign_path(hass, "/", timedelta(seconds=5)) signature = yarl.URL(signed_path).query["authSig"] claims = jwt.decode( signature, hass.data[DATA_SIGN_SECRET], algorithms=["HS256"], options={"verify_signature": False}, ) assert claims["iss"] == hass.data[STORAGE_KEY]
def websocket_sign_path(hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg): """Handle a sign path request.""" connection.send_message( websocket_api.result_message( msg["id"], { "path": async_sign_path( hass, msg["path"], timedelta(seconds=msg["expires"]), ) }, ))
async def websocket_resolve_media(hass, connection, msg): """Resolve media.""" try: media = await async_resolve_media(hass, msg["media_content_id"]) url = media.url except Unresolvable as err: connection.send_error(msg["id"], "resolve_media_failed", str(err)) else: if url[0] == "/": url = async_sign_path( hass, connection.refresh_token_id, url, timedelta(seconds=msg["expires"]), ) connection.send_result(msg["id"], {"url": url, "mime_type": media.mime_type})
async def websocket_resolve_media( hass: HomeAssistant, connection: ActiveConnection, msg: dict ) -> None: """Resolve media.""" try: media = await async_resolve_media(hass, msg["media_content_id"]) url = media.url except Unresolvable as err: connection.send_error(msg["id"], "resolve_media_failed", str(err)) else: if url[0] == "/": url = async_sign_path( hass, quote(url), timedelta(seconds=msg["expires"]), ) connection.send_result(msg["id"], {"url": url, "mime_type": media.mime_type})
async def websocket_resolve_media(hass: HomeAssistant, connection: ActiveConnection, msg: dict) -> None: """Resolve media.""" try: media = await async_resolve_media(hass, msg["media_content_id"]) except Unresolvable as err: connection.send_error(msg["id"], "resolve_media_failed", str(err)) return data = dataclasses.asdict(media) if data["url"][0] == "/": data["url"] = async_sign_path( hass, quote(data["url"]), timedelta(seconds=msg["expires"]), ) connection.send_result(msg["id"], data)
async def test_auth_access_signed_path_with_query_param( hass, app, aiohttp_client, hass_access_token): """Test access with signed url and query params.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( hass_access_token) signed_path = async_sign_path(hass, "/?test=test", timedelta(seconds=5), refresh_token_id=refresh_token.id) req = await client.get(signed_path) assert req.status == HTTPStatus.OK data = await req.json() assert data["user_id"] == refresh_token.user.id
async def test_auth_access_signed_path_with_query_param_tamper( hass, app, aiohttp_client, hass_access_token, base_url: str, test_url: str): """Test access with signed url and query params that have been tampered with.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( hass_access_token) signed_path = async_sign_path(hass, base_url, timedelta(seconds=5), refresh_token_id=refresh_token.id) url = yarl.URL(signed_path) token = url.query.get(SIGN_QUERY_PARAM) req = await client.get(f"{test_url}&{SIGN_QUERY_PARAM}={token}") assert req.status == HTTPStatus.UNAUTHORIZED
async def test_auth_access_signed_path_with_query_param_order( hass, app, aiohttp_client, hass_access_token): """Test access with signed url and query params different order.""" app.router.add_post("/", mock_handler) app.router.add_get("/another_path", mock_handler) await async_setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( hass_access_token) signed_path = async_sign_path( hass, "/?test=test&foo=bar", timedelta(seconds=5), refresh_token_id=refresh_token.id, ) url = yarl.URL(signed_path) signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" req = await client.get(signed_path) assert req.status == HTTPStatus.OK data = await req.json() assert data["user_id"] == refresh_token.user.id
async def async_play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" # Handle media_source if media_source.is_media_source_id(media_id): sourced_media = await media_source.async_resolve_media( self.hass, media_id) media_type = sourced_media.mime_type media_id = sourced_media.url # If media ID is a relative URL, we serve it from HA. # Create a signed path. if media_id[0] == "/": media_id = async_sign_path( self.hass, quote(media_id), timedelta(seconds=media_source.DEFAULT_EXPIRY_TIME), ) # prepend external URL hass_url = get_url(self.hass, prefer_external=True) media_id = f"{hass_url}{media_id}" extra = kwargs.get(ATTR_MEDIA_EXTRA, {}) metadata = extra.get("metadata") # Handle media supported by a known cast app if media_type == CAST_DOMAIN: try: app_data = json.loads(media_id) if metadata is not None: app_data["metadata"] = extra.get("metadata") except json.JSONDecodeError: _LOGGER.error("Invalid JSON in media_content_id") raise # Special handling for passed `app_id` parameter. This will only launch # an arbitrary cast app, generally for UX. if "app_id" in app_data: app_id = app_data.pop("app_id") _LOGGER.info("Starting Cast app by ID %s", app_id) await self.hass.async_add_executor_job( self._chromecast.start_app, app_id) if app_data: _LOGGER.warning( "Extra keys %s were ignored. Please use app_name to cast media", app_data.keys(), ) return app_name = app_data.pop("app_name") try: await self.hass.async_add_executor_job(quick_play, self._chromecast, app_name, app_data) except NotImplementedError: _LOGGER.error("App %s not supported", app_name) return # Try the cast platforms for platform in self.hass.data[CAST_DOMAIN].values(): result = await platform.async_play_media(self.hass, self.entity_id, self._chromecast, media_type, media_id) if result: return # Default to play with the default media receiver app_data = {"media_id": media_id, "media_type": media_type, **extra} await self.hass.async_add_executor_job(quick_play, self._chromecast, "default_media_receiver", app_data)
def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: """ Send the play_media command to the media player. If media_id is a Plex payload, attempt Plex->Sonos playback. If media_id is an Apple Music, Deezer, Sonos, or Tidal share link, attempt playback using the respective service. If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ if spotify.is_spotify_media_type(media_type): media_type = spotify.resolve_spotify_media_type(media_type) if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC media_id = (run_coroutine_threadsafe( media_source.async_resolve_media(self.hass, media_id), self.hass.loop, ).result().url) if media_type == "favorite_item_id": favorite = self.speaker.favorites.lookup_by_item_id(media_id) if favorite is None: raise ValueError(f"Missing favorite for media_id: {media_id}") self._play_favorite(favorite) return soco = self.coordinator.soco if media_id and media_id.startswith(PLEX_URI_SCHEME): plex_plugin = self.speaker.plex_plugin media_id = media_id[len(PLEX_URI_SCHEME):] payload = json.loads(media_id) if isinstance(payload, dict): shuffle = payload.pop("shuffle", False) else: shuffle = False media = lookup_plex_media(self.hass, media_type, json.dumps(payload)) if not kwargs.get(ATTR_MEDIA_ENQUEUE): soco.clear_queue() if shuffle: self.set_shuffle(True) plex_plugin.play_now(media) return share_link = self.coordinator.share_link if share_link.is_share_link(media_id): if kwargs.get(ATTR_MEDIA_ENQUEUE): share_link.add_share_link_to_queue(media_id) else: soco.clear_queue() share_link.add_share_link_to_queue(media_id) soco.play_from_queue(0) elif media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK): # If media ID is a relative URL, we serve it from HA. # Create a signed path. if media_id[0] == "/": media_id = async_sign_path( self.hass, quote(media_id), datetime.timedelta( seconds=media_source.DEFAULT_EXPIRY_TIME), ) # prepend external URL hass_url = get_url(self.hass, prefer_external=True) media_id = f"{hass_url}{media_id}" if kwargs.get(ATTR_MEDIA_ENQUEUE): soco.add_uri_to_queue(media_id) else: soco.play_uri(media_id) elif media_type == MEDIA_TYPE_PLAYLIST: if media_id.startswith("S:"): item = media_browser.get_media( self.media.library, media_id, media_type) # type: ignore[no-untyped-call] soco.play_uri(item.get_uri()) return try: playlists = soco.get_sonos_playlists() playlist = next(p for p in playlists if p.title == media_id) except StopIteration: _LOGGER.error('Could not find a Sonos playlist named "%s"', media_id) else: soco.clear_queue() soco.add_to_queue(playlist) soco.play_from_queue(0) elif media_type in PLAYABLE_MEDIA_TYPES: item = media_browser.get_media( self.media.library, media_id, media_type) # type: ignore[no-untyped-call] if not item: _LOGGER.error('Could not find "%s" in the library', media_id) return soco.play_uri(item.get_uri()) else: _LOGGER.error('Sonos does not support a media type of "%s"', media_type)
def get_signed_path(hass, connection, msg): connection.send_result( msg["id"], {"path": async_sign_path(hass, "/", timedelta(seconds=5))})
async def mock_handler(request): """Return signed path.""" return web.json_response( data={"path": async_sign_path(hass, "/", timedelta(seconds=-5))})