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_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 = [

View File

@ -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

View File

@ -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."""

View File

@ -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 []

View File

@ -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({

View File

@ -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

View File

@ -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(