Add support for music library folder to Sonos (#139554)

* initial prototype

* use constants

* make playing work

* remove unneeded code

* remove unneeded code

* fix regressions issues

* refactor add_to_queue

* refactor add_to_queue

* refactor add_to_queue

* simplify

* add tests

* remove bad test

* rename constants

* comments

* comments

* comments

* use snapshot

* refactor to use add_to_queue

* refactor to use add_to_queue

* add comments, redo snapshots

* update comment

* merge formatting

* code review changes

* fix: merge issue

* fix: update snapshot to include new can_search field
This commit is contained in:
Pete Sage 2025-05-20 15:48:33 -04:00 committed by GitHub
parent 3ff3cb975b
commit 73811eac0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 258 additions and 10 deletions

View File

@ -31,9 +31,12 @@ SONOS_ALBUM_ARTIST = "album_artists"
SONOS_TRACKS = "tracks" SONOS_TRACKS = "tracks"
SONOS_COMPOSER = "composers" SONOS_COMPOSER = "composers"
SONOS_RADIO = "radio" SONOS_RADIO = "radio"
SONOS_SHARE = "share"
SONOS_OTHER_ITEM = "other items" SONOS_OTHER_ITEM = "other items"
SONOS_AUDIO_BOOK = "audio book" SONOS_AUDIO_BOOK = "audio book"
MEDIA_TYPE_DIRECTORY = MediaClass.DIRECTORY
SONOS_STATE_PLAYING = "PLAYING" SONOS_STATE_PLAYING = "PLAYING"
SONOS_STATE_TRANSITIONING = "TRANSITIONING" SONOS_STATE_TRANSITIONING = "TRANSITIONING"
@ -43,12 +46,14 @@ EXPANDABLE_MEDIA_TYPES = [
MediaType.COMPOSER, MediaType.COMPOSER,
MediaType.GENRE, MediaType.GENRE,
MediaType.PLAYLIST, MediaType.PLAYLIST,
MEDIA_TYPE_DIRECTORY,
SONOS_ALBUM, SONOS_ALBUM,
SONOS_ALBUM_ARTIST, SONOS_ALBUM_ARTIST,
SONOS_ARTIST, SONOS_ARTIST,
SONOS_GENRE, SONOS_GENRE,
SONOS_COMPOSER, SONOS_COMPOSER,
SONOS_PLAYLISTS, SONOS_PLAYLISTS,
SONOS_SHARE,
] ]
SONOS_TO_MEDIA_CLASSES = { SONOS_TO_MEDIA_CLASSES = {
@ -59,6 +64,8 @@ SONOS_TO_MEDIA_CLASSES = {
SONOS_GENRE: MediaClass.GENRE, SONOS_GENRE: MediaClass.GENRE,
SONOS_PLAYLISTS: MediaClass.PLAYLIST, SONOS_PLAYLISTS: MediaClass.PLAYLIST,
SONOS_TRACKS: MediaClass.TRACK, SONOS_TRACKS: MediaClass.TRACK,
SONOS_SHARE: MediaClass.DIRECTORY,
"object.container": MediaClass.DIRECTORY,
"object.container.album.musicAlbum": MediaClass.ALBUM, "object.container.album.musicAlbum": MediaClass.ALBUM,
"object.container.genre.musicGenre": MediaClass.PLAYLIST, "object.container.genre.musicGenre": MediaClass.PLAYLIST,
"object.container.person.composer": MediaClass.PLAYLIST, "object.container.person.composer": MediaClass.PLAYLIST,
@ -79,6 +86,7 @@ SONOS_TO_MEDIA_TYPES = {
SONOS_GENRE: MediaType.GENRE, SONOS_GENRE: MediaType.GENRE,
SONOS_PLAYLISTS: MediaType.PLAYLIST, SONOS_PLAYLISTS: MediaType.PLAYLIST,
SONOS_TRACKS: MediaType.TRACK, SONOS_TRACKS: MediaType.TRACK,
"object.container": MEDIA_TYPE_DIRECTORY,
"object.container.album.musicAlbum": MediaType.ALBUM, "object.container.album.musicAlbum": MediaType.ALBUM,
"object.container.genre.musicGenre": MediaType.PLAYLIST, "object.container.genre.musicGenre": MediaType.PLAYLIST,
"object.container.person.composer": 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.GENRE: SONOS_GENRE,
MediaType.PLAYLIST: SONOS_PLAYLISTS, MediaType.PLAYLIST: SONOS_PLAYLISTS,
MediaType.TRACK: SONOS_TRACKS, MediaType.TRACK: SONOS_TRACKS,
MEDIA_TYPE_DIRECTORY: SONOS_SHARE,
} }
SONOS_TYPES_MAPPING = { SONOS_TYPES_MAPPING = {
@ -127,6 +136,7 @@ LIBRARY_TITLES_MAPPING = {
"A:GENRE": "Genres", "A:GENRE": "Genres",
"A:PLAYLISTS": "Playlists", "A:PLAYLISTS": "Playlists",
"A:TRACKS": "Tracks", "A:TRACKS": "Tracks",
"S:": "Folders",
} }
PLAYABLE_MEDIA_TYPES = [ PLAYABLE_MEDIA_TYPES = [

View File

@ -9,7 +9,7 @@ import logging
from typing import cast from typing import cast
import urllib.parse 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.ms_data_structures import MusicServiceItem
from soco.music_library import MusicLibrary from soco.music_library import MusicLibrary
@ -32,6 +32,7 @@ from .const import (
SONOS_ALBUM, SONOS_ALBUM,
SONOS_ALBUM_ARTIST, SONOS_ALBUM_ARTIST,
SONOS_GENRE, SONOS_GENRE,
SONOS_SHARE,
SONOS_TO_MEDIA_CLASSES, SONOS_TO_MEDIA_CLASSES,
SONOS_TO_MEDIA_TYPES, SONOS_TO_MEDIA_TYPES,
SONOS_TRACKS, SONOS_TRACKS,
@ -105,6 +106,24 @@ def media_source_filter(item: BrowseMedia) -> bool:
return item.media_content_type.startswith("audio/") 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( async def async_browse_media(
hass: HomeAssistant, hass: HomeAssistant,
speaker: SonosSpeaker, speaker: SonosSpeaker,
@ -240,10 +259,7 @@ def build_item_response(
thumbnail = get_thumbnail_url(search_type, payload["idstring"]) thumbnail = get_thumbnail_url(search_type, payload["idstring"])
if not title: if not title:
try: title = _get_title(id_string=payload["idstring"])
title = urllib.parse.unquote(payload["idstring"].split("/")[1])
except IndexError:
title = LIBRARY_TITLES_MAPPING[payload["idstring"]]
try: try:
media_class = SONOS_TO_MEDIA_CLASSES[ 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) thumbnail = get_thumbnail_url(media_class, content_id, item=item)
return BrowseMedia( return BrowseMedia(
title=item.title, title=_get_title(item.item_id) if item.title is None else item.title,
thumbnail=thumbnail, thumbnail=thumbnail,
media_class=media_class, media_class=media_class,
media_content_id=content_id, media_content_id=content_id,
media_content_type=SONOS_TO_MEDIA_TYPES[media_type], 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), can_expand=can_expand(item),
) )
@ -396,6 +412,10 @@ def library_payload(media_library: MusicLibrary, get_thumbnail_url=None) -> Brow
with suppress(UnknownMediaType): with suppress(UnknownMediaType):
children.append(item_payload(item, get_thumbnail_url)) 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( return BrowseMedia(
title="Music Library", title="Music Library",
media_class=MediaClass.DIRECTORY, 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) 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. """Test if playable.
Used by async_browse_media. 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: def can_expand(item: DidlObject) -> bool:
@ -565,6 +589,19 @@ def get_media(
matches = media_library.get_music_library_information( matches = media_library.get_music_library_information(
search_type, search_term=search_term, full_album_art_uri=True 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: else:
# When requesting media by album_artist, composer, genre use the browse interface # 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 # to navigate the hierarchy. This occurs when invoked from media browser or service

View File

@ -53,6 +53,7 @@ from . import UnjoinData, media_browser
from .const import ( from .const import (
DATA_SONOS, DATA_SONOS,
DOMAIN, DOMAIN,
MEDIA_TYPE_DIRECTORY,
MEDIA_TYPES_TO_SONOS, MEDIA_TYPES_TO_SONOS,
MODELS_LINEIN_AND_TV, MODELS_LINEIN_AND_TV,
MODELS_LINEIN_ONLY, MODELS_LINEIN_ONLY,
@ -656,6 +657,10 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
media_id, timeout=LONG_SERVICE_TIMEOUT media_id, timeout=LONG_SERVICE_TIMEOUT
) )
soco.play_from_queue(0) 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}: elif media_type in {MediaType.MUSIC, MediaType.TRACK}:
# If media ID is a relative URL, we serve it from HA. # If media ID is a relative URL, we serve it from HA.
media_id = async_process_play_media_url(self.hass, media_id) media_id = async_process_play_media_url(self.hass, media_id)
@ -738,6 +743,25 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
if enqueue == MediaPlayerEnqueue.PLAY: if enqueue == MediaPlayerEnqueue.PLAY:
soco.play_from_queue(new_pos - 1) 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() @soco_error()
def set_sleep_timer(self, sleep_time: int) -> None: def set_sleep_timer(self, sleep_time: int) -> None:
"""Set the timer on the player.""" """Set the timer on the player."""

View File

@ -21,6 +21,7 @@ from soco.events_base import Event as SonosEvent
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.components.sonos import DOMAIN from homeassistant.components.sonos import DOMAIN
from homeassistant.components.sonos.const import SONOS_SHARE
from homeassistant.const import CONF_HOSTS from homeassistant.const import CONF_HOSTS
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.service_info.ssdp import ATTR_UPNP_UDN, SsdpServiceInfo 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") return list_from_json_fixture("music_library_tracks.json")
if search_type == "albums" and idstring == "A:ALBUM": if search_type == "albums" and idstring == "A:ALBUM":
return list_from_json_fixture("music_library_albums.json") 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 [] return []

View File

@ -192,6 +192,17 @@
'thumbnail': None, 'thumbnail': None,
'title': 'Playlists', '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 # 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 # name: test_browse_media_root
list([ list([
dict({ dict({

View File

@ -5,11 +5,19 @@ from functools import partial
import pytest import pytest
from syrupy.assertion import SnapshotAssertion 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 ( from homeassistant.components.sonos.media_browser import (
build_item_response, build_item_response,
get_thumbnail_url_full, get_thumbnail_url_full,
) )
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from .conftest import SoCoMockFactory from .conftest import SoCoMockFactory
@ -217,3 +225,37 @@ async def test_browse_media_favorites(
response = await client.receive_json() response = await client.receive_json()
assert response["success"] assert response["success"]
assert response["result"] == snapshot 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

View File

@ -28,6 +28,7 @@ from homeassistant.components.media_player import (
) )
from homeassistant.components.sonos.const import ( from homeassistant.components.sonos.const import (
DOMAIN as SONOS_DOMAIN, DOMAIN as SONOS_DOMAIN,
MEDIA_TYPE_DIRECTORY,
SOURCE_LINEIN, SOURCE_LINEIN,
SOURCE_TV, SOURCE_TV,
) )
@ -182,6 +183,19 @@ async def test_entity_basic(
"play_pos": 0, "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( async def test_play_media_library(
@ -247,6 +261,11 @@ async def test_play_media_library(
"A:ALBUM/UnknowAlbum", "A:ALBUM/UnknowAlbum",
"Sonos does not support media content type: UnknownContent", "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( async def test_play_media_library_content_error(