Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions music_assistant/providers/spotify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
ProviderFeature.LIBRARY_ALBUMS_EDIT,
ProviderFeature.LIBRARY_PLAYLISTS_EDIT,
ProviderFeature.LIBRARY_TRACKS_EDIT,
ProviderFeature.PLAYLIST_CREATE,
ProviderFeature.PLAYLIST_TRACKS_EDIT,
ProviderFeature.BROWSE,
ProviderFeature.SEARCH,
Expand Down
131 changes: 69 additions & 62 deletions music_assistant/providers/spotify/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,8 @@ async def get_library_playlists(self) -> AsyncGenerator[Playlist, None]:
async def search(
self, search_query: str, media_types: list[MediaType] | None = None, limit: int = 5
) -> SearchResults:
"""Perform search on musicprovider.
"""
Perform search on musicprovider.

:param search_query: Search query.
:param media_types: A list of media_types to include.
Expand All @@ -244,7 +245,7 @@ async def search(

search_query = search_query.replace("'", "")
offset = 0
page_limit = min(limit, 50)
page_limit = min(limit, 10)

while True:
api_result = await self._get_data(
Expand Down Expand Up @@ -278,7 +279,8 @@ def _build_search_types(self, media_types: list[MediaType]) -> str:
def _process_search_results(
self, api_result: dict[str, Any], searchresult: SearchResults
) -> int:
"""Process API search results and update searchresult object.
"""
Process API search results and update searchresult object.

Returns the total number of items received.
"""
Expand Down Expand Up @@ -424,10 +426,6 @@ async def get_podcast_episodes(
self, prov_podcast_id: str
) -> AsyncGenerator[PodcastEpisode, None]:
"""Get all podcast episodes."""
# Get podcast object for context if available
podcast = await self.mass.music.podcasts.get_library_item_by_prov_id(
prov_podcast_id, self.instance_id
)
podcast = await self.get_podcast(prov_podcast_id)

# Get (cached) episode data
Expand Down Expand Up @@ -579,7 +577,7 @@ async def on_played(
# The resume position will be automatically updated by MA's internal tracking
# and will be retrieved via get_audiobook() which combines MA + Spotify positions

@use_cache()
@use_cache(86400 * 365) # 1 year - album track listings are immutable
async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
"""Get all album tracks for given album id."""
return [
Expand All @@ -588,7 +586,7 @@ async def get_album_tracks(self, prov_album_id: str) -> list[Track]:
if item["id"]
]

@use_cache(2600 * 3) # 3 hours
@use_cache(3600 * 3) # 3 hours
async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> list[Track]:
"""Get playlist tracks."""
is_liked_songs = prov_playlist_id == self._get_liked_songs_playlist_id()
Expand All @@ -615,14 +613,17 @@ async def get_playlist_tracks(self, prov_playlist_id: str, page: int = 0) -> lis
uri, cache_checksum, limit=page_size, offset=offset, use_global_session=use_global
)
total = spotify_result.get("total", 0)
for index, item in enumerate(spotify_result["items"], 1):
items = spotify_result.get("items", [])
# me/tracks returns item["track"], playlists/{id}/items returns item["item"]
item_key = "track" if is_liked_songs else "item"
for index, item in enumerate(items, 1):
# Spotify wraps/recycles items for offsets beyond the playlist size,
# so we need to break when we've reached the total.
if (offset + index) > total:
break
if not (item and item["track"] and item["track"]["id"]):
if not (item and item.get(item_key) and item[item_key].get("id")):
continue
track = parse_track(item["track"], self)
track = parse_track(item[item_key], self)
track.position = offset + index
result.append(track)
return result
Expand Down Expand Up @@ -652,34 +653,40 @@ async def get_artist_toptracks(self, prov_artist_id: str) -> list[Track]:

async def library_add(self, item: MediaItemType) -> bool:
"""Add item to library."""
if item.media_type == MediaType.ARTIST:
await self._put_data("me/following", {"ids": [item.item_id]}, type="artist")
elif item.media_type == MediaType.ALBUM:
await self._put_data("me/albums", {"ids": [item.item_id]})
elif item.media_type == MediaType.TRACK:
await self._put_data("me/tracks", {"ids": [item.item_id]})
elif item.media_type == MediaType.PLAYLIST:
await self._put_data(f"playlists/{item.item_id}/followers", data={"public": False})
elif item.media_type == MediaType.PODCAST:
await self._put_data("me/shows", ids=item.item_id)
elif item.media_type == MediaType.AUDIOBOOK and self.audiobooks_supported:
await self._put_data("me/audiobooks", ids=item.item_id)
uri_type_map = {
MediaType.ARTIST: "artist",
MediaType.ALBUM: "album",
MediaType.TRACK: "track",
MediaType.PLAYLIST: "playlist",
MediaType.PODCAST: "show",
MediaType.AUDIOBOOK: "audiobook",
}
if item.media_type == MediaType.AUDIOBOOK and not self.audiobooks_supported:
return False
uri_type = uri_type_map.get(item.media_type)
if not uri_type:
return False
uri = f"spotify:{uri_type}:{item.item_id}"
await self._put_data("me/library", uris=uri)
return True

async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool:
"""Remove item from library."""
if media_type == MediaType.ARTIST:
await self._delete_data("me/following", {"ids": [prov_item_id]}, type="artist")
elif media_type == MediaType.ALBUM:
await self._delete_data("me/albums", {"ids": [prov_item_id]})
elif media_type == MediaType.TRACK:
await self._delete_data("me/tracks", {"ids": [prov_item_id]})
elif media_type == MediaType.PLAYLIST:
await self._delete_data(f"playlists/{prov_item_id}/followers")
elif media_type == MediaType.PODCAST:
await self._delete_data("me/shows", ids=prov_item_id)
elif media_type == MediaType.AUDIOBOOK and self.audiobooks_supported:
await self._delete_data("me/audiobooks", ids=prov_item_id)
uri_type_map = {
MediaType.ARTIST: "artist",
MediaType.ALBUM: "album",
MediaType.TRACK: "track",
MediaType.PLAYLIST: "playlist",
MediaType.PODCAST: "show",
MediaType.AUDIOBOOK: "audiobook",
}
if media_type == MediaType.AUDIOBOOK and not self.audiobooks_supported:
return False
uri_type = uri_type_map.get(media_type)
if not uri_type:
return False
uri = f"spotify:{uri_type}:{prov_item_id}"
await self._delete_data("me/library", uris=uri)
return True

async def add_playlist_tracks(self, prov_playlist_id: str, prov_track_ids: list[str]) -> None:
Expand All @@ -697,18 +704,16 @@ async def remove_playlist_tracks(
uri = f"playlists/{prov_playlist_id}/items"
spotify_result = await self._get_data(uri, limit=1, offset=pos - 1)
for item in spotify_result["items"]:
if not (item and item["track"] and item["track"]["id"]):
if not (item and item["item"] and item["item"]["id"]):
continue
track_uris.append({"uri": f"spotify:track:{item['track']['id']}"})
data = {"tracks": track_uris}
track_uris.append({"uri": f"spotify:track:{item['item']['id']}"})
data = {"items": track_uris}
await self._delete_data(f"playlists/{prov_playlist_id}/items", data=data)

async def create_playlist(self, name: str, media_types: set[MediaType]) -> Playlist:
"""Create a new playlist on provider with given name."""
if self._sp_user is None:
raise LoginFailed("User info not available - not logged in")
data = {"name": name, "public": False}
new_playlist = await self._post_data(f"users/{self._sp_user['id']}/playlists", data=data)
new_playlist = await self._post_data("me/playlists", data=data)
self._fix_create_playlist_api_bug(new_playlist)
return parse_playlist(new_playlist, self)

Expand Down Expand Up @@ -821,7 +826,8 @@ async def get_audio_stream(

@lock
async def login(self, force_refresh: bool = False) -> dict[str, Any]:
"""Log-in Spotify global session and return Auth/token info.
"""
Log-in Spotify global session and return Auth/token info.

This uses MA's global client ID which has full API access but heavy rate limits.
"""
Expand Down Expand Up @@ -872,13 +878,15 @@ async def login(self, force_refresh: bool = False) -> dict[str, Any]:
self._sp_user = userinfo = await self._get_data(
"me", auth_info=auth_info, use_global_session=True
)
self.mass.metadata.set_default_preferred_language(userinfo["country"])
if country := userinfo.get("country"):
self.mass.metadata.set_default_preferred_language(country)
self.logger.info("Successfully logged in to Spotify as %s", userinfo["display_name"])
return auth_info

@lock
async def login_dev(self, force_refresh: bool = False) -> dict[str, Any]:
"""Log-in Spotify developer session and return Auth/token info.
"""
Log-in Spotify developer session and return Auth/token info.

This uses the user's custom client ID which has less rate limits but limited API access.
"""
Expand Down Expand Up @@ -926,7 +934,8 @@ async def login_dev(self, force_refresh: bool = False) -> dict[str, Any]:
return auth_info

async def _setup_librespot_auth(self, access_token: str) -> None:
"""Set up librespot authentication with the given access token.
"""
Set up librespot authentication with the given access token.

:param access_token: Spotify access token to use for librespot authentication.
"""
Expand Down Expand Up @@ -955,7 +964,8 @@ async def _setup_librespot_auth(self, access_token: str) -> None:
raise LoginFailed(f"Failed to verify credentials on Librespot: {err_str}")

async def _get_auth_info(self, use_global_session: bool = False) -> dict[str, Any]:
"""Get auth info for API requests, preferring dev session if available.
"""
Get auth info for API requests, preferring dev session if available.

:param use_global_session: Force use of global session (for features not available on dev).
"""
Expand Down Expand Up @@ -1010,7 +1020,8 @@ async def _get_liked_songs_playlist(self) -> Playlist:
return liked_songs

async def _playlist_requires_global_token(self, prov_playlist_id: str) -> bool:
"""Check if a playlist requires global token (cached).
"""
Check if a playlist requires global token (cached).

:param prov_playlist_id: The Spotify playlist ID.
:returns: True if the playlist requires global token.
Expand All @@ -1019,7 +1030,8 @@ async def _playlist_requires_global_token(self, prov_playlist_id: str) -> bool:
return bool(await self.mass.cache.get(cache_key, provider=self.instance_id))

async def _set_playlist_requires_global_token(self, prov_playlist_id: str) -> None:
"""Mark a playlist as requiring global token in cache.
"""
Mark a playlist as requiring global token in cache.

:param prov_playlist_id: The Spotify playlist ID.
"""
Expand Down Expand Up @@ -1056,13 +1068,10 @@ async def _add_audiobook_chapters(self, audiobook: Audiobook) -> None:

@use_cache(43200) # 12 hours - balances freshness with performance
async def _get_podcast_episodes_data(self, prov_podcast_id: str) -> list[dict[str, Any]]:
"""Get raw episode data from Spotify API (cached).

Args:
prov_podcast_id: Spotify podcast ID
"""
Get raw episode data from Spotify API (cached).

Returns:
List of episode data dictionaries
:param prov_podcast_id: Spotify podcast ID.
"""
episodes_data: list[dict[str, Any]] = []

Expand All @@ -1085,13 +1094,10 @@ async def _get_podcast_episodes_data(self, prov_podcast_id: str) -> list[dict[st

@use_cache(7200) # 2 hours - shorter cache for resume point data
async def _get_audiobook_chapters_data(self, prov_audiobook_id: str) -> list[dict[str, Any]]:
"""Get raw chapter data from Spotify API (cached).

Args:
prov_audiobook_id: Spotify audiobook ID
"""
Get raw chapter data from Spotify API (cached).

Returns:
List of chapter data dictionaries
:param prov_audiobook_id: Spotify audiobook ID.
"""
chapters_data: list[dict[str, Any]] = []

Expand Down Expand Up @@ -1158,7 +1164,8 @@ async def _get_etag(self, endpoint: str, **kwargs: Any) -> str | None:

@throttle_with_retries
async def _get_data(self, endpoint: str, **kwargs: Any) -> dict[str, Any]:
"""Get data from api.
"""
Get data from api.

:param endpoint: API endpoint to call.
:param use_global_session: Force use of global session (for features not available on dev).
Expand Down
Loading