diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index cda40729dbc..614be2b1817 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -31,9 +31,12 @@ SONOS_ALBUM_ARTIST = "album_artists" SONOS_TRACKS = "tracks" SONOS_COMPOSER = "composers" SONOS_RADIO = "radio" +SONOS_SHARE = "share" SONOS_OTHER_ITEM = "other items" SONOS_AUDIO_BOOK = "audio book" +MEDIA_TYPE_DIRECTORY = MediaClass.DIRECTORY + SONOS_STATE_PLAYING = "PLAYING" SONOS_STATE_TRANSITIONING = "TRANSITIONING" @@ -43,12 +46,14 @@ EXPANDABLE_MEDIA_TYPES = [ MediaType.COMPOSER, MediaType.GENRE, MediaType.PLAYLIST, + MEDIA_TYPE_DIRECTORY, SONOS_ALBUM, SONOS_ALBUM_ARTIST, SONOS_ARTIST, SONOS_GENRE, SONOS_COMPOSER, SONOS_PLAYLISTS, + SONOS_SHARE, ] SONOS_TO_MEDIA_CLASSES = { @@ -59,6 +64,8 @@ SONOS_TO_MEDIA_CLASSES = { SONOS_GENRE: MediaClass.GENRE, SONOS_PLAYLISTS: MediaClass.PLAYLIST, SONOS_TRACKS: MediaClass.TRACK, + SONOS_SHARE: MediaClass.DIRECTORY, + "object.container": MediaClass.DIRECTORY, "object.container.album.musicAlbum": MediaClass.ALBUM, "object.container.genre.musicGenre": MediaClass.PLAYLIST, "object.container.person.composer": MediaClass.PLAYLIST, @@ -79,6 +86,7 @@ SONOS_TO_MEDIA_TYPES = { SONOS_GENRE: MediaType.GENRE, SONOS_PLAYLISTS: MediaType.PLAYLIST, SONOS_TRACKS: MediaType.TRACK, + "object.container": MEDIA_TYPE_DIRECTORY, "object.container.album.musicAlbum": MediaType.ALBUM, "object.container.genre.musicGenre": MediaType.PLAYLIST, "object.container.person.composer": MediaType.PLAYLIST, @@ -97,6 +105,7 @@ MEDIA_TYPES_TO_SONOS: dict[MediaType | str, str] = { MediaType.GENRE: SONOS_GENRE, MediaType.PLAYLIST: SONOS_PLAYLISTS, MediaType.TRACK: SONOS_TRACKS, + MEDIA_TYPE_DIRECTORY: SONOS_SHARE, } SONOS_TYPES_MAPPING = { @@ -127,6 +136,7 @@ LIBRARY_TITLES_MAPPING = { "A:GENRE": "Genres", "A:PLAYLISTS": "Playlists", "A:TRACKS": "Tracks", + "S:": "Folders", } PLAYABLE_MEDIA_TYPES = [ diff --git a/homeassistant/components/sonos/media_browser.py b/homeassistant/components/sonos/media_browser.py index 16b425dae50..255daf22829 100644 --- a/homeassistant/components/sonos/media_browser.py +++ b/homeassistant/components/sonos/media_browser.py @@ -9,7 +9,7 @@ import logging from typing import cast import urllib.parse -from soco.data_structures import DidlObject +from soco.data_structures import DidlContainer, DidlObject from soco.ms_data_structures import MusicServiceItem from soco.music_library import MusicLibrary @@ -32,6 +32,7 @@ from .const import ( SONOS_ALBUM, SONOS_ALBUM_ARTIST, SONOS_GENRE, + SONOS_SHARE, SONOS_TO_MEDIA_CLASSES, SONOS_TO_MEDIA_TYPES, SONOS_TRACKS, @@ -105,6 +106,24 @@ def media_source_filter(item: BrowseMedia) -> bool: return item.media_content_type.startswith("audio/") +def _get_title(id_string: str) -> str: + """Extract a suitable title from the content id string.""" + if id_string.startswith("S:"): + # Format is S://server/share/folder + # If just S: this will be in the mappings; otherwise use the last folder in path. + title = LIBRARY_TITLES_MAPPING.get( + id_string, urllib.parse.unquote(id_string.split("/")[-1]) + ) + else: + parts = id_string.split("/") + title = ( + urllib.parse.unquote(parts[1]) + if len(parts) > 1 + else LIBRARY_TITLES_MAPPING.get(id_string, id_string) + ) + return title + + async def async_browse_media( hass: HomeAssistant, speaker: SonosSpeaker, @@ -240,10 +259,7 @@ def build_item_response( thumbnail = get_thumbnail_url(search_type, payload["idstring"]) if not title: - try: - title = urllib.parse.unquote(payload["idstring"].split("/")[1]) - except IndexError: - title = LIBRARY_TITLES_MAPPING[payload["idstring"]] + title = _get_title(id_string=payload["idstring"]) try: media_class = SONOS_TO_MEDIA_CLASSES[ @@ -288,12 +304,12 @@ def item_payload(item: DidlObject, get_thumbnail_url=None) -> BrowseMedia: thumbnail = get_thumbnail_url(media_class, content_id, item=item) return BrowseMedia( - title=item.title, + title=_get_title(item.item_id) if item.title is None else item.title, thumbnail=thumbnail, media_class=media_class, media_content_id=content_id, media_content_type=SONOS_TO_MEDIA_TYPES[media_type], - can_play=can_play(item.item_class), + can_play=can_play(item.item_class, item_id=content_id), can_expand=can_expand(item), ) @@ -396,6 +412,10 @@ def library_payload(media_library: MusicLibrary, get_thumbnail_url=None) -> Brow with suppress(UnknownMediaType): children.append(item_payload(item, get_thumbnail_url)) + # Add entry for Folders at the top level of the music library. + didl_item = DidlContainer(title="Folders", parent_id="", item_id="S:") + children.append(item_payload(didl_item, get_thumbnail_url)) + return BrowseMedia( title="Music Library", media_class=MediaClass.DIRECTORY, @@ -508,12 +528,16 @@ def get_media_type(item: DidlObject) -> str: return SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0], item.item_class) -def can_play(item: DidlObject) -> bool: +def can_play(item_class: str, item_id: str | None = None) -> bool: """Test if playable. Used by async_browse_media. """ - return SONOS_TO_MEDIA_TYPES.get(item) in PLAYABLE_MEDIA_TYPES + # Folders are playable once we reach the folder level. + # Format is S://server_address/share/folder + if item_id and item_id.startswith("S:") and item_class == "object.container": + return item_id.count("/") >= 4 + return SONOS_TO_MEDIA_TYPES.get(item_class) in PLAYABLE_MEDIA_TYPES def can_expand(item: DidlObject) -> bool: @@ -565,6 +589,19 @@ def get_media( matches = media_library.get_music_library_information( search_type, search_term=search_term, full_album_art_uri=True ) + elif search_type == SONOS_SHARE: + # In order to get the MusicServiceItem, we browse the parent folder + # and find one that matches on item_id. + parts = item_id.rstrip("/").split("/") + parent_folder = "/".join(parts[:-1]) + matches = media_library.browse_by_idstring( + search_type, parent_folder, full_album_art_uri=True + ) + result = next( + (item for item in matches if (item_id == item.item_id)), + None, + ) + matches = [result] else: # When requesting media by album_artist, composer, genre use the browse interface # to navigate the hierarchy. This occurs when invoked from media browser or service diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 573c28d700a..f1f95659469 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -53,6 +53,7 @@ from . import UnjoinData, media_browser from .const import ( DATA_SONOS, DOMAIN, + MEDIA_TYPE_DIRECTORY, MEDIA_TYPES_TO_SONOS, MODELS_LINEIN_AND_TV, MODELS_LINEIN_ONLY, @@ -656,6 +657,10 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_id, timeout=LONG_SERVICE_TIMEOUT ) soco.play_from_queue(0) + elif media_type == MEDIA_TYPE_DIRECTORY: + self._play_media_directory( + soco=soco, media_type=media_type, media_id=media_id, enqueue=enqueue + ) elif media_type in {MediaType.MUSIC, MediaType.TRACK}: # If media ID is a relative URL, we serve it from HA. media_id = async_process_play_media_url(self.hass, media_id) @@ -738,6 +743,25 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) + def _play_media_directory( + self, + soco: SoCo, + media_type: MediaType | str, + media_id: str, + enqueue: MediaPlayerEnqueue, + ): + """Play a directory from a music library share.""" + item = media_browser.get_media(self.media.library, media_id, media_type) + if not item: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_media", + translation_placeholders={ + "media_id": media_id, + }, + ) + self._play_media_queue(soco, item, enqueue) + @soco_error() def set_sleep_timer(self, sleep_time: int) -> None: """Set the timer on the player.""" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index b33151678a5..5043c9331fc 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -21,6 +21,7 @@ from soco.events_base import Event as SonosEvent from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN +from homeassistant.components.sonos.const import SONOS_SHARE from homeassistant.const import CONF_HOSTS from homeassistant.core import HomeAssistant from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo @@ -501,6 +502,45 @@ def mock_browse_by_idstring( return list_from_json_fixture("music_library_tracks.json") if search_type == "albums" and idstring == "A:ALBUM": return list_from_json_fixture("music_library_albums.json") + if search_type == SONOS_SHARE and idstring == "S:": + return [ + MockMusicServiceItem( + None, + "S://192.168.1.1/music", + "S:", + "object.container", + ) + ] + if search_type == SONOS_SHARE and idstring == "S://192.168.1.1/music": + return [ + MockMusicServiceItem( + None, + "S://192.168.1.1/music/beatles", + "S://192.168.1.1/music", + "object.container", + ), + MockMusicServiceItem( + None, + "S://192.168.1.1/music/elton%20john", + "S://192.168.1.1/music", + "object.container", + ), + ] + if search_type == SONOS_SHARE and idstring == "S://192.168.1.1/music/elton%20john": + return [ + MockMusicServiceItem( + None, + "S://192.168.1.1/music/elton%20john/Greatest%20Hits", + "S://192.168.1.1/music/elton%20john", + "object.container", + ), + MockMusicServiceItem( + None, + "S://192.168.1.1/music/elton%20john/Good%20Bye%20Yellow%20Brick%20Road", + "S://192.168.1.1/music/elton%20john", + "object.container", + ), + ] return [] diff --git a/tests/components/sonos/snapshots/test_media_browser.ambr b/tests/components/sonos/snapshots/test_media_browser.ambr index 07992c4474c..ddf03ca3b37 100644 --- a/tests/components/sonos/snapshots/test_media_browser.ambr +++ b/tests/components/sonos/snapshots/test_media_browser.ambr @@ -192,6 +192,17 @@ 'thumbnail': None, 'title': 'Playlists', }), + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'S:', + 'media_content_type': 'directory', + 'thumbnail': None, + 'title': 'Folders', + }), ]) # --- # name: test_browse_media_library_albums @@ -242,6 +253,71 @@ }), ]) # --- +# name: test_browse_media_library_folders[S://192.168.1.1/music] + dict({ + 'can_expand': False, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'S://192.168.1.1/music/beatles', + 'media_content_type': 'directory', + 'thumbnail': None, + 'title': 'beatles', + }), + dict({ + 'can_expand': True, + 'can_play': True, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'S://192.168.1.1/music/elton%20john', + 'media_content_type': 'directory', + 'thumbnail': None, + 'title': 'elton john', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'S://192.168.1.1/music', + 'media_content_type': 'directory', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'music', + }) +# --- +# name: test_browse_media_library_folders[S:] + dict({ + 'can_expand': False, + 'can_play': False, + 'can_search': False, + 'children': list([ + dict({ + 'can_expand': True, + 'can_play': False, + 'can_search': False, + 'children_media_class': None, + 'media_class': 'directory', + 'media_content_id': 'S://192.168.1.1/music', + 'media_content_type': 'directory', + 'thumbnail': None, + 'title': 'music', + }), + ]), + 'children_media_class': 'directory', + 'media_class': 'directory', + 'media_content_id': 'S:', + 'media_content_type': 'directory', + 'not_shown': 0, + 'thumbnail': None, + 'title': 'Folders', + }) +# --- # name: test_browse_media_root list([ dict({ diff --git a/tests/components/sonos/test_media_browser.py b/tests/components/sonos/test_media_browser.py index 669e9168297..3be0767ca99 100644 --- a/tests/components/sonos/test_media_browser.py +++ b/tests/components/sonos/test_media_browser.py @@ -5,11 +5,19 @@ from functools import partial import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.media_player import BrowseMedia, MediaClass, MediaType +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + BrowseMedia, + MediaClass, + MediaType, +) +from homeassistant.components.sonos.const import MEDIA_TYPE_DIRECTORY from homeassistant.components.sonos.media_browser import ( build_item_response, get_thumbnail_url_full, ) +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from .conftest import SoCoMockFactory @@ -217,3 +225,37 @@ async def test_browse_media_favorites( response = await client.receive_json() assert response["success"] assert response["result"] == snapshot + + +@pytest.mark.parametrize( + "media_content_id", + [ + ("S:"), + ("S://192.168.1.1/music"), + ], +) +async def test_browse_media_library_folders( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + async_autosetup_sonos, + media_content_id: str, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test the async_browse_media method.""" + soco_mock = soco_factory.mock_list.get("192.168.42.2") + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "media_player/browse_media", + ATTR_ENTITY_ID: "media_player.zone_a", + ATTR_MEDIA_CONTENT_ID: media_content_id, + ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_DIRECTORY, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == snapshot + assert soco_mock.music_library.browse_by_idstring.call_count == 1 diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index aaaaac6a4ba..37ce119b0de 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -28,6 +28,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.sonos.const import ( DOMAIN as SONOS_DOMAIN, + MEDIA_TYPE_DIRECTORY, SOURCE_LINEIN, SOURCE_TV, ) @@ -182,6 +183,19 @@ async def test_entity_basic( "play_pos": 0, }, ), + ( + MEDIA_TYPE_DIRECTORY, + "S://192.168.1.1/music/elton%20john", + MediaPlayerEnqueue.REPLACE, + { + "title": None, + "item_id": "S://192.168.1.1/music/elton%20john", + "clear_queue": 1, + "position": None, + "play": 1, + "play_pos": 0, + }, + ), ], ) async def test_play_media_library( @@ -247,6 +261,11 @@ async def test_play_media_library( "A:ALBUM/UnknowAlbum", "Sonos does not support media content type: UnknownContent", ), + ( + MEDIA_TYPE_DIRECTORY, + "S://192.168.1.1/music/error", + "Could not find media in library: S://192.168.1.1/music/error", + ), ], ) async def test_play_media_library_content_error(