725 lines
31 KiB
Python
725 lines
31 KiB
Python
"""Support for Spotify media browsing."""
|
|
from __future__ import annotations
|
|
from functools import partial
|
|
from enum import StrEnum
|
|
import logging
|
|
import base64
|
|
import os
|
|
import pickle
|
|
from typing import Any
|
|
|
|
from spotifywebapipython import SpotifyClient
|
|
from spotifywebapipython.models import *
|
|
|
|
from homeassistant.components.media_player import (
|
|
BrowseError,
|
|
BrowseMedia,
|
|
MediaClass,
|
|
MediaType,
|
|
)
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.exceptions import IntegrationError
|
|
|
|
from .const import DOMAIN
|
|
|
|
# get smartinspect logger reference; create a new session for this module name.
|
|
from smartinspectpython.siauto import SIAuto, SILevel, SISession, SIMethodParmListContext
|
|
import logging
|
|
_logsi:SISession = SIAuto.Si.GetSession(__name__)
|
|
if (_logsi == None):
|
|
_logsi = SIAuto.Si.AddSession(__name__, True)
|
|
_logsi.SystemLogger = logging.getLogger(__name__)
|
|
|
|
|
|
class BrowsableMedia(StrEnum):
|
|
"""
|
|
Enum of browsable media.
|
|
Contains the library root node key value definitions.
|
|
"""
|
|
# library custom root node title definitions.
|
|
SPOTIFY_LIBRARY_INDEX = "spotify_library_index"
|
|
SPOTIFY_CATEGORY_PLAYLISTS = "spotify_category_playlists"
|
|
SPOTIFY_CATEGORY_PLAYLISTS_MADEFORYOU = "spotify_category_playlists_madeforyou"
|
|
SPOTIFY_CATEGORYS = "spotify_categorys"
|
|
SPOTIFY_FEATURED_PLAYLISTS = "spotify_featured_playlists"
|
|
SPOTIFY_NEW_RELEASES = "spotify_new_releases"
|
|
SPOTIFY_USER_FOLLOWED_ARTISTS = "spotify_user_followed_artists"
|
|
SPOTIFY_USER_PLAYLISTS = "spotify_user_playlists"
|
|
SPOTIFY_USER_RECENTLY_PLAYED = "spotify_user_recently_played"
|
|
SPOTIFY_USER_SAVED_ALBUMS = "spotify_user_saved_albums"
|
|
SPOTIFY_USER_SAVED_AUDIOBOOKS = "spotify_user_saved_audiobooks"
|
|
SPOTIFY_USER_SAVED_SHOWS = "spotify_user_saved_shows"
|
|
SPOTIFY_USER_SAVED_TRACKS = "spotify_user_saved_tracks"
|
|
SPOTIFY_USER_TOP_ARTISTS = "spotify_user_top_artists"
|
|
SPOTIFY_USER_TOP_TRACKS = "spotify_user_top_tracks"
|
|
|
|
|
|
# Spotify Library index definitions, containing media attributes that control content display.
|
|
# The order listed is how they are displayed in the media browser.
|
|
SPOTIFY_LIBRARY_MAP = {
|
|
BrowsableMedia.SPOTIFY_LIBRARY_INDEX.value: {
|
|
"title": "SpotifyPlus Media Library",
|
|
"image": None,
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.DIRECTORY,
|
|
"is_index_item": False,
|
|
},
|
|
BrowsableMedia.SPOTIFY_USER_PLAYLISTS.value: {
|
|
"title": "Playlists",
|
|
"title_node": "Spotify Playlist Favorites",
|
|
"image": f"/local/images/{DOMAIN}_medialib_playlists.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.PLAYLIST,
|
|
},
|
|
BrowsableMedia.SPOTIFY_USER_FOLLOWED_ARTISTS.value: {
|
|
"title": "Artists",
|
|
"title_node": "Spotify Artists Followed",
|
|
"image": f"/local/images/{DOMAIN}_medialib_artists.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.ARTIST,
|
|
},
|
|
BrowsableMedia.SPOTIFY_USER_SAVED_ALBUMS.value: {
|
|
"title": "Albums",
|
|
"title_node": "Spotify Album Favorites",
|
|
"image": f"/local/images/{DOMAIN}_medialib_albums.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.ALBUM,
|
|
},
|
|
BrowsableMedia.SPOTIFY_USER_SAVED_TRACKS.value: {
|
|
"title": "Tracks",
|
|
"title_node": "Spotify Track Favorites",
|
|
"image": f"/local/images/{DOMAIN}_medialib_tracks.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.TRACK,
|
|
},
|
|
BrowsableMedia.SPOTIFY_USER_SAVED_SHOWS.value: {
|
|
"title": "Podcasts",
|
|
"title_node": "Spotify Podcast Favorites",
|
|
"image": f"/local/images/{DOMAIN}_medialib_podcasts.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.PODCAST,
|
|
},
|
|
BrowsableMedia.SPOTIFY_USER_SAVED_AUDIOBOOKS.value: {
|
|
"title": "Audiobooks",
|
|
"title_node": "Spotify Audiobook Favorites",
|
|
"image": f"/local/images/{DOMAIN}_medialib_audiobooks.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.APP, # spotify audiobook support
|
|
},
|
|
BrowsableMedia.SPOTIFY_USER_TOP_ARTISTS.value: {
|
|
"title": "Top Artists",
|
|
"title_node": "Spotify Top Artists",
|
|
"image": f"/local/images/{DOMAIN}_medialib_top_artists.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.ARTIST,
|
|
},
|
|
BrowsableMedia.SPOTIFY_USER_TOP_TRACKS.value: {
|
|
"title": "Top Tracks",
|
|
"title_node": "Spotify Top Tracks",
|
|
"image": f"/local/images/{DOMAIN}_medialib_top_tracks.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.TRACK,
|
|
},
|
|
BrowsableMedia.SPOTIFY_FEATURED_PLAYLISTS.value: {
|
|
"title": "Featured Playlists",
|
|
"title_node": "Spotify Featured Playlists",
|
|
"image": f"/local/images/{DOMAIN}_medialib_featured_playlists.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.PLAYLIST,
|
|
},
|
|
BrowsableMedia.SPOTIFY_NEW_RELEASES.value: {
|
|
"title": "New Releases",
|
|
"title_node": "Spotify New Releases",
|
|
"image": f"/local/images/{DOMAIN}_medialib_new_releases.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.ALBUM,
|
|
},
|
|
BrowsableMedia.SPOTIFY_CATEGORYS.value: {
|
|
"title": "Categories",
|
|
"title_node": "Spotify Categories ",
|
|
"image": f"/local/images/{DOMAIN}_medialib_categorys.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.GENRE,
|
|
},
|
|
BrowsableMedia.SPOTIFY_CATEGORY_PLAYLISTS.value: {
|
|
"title": "Category Playlists",
|
|
"title_node": "Spotify Category Playlists",
|
|
"image": f"/local/images/{DOMAIN}_medialib_category_playlists.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.PLAYLIST,
|
|
"is_index_item": False,
|
|
},
|
|
BrowsableMedia.SPOTIFY_CATEGORY_PLAYLISTS_MADEFORYOU.value: {
|
|
"title": "Made For You",
|
|
"title_node": "Spotify Playlists Made For You",
|
|
"image": f"/local/images/{DOMAIN}_medialib_spotify_category_playlists_made_for_you.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.PLAYLIST,
|
|
},
|
|
BrowsableMedia.SPOTIFY_USER_RECENTLY_PLAYED.value: {
|
|
"title": "Recently Played",
|
|
"title_node": "Spotify Recently Played",
|
|
"image": f"/local/images/{DOMAIN}_medialib_recently_played.png",
|
|
"parent": MediaClass.DIRECTORY,
|
|
"children": MediaClass.TRACK,
|
|
},
|
|
# the following are HA media types, and will not be displayed in the library index.
|
|
# they are required for playing base-level types of media for this library index.
|
|
MediaType.ALBUM: {
|
|
"parent": MediaClass.ALBUM,
|
|
"children": MediaClass.TRACK,
|
|
"is_index_item": False,
|
|
},
|
|
MediaType.ARTIST: {
|
|
"parent": MediaClass.ARTIST,
|
|
"children": MediaClass.ALBUM,
|
|
"is_index_item": False,
|
|
},
|
|
MediaType.APP: { # spotify audiobook support
|
|
"parent": MediaClass.APP,
|
|
"children": MediaClass.TRACK,
|
|
"is_index_item": False,
|
|
},
|
|
MediaType.EPISODE: {
|
|
"parent": MediaClass.EPISODE,
|
|
"children": None,
|
|
"is_index_item": False,
|
|
},
|
|
MediaType.GENRE: {
|
|
"parent": MediaClass.PLAYLIST,
|
|
"children": MediaClass.TRACK,
|
|
"is_index_item": False,
|
|
},
|
|
MediaType.PLAYLIST: {
|
|
"parent": MediaClass.PLAYLIST,
|
|
"children": MediaClass.TRACK,
|
|
"is_index_item": False,
|
|
},
|
|
MediaType.PODCAST: {
|
|
"parent": MediaClass.PODCAST,
|
|
"children": MediaClass.EPISODE,
|
|
"is_index_item": False,
|
|
},
|
|
MediaType.TRACK: {
|
|
"parent": MediaClass.TRACK,
|
|
"children": None,
|
|
"is_index_item": False,
|
|
}
|
|
}
|
|
"""
|
|
# Spotify Library index definitions, containing media attributes that control content display.
|
|
# The order listed is how they are displayed in the media browser.
|
|
"""
|
|
|
|
BROWSE_LIMIT = 50
|
|
""" Max number of items to return from a Spotify Web API query. """
|
|
|
|
SPOTIFY_BROWSE_LIMIT_TOTAL = 100
|
|
""" Max number of items to return from a SpotifyPlus integration request that supports paging. """
|
|
|
|
CATEGORY_BASE64:str = "category_base64::"
|
|
""" Eye-catcher used to denote a serialized ContentItem. """
|
|
|
|
LOCAL_IMAGE_PREFIX:str = "/local/"
|
|
""" Local image prefix value. """
|
|
|
|
PLAYABLE_MEDIA_TYPES = [
|
|
MediaType.PLAYLIST,
|
|
MediaType.ALBUM,
|
|
MediaType.ARTIST,
|
|
MediaType.APP, # spotify audiobook support
|
|
MediaType.EPISODE,
|
|
MediaType.PODCAST,
|
|
MediaType.TRACK,
|
|
]
|
|
""" Array of all media types that are playable. """
|
|
|
|
|
|
class MissingMediaInformation(BrowseError):
|
|
"""Missing media required information."""
|
|
|
|
|
|
class UnknownMediaType(BrowseError):
|
|
"""Unknown media type."""
|
|
|
|
|
|
def deserialize_object(txt:str) -> object:
|
|
"""
|
|
Deserialize an object from a plain text string.
|
|
|
|
Args:
|
|
txt (str):
|
|
The serialized version of the object in the form of a string.
|
|
|
|
Returns:
|
|
An object that was deserialized from a base64 string representation.
|
|
"""
|
|
base64_bytes = txt.encode('ascii')
|
|
message_bytes = base64.b64decode(base64_bytes)
|
|
obj = pickle.loads(message_bytes)
|
|
return obj
|
|
|
|
|
|
@staticmethod
|
|
def serialize_object(obj:object) -> str:
|
|
"""
|
|
Serialize an object into a plain text string.
|
|
|
|
Args:
|
|
obj (object):
|
|
The object to serialize.
|
|
|
|
Returns:
|
|
A serialized base64 string representation of the object.
|
|
"""
|
|
message_bytes = pickle.dumps(obj)
|
|
base64_bytes = base64.b64encode(message_bytes)
|
|
txt = base64_bytes.decode('ascii')
|
|
return txt
|
|
|
|
|
|
async def async_browse_media_library_index(
|
|
hass:HomeAssistant,
|
|
client:SpotifyClient,
|
|
playerName:str,
|
|
source:str|None,
|
|
libraryMap:dict,
|
|
libraryIndex:BrowsableMedia,
|
|
media_content_type:str|None,
|
|
media_content_id:str|None,
|
|
) -> BrowseMedia:
|
|
"""
|
|
Builds a BrowseMedia object for the top level index page, and all of it's
|
|
child nodes.
|
|
|
|
Args:
|
|
hass (HomeAssistant):
|
|
HomeAssistant instance.
|
|
client (SpotifyClient):
|
|
The SpotifyClient instance that will make calls to the device
|
|
to retrieve the data for display in the media browser.
|
|
playerName (str):
|
|
Name of the media player that is calling this method (for tracing purposes).
|
|
source (str):
|
|
Currently selected source value.
|
|
libraryMap (dict):
|
|
The library map that contains media content attributes for each library index entry.
|
|
libraryIndex (BrowseMedia):
|
|
The library index of media content types.
|
|
media_content_type (str):
|
|
Selected media content type in the media browser.
|
|
This value will be None upon the initial entry to the media browser.
|
|
media_content_id (str):
|
|
Selected media content id in the media browser.
|
|
This value will be None upon the initial entry to the media browser.
|
|
"""
|
|
methodParms:SIMethodParmListContext = None
|
|
|
|
try:
|
|
|
|
# trace.
|
|
methodParms = _logsi.EnterMethodParmList(SILevel.Debug)
|
|
methodParms.AppendKeyValue("playerName", playerName)
|
|
methodParms.AppendKeyValue("source", source)
|
|
methodParms.AppendKeyValue("libraryMap", libraryMap)
|
|
methodParms.AppendKeyValue("libraryIndex", libraryIndex)
|
|
methodParms.AppendKeyValue("media_content_type", media_content_type)
|
|
methodParms.AppendKeyValue("media_content_id", media_content_id)
|
|
_logsi.LogMethodParmList(SILevel.Verbose, "'%s': browsing for media - top level index: '%s'" % (playerName, libraryIndex), methodParms)
|
|
|
|
# validations.
|
|
if source is None:
|
|
source = "unknownSource"
|
|
|
|
# get parent media atttributes based upon selected media content type.
|
|
parentAttrs:dict[str, Any] = libraryMap.get(libraryIndex.value, None)
|
|
_logsi.LogDictionary(SILevel.Verbose, "'%s': BrowseMedia attributes for parent media content type: '%s'" % (playerName, libraryIndex.value), parentAttrs)
|
|
|
|
# create the index.
|
|
browseMedia:BrowseMedia = BrowseMedia(
|
|
can_expand=True,
|
|
can_play=False,
|
|
children=[],
|
|
children_media_class=parentAttrs["children"],
|
|
media_class=parentAttrs["parent"],
|
|
media_content_id=libraryIndex.value,
|
|
media_content_type=libraryIndex.value,
|
|
thumbnail=parentAttrs["image"],
|
|
title=parentAttrs["title"],
|
|
)
|
|
|
|
# add child items to the index.
|
|
for mediaType, childAttrs in libraryMap.items():
|
|
|
|
# if not an index item then don't bother.
|
|
isIndexItem:bool = childAttrs.get("is_index_item", True)
|
|
if not isIndexItem:
|
|
continue
|
|
|
|
# trace.
|
|
#_logsi.LogDictionary(SILevel.Verbose, "'%s': BrowseMedia attributes for child media content type: '%s'" % (playerName, mediaType), childAttrs)
|
|
|
|
# if a LOCAL index image was specified, then ensure it exists.
|
|
# otherwise, default to null.
|
|
image:str = childAttrs.get("image", None)
|
|
if image is not None and image.startswith(LOCAL_IMAGE_PREFIX):
|
|
imagePath:str = "%s/www/%s" % (hass.config.config_dir, image[len(LOCAL_IMAGE_PREFIX):])
|
|
if not os.path.exists(imagePath):
|
|
#_logsi.LogVerbose("'%s': could not find logo image path '%s'; image will be reset to null" % (playerName, imagePath))
|
|
image = None
|
|
|
|
browseMediaChild:BrowseMedia = BrowseMedia(
|
|
can_expand=True,
|
|
can_play=False,
|
|
children=None,
|
|
children_media_class=childAttrs["children"],
|
|
media_class=childAttrs["parent"],
|
|
media_content_id=f"{mediaType}",
|
|
media_content_type=f"{mediaType}",
|
|
thumbnail=image,
|
|
title=childAttrs["title"],
|
|
)
|
|
browseMedia.children.append(browseMediaChild)
|
|
_logsi.LogObject(SILevel.Verbose, "'%s': BrowseMedia Child Object: Type='%s', Id='%s', Title='%s'" % (playerName, browseMediaChild.media_content_type, browseMediaChild.media_content_id, browseMediaChild.title), browseMediaChild)
|
|
|
|
# trace.
|
|
_logsi.LogObject(SILevel.Verbose, "'%s': BrowseMedia Parent Object: Type='%s', Id='%s', Title='%s'" % (playerName, browseMedia.media_content_type, browseMedia.media_content_id, browseMedia.title), browseMedia)
|
|
|
|
return browseMedia
|
|
|
|
except Exception as ex:
|
|
|
|
# trace.
|
|
_logsi.LogException("'%s': BrowseMedia async_browse_media_library_index exception: %s" % (playerName, str(ex)), ex, logToSystemLogger=False)
|
|
raise IntegrationError(str(ex)) from ex
|
|
|
|
finally:
|
|
|
|
# trace.
|
|
_logsi.LeaveMethod(SILevel.Debug)
|
|
|
|
|
|
def browse_media_node(
|
|
hass:HomeAssistant,
|
|
client:SpotifyClient,
|
|
playerName:str,
|
|
source:str|None,
|
|
libraryMap:dict,
|
|
media_content_type:str|None,
|
|
media_content_id:str|None,
|
|
) -> BrowseMedia:
|
|
"""
|
|
Builds a BrowseMedia object for a selected media content type, and all of it's
|
|
child nodes.
|
|
|
|
Args:
|
|
hass (HomeAssistant):
|
|
HomeAssistant instance.
|
|
client (SpotifyClient):
|
|
The SpotifyClient instance that will make calls to the device
|
|
to retrieve the data for display in the media browser.
|
|
playerName (str):
|
|
Name of the media player that is calling this method (for tracing purposes).
|
|
source (str):
|
|
Currently selected source value.
|
|
libraryMap (dict):
|
|
The library map that contains media content attributes for each library index entry.
|
|
media_content_type (str):
|
|
Selected media content type in the media browser.
|
|
This value will be None upon the initial entry to the media browser.
|
|
media_content_id (str):
|
|
Selected media content id in the media browser.
|
|
This value will be None upon the initial entry to the media browser.
|
|
"""
|
|
methodParms:SIMethodParmListContext = None
|
|
|
|
try:
|
|
|
|
# trace.
|
|
methodParms = _logsi.EnterMethodParmList(SILevel.Debug)
|
|
methodParms.AppendKeyValue("playerName", playerName)
|
|
methodParms.AppendKeyValue("source", source)
|
|
methodParms.AppendKeyValue("libraryMap", libraryMap)
|
|
methodParms.AppendKeyValue("media_content_type", media_content_type)
|
|
methodParms.AppendKeyValue("media_content_id", media_content_id)
|
|
_logsi.LogMethodParmList(SILevel.Verbose, "'%s': browsing for media - selected node: '%s'" % (playerName, media_content_type), methodParms)
|
|
|
|
# validations.
|
|
if source is None:
|
|
source = "unknownSource"
|
|
|
|
# initialize child item attributes.
|
|
title:str = None
|
|
image:str = None
|
|
media:object = None
|
|
items:list = []
|
|
|
|
# build selection list based upon the browsable media type.
|
|
# - media: will contain the result of the spotify web api call.
|
|
# - items: will contain the child items to display for the parent media item.
|
|
# - title: the title to display in the media browser.
|
|
# - image: the image (if any) to display in the media browser (can be none).
|
|
if media_content_type == BrowsableMedia.SPOTIFY_USER_PLAYLISTS:
|
|
_logsi.LogVerbose("'%s': querying spotify for Playlist Favorites" % playerName)
|
|
media:PlaylistPageSimplified = client.GetPlaylistFavorites(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_USER_FOLLOWED_ARTISTS:
|
|
_logsi.LogVerbose("'%s': querying spotify for Artists Followed" % playerName)
|
|
media:ArtistPage = client.GetArtistsFollowed(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_USER_SAVED_ALBUMS:
|
|
_logsi.LogVerbose("Getting Spotify user Album favorites")
|
|
media:AlbumPageSaved = client.GetAlbumFavorites(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.GetAlbums()
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_USER_SAVED_TRACKS:
|
|
_logsi.LogVerbose("Getting Spotify user Track favorites")
|
|
media:TrackPageSaved = client.GetTrackFavorites(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.GetTracks()
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_USER_SAVED_SHOWS:
|
|
_logsi.LogVerbose("Getting Spotify user Show favorites")
|
|
media:ShowPageSaved = client.GetShowFavorites(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.GetShows()
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_USER_SAVED_AUDIOBOOKS:
|
|
_logsi.LogVerbose("Getting Spotify user Audiobook favorites")
|
|
media:AudiobookPageSimplified = client.GetAudiobookFavorites(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_USER_RECENTLY_PLAYED:
|
|
_logsi.LogVerbose("Getting Spotify user Recently Played Tracks")
|
|
media:PlayHistoryPage = client.GetPlayerRecentTracks(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.GetTracks()
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_USER_TOP_ARTISTS:
|
|
_logsi.LogVerbose("Getting Spotify user Top Artists")
|
|
media:ArtistPage = client.GetUsersTopArtists(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_USER_TOP_TRACKS:
|
|
_logsi.LogVerbose("Getting Spotify user Top Tracks")
|
|
media:TrackPage = client.GetUsersTopTracks(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_FEATURED_PLAYLISTS:
|
|
_logsi.LogVerbose("Getting Spotify Featured Playlists")
|
|
media:PlaylistPageSimplified
|
|
media, message = client.GetFeaturedPlaylists(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_CATEGORYS:
|
|
_logsi.LogVerbose("Getting Spotify Categories")
|
|
media:list[Category] = client.GetBrowseCategorysList(refresh=False)
|
|
items = media
|
|
|
|
# add a "Uri" attribute to each category in the cached category list.
|
|
# this is so we can process categories just like other index types, as
|
|
# spotify categories are simply playlists of tracks.
|
|
category:Category
|
|
for category in client.ConfigurationCache["GetBrowseCategorysList"]:
|
|
if hasattr(category, "Uri"):
|
|
_logsi.LogVerbose("Category Uri's have already been set in the cache; don't need to do it again")
|
|
break
|
|
setattr(category, "Uri", f"spotify:category:{category.Id}")
|
|
_logsi.LogVerbose("Category Uri set: Name='%s', Id='%s', Uri='%s'" % (category.Name, category.Id, category.Uri))
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_CATEGORY_PLAYLISTS:
|
|
_logsi.LogVerbose("Getting Spotify Category Playlist")
|
|
|
|
# was a base64 encoded category object supplied? if not, then it's a problem!
|
|
if not media_content_id.startswith(CATEGORY_BASE64):
|
|
raise ValueError("'%s': media content type '%s' is not a serialized Category object!" % (playerName, media_content_type))
|
|
|
|
# drop the eye-ctacher and deserialize the category object.
|
|
category:Category = Category()
|
|
media_content_id = media_content_id[len(CATEGORY_BASE64):]
|
|
category = deserialize_object(media_content_id)
|
|
_logsi.LogObject(SILevel.Verbose, "'%s': deserialized %s" % (playerName, category.ToString()), category, excludeNonPublic=True)
|
|
media_content_id = category.Id
|
|
|
|
# get the playlists for the category id.
|
|
media:PlaylistPageSimplified
|
|
media, message = client.GetCategoryPlaylists(media_content_id, limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
title = category.Name
|
|
image = category.ImageUrl
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_CATEGORY_PLAYLISTS_MADEFORYOU:
|
|
_logsi.LogVerbose("Getting Spotify 'Made For You' Category Playlist")
|
|
media_content_id = '0JQ5DAt0tbjZptfcdMSKl3' # special hidden category "Made For You"
|
|
media, message = client.GetCategoryPlaylists(media_content_id, limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
|
|
elif media_content_type == BrowsableMedia.SPOTIFY_NEW_RELEASES:
|
|
_logsi.LogVerbose("Getting Spotify Album New Releases")
|
|
media:AlbumPageSimplified = client.GetAlbumNewReleases(limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
|
|
elif media_content_type == MediaType.ALBUM:
|
|
_logsi.LogVerbose("Getting Spotify Album Tracks")
|
|
spotifyId:str = SpotifyClient.GetIdFromUri(media_content_id)
|
|
media:Album = client.GetAlbum(spotifyId)
|
|
items = media.Tracks.Items
|
|
title = media.Name
|
|
image = media.ImageUrl
|
|
|
|
elif media_content_type == MediaType.ARTIST:
|
|
_logsi.LogVerbose("Getting Spotify Artist Albums")
|
|
spotifyId:str = SpotifyClient.GetIdFromUri(media_content_id)
|
|
artist:Artist = client.GetArtist(spotifyId) # for cover image
|
|
media:AlbumPageSimplified = client.GetArtistAlbums(spotifyId, include_groups='album', limitTotal=SPOTIFY_BROWSE_LIMIT_TOTAL)
|
|
items = media.Items
|
|
title = artist.Name
|
|
image = artist.ImageUrl
|
|
|
|
elif media_content_type == MediaType.GENRE:
|
|
_logsi.LogVerbose("Getting Spotify Genre Playlist")
|
|
spotifyId:str = SpotifyClient.GetIdFromUri(media_content_id)
|
|
media:Playlist = client.GetPlaylist(spotifyId)
|
|
items = media.GetTracks()
|
|
title = media.Name
|
|
image = media.ImageUrl
|
|
|
|
elif media_content_type == MediaType.PLAYLIST:
|
|
_logsi.LogVerbose("Getting Spotify Playlist")
|
|
spotifyId:str = SpotifyClient.GetIdFromUri(media_content_id)
|
|
media:Playlist = client.GetPlaylist(spotifyId)
|
|
items = media.GetTracks()
|
|
title = media.Name
|
|
image = media.ImageUrl
|
|
|
|
elif media_content_type == MediaType.PODCAST:
|
|
_logsi.LogVerbose("Getting Spotify Show / Podcast")
|
|
spotifyId:str = SpotifyClient.GetIdFromUri(media_content_id)
|
|
media:Show = client.GetShow(spotifyId)
|
|
items = media.Episodes.Items
|
|
title = media.Name
|
|
image = media.ImageUrl
|
|
|
|
elif media_content_type == MediaType.APP: # spotify audiobook support
|
|
_logsi.LogVerbose("Getting Spotify Audiobook")
|
|
spotifyId:str = SpotifyClient.GetIdFromUri(media_content_id)
|
|
media:Audiobook = client.GetAudiobook(spotifyId)
|
|
items = media.Chapters.Items
|
|
title = media.Name
|
|
image = media.ImageUrl
|
|
|
|
# if media was not set then we are done.
|
|
if media is None:
|
|
raise ValueError("'%s': could not find media items for content type '%s'" % (playerName, media_content_type))
|
|
|
|
# set index flag indicating if index media can be played or not.
|
|
canPlay:bool = media_content_type in PLAYABLE_MEDIA_TYPES
|
|
|
|
# track and episode media items cannot be expanded (only played);
|
|
# other media types can be expanded to display child items (e.g. Album, Artist, Playlist, etc).
|
|
canExpand = media_content_type not in [
|
|
MediaType.TRACK,
|
|
MediaType.EPISODE,
|
|
]
|
|
|
|
# if a LOCAL index image was specified, then ensure it exists.
|
|
# otherwise, default to null.
|
|
if image is not None and image.startswith(LOCAL_IMAGE_PREFIX):
|
|
imagePath:str = "%s/www/%s" % (hass.config.config_dir, image[len(LOCAL_IMAGE_PREFIX):])
|
|
if not os.path.exists(imagePath):
|
|
image = None
|
|
|
|
# get parent media atttributes based upon selected media content type.
|
|
parentAttrs:dict[str, Any] = libraryMap.get(media_content_type, None)
|
|
_logsi.LogDictionary(SILevel.Verbose, "'%s': BrowseMedia attributes for parent media content type: '%s'" % (playerName, media_content_type), parentAttrs)
|
|
|
|
# get parent attributes that are not set.
|
|
if title is None:
|
|
title = parentAttrs.get("title_node", media_content_id)
|
|
|
|
# create the index.
|
|
browseMedia:BrowseMedia = BrowseMedia(
|
|
can_expand=canExpand,
|
|
can_play=canPlay,
|
|
children=[],
|
|
children_media_class=parentAttrs["children"],
|
|
media_class=parentAttrs["parent"],
|
|
media_content_id=media_content_id,
|
|
media_content_type=media_content_type,
|
|
thumbnail=image,
|
|
title=title,
|
|
)
|
|
|
|
# add child items to the index.
|
|
for item in items:
|
|
|
|
# resolve media content type.
|
|
mediaType:str = parentAttrs["children"]
|
|
|
|
# get child media atttributes based upon child item media content type.
|
|
childAttrs:dict[str, Any] = libraryMap.get(mediaType, None)
|
|
#_logsi.LogDictionary(SILevel.Verbose, "'%s': BrowseMedia attributes for child media content type: '%s'" % (playerName, mediaType), childAttrs)
|
|
|
|
# set child flag indicating if media can be played or not.
|
|
canPlay:bool = mediaType in PLAYABLE_MEDIA_TYPES
|
|
|
|
# track and episode media items cannot be expanded (only played);
|
|
# other media types can be expanded to display child items (e.g. Album, Artist, Playlist, etc).
|
|
canExpand = mediaType not in [
|
|
MediaType.TRACK,
|
|
MediaType.EPISODE,
|
|
]
|
|
|
|
# resolve media content id.
|
|
# default the value to the media type.
|
|
mediaId:str = mediaType
|
|
if canPlay:
|
|
|
|
# if it is playable then use the Uri value instead, as we know it's a track or episode.
|
|
mediaId = item.Uri
|
|
|
|
elif mediaType == MediaType.GENRE:
|
|
|
|
# if it's GENRE content, then serialize the Category object and use it instead so that
|
|
# we don't have to go get it again - we will deserialize it when the child node is
|
|
# selected, and use it to resolve the category Id, Name, and imageUrl values;
|
|
mediaId = "%s%s" % (CATEGORY_BASE64, serialize_object(item))
|
|
mediaType = BrowsableMedia.SPOTIFY_CATEGORY_PLAYLISTS.value
|
|
|
|
# build the child node.
|
|
browseMediaChild:BrowseMedia = BrowseMedia(
|
|
can_expand=canExpand,
|
|
can_play=canPlay,
|
|
children=None,
|
|
children_media_class=childAttrs["children"],
|
|
media_class=childAttrs["parent"],
|
|
media_content_id=mediaId,
|
|
media_content_type=mediaType,
|
|
thumbnail=item.ImageUrl,
|
|
title=item.Name,
|
|
)
|
|
|
|
# if parent is an audiobook and we are displaying its track items (e.g. chapters), then
|
|
# remove child images as they are all the same.
|
|
if media_content_type == MediaType.APP and mediaType == MediaType.TRACK:
|
|
browseMediaChild.thumbnail = None
|
|
|
|
# add the child item to the list of children.
|
|
browseMedia.children.append(browseMediaChild)
|
|
_logsi.LogObject(SILevel.Verbose, "'%s': BrowseMedia Child Object: Type='%s', Id='%s', Title='%s'" % (playerName, browseMediaChild.media_content_type, browseMediaChild.media_content_id, browseMediaChild.title), browseMediaChild)
|
|
|
|
# trace.
|
|
_logsi.LogObject(SILevel.Verbose, "'%s': BrowseMedia Parent Object: Type='%s', Id='%s', Title='%s'" % (playerName, browseMedia.media_content_type, browseMedia.media_content_id, browseMedia.title), browseMedia)
|
|
|
|
return browseMedia
|
|
|
|
except Exception as ex:
|
|
|
|
# trace.
|
|
_logsi.LogException("'%s': BrowseMedia browse_media_node exception: %s" % (playerName, str(ex)), ex, logToSystemLogger=False)
|
|
raise IntegrationError(str(ex)) from ex
|
|
|
|
finally:
|
|
|
|
# trace.
|
|
_logsi.LeaveMethod(SILevel.Debug)
|