diff --git a/homeassistant/components/forked_daapd/browse_media.py b/homeassistant/components/forked_daapd/browse_media.py new file mode 100644 index 00000000000..9ea7104186e --- /dev/null +++ b/homeassistant/components/forked_daapd/browse_media.py @@ -0,0 +1,331 @@ +"""Browse media for forked-daapd.""" +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Union, cast +from urllib.parse import quote, unquote + +from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType +from homeassistant.components.media_player.errors import BrowseError +from homeassistant.helpers.network import is_internal_request + +from .const import CAN_PLAY_TYPE, URI_SCHEMA + +if TYPE_CHECKING: + from . import media_player + +MEDIA_TYPE_DIRECTORY = "directory" + +TOP_LEVEL_LIBRARY = { + "Albums": (MediaClass.ALBUM, MediaType.ALBUM, ""), + "Artists": (MediaClass.ARTIST, MediaType.ARTIST, ""), + "Playlists": (MediaClass.PLAYLIST, MediaType.PLAYLIST, ""), + "Albums by Genre": (MediaClass.GENRE, MediaType.GENRE, MediaType.ALBUM), + "Tracks by Genre": (MediaClass.GENRE, MediaType.GENRE, MediaType.TRACK), + "Artists by Genre": (MediaClass.GENRE, MediaType.GENRE, MediaType.ARTIST), + "Directories": (MediaClass.DIRECTORY, MEDIA_TYPE_DIRECTORY, ""), +} +MEDIA_TYPE_TO_MEDIA_CLASS = { + MediaType.ALBUM: MediaClass.ALBUM, + MediaType.APP: MediaClass.APP, + MediaType.ARTIST: MediaClass.ARTIST, + MediaType.TRACK: MediaClass.TRACK, + MediaType.PLAYLIST: MediaClass.PLAYLIST, + MediaType.GENRE: MediaClass.GENRE, + MEDIA_TYPE_DIRECTORY: MediaClass.DIRECTORY, +} +CAN_EXPAND_TYPE = { + MediaType.ALBUM, + MediaType.ARTIST, + MediaType.PLAYLIST, + MediaType.GENRE, + MEDIA_TYPE_DIRECTORY, +} +# The keys and values in the below dict are identical only because the +# HA constants happen to align with the Owntone constants. +OWNTONE_TYPE_TO_MEDIA_TYPE = { + "track": MediaType.TRACK, + "playlist": MediaType.PLAYLIST, + "artist": MediaType.ARTIST, + "album": MediaType.ALBUM, + "genre": MediaType.GENRE, + MediaType.APP: MediaType.APP, # This is just for passthrough + MEDIA_TYPE_DIRECTORY: MEDIA_TYPE_DIRECTORY, # This is just for passthrough +} +MEDIA_TYPE_TO_OWNTONE_TYPE = {v: k for k, v in OWNTONE_TYPE_TO_MEDIA_TYPE.items()} + +""" +media_content_id is a uri in the form of SCHEMA:Title:OwnToneURI:Subtype (Subtype only used for Genre) +OwnToneURI is in format library:type:id (for directories, id is path) +media_content_type - type of item (mostly used to check if playable or can expand) +Owntone type may differ from media_content_type when media_content_type is a directory +Owntown type is used in our own branching, but media_content_type is used for determining playability +""" + + +@dataclass +class MediaContent: + """Class for representing Owntone media content.""" + + title: str + type: str + id_or_path: str + subtype: str + + def __init__(self, media_content_id: str) -> None: + """Create MediaContent from media_content_id.""" + ( + _schema, + self.title, + _library, + self.type, + self.id_or_path, + self.subtype, + ) = media_content_id.split(":") + self.title = unquote(self.title) # Title may have special characters + self.id_or_path = unquote(self.id_or_path) # May have special characters + self.type = OWNTONE_TYPE_TO_MEDIA_TYPE[self.type] + + +def create_owntone_uri(media_type: str, id_or_path: str) -> str: + """Create an Owntone uri.""" + return f"library:{MEDIA_TYPE_TO_OWNTONE_TYPE[media_type]}:{quote(id_or_path)}" + + +def create_media_content_id( + title: str, + owntone_uri: str = "", + media_type: str = "", + id_or_path: str = "", + subtype: str = "", +) -> str: + """Create a media_content_id. + + Either owntone_uri or both type and id_or_path must be specified. + """ + if not owntone_uri: + owntone_uri = create_owntone_uri(media_type, id_or_path) + return f"{URI_SCHEMA}:{quote(title)}:{owntone_uri}:{subtype}" + + +def is_owntone_media_content_id(media_content_id: str) -> bool: + """Return whether this media_content_id is from our integration.""" + return media_content_id[: len(URI_SCHEMA)] == URI_SCHEMA + + +def convert_to_owntone_uri(media_content_id: str) -> str: + """Convert media_content_id to Owntone URI.""" + return ":".join(media_content_id.split(":")[2:-1]) + + +async def get_owntone_content( + master: media_player.ForkedDaapdMaster, + media_content_id: str, +) -> BrowseMedia: + """Create response for the given media_content_id.""" + + media_content = MediaContent(media_content_id) + result: list[dict[str, int | str]] | dict[str, Any] | None = None + if media_content.type == MediaType.APP: + return base_owntone_library() + # Query API for next level + if media_content.type == MEDIA_TYPE_DIRECTORY: + # returns tracks, directories, and playlists + directory_path = media_content.id_or_path + if directory_path: + result = await master.api.get_directory(directory=directory_path) + else: + result = await master.api.get_directory() + if result is None: + raise BrowseError( + f"Media not found for {media_content.type} / {media_content_id}" + ) + # Fill in children with subdirectories + children = [] + assert isinstance(result, dict) + for directory in result["directories"]: + path = directory["path"] + children.append( + BrowseMedia( + title=path, + media_class=MediaClass.DIRECTORY, + media_content_id=create_media_content_id( + title=path, media_type=MEDIA_TYPE_DIRECTORY, id_or_path=path + ), + media_content_type=MEDIA_TYPE_DIRECTORY, + can_play=False, + can_expand=True, + ) + ) + result = result["tracks"]["items"] + result["playlists"]["items"] + return create_browse_media_response( + master, + media_content, + cast(list[dict[str, Union[int, str]]], result), + children, + ) + if media_content.id_or_path == "": # top level search + if media_content.type == MediaType.ALBUM: + result = ( + await master.api.get_albums() + ) # list of albums with name, artist, uri + elif media_content.type == MediaType.ARTIST: + result = await master.api.get_artists() # list of artists with name, uri + elif media_content.type == MediaType.GENRE: + if result := await master.api.get_genres(): # returns list of genre names + for item in result: # pylint: disable=not-an-iterable + # add generated genre uris to list of genre names + item["uri"] = create_owntone_uri( + MediaType.GENRE, cast(str, item["name"]) + ) + elif media_content.type == MediaType.PLAYLIST: + result = ( + await master.api.get_playlists() + ) # list of playlists with name, uri + if result is None: + raise BrowseError( + f"Media not found for {media_content.type} / {media_content_id}" + ) + return create_browse_media_response( + master, + media_content, + cast(list[dict[str, Union[int, str]]], result), + ) + # Not a directory or top level of library + # We should have content type and id + if media_content.type == MediaType.ALBUM: + result = await master.api.get_tracks(album_id=media_content.id_or_path) + elif media_content.type == MediaType.ARTIST: + result = await master.api.get_albums(artist_id=media_content.id_or_path) + elif media_content.type == MediaType.GENRE: + if media_content.subtype in { + MediaType.ALBUM, + MediaType.ARTIST, + MediaType.TRACK, + }: + result = await master.api.get_genre( + media_content.id_or_path, media_type=media_content.subtype + ) + elif media_content.type == MediaType.PLAYLIST: + result = await master.api.get_tracks(playlist_id=media_content.id_or_path) + + if result is None: + raise BrowseError( + f"Media not found for {media_content.type} / {media_content_id}" + ) + + return create_browse_media_response( + master, media_content, cast(list[dict[str, Union[int, str]]], result) + ) + + +def create_browse_media_response( + master: media_player.ForkedDaapdMaster, + media_content: MediaContent, + result: list[dict[str, int | str]], + children: list[BrowseMedia] | None = None, +) -> BrowseMedia: + """Convert the results into a browse media response.""" + internal_request = is_internal_request(master.hass) + if not children: # Directory searches will pass in subdirectories as children + children = [] + for item in result: + assert isinstance(item["uri"], str) + media_type = OWNTONE_TYPE_TO_MEDIA_TYPE[item["uri"].split(":")[1]] + title = item.get("name") or item.get("title") # only tracks use title + assert isinstance(title, str) + media_content_id = create_media_content_id( + title=f"{media_content.title} / {title}", + owntone_uri=item["uri"], + subtype=media_content.subtype, + ) + if artwork := item.get("artwork_url"): + thumbnail = ( + master.api.full_url(cast(str, artwork)) + if internal_request + else master.get_browse_image_url(media_type, media_content_id) + ) + else: + thumbnail = None + children.append( + BrowseMedia( + title=title, + media_class=MEDIA_TYPE_TO_MEDIA_CLASS[media_type], + media_content_id=media_content_id, + media_content_type=media_type, + can_play=media_type in CAN_PLAY_TYPE, + can_expand=media_type in CAN_EXPAND_TYPE, + thumbnail=thumbnail, + ) + ) + return BrowseMedia( + title=media_content.id_or_path + if media_content.type == MEDIA_TYPE_DIRECTORY + else media_content.title, + media_class=MEDIA_TYPE_TO_MEDIA_CLASS[media_content.type], + media_content_id="", + media_content_type=media_content.type, + can_play=media_content.type in CAN_PLAY_TYPE, + can_expand=media_content.type in CAN_EXPAND_TYPE, + children=children, + ) + + +def base_owntone_library() -> BrowseMedia: + """Return the base of our Owntone library.""" + children = [ + BrowseMedia( + title=name, + media_class=media_class, + media_content_id=create_media_content_id( + title=name, media_type=media_type, subtype=media_subtype + ), + media_content_type=MEDIA_TYPE_DIRECTORY, + can_play=False, + can_expand=True, + ) + for name, (media_class, media_type, media_subtype) in TOP_LEVEL_LIBRARY.items() + ] + return BrowseMedia( + title="Owntone Library", + media_class=MediaClass.APP, + media_content_id=create_media_content_id( + title="Owntone Library", media_type=MediaType.APP + ), + media_content_type=MediaType.APP, + can_play=False, + can_expand=True, + children=children, + thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png", + ) + + +def library(other: Sequence[BrowseMedia] | None) -> BrowseMedia: + """Create response to describe contents of library.""" + + top_level_items = [ + BrowseMedia( + title="Owntone Library", + media_class=MediaClass.APP, + media_content_id=create_media_content_id( + title="Owntone Library", media_type=MediaType.APP + ), + media_content_type=MediaType.APP, + can_play=False, + can_expand=True, + thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png", + ) + ] + if other: + top_level_items.extend(other) + + return BrowseMedia( + title="Owntone", + media_class=MediaClass.DIRECTORY, + media_content_id="", + media_content_type=MEDIA_TYPE_DIRECTORY, + can_play=False, + can_expand=True, + children=top_level_items, + ) diff --git a/homeassistant/components/forked_daapd/const.py b/homeassistant/components/forked_daapd/const.py index 85b51b3b6ae..bd0ab02e6c2 100644 --- a/homeassistant/components/forked_daapd/const.py +++ b/homeassistant/components/forked_daapd/const.py @@ -1,5 +1,5 @@ """Const for forked-daapd.""" -from homeassistant.components.media_player import MediaPlayerEntityFeature +from homeassistant.components.media_player import MediaPlayerEntityFeature, MediaType CALLBACK_TIMEOUT = 8 # max time between command and callback from forked-daapd server CAN_PLAY_TYPE = { @@ -11,6 +11,12 @@ CAN_PLAY_TYPE = { "audio/x-ms-wma", "audio/aiff", "audio/wav", + MediaType.TRACK, + MediaType.PLAYLIST, + MediaType.ARTIST, + MediaType.ALBUM, + MediaType.GENRE, + MediaType.MUSIC, } CONF_LIBRESPOT_JAVA_PORT = "librespot_java_port" CONF_MAX_PLAYLISTS = "max_playlists" @@ -82,3 +88,4 @@ SUPPORTED_FEATURES_ZONE = ( | MediaPlayerEntityFeature.TURN_OFF ) TTS_TIMEOUT = 20 # max time to wait between TTS getting sent and starting to play +URI_SCHEMA = "owntone" diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index 389942a9f59..3229e192884 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -32,6 +32,12 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow +from .browse_media import ( + convert_to_owntone_uri, + get_owntone_content, + is_owntone_media_content_id, + library, +) from .const import ( CALLBACK_TIMEOUT, CAN_PLAY_TYPE, @@ -238,7 +244,8 @@ class ForkedDaapdMaster(MediaPlayerEntity): self, clientsession, api, ip_address, api_port, api_password, config_entry ): """Initialize the ForkedDaapd Master Device.""" - self._api = api + # Leave the api public so the browse media helpers can use it + self.api = api self._player = STARTUP_DATA[ "player" ] # _player, _outputs, and _queue are loaded straight from api @@ -416,13 +423,13 @@ class ForkedDaapdMaster(MediaPlayerEntity): async def async_turn_on(self) -> None: """Restore the last on outputs state.""" # restore state - await self._api.set_volume(volume=self._last_volume * 100) + await self.api.set_volume(volume=self._last_volume * 100) if self._last_outputs: futures: list[asyncio.Task[int]] = [] for output in self._last_outputs: futures.append( asyncio.create_task( - self._api.change_output( + self.api.change_output( output["id"], selected=output["selected"], volume=output["volume"], @@ -431,7 +438,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): ) await asyncio.wait(futures) else: # enable all outputs - await self._api.set_enabled_outputs( + await self.api.set_enabled_outputs( [output["id"] for output in self._outputs] ) @@ -440,7 +447,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): await self.async_media_pause() self._last_outputs = self._outputs if any(output["selected"] for output in self._outputs): - await self._api.set_enabled_outputs([]) + await self.api.set_enabled_outputs([]) async def async_toggle(self) -> None: """Toggle the power on the device. @@ -568,32 +575,32 @@ class ForkedDaapdMaster(MediaPlayerEntity): target_volume = 0 else: target_volume = self._last_volume # restore volume level - await self._api.set_volume(volume=target_volume * 100) + await self.api.set_volume(volume=target_volume * 100) async def async_set_volume_level(self, volume: float) -> None: """Set volume - input range [0,1].""" - await self._api.set_volume(volume=volume * 100) + await self.api.set_volume(volume=volume * 100) async def async_media_play(self) -> None: """Start playback.""" if self._use_pipe_control(): await self._pipe_call(self._use_pipe_control(), "async_media_play") else: - await self._api.start_playback() + await self.api.start_playback() async def async_media_pause(self) -> None: """Pause playback.""" if self._use_pipe_control(): await self._pipe_call(self._use_pipe_control(), "async_media_pause") else: - await self._api.pause_playback() + await self.api.pause_playback() async def async_media_stop(self) -> None: """Stop playback.""" if self._use_pipe_control(): await self._pipe_call(self._use_pipe_control(), "async_media_stop") else: - await self._api.stop_playback() + await self.api.stop_playback() async def async_media_previous_track(self) -> None: """Skip to previous track.""" @@ -602,32 +609,32 @@ class ForkedDaapdMaster(MediaPlayerEntity): self._use_pipe_control(), "async_media_previous_track" ) else: - await self._api.previous_track() + await self.api.previous_track() async def async_media_next_track(self) -> None: """Skip to next track.""" if self._use_pipe_control(): await self._pipe_call(self._use_pipe_control(), "async_media_next_track") else: - await self._api.next_track() + await self.api.next_track() async def async_media_seek(self, position: float) -> None: """Seek to position.""" - await self._api.seek(position_ms=position * 1000) + await self.api.seek(position_ms=position * 1000) async def async_clear_playlist(self) -> None: """Clear playlist.""" - await self._api.clear_queue() + await self.api.clear_queue() async def async_set_shuffle(self, shuffle: bool) -> None: """Enable/disable shuffle mode.""" - await self._api.shuffle(shuffle) + await self.api.shuffle(shuffle) @property def media_image_url(self): """Image url of current playing media.""" if url := self._track_info.get("artwork_url"): - url = self._api.full_url(url) + url = self.api.full_url(url) return url async def _save_and_set_tts_volumes(self): @@ -635,11 +642,11 @@ class ForkedDaapdMaster(MediaPlayerEntity): self._last_volume = self.volume_level self._last_outputs = self._outputs if self._outputs: - await self._api.set_volume(volume=self._tts_volume * 100) + await self.api.set_volume(volume=self._tts_volume * 100) futures = [] for output in self._outputs: futures.append( - self._api.change_output( + self.api.change_output( output["id"], selected=True, volume=self._tts_volume * 100 ) ) @@ -660,12 +667,20 @@ class ForkedDaapdMaster(MediaPlayerEntity): self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play a URI.""" + + # Preprocess media_ids if media_source.is_media_source_id(media_id): media_type = MediaType.MUSIC play_item = await media_source.async_resolve_media( self.hass, media_id, self.entity_id ) media_id = play_item.url + elif is_owntone_media_content_id(media_id): + media_id = convert_to_owntone_uri(media_id) + + if media_type not in CAN_PLAY_TYPE: + _LOGGER.warning("Media type '%s' not supported", media_type) + return if media_type == MediaType.MUSIC: media_id = async_process_play_media_url(self.hass, media_id) @@ -684,7 +699,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE ) if enqueue in {True, MediaPlayerEnqueue.ADD, MediaPlayerEnqueue.REPLACE}: - return await self._api.add_to_queue( + return await self.api.add_to_queue( uris=media_id, playback="start", clear=enqueue == MediaPlayerEnqueue.REPLACE, @@ -699,13 +714,13 @@ class ForkedDaapdMaster(MediaPlayerEntity): 0, ) if enqueue == MediaPlayerEnqueue.NEXT: - return await self._api.add_to_queue( + return await self.api.add_to_queue( uris=media_id, playback="start", position=current_position + 1, ) # enqueue == MediaPlayerEnqueue.PLAY - return await self._api.add_to_queue( + return await self.api.add_to_queue( uris=media_id, playback="start", position=current_position, @@ -732,7 +747,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): ) self._tts_requested = True await sleep_future - await self._api.add_to_queue(uris=media_id, playback="start", clear=True) + await self.api.add_to_queue(uris=media_id, playback="start", clear=True) try: async with async_timeout.timeout(TTS_TIMEOUT): await self._tts_playing_event.wait() @@ -751,7 +766,7 @@ class ForkedDaapdMaster(MediaPlayerEntity): if saved_mute: # mute if we were muted await self.async_mute_volume(True) if self._use_pipe_control(): # resume pipe - await self._api.add_to_queue( + await self.api.add_to_queue( uris=self._sources_uris[self._source], clear=True ) if saved_state == MediaPlayerState.PLAYING: @@ -760,13 +775,13 @@ class ForkedDaapdMaster(MediaPlayerEntity): if not saved_queue: return # Restore stashed queue - await self._api.add_to_queue( + await self.api.add_to_queue( uris=",".join(item["uri"] for item in saved_queue["items"]), playback="start", playback_from_position=saved_queue_position, clear=True, ) - await self._api.seek(position_ms=saved_song_position) + await self.api.seek(position_ms=saved_song_position) if saved_state == MediaPlayerState.PAUSED: await self.async_media_pause() return @@ -788,9 +803,9 @@ class ForkedDaapdMaster(MediaPlayerEntity): if not self._use_pipe_control(): # playlist or clear ends up at default self._source = SOURCE_NAME_DEFAULT if self._sources_uris.get(source): # load uris for pipes or playlists - await self._api.add_to_queue(uris=self._sources_uris[source], clear=True) + await self.api.add_to_queue(uris=self._sources_uris[source], clear=True) elif source == SOURCE_NAME_CLEAR: # clear playlist - await self._api.clear_queue() + await self.api.clear_queue() self.async_write_ha_state() def _use_pipe_control(self): @@ -813,11 +828,50 @@ class ForkedDaapdMaster(MediaPlayerEntity): media_content_id: str | None = None, ) -> BrowseMedia: """Implement the websocket media browsing helper.""" - return await media_source.async_browse_media( - self.hass, - media_content_id, - content_filter=lambda bm: bm.media_content_type in CAN_PLAY_TYPE, - ) + if media_content_id is None or media_source.is_media_source_id( + media_content_id + ): + ms_result = await media_source.async_browse_media( + self.hass, + media_content_id, + content_filter=lambda bm: bm.media_content_type in CAN_PLAY_TYPE, + ) + if media_content_type is None: + # This is the base level, so we combine our library with the media source + return library(ms_result.children) + return ms_result + # media_content_type should only be None if media_content_id is None + assert media_content_type + return await get_owntone_content(self, media_content_id) + + async def async_get_browse_image( + self, + media_content_type: str, + media_content_id: str, + media_image_id: str | None = None, + ) -> tuple[bytes | None, str | None]: + """Fetch image for media browser.""" + + if media_content_type not in { + MediaType.TRACK, + MediaType.ALBUM, + MediaType.ARTIST, + }: + return None, None + owntone_uri = convert_to_owntone_uri(media_content_id) + item_id_str = owntone_uri.rsplit(":", maxsplit=1)[-1] + if media_content_type == MediaType.TRACK: + result = await self.api.get_track(int(item_id_str)) + elif media_content_type == MediaType.ALBUM: + if result := await self.api.get_albums(): + result = next( + (item for item in result if item["id"] == item_id_str), None + ) + elif result := await self.api.get_artists(): + result = next((item for item in result if item["id"] == item_id_str), None) + if url := result.get("artwork_url"): + return await self._async_fetch_image(self.api.full_url(url)) + return None, None class ForkedDaapdUpdater: diff --git a/tests/components/forked_daapd/conftest.py b/tests/components/forked_daapd/conftest.py new file mode 100644 index 00000000000..b9dd7087aef --- /dev/null +++ b/tests/components/forked_daapd/conftest.py @@ -0,0 +1,28 @@ +"""Common fixtures for forked_daapd tests.""" + +import pytest + +from homeassistant.components.forked_daapd.const import CONF_TTS_PAUSE_TIME, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(): + """Create hass config_entry fixture.""" + data = { + CONF_HOST: "192.168.1.1", + CONF_PORT: "2345", + CONF_PASSWORD: "", + } + return MockConfigEntry( + version=1, + domain=DOMAIN, + title="", + data=data, + options={CONF_TTS_PAUSE_TIME: 0}, + source=SOURCE_USER, + entry_id=1, + ) diff --git a/tests/components/forked_daapd/test_browse_media.py b/tests/components/forked_daapd/test_browse_media.py new file mode 100644 index 00000000000..6c7b77b97ea --- /dev/null +++ b/tests/components/forked_daapd/test_browse_media.py @@ -0,0 +1,294 @@ +"""Media browsing tests for the forked_daapd media player platform.""" + +from http import HTTPStatus +from unittest.mock import patch + +from homeassistant.components import media_source +from homeassistant.components.forked_daapd.browse_media import create_media_content_id +from homeassistant.components.media_player import MediaType +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.setup import async_setup_component + +TEST_MASTER_ENTITY_NAME = "media_player.forked_daapd_server" + + +async def test_async_browse_media(hass, hass_ws_client, config_entry): + """Test browse media.""" + + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + autospec=True, + ) as mock_api: + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) + await hass.async_block_till_done() + + mock_api.return_value.full_url = lambda x: "http://owntone_instance/" + x + mock_api.return_value.get_directory.side_effect = [ + { + "directories": [ + {"path": "/music/srv/Audiobooks"}, + {"path": "/music/srv/Music"}, + {"path": "/music/srv/Playlists"}, + {"path": "/music/srv/Podcasts"}, + ], + "tracks": { + "items": [ + { + "id": 1, + "title": "input.pipe", + "artist": "Unknown artist", + "artist_sort": "Unknown artist", + "album": "Unknown album", + "album_sort": "Unknown album", + "album_id": "4201163758598356043", + "album_artist": "Unknown artist", + "album_artist_sort": "Unknown artist", + "album_artist_id": "4187901437947843388", + "genre": "Unknown genre", + "year": 0, + "track_number": 0, + "disc_number": 0, + "length_ms": 0, + "play_count": 0, + "skip_count": 0, + "time_added": "2018-11-24T08:41:35Z", + "seek_ms": 0, + "media_kind": "music", + "data_kind": "pipe", + "path": "/music/srv/input.pipe", + "uri": "library:track:1", + "artwork_url": "/artwork/item/1", + } + ], + "total": 1, + "offset": 0, + "limit": -1, + }, + "playlists": { + "items": [ + { + "id": 8, + "name": "radio", + "path": "/music/srv/radio.m3u", + "smart_playlist": True, + "uri": "library:playlist:8", + } + ], + "total": 1, + "offset": 0, + "limit": -1, + }, + } + ] + 4 * [ + {"directories": [], "tracks": {"items": []}, "playlists": {"items": []}} + ] + mock_api.return_value.get_albums.return_value = [ + { + "id": "8009851123233197743", + "name": "Add Violence", + "name_sort": "Add Violence", + "artist": "Nine Inch Nails", + "artist_id": "32561671101664759", + "track_count": 5, + "length_ms": 1634961, + "uri": "library:album:8009851123233197743", + }, + ] + mock_api.return_value.get_artists.return_value = [ + { + "id": "3815427709949443149", + "name": "ABAY", + "name_sort": "ABAY", + "album_count": 1, + "track_count": 10, + "length_ms": 2951554, + "uri": "library:artist:3815427709949443149", + }, + ] + mock_api.return_value.get_genres.return_value = [ + {"name": "Classical"}, + {"name": "Drum & Bass"}, + {"name": "Pop"}, + {"name": "Rock/Pop"}, + {"name": "'90s Alternative"}, + ] + mock_api.return_value.get_playlists.return_value = [ + { + "id": 1, + "name": "radio", + "path": "/music/srv/radio.m3u", + "smart_playlist": False, + "uri": "library:playlist:1", + }, + ] + + # Request playlist through WebSocket + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + "entity_id": TEST_MASTER_ENTITY_NAME, + } + ) + msg = await client.receive_json() + # Assert WebSocket response + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg_id = 2 + + async def browse_children(children): + """Browse the children of this BrowseMedia.""" + nonlocal msg_id + for child in children: + if child["can_expand"]: + print("EXPANDING CHILD", child) + await client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": TEST_MASTER_ENTITY_NAME, + "media_content_type": child["media_content_type"], + "media_content_id": child["media_content_id"], + } + ) + msg = await client.receive_json() + assert msg["success"] + msg_id += 1 + await browse_children(msg["result"]["children"]) + + await browse_children(msg["result"]["children"]) + + +async def test_async_browse_media_not_found(hass, hass_ws_client, config_entry): + """Test browse media not found.""" + + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + autospec=True, + ) as mock_api: + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) + await hass.async_block_till_done() + + mock_api.return_value.get_directory.return_value = None + mock_api.return_value.get_albums.return_value = None + mock_api.return_value.get_artists.return_value = None + mock_api.return_value.get_genres.return_value = None + mock_api.return_value.get_playlists.return_value = None + + # Request playlist through WebSocket + client = await hass_ws_client(hass) + msg_id = 1 + for media_type in ( + "directory", + MediaType.ALBUM, + MediaType.ARTIST, + MediaType.GENRE, + MediaType.PLAYLIST, + ): + await client.send_json( + { + "id": msg_id, + "type": "media_player/browse_media", + "entity_id": TEST_MASTER_ENTITY_NAME, + "media_content_type": media_type, + "media_content_id": ( + media_content_id := create_media_content_id( + "title", f"library:{media_type}:" + ) + ), + } + ) + msg = await client.receive_json() + # Assert WebSocket response + assert msg["id"] == msg_id + assert msg["type"] == TYPE_RESULT + assert not msg["success"] + assert ( + msg["error"]["message"] + == f"Media not found for {media_type} / {media_content_id}" + ) + msg_id += 1 + + +async def test_async_browse_image(hass, hass_client, config_entry): + """Test browse media images.""" + + with patch( + "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + autospec=True, + ) as mock_api: + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) + await hass.async_block_till_done() + client = await hass_client() + mock_api.return_value.full_url = lambda x: "http://owntone_instance/" + x + mock_api.return_value.get_albums.return_value = [ + {"id": "8009851123233197743", "artwork_url": "some_album_image"}, + ] + mock_api.return_value.get_artists.return_value = [ + {"id": "3815427709949443149", "artwork_url": "some_artist_image"}, + ] + mock_api.return_value.get_track.return_value = { + "id": 456, + "artwork_url": "some_track_image", + } + media_content_id = create_media_content_id( + "title", media_type=MediaType.ALBUM, id_or_path="8009851123233197743" + ) + + with patch( + "homeassistant.components.media_player.async_fetch_image" + ) as mock_fetch_image: + for media_type, media_id in ( + (MediaType.ALBUM, "8009851123233197743"), + (MediaType.ARTIST, "3815427709949443149"), + (MediaType.TRACK, "456"), + ): + mock_fetch_image.return_value = (b"image_bytes", media_type) + media_content_id = create_media_content_id( + "title", media_type=media_type, id_or_path=media_id + ) + resp = await client.get( + f"/api/media_player_proxy/{TEST_MASTER_ENTITY_NAME}/browse_media/{media_type}/{media_content_id}" + ) + assert ( + mock_fetch_image.call_args[0][2] + == f"http://owntone_instance/some_{media_type}_image" + ) + assert resp.status == HTTPStatus.OK + assert resp.content_type == media_type + assert await resp.read() == b"image_bytes" + + +async def test_async_browse_image_missing(hass, hass_client, config_entry, caplog): + """Test browse media images with no image available.""" + + with patch( + "homeassistant.components.forked_daapd.media_player.ForkedDaapdAPI", + autospec=True, + ) as mock_api: + config_entry.add_to_hass(hass) + await config_entry.async_setup(hass) + await hass.async_block_till_done() + client = await hass_client() + mock_api.return_value.full_url = lambda x: "http://owntone_instance/" + x + mock_api.return_value.get_track.return_value = {} + + media_content_id = create_media_content_id( + "title", media_type=MediaType.TRACK, id_or_path="456" + ) + resp = await client.get( + f"/api/media_player_proxy/{TEST_MASTER_ENTITY_NAME}/browse_media/{MediaType.TRACK}/{media_content_id}" + ) + assert resp.status == HTTPStatus.INTERNAL_SERVER_ERROR diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py index 307eb8deea6..893b6c875e2 100644 --- a/tests/components/forked_daapd/test_media_player.py +++ b/tests/components/forked_daapd/test_media_player.py @@ -4,12 +4,12 @@ from unittest.mock import patch import pytest +from homeassistant.components.forked_daapd.browse_media import create_media_content_id from homeassistant.components.forked_daapd.const import ( CONF_LIBRESPOT_JAVA_PORT, CONF_MAX_PLAYLISTS, CONF_TTS_PAUSE_TIME, CONF_TTS_VOLUME, - DOMAIN, SIGNAL_UPDATE_OUTPUTS, SIGNAL_UPDATE_PLAYER, SIGNAL_UPDATE_QUEUE, @@ -54,25 +54,21 @@ from homeassistant.components.media_player import ( MediaPlayerEnqueue, MediaType, ) -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES, - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, STATE_ON, STATE_PAUSED, STATE_UNAVAILABLE, ) -from tests.common import MockConfigEntry, async_mock_signal +from tests.common import async_mock_signal TEST_MASTER_ENTITY_NAME = "media_player.forked_daapd_server" TEST_ZONE_ENTITY_NAMES = [ "media_player.forked_daapd_output_" + x - for x in ["kitchen", "computer", "daapd_fifo"] + for x in ("kitchen", "computer", "daapd_fifo") ] OPTIONS_DATA = { @@ -290,25 +286,6 @@ SAMPLE_PIPES = [ SAMPLE_PLAYLISTS = [{"id": 7, "name": "test_playlist", "uri": "library:playlist:2"}] -@pytest.fixture(name="config_entry") -def config_entry_fixture(): - """Create hass config_entry fixture.""" - data = { - CONF_HOST: "192.168.1.1", - CONF_PORT: "2345", - CONF_PASSWORD: "", - } - return MockConfigEntry( - version=1, - domain=DOMAIN, - title="", - data=data, - options={CONF_TTS_PAUSE_TIME: 0}, - source=SOURCE_USER, - entry_id=1, - ) - - @pytest.fixture(name="get_request_return_values") async def get_request_return_values_fixture(): """Get request return values we can change later.""" @@ -888,3 +865,29 @@ async def test_async_play_media_enqueue(hass, mock_api_object): mock_api_object.add_to_queue.assert_called_with( uris="http://example.com/next.mp3", playback="start", position=1 ) + + +async def test_play_owntone_media(hass, mock_api_object): + """Test async play media with an owntone source.""" + initial_state = hass.states.get(TEST_MASTER_ENTITY_NAME) + await _service_call( + hass, + TEST_MASTER_ENTITY_NAME, + SERVICE_PLAY_MEDIA, + { + ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC, + ATTR_MEDIA_CONTENT_ID: create_media_content_id( + "some song", "library:track:456" + ), + ATTR_MEDIA_ENQUEUE: MediaPlayerEnqueue.PLAY, + }, + ) + state = hass.states.get(TEST_MASTER_ENTITY_NAME) + assert state.state == initial_state.state + assert state.last_updated > initial_state.last_updated + mock_api_object.add_to_queue.assert_called_with( + uris="library:track:456", + playback="start", + position=0, + playback_from_position=0, + )