mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 15:47:12 +00:00
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:
parent
3ff3cb975b
commit
73811eac0a
@ -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 = [
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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 []
|
||||
|
||||
|
||||
|
@ -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({
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
Loading…
x
Reference in New Issue
Block a user