1 """Support for media browsing."""
3 from __future__
import annotations
5 from collections.abc
import Callable
6 from contextlib
import suppress
7 from functools
import partial
9 from typing
import cast
12 from soco.data_structures
import DidlObject
13 from soco.ms_data_structures
import MusicServiceItem
14 from soco.music_library
import MusicLibrary
28 EXPANDABLE_MEDIA_TYPES,
29 LIBRARY_TITLES_MAPPING,
35 SONOS_TO_MEDIA_CLASSES,
40 from .exception
import UnknownMediaType
41 from .favorites
import SonosFavorites
42 from .speaker
import SonosMedia, SonosSpeaker
44 _LOGGER = logging.getLogger(__name__)
46 type GetBrowseImageUrlType = Callable[[str, str, str |
None], str]
50 """Update the image url to fully encode characters to allow image display in media_browser UI.
52 Images whose file path contains characters such as ',()+ are not loaded without escaping them.
56 original_url: str = urllib.parse.unquote(url).replace(
"+",
"%2B")
57 parsed_url = urllib.parse.urlparse(original_url)
58 query_params = urllib.parse.parse_qsl(parsed_url.query)
59 new_url = urllib.parse.urlunsplit(
64 urllib.parse.urlencode(
65 query_params, quote_via=urllib.parse.quote, safe=
"/:"
70 if original_url != new_url:
71 _LOGGER.debug(
"fix_sonos_image_url original: %s new: %s", original_url, new_url)
78 get_browse_image_url: GetBrowseImageUrlType,
79 media_content_type: str,
80 media_content_id: str,
81 media_image_id: str |
None =
None,
82 item: MusicServiceItem |
None =
None,
84 """Get thumbnail URL."""
94 return urllib.parse.unquote(
104 """Filter media sources."""
105 return item.media_content_type.startswith(
"audio/")
110 speaker: SonosSpeaker,
112 get_browse_image_url: GetBrowseImageUrlType,
113 media_content_id: str |
None,
114 media_content_type: str |
None,
118 if media_content_id
is None:
123 get_browse_image_url,
125 assert media_content_type
is not None
127 if media_source.is_media_source_id(media_content_id):
128 return await media_source.async_browse_media(
129 hass, media_content_id, content_filter=media_source_filter
132 if plex.is_plex_media_id(media_content_id):
133 return await plex.async_browse_media(
134 hass, media_content_type, media_content_id, platform=DOMAIN
137 if media_content_type ==
"plex":
138 return await plex.async_browse_media(hass,
None,
None, platform=DOMAIN)
140 if spotify.is_spotify_media_type(media_content_type):
141 return await spotify.async_browse_media(
142 hass, media_content_type, media_content_id, can_play_artist=
False
145 if media_content_type ==
"library":
146 return await hass.async_add_executor_job(
150 get_thumbnail_url_full,
153 get_browse_image_url,
157 if media_content_type ==
"favorites":
158 return await hass.async_add_executor_job(
163 if media_content_type ==
"favorites_folder":
164 return await hass.async_add_executor_job(
165 favorites_folder_payload,
171 "search_type": media_content_type,
172 "idstring": media_content_id,
174 response = await hass.async_add_executor_job(
179 get_thumbnail_url_full,
182 get_browse_image_url,
186 raise BrowseError(f
"Media not found: {media_content_type} / {media_content_id}")
191 media_library: MusicLibrary, payload: dict[str, str], get_thumbnail_url=
None
192 ) -> BrowseMedia |
None:
193 """Create response payload for the provided media query."""
194 if payload[
"search_type"] == MediaType.ALBUM
and payload[
"idstring"].startswith(
195 (
"A:GENRE",
"A:COMPOSER")
197 payload[
"idstring"] =
"A:ALBUMARTIST/" +
"/".join(
198 payload[
"idstring"].split(
"/")[2:]
200 payload[
"idstring"] = urllib.parse.unquote(payload[
"idstring"])
203 search_type = MEDIA_TYPES_TO_SONOS[payload[
"search_type"]]
206 "Unknown media type received when building item response: %s",
207 payload[
"search_type"],
211 media = media_library.browse_by_idstring(
214 full_album_art_uri=
True,
227 payload[
"search_type"] == MediaType.ALBUM
228 and media[0].item_class ==
"object.item.audioItem.musicTrack"
230 idstring = payload[
"idstring"]
231 if idstring.startswith(
"A:ALBUMARTIST/"):
232 search_type = SONOS_ALBUM_ARTIST
233 elif idstring.startswith(
"A:ALBUM/"):
234 search_type = SONOS_ALBUM
235 item =
get_media(media_library, idstring, search_type)
237 title = getattr(item,
"title",
None)
238 thumbnail = get_thumbnail_url(search_type, payload[
"idstring"])
242 title = urllib.parse.unquote(payload[
"idstring"].split(
"/")[1])
244 title = LIBRARY_TITLES_MAPPING[payload[
"idstring"]]
247 media_class = SONOS_TO_MEDIA_CLASSES[
248 MEDIA_TYPES_TO_SONOS[payload[
"search_type"]]
251 _LOGGER.debug(
"Unknown media type received %s", payload[
"search_type"])
256 with suppress(UnknownMediaType):
262 media_class=media_class,
263 media_content_id=payload[
"idstring"],
264 media_content_type=payload[
"search_type"],
266 can_play=
can_play(payload[
"search_type"]),
267 can_expand=
can_expand(payload[
"search_type"]),
271 def item_payload(item: DidlObject, get_thumbnail_url=
None) -> BrowseMedia:
272 """Create response payload for a single media item.
274 Used by async_browse_media.
278 media_class = SONOS_TO_MEDIA_CLASSES[media_type]
279 except KeyError
as err:
280 _LOGGER.debug(
"Unknown media type received %s", media_type)
281 raise UnknownMediaType
from err
285 if getattr(item,
"album_art_uri",
None):
286 thumbnail = get_thumbnail_url(media_class, content_id, item=item)
291 media_class=media_class,
292 media_content_id=content_id,
293 media_content_type=SONOS_TO_MEDIA_TYPES[media_type],
301 speaker: SonosSpeaker,
303 get_browse_image_url: GetBrowseImageUrlType,
305 """Return root payload for Sonos."""
306 children: list[BrowseMedia] = []
308 if speaker.favorites:
312 media_class=MediaClass.DIRECTORY,
314 media_content_type=
"favorites",
315 thumbnail=
"https://brands.home-assistant.io/_/sonos/logo.png",
321 if await hass.async_add_executor_job(
322 partial(media.library.browse_by_idstring,
"tracks",
"", max_items=1)
326 title=
"Music Library",
327 media_class=MediaClass.DIRECTORY,
329 media_content_type=
"library",
330 thumbnail=
"https://brands.home-assistant.io/_/sonos/logo.png",
336 if "plex" in hass.config.components:
340 media_class=MediaClass.APP,
342 media_content_type=
"plex",
343 thumbnail=
"https://brands.home-assistant.io/_/plex/logo.png",
349 if "spotify" in hass.config.components:
350 result = await spotify.async_browse_media(hass,
None,
None)
352 children.extend(result.children)
355 item = await media_source.async_browse_media(
356 hass,
None, content_filter=media_source_filter
359 if item.domain
is None and item.children
is not None:
360 children.extend(item.children)
362 children.append(item)
366 if len(children) == 1:
371 get_browse_image_url,
372 children[0].media_content_id,
373 children[0].media_content_type,
378 media_class=MediaClass.DIRECTORY,
380 media_content_type=
"root",
387 def library_payload(media_library: MusicLibrary, get_thumbnail_url=
None) -> BrowseMedia:
388 """Create response payload to describe contents of a specific library.
390 Used by async_browse_media.
393 for item
in media_library.browse():
394 with suppress(UnknownMediaType):
398 title=
"Music Library",
399 media_class=MediaClass.DIRECTORY,
400 media_content_id=
"library",
401 media_content_type=
"library",
409 """Create response payload to describe contents of a specific library.
411 Used by async_browse_media.
413 children: list[BrowseMedia] = []
415 group_types: set[str] = {fav.reference.item_class
for fav
in favorites}
416 for group_type
in sorted(group_types):
418 media_content_type = SONOS_TYPES_MAPPING[group_type]
419 media_class = SONOS_TO_MEDIA_CLASSES[group_type]
421 _LOGGER.debug(
"Unknown media type or class received %s", group_type)
425 title=media_content_type.title(),
426 media_class=media_class,
427 media_content_id=group_type,
428 media_content_type=
"favorites_folder",
436 media_class=MediaClass.DIRECTORY,
438 media_content_type=
"favorites",
446 favorites: SonosFavorites, media_content_id: str
448 """Create response payload to describe all items of a type of favorite.
450 Used by async_browse_media.
452 children: list[BrowseMedia] = []
453 content_type = SONOS_TYPES_MAPPING[media_content_id]
455 for favorite
in favorites:
456 if favorite.reference.item_class != media_content_id:
460 title=favorite.title,
461 media_class=SONOS_TO_MEDIA_CLASSES[favorite.reference.item_class],
462 media_content_id=favorite.item_id,
463 media_content_type=
"favorite_item_id",
466 thumbnail=getattr(favorite,
"album_art_uri",
None),
471 title=content_type.title(),
472 media_class=MediaClass.DIRECTORY,
474 media_content_type=
"favorites",
482 """Extract media type of item."""
483 if item.item_class ==
"object.item.audioItem.musicTrack":
487 item.item_class ==
"object.container.album.musicAlbum"
488 and SONOS_TYPES_MAPPING.get(item.item_id.split(
"/")[0])
494 return SONOS_TYPES_MAPPING[item.item_class]
496 return SONOS_TYPES_MAPPING.get(item.item_id.split(
"/")[0], item.item_class)
502 Used by async_browse_media.
504 return SONOS_TO_MEDIA_TYPES.get(item)
in PLAYABLE_MEDIA_TYPES
508 """Test if expandable.
510 Used by async_browse_media.
512 if isinstance(item, str):
513 return SONOS_TYPES_MAPPING.get(item)
in EXPANDABLE_MEDIA_TYPES
515 if SONOS_TO_MEDIA_TYPES.get(item.item_class)
in EXPANDABLE_MEDIA_TYPES:
518 return SONOS_TYPES_MAPPING.get(item.item_id)
in EXPANDABLE_MEDIA_TYPES
522 """Extract content id or uri."""
523 if item.item_class ==
"object.item.audioItem.musicTrack":
524 return cast(str, item.get_uri())
525 return cast(str, item.item_id)
529 media_library: MusicLibrary, item_id: str, search_type: str
530 ) -> MusicServiceItem |
None:
531 """Fetch a single media/album."""
532 _LOGGER.debug(
"get_media item_id [%s], search_type [%s]", item_id, search_type)
533 search_type = MEDIA_TYPES_TO_SONOS.get(search_type, search_type)
535 if search_type ==
"playlists":
537 splits = item_id.split(
":")
538 title = splits[1]
if len(splits) > 1
else None
542 for p
in media_library.get_playlists()
543 if (item_id == p.item_id
or title == p.title)
548 if not item_id.startswith(
"A:ALBUM")
and search_type == SONOS_ALBUM:
549 item_id =
"A:ALBUMARTIST/" +
"/".join(item_id.split(
"/")[2:])
551 if item_id.startswith(
"A:ALBUM/")
or search_type ==
"tracks":
552 search_term = urllib.parse.unquote(item_id.split(
"/")[-1])
553 matches = media_library.get_music_library_information(
554 search_type, search_term=search_term, full_album_art_uri=
True
564 splits = item_id.split(
"/")
565 title = urllib.parse.unquote(splits[2])
if len(splits) > 2
else None
566 browse_id_string = splits[0] +
"/" + splits[1]
567 matches = media_library.browse_by_idstring(
568 search_type, browse_id_string, full_album_art_uri=
True
572 (item
for item
in matches
if (title == item.title)),
578 "get_media search_type [%s] item_id [%s] matches [%d]",
bool is_internal_request(HomeAssistant hass)