agno 2.3.5__py3-none-any.whl → 2.3.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agno/agent/agent.py +7 -4
- agno/db/postgres/async_postgres.py +14 -4
- agno/db/postgres/postgres.py +9 -3
- agno/db/sqlite/async_sqlite.py +1 -1
- agno/db/sqlite/sqlite.py +3 -4
- agno/eval/accuracy.py +8 -4
- agno/integrations/discord/client.py +1 -1
- agno/models/cerebras/cerebras.py +11 -12
- agno/os/interfaces/whatsapp/security.py +3 -1
- agno/os/schema.py +2 -1
- agno/session/team.py +0 -1
- agno/table.py +1 -1
- agno/team/team.py +7 -4
- agno/tools/google_drive.py +4 -3
- agno/tools/spotify.py +922 -0
- agno/utils/agent.py +2 -2
- agno/utils/mcp.py +1 -1
- agno/workflow/workflow.py +5 -2
- {agno-2.3.5.dist-info → agno-2.3.6.dist-info}/METADATA +37 -32
- {agno-2.3.5.dist-info → agno-2.3.6.dist-info}/RECORD +23 -22
- {agno-2.3.5.dist-info → agno-2.3.6.dist-info}/WHEEL +0 -0
- {agno-2.3.5.dist-info → agno-2.3.6.dist-info}/licenses/LICENSE +0 -0
- {agno-2.3.5.dist-info → agno-2.3.6.dist-info}/top_level.txt +0 -0
agno/tools/spotify.py
ADDED
|
@@ -0,0 +1,922 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Spotify Toolkit for Agno SDK
|
|
3
|
+
|
|
4
|
+
A toolkit for searching songs, creating playlists, and updating playlists on Spotify.
|
|
5
|
+
Requires a valid Spotify access token with appropriate scopes.
|
|
6
|
+
|
|
7
|
+
Required scopes:
|
|
8
|
+
- user-read-private (for getting user ID)
|
|
9
|
+
- playlist-modify-public (for public playlists)
|
|
10
|
+
- playlist-modify-private (for private playlists)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, List, Optional
|
|
15
|
+
|
|
16
|
+
from agno.tools import Toolkit
|
|
17
|
+
from agno.utils.log import log_debug
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import httpx
|
|
21
|
+
except ImportError:
|
|
22
|
+
raise ImportError("`httpx` not installed. Please install using `pip install httpx`")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SpotifyTools(Toolkit):
|
|
26
|
+
"""
|
|
27
|
+
Spotify toolkit for searching songs and managing playlists.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
access_token: Spotify OAuth access token with required scopes.
|
|
31
|
+
default_market: Default market/country code for search results (e.g., 'US', 'GB').
|
|
32
|
+
timeout: Request timeout in seconds.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
access_token: str,
|
|
38
|
+
default_market: Optional[str] = "US",
|
|
39
|
+
timeout: int = 30,
|
|
40
|
+
**kwargs,
|
|
41
|
+
):
|
|
42
|
+
self.access_token = access_token
|
|
43
|
+
self.default_market = default_market
|
|
44
|
+
self.timeout = timeout
|
|
45
|
+
self.base_url = "https://api.spotify.com/v1"
|
|
46
|
+
|
|
47
|
+
tools: List[Any] = [
|
|
48
|
+
self.search_tracks,
|
|
49
|
+
self.search_playlists,
|
|
50
|
+
self.search_artists,
|
|
51
|
+
self.search_albums,
|
|
52
|
+
self.get_user_playlists,
|
|
53
|
+
self.get_track_recommendations,
|
|
54
|
+
self.get_artist_top_tracks,
|
|
55
|
+
self.get_album_tracks,
|
|
56
|
+
self.get_my_top_tracks,
|
|
57
|
+
self.get_my_top_artists,
|
|
58
|
+
self.create_playlist,
|
|
59
|
+
self.add_tracks_to_playlist,
|
|
60
|
+
self.get_playlist,
|
|
61
|
+
self.update_playlist_details,
|
|
62
|
+
self.remove_tracks_from_playlist,
|
|
63
|
+
self.get_current_user,
|
|
64
|
+
self.play_track,
|
|
65
|
+
self.get_currently_playing,
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
super().__init__(name="spotify", tools=tools, **kwargs)
|
|
69
|
+
|
|
70
|
+
def _make_request(
|
|
71
|
+
self,
|
|
72
|
+
endpoint: str,
|
|
73
|
+
method: str = "GET",
|
|
74
|
+
body: Optional[dict] = None,
|
|
75
|
+
params: Optional[dict] = None,
|
|
76
|
+
) -> dict:
|
|
77
|
+
"""Make an authenticated request to the Spotify API."""
|
|
78
|
+
url = f"{self.base_url}/{endpoint}"
|
|
79
|
+
headers = {
|
|
80
|
+
"Authorization": f"Bearer {self.access_token}",
|
|
81
|
+
"Content-Type": "application/json",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
with httpx.Client(timeout=self.timeout) as client:
|
|
85
|
+
response = client.request(
|
|
86
|
+
method=method,
|
|
87
|
+
url=url,
|
|
88
|
+
headers=headers,
|
|
89
|
+
json=body,
|
|
90
|
+
params=params,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if response.status_code == 204:
|
|
94
|
+
return {"success": True}
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
return response.json()
|
|
98
|
+
except json.JSONDecodeError:
|
|
99
|
+
return {"error": f"Failed to parse response: {response.text}"}
|
|
100
|
+
|
|
101
|
+
def get_current_user(self) -> str:
|
|
102
|
+
"""Get the current authenticated user's profile.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
JSON string containing user profile with id, display_name, and email.
|
|
106
|
+
"""
|
|
107
|
+
log_debug("Fetching current Spotify user profile")
|
|
108
|
+
result = self._make_request("me")
|
|
109
|
+
return json.dumps(result, indent=2)
|
|
110
|
+
|
|
111
|
+
def get_my_top_tracks(
|
|
112
|
+
self,
|
|
113
|
+
time_range: str = "medium_term",
|
|
114
|
+
limit: int = 20,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""Get the current user's most played tracks.
|
|
117
|
+
|
|
118
|
+
Requires the 'user-top-read' scope.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
time_range: Time period for top tracks:
|
|
122
|
+
- "short_term": Last 4 weeks
|
|
123
|
+
- "medium_term": Last 6 months (default)
|
|
124
|
+
- "long_term": All time (several years)
|
|
125
|
+
limit: Number of tracks to return (default 20, max 50).
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
JSON string containing list of user's top tracks with id, name, artists, album, and uri.
|
|
129
|
+
"""
|
|
130
|
+
log_debug(f"Fetching user's top tracks: {time_range}")
|
|
131
|
+
|
|
132
|
+
params = {
|
|
133
|
+
"time_range": time_range,
|
|
134
|
+
"limit": min(limit, 50),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
result = self._make_request("me/top/tracks", params=params)
|
|
138
|
+
|
|
139
|
+
if "error" in result:
|
|
140
|
+
return json.dumps(result, indent=2)
|
|
141
|
+
|
|
142
|
+
tracks = result.get("items", [])
|
|
143
|
+
simplified_tracks = [
|
|
144
|
+
{
|
|
145
|
+
"rank": i + 1,
|
|
146
|
+
"id": track["id"],
|
|
147
|
+
"name": track["name"],
|
|
148
|
+
"artists": [artist["name"] for artist in track["artists"]],
|
|
149
|
+
"album": track["album"]["name"],
|
|
150
|
+
"uri": track["uri"],
|
|
151
|
+
"popularity": track.get("popularity"),
|
|
152
|
+
}
|
|
153
|
+
for i, track in enumerate(tracks)
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
return json.dumps(simplified_tracks, indent=2)
|
|
157
|
+
|
|
158
|
+
def get_my_top_artists(
|
|
159
|
+
self,
|
|
160
|
+
time_range: str = "medium_term",
|
|
161
|
+
limit: int = 20,
|
|
162
|
+
) -> str:
|
|
163
|
+
"""Get the current user's most played artists.
|
|
164
|
+
|
|
165
|
+
Requires the 'user-top-read' scope.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
time_range: Time period for top artists:
|
|
169
|
+
- "short_term": Last 4 weeks
|
|
170
|
+
- "medium_term": Last 6 months (default)
|
|
171
|
+
- "long_term": All time (several years)
|
|
172
|
+
limit: Number of artists to return (default 20, max 50).
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
JSON string containing list of user's top artists with id, name, genres, and uri.
|
|
176
|
+
"""
|
|
177
|
+
log_debug(f"Fetching user's top artists: {time_range}")
|
|
178
|
+
|
|
179
|
+
params = {
|
|
180
|
+
"time_range": time_range,
|
|
181
|
+
"limit": min(limit, 50),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
result = self._make_request("me/top/artists", params=params)
|
|
185
|
+
|
|
186
|
+
if "error" in result:
|
|
187
|
+
return json.dumps(result, indent=2)
|
|
188
|
+
|
|
189
|
+
artists = result.get("items", [])
|
|
190
|
+
simplified_artists = [
|
|
191
|
+
{
|
|
192
|
+
"rank": i + 1,
|
|
193
|
+
"id": artist["id"],
|
|
194
|
+
"name": artist["name"],
|
|
195
|
+
"genres": artist.get("genres", []),
|
|
196
|
+
"uri": artist["uri"],
|
|
197
|
+
"popularity": artist.get("popularity"),
|
|
198
|
+
"followers": artist.get("followers", {}).get("total"),
|
|
199
|
+
}
|
|
200
|
+
for i, artist in enumerate(artists)
|
|
201
|
+
]
|
|
202
|
+
|
|
203
|
+
return json.dumps(simplified_artists, indent=2)
|
|
204
|
+
|
|
205
|
+
def search_playlists(
|
|
206
|
+
self,
|
|
207
|
+
query: str,
|
|
208
|
+
max_results: int = 10,
|
|
209
|
+
) -> str:
|
|
210
|
+
"""Search for playlists on Spotify by name.
|
|
211
|
+
|
|
212
|
+
Use this to find playlists by name before updating them.
|
|
213
|
+
Example: "Good Vibes", "Workout Mix", "Chill Beats"
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
query: Search query - playlist name or keywords.
|
|
217
|
+
max_results: Maximum number of playlists to return (default 10, max 50).
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
JSON string containing list of playlists with id, name, owner, track_count, and url.
|
|
221
|
+
"""
|
|
222
|
+
log_debug(f"Searching Spotify for playlists: {query}")
|
|
223
|
+
|
|
224
|
+
params = {
|
|
225
|
+
"q": query,
|
|
226
|
+
"type": "playlist",
|
|
227
|
+
"limit": min(max_results, 50),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
result = self._make_request("search", params=params)
|
|
231
|
+
|
|
232
|
+
if "error" in result:
|
|
233
|
+
return json.dumps(result, indent=2)
|
|
234
|
+
|
|
235
|
+
playlists = result.get("playlists", {}).get("items", [])
|
|
236
|
+
simplified_playlists = [
|
|
237
|
+
{
|
|
238
|
+
"id": playlist["id"],
|
|
239
|
+
"name": playlist["name"],
|
|
240
|
+
"owner": playlist["owner"]["display_name"],
|
|
241
|
+
"track_count": playlist["tracks"]["total"],
|
|
242
|
+
"url": playlist["external_urls"]["spotify"],
|
|
243
|
+
"uri": playlist["uri"],
|
|
244
|
+
"public": playlist.get("public"),
|
|
245
|
+
}
|
|
246
|
+
for playlist in playlists
|
|
247
|
+
if playlist is not None
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
return json.dumps(simplified_playlists, indent=2)
|
|
251
|
+
|
|
252
|
+
def get_user_playlists(
|
|
253
|
+
self,
|
|
254
|
+
max_results: int = 20,
|
|
255
|
+
) -> str:
|
|
256
|
+
"""Get the current user's playlists.
|
|
257
|
+
|
|
258
|
+
Use this to find playlists owned by or followed by the current user.
|
|
259
|
+
This is more reliable than search when looking for the user's own playlists.
|
|
260
|
+
|
|
261
|
+
Args:
|
|
262
|
+
max_results: Maximum number of playlists to return (default 20, max 50).
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
JSON string containing list of user's playlists with id, name, owner, track_count, and url.
|
|
266
|
+
"""
|
|
267
|
+
log_debug("Fetching current user's playlists")
|
|
268
|
+
|
|
269
|
+
params = {
|
|
270
|
+
"limit": min(max_results, 50),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
result = self._make_request("me/playlists", params=params)
|
|
274
|
+
|
|
275
|
+
if "error" in result:
|
|
276
|
+
return json.dumps(result, indent=2)
|
|
277
|
+
|
|
278
|
+
playlists = result.get("items", [])
|
|
279
|
+
simplified_playlists = [
|
|
280
|
+
{
|
|
281
|
+
"id": playlist["id"],
|
|
282
|
+
"name": playlist["name"],
|
|
283
|
+
"owner": playlist["owner"]["display_name"],
|
|
284
|
+
"track_count": playlist["tracks"]["total"],
|
|
285
|
+
"url": playlist["external_urls"]["spotify"],
|
|
286
|
+
"uri": playlist["uri"],
|
|
287
|
+
"public": playlist.get("public"),
|
|
288
|
+
}
|
|
289
|
+
for playlist in playlists
|
|
290
|
+
if playlist is not None
|
|
291
|
+
]
|
|
292
|
+
|
|
293
|
+
return json.dumps(simplified_playlists, indent=2)
|
|
294
|
+
|
|
295
|
+
def search_artists(
|
|
296
|
+
self,
|
|
297
|
+
query: str,
|
|
298
|
+
max_results: int = 5,
|
|
299
|
+
) -> str:
|
|
300
|
+
"""Search for artists on Spotify.
|
|
301
|
+
|
|
302
|
+
Use this to find an artist's ID before getting their top tracks.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
query: Artist name to search for.
|
|
306
|
+
max_results: Maximum number of artists to return (default 5, max 50).
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
JSON string containing list of artists with id, name, genres, popularity, and uri.
|
|
310
|
+
"""
|
|
311
|
+
log_debug(f"Searching Spotify for artists: {query}")
|
|
312
|
+
|
|
313
|
+
params = {
|
|
314
|
+
"q": query,
|
|
315
|
+
"type": "artist",
|
|
316
|
+
"limit": min(max_results, 50),
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
result = self._make_request("search", params=params)
|
|
320
|
+
|
|
321
|
+
if "error" in result:
|
|
322
|
+
return json.dumps(result, indent=2)
|
|
323
|
+
|
|
324
|
+
artists = result.get("artists", {}).get("items", [])
|
|
325
|
+
simplified_artists = [
|
|
326
|
+
{
|
|
327
|
+
"id": artist["id"],
|
|
328
|
+
"name": artist["name"],
|
|
329
|
+
"genres": artist.get("genres", []),
|
|
330
|
+
"popularity": artist.get("popularity"),
|
|
331
|
+
"uri": artist["uri"],
|
|
332
|
+
"followers": artist.get("followers", {}).get("total"),
|
|
333
|
+
}
|
|
334
|
+
for artist in artists
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
return json.dumps(simplified_artists, indent=2)
|
|
338
|
+
|
|
339
|
+
def search_albums(
|
|
340
|
+
self,
|
|
341
|
+
query: str,
|
|
342
|
+
max_results: int = 10,
|
|
343
|
+
market: Optional[str] = None,
|
|
344
|
+
) -> str:
|
|
345
|
+
"""Search for albums on Spotify.
|
|
346
|
+
|
|
347
|
+
Use this to find an album's ID before getting its tracks.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
query: Album name or artist + album name to search for.
|
|
351
|
+
max_results: Maximum number of albums to return (default 10, max 50).
|
|
352
|
+
market: Country code for market (e.g., 'US'). Uses default if not specified.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
JSON string containing list of albums with id, name, artists, release_date, total_tracks, and uri.
|
|
356
|
+
"""
|
|
357
|
+
log_debug(f"Searching Spotify for albums: {query}")
|
|
358
|
+
|
|
359
|
+
params = {
|
|
360
|
+
"q": query,
|
|
361
|
+
"type": "album",
|
|
362
|
+
"limit": min(max_results, 50),
|
|
363
|
+
"market": market or self.default_market,
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
result = self._make_request("search", params=params)
|
|
367
|
+
|
|
368
|
+
if "error" in result:
|
|
369
|
+
return json.dumps(result, indent=2)
|
|
370
|
+
|
|
371
|
+
albums = result.get("albums", {}).get("items", [])
|
|
372
|
+
simplified_albums = [
|
|
373
|
+
{
|
|
374
|
+
"id": album["id"],
|
|
375
|
+
"name": album["name"],
|
|
376
|
+
"artists": [artist["name"] for artist in album["artists"]],
|
|
377
|
+
"release_date": album.get("release_date"),
|
|
378
|
+
"total_tracks": album.get("total_tracks"),
|
|
379
|
+
"uri": album["uri"],
|
|
380
|
+
"album_type": album.get("album_type"),
|
|
381
|
+
}
|
|
382
|
+
for album in albums
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
return json.dumps(simplified_albums, indent=2)
|
|
386
|
+
|
|
387
|
+
def get_album_tracks(
|
|
388
|
+
self,
|
|
389
|
+
album_id: str,
|
|
390
|
+
market: Optional[str] = None,
|
|
391
|
+
) -> str:
|
|
392
|
+
"""Get all tracks from an album.
|
|
393
|
+
|
|
394
|
+
Use search_albums first to get the album_id if you don't have it.
|
|
395
|
+
Useful for adding entire albums to a playlist.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
album_id: The Spotify ID of the album.
|
|
399
|
+
market: Country code for market (e.g., 'US'). Uses default if not specified.
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
JSON string containing album info and list of tracks with id, name, track_number, duration, and uri.
|
|
403
|
+
"""
|
|
404
|
+
log_debug(f"Fetching tracks for album: {album_id}")
|
|
405
|
+
|
|
406
|
+
# First get album details
|
|
407
|
+
album_result = self._make_request(f"albums/{album_id}", params={"market": market or self.default_market})
|
|
408
|
+
|
|
409
|
+
if "error" in album_result:
|
|
410
|
+
return json.dumps(album_result, indent=2)
|
|
411
|
+
|
|
412
|
+
tracks = album_result.get("tracks", {}).get("items", [])
|
|
413
|
+
simplified_tracks = [
|
|
414
|
+
{
|
|
415
|
+
"id": track["id"],
|
|
416
|
+
"name": track["name"],
|
|
417
|
+
"track_number": track["track_number"],
|
|
418
|
+
"duration_ms": track["duration_ms"],
|
|
419
|
+
"uri": track["uri"],
|
|
420
|
+
"artists": [artist["name"] for artist in track["artists"]],
|
|
421
|
+
}
|
|
422
|
+
for track in tracks
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
response = {
|
|
426
|
+
"album": {
|
|
427
|
+
"id": album_result["id"],
|
|
428
|
+
"name": album_result["name"],
|
|
429
|
+
"artists": [artist["name"] for artist in album_result["artists"]],
|
|
430
|
+
"release_date": album_result.get("release_date"),
|
|
431
|
+
"total_tracks": album_result.get("total_tracks"),
|
|
432
|
+
"uri": album_result["uri"],
|
|
433
|
+
},
|
|
434
|
+
"tracks": simplified_tracks,
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
return json.dumps(response, indent=2)
|
|
438
|
+
|
|
439
|
+
def get_artist_top_tracks(
|
|
440
|
+
self,
|
|
441
|
+
artist_id: str,
|
|
442
|
+
market: Optional[str] = None,
|
|
443
|
+
) -> str:
|
|
444
|
+
"""Get an artist's top tracks on Spotify.
|
|
445
|
+
|
|
446
|
+
Use search_artists first to get the artist_id if you don't have it.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
artist_id: The Spotify ID of the artist.
|
|
450
|
+
market: Country code for market (e.g., 'US'). Uses default if not specified.
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
JSON string containing list of top tracks with id, name, album, popularity, and uri.
|
|
454
|
+
"""
|
|
455
|
+
log_debug(f"Fetching top tracks for artist: {artist_id}")
|
|
456
|
+
|
|
457
|
+
params = {
|
|
458
|
+
"market": market or self.default_market,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
result = self._make_request(f"artists/{artist_id}/top-tracks", params=params)
|
|
462
|
+
|
|
463
|
+
if "error" in result:
|
|
464
|
+
return json.dumps(result, indent=2)
|
|
465
|
+
|
|
466
|
+
tracks = result.get("tracks", [])
|
|
467
|
+
simplified_tracks = [
|
|
468
|
+
{
|
|
469
|
+
"id": track["id"],
|
|
470
|
+
"name": track["name"],
|
|
471
|
+
"artists": [artist["name"] for artist in track["artists"]],
|
|
472
|
+
"album": track["album"]["name"],
|
|
473
|
+
"uri": track["uri"],
|
|
474
|
+
"popularity": track.get("popularity"),
|
|
475
|
+
"preview_url": track.get("preview_url"),
|
|
476
|
+
}
|
|
477
|
+
for track in tracks
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
return json.dumps(simplified_tracks, indent=2)
|
|
481
|
+
|
|
482
|
+
def get_track_recommendations(
|
|
483
|
+
self,
|
|
484
|
+
seed_tracks: Optional[List[str]] = None,
|
|
485
|
+
seed_artists: Optional[List[str]] = None,
|
|
486
|
+
seed_genres: Optional[List[str]] = None,
|
|
487
|
+
limit: int = 20,
|
|
488
|
+
target_energy: Optional[float] = None,
|
|
489
|
+
target_valence: Optional[float] = None,
|
|
490
|
+
target_danceability: Optional[float] = None,
|
|
491
|
+
target_tempo: Optional[float] = None,
|
|
492
|
+
) -> str:
|
|
493
|
+
"""Get track recommendations based on seed tracks, artists, or genres.
|
|
494
|
+
|
|
495
|
+
Must provide at least one seed (track, artist, or genre). Maximum 5 seeds total.
|
|
496
|
+
|
|
497
|
+
For mood-based playlists, use these audio features (0.0 to 1.0 scale):
|
|
498
|
+
- valence: happiness (0=sad, 1=happy)
|
|
499
|
+
- energy: intensity (0=calm, 1=energetic)
|
|
500
|
+
- danceability: how danceable (0=least, 1=most)
|
|
501
|
+
- tempo: BPM (e.g., 120 for upbeat)
|
|
502
|
+
|
|
503
|
+
Args:
|
|
504
|
+
seed_tracks: List of Spotify track IDs (not URIs) to use as seeds.
|
|
505
|
+
seed_artists: List of Spotify artist IDs to use as seeds.
|
|
506
|
+
seed_genres: List of genres (e.g., "pop", "hip-hop", "rock", "electronic").
|
|
507
|
+
limit: Number of recommendations to return (default 20, max 100).
|
|
508
|
+
target_energy: Target energy level 0.0-1.0 (higher = more energetic).
|
|
509
|
+
target_valence: Target happiness level 0.0-1.0 (higher = happier).
|
|
510
|
+
target_danceability: Target danceability 0.0-1.0 (higher = more danceable).
|
|
511
|
+
target_tempo: Target tempo in BPM (e.g., 120).
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
JSON string containing list of recommended tracks.
|
|
515
|
+
"""
|
|
516
|
+
log_debug("Fetching track recommendations")
|
|
517
|
+
|
|
518
|
+
params: dict[str, Any] = {
|
|
519
|
+
"limit": min(limit, 100),
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if seed_tracks:
|
|
523
|
+
params["seed_tracks"] = ",".join(seed_tracks[:5])
|
|
524
|
+
if seed_artists:
|
|
525
|
+
params["seed_artists"] = ",".join(seed_artists[:5])
|
|
526
|
+
if seed_genres:
|
|
527
|
+
params["seed_genres"] = ",".join(seed_genres[:5])
|
|
528
|
+
|
|
529
|
+
# Audio feature targets
|
|
530
|
+
if target_energy is not None:
|
|
531
|
+
params["target_energy"] = target_energy
|
|
532
|
+
if target_valence is not None:
|
|
533
|
+
params["target_valence"] = target_valence
|
|
534
|
+
if target_danceability is not None:
|
|
535
|
+
params["target_danceability"] = target_danceability
|
|
536
|
+
if target_tempo is not None:
|
|
537
|
+
params["target_tempo"] = target_tempo
|
|
538
|
+
|
|
539
|
+
# Validate at least one seed
|
|
540
|
+
if not any([seed_tracks, seed_artists, seed_genres]):
|
|
541
|
+
return json.dumps({"error": "At least one seed (tracks, artists, or genres) is required"}, indent=2)
|
|
542
|
+
|
|
543
|
+
result = self._make_request("recommendations", params=params)
|
|
544
|
+
|
|
545
|
+
if "error" in result:
|
|
546
|
+
return json.dumps(result, indent=2)
|
|
547
|
+
|
|
548
|
+
tracks = result.get("tracks", [])
|
|
549
|
+
simplified_tracks = [
|
|
550
|
+
{
|
|
551
|
+
"id": track["id"],
|
|
552
|
+
"name": track["name"],
|
|
553
|
+
"artists": [artist["name"] for artist in track["artists"]],
|
|
554
|
+
"album": track["album"]["name"],
|
|
555
|
+
"uri": track["uri"],
|
|
556
|
+
"popularity": track.get("popularity"),
|
|
557
|
+
"preview_url": track.get("preview_url"),
|
|
558
|
+
}
|
|
559
|
+
for track in tracks
|
|
560
|
+
]
|
|
561
|
+
|
|
562
|
+
return json.dumps(simplified_tracks, indent=2)
|
|
563
|
+
|
|
564
|
+
def play_track(
|
|
565
|
+
self,
|
|
566
|
+
track_uri: Optional[str] = None,
|
|
567
|
+
context_uri: Optional[str] = None,
|
|
568
|
+
device_id: Optional[str] = None,
|
|
569
|
+
position_ms: int = 0,
|
|
570
|
+
) -> str:
|
|
571
|
+
"""Start or resume playback on the user's Spotify.
|
|
572
|
+
|
|
573
|
+
Requires an active Spotify session (open Spotify app on any device).
|
|
574
|
+
Requires the 'user-modify-playback-state' scope.
|
|
575
|
+
|
|
576
|
+
Args:
|
|
577
|
+
track_uri: Spotify URI of track to play (e.g., "spotify:track:xxx").
|
|
578
|
+
If not provided, resumes current playback.
|
|
579
|
+
context_uri: Spotify URI of context to play (album, artist, playlist).
|
|
580
|
+
e.g., "spotify:playlist:xxx" or "spotify:album:xxx"
|
|
581
|
+
device_id: Optional device ID to play on. Uses active device if not specified.
|
|
582
|
+
position_ms: Position in milliseconds to start from (default 0).
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
JSON string with success status or error.
|
|
586
|
+
"""
|
|
587
|
+
log_debug(f"Starting playback: track={track_uri}, context={context_uri}")
|
|
588
|
+
|
|
589
|
+
params = {}
|
|
590
|
+
if device_id:
|
|
591
|
+
params["device_id"] = device_id
|
|
592
|
+
|
|
593
|
+
body: dict[str, Any] = {}
|
|
594
|
+
if track_uri:
|
|
595
|
+
body["uris"] = [track_uri]
|
|
596
|
+
if context_uri:
|
|
597
|
+
body["context_uri"] = context_uri
|
|
598
|
+
if position_ms:
|
|
599
|
+
body["position_ms"] = position_ms
|
|
600
|
+
|
|
601
|
+
result = self._make_request(
|
|
602
|
+
"me/player/play", method="PUT", body=body if body else None, params=params if params else None
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
if result.get("success") or not result.get("error"):
|
|
606
|
+
return json.dumps({"success": True, "message": "Playback started"}, indent=2)
|
|
607
|
+
|
|
608
|
+
# Common error: no active device
|
|
609
|
+
if result.get("error", {}).get("reason") == "NO_ACTIVE_DEVICE":
|
|
610
|
+
return json.dumps(
|
|
611
|
+
{
|
|
612
|
+
"error": "No active Spotify device found. Please open Spotify on any device first.",
|
|
613
|
+
"reason": "NO_ACTIVE_DEVICE",
|
|
614
|
+
},
|
|
615
|
+
indent=2,
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
return json.dumps(result, indent=2)
|
|
619
|
+
|
|
620
|
+
def get_currently_playing(self) -> str:
|
|
621
|
+
"""Get information about the user's current playback state.
|
|
622
|
+
|
|
623
|
+
Returns:
|
|
624
|
+
JSON string containing current track, device, progress, and playback state.
|
|
625
|
+
"""
|
|
626
|
+
log_debug("Fetching currently playing track")
|
|
627
|
+
|
|
628
|
+
result = self._make_request("me/player/currently-playing")
|
|
629
|
+
|
|
630
|
+
if not result or result.get("success"):
|
|
631
|
+
return json.dumps({"message": "Nothing currently playing"}, indent=2)
|
|
632
|
+
|
|
633
|
+
if "error" in result:
|
|
634
|
+
return json.dumps(result, indent=2)
|
|
635
|
+
|
|
636
|
+
track = result.get("item", {})
|
|
637
|
+
response = {
|
|
638
|
+
"is_playing": result.get("is_playing"),
|
|
639
|
+
"progress_ms": result.get("progress_ms"),
|
|
640
|
+
"track": {
|
|
641
|
+
"id": track.get("id"),
|
|
642
|
+
"name": track.get("name"),
|
|
643
|
+
"artists": [a["name"] for a in track.get("artists", [])],
|
|
644
|
+
"album": track.get("album", {}).get("name"),
|
|
645
|
+
"uri": track.get("uri"),
|
|
646
|
+
"duration_ms": track.get("duration_ms"),
|
|
647
|
+
}
|
|
648
|
+
if track
|
|
649
|
+
else None,
|
|
650
|
+
"device": result.get("device", {}).get("name"),
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
return json.dumps(response, indent=2)
|
|
654
|
+
|
|
655
|
+
def search_tracks(
|
|
656
|
+
self,
|
|
657
|
+
query: str,
|
|
658
|
+
max_results: int = 10,
|
|
659
|
+
market: Optional[str] = None,
|
|
660
|
+
) -> str:
|
|
661
|
+
"""Search for tracks on Spotify.
|
|
662
|
+
|
|
663
|
+
Use this to find songs by name, artist, album, or any combination.
|
|
664
|
+
Examples: "happy Eminem", "Coldplay Paradise", "upbeat pop songs"
|
|
665
|
+
|
|
666
|
+
Args:
|
|
667
|
+
query: Search query - can include track name, artist, genre, mood, etc.
|
|
668
|
+
max_results: Maximum number of tracks to return (default 10, max 50).
|
|
669
|
+
market: Country code for market (e.g., 'US'). Uses default if not specified.
|
|
670
|
+
|
|
671
|
+
Returns:
|
|
672
|
+
JSON string containing list of tracks with id, name, artists, album, uri, and preview_url.
|
|
673
|
+
"""
|
|
674
|
+
log_debug(f"Searching Spotify for tracks: {query}")
|
|
675
|
+
|
|
676
|
+
params = {
|
|
677
|
+
"q": query,
|
|
678
|
+
"type": "track",
|
|
679
|
+
"limit": min(max_results, 50),
|
|
680
|
+
"market": market or self.default_market,
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
result = self._make_request("search", params=params)
|
|
684
|
+
|
|
685
|
+
if "error" in result:
|
|
686
|
+
return json.dumps(result, indent=2)
|
|
687
|
+
|
|
688
|
+
tracks = result.get("tracks", {}).get("items", [])
|
|
689
|
+
simplified_tracks = [
|
|
690
|
+
{
|
|
691
|
+
"id": track["id"],
|
|
692
|
+
"name": track["name"],
|
|
693
|
+
"artists": [artist["name"] for artist in track["artists"]],
|
|
694
|
+
"album": track["album"]["name"],
|
|
695
|
+
"uri": track["uri"],
|
|
696
|
+
"preview_url": track.get("preview_url"),
|
|
697
|
+
"popularity": track.get("popularity"),
|
|
698
|
+
}
|
|
699
|
+
for track in tracks
|
|
700
|
+
]
|
|
701
|
+
|
|
702
|
+
return json.dumps(simplified_tracks, indent=2)
|
|
703
|
+
|
|
704
|
+
def create_playlist(
|
|
705
|
+
self,
|
|
706
|
+
name: str,
|
|
707
|
+
description: Optional[str] = None,
|
|
708
|
+
public: bool = False,
|
|
709
|
+
track_uris: Optional[List[str]] = None,
|
|
710
|
+
) -> str:
|
|
711
|
+
"""Create a new playlist for the current user.
|
|
712
|
+
|
|
713
|
+
Args:
|
|
714
|
+
name: Name of the playlist.
|
|
715
|
+
description: Optional description for the playlist.
|
|
716
|
+
public: Whether the playlist should be public (default False).
|
|
717
|
+
track_uris: Optional list of Spotify track URIs to add initially.
|
|
718
|
+
Format: ["spotify:track:xxx", "spotify:track:yyy"]
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
JSON string containing the created playlist details including id, name, and url.
|
|
722
|
+
"""
|
|
723
|
+
log_debug(f"Creating Spotify playlist: {name}")
|
|
724
|
+
|
|
725
|
+
# First get the current user's ID
|
|
726
|
+
user_response = self._make_request("me")
|
|
727
|
+
if "error" in user_response:
|
|
728
|
+
return json.dumps(user_response, indent=2)
|
|
729
|
+
|
|
730
|
+
user_id = user_response["id"]
|
|
731
|
+
|
|
732
|
+
# Create the playlist
|
|
733
|
+
body = {
|
|
734
|
+
"name": name,
|
|
735
|
+
"description": description or "",
|
|
736
|
+
"public": public,
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
playlist = self._make_request(f"users/{user_id}/playlists", method="POST", body=body)
|
|
740
|
+
|
|
741
|
+
if "error" in playlist:
|
|
742
|
+
return json.dumps(playlist, indent=2)
|
|
743
|
+
|
|
744
|
+
# Add tracks if provided
|
|
745
|
+
if track_uris and len(track_uris) > 0:
|
|
746
|
+
add_result = self._make_request(
|
|
747
|
+
f"playlists/{playlist['id']}/tracks",
|
|
748
|
+
method="POST",
|
|
749
|
+
body={"uris": track_uris[:100]}, # Spotify allows max 100 per request
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
if "error" in add_result:
|
|
753
|
+
playlist["track_add_error"] = add_result["error"]
|
|
754
|
+
else:
|
|
755
|
+
playlist["tracks_added"] = len(track_uris[:100])
|
|
756
|
+
|
|
757
|
+
result = {
|
|
758
|
+
"id": playlist["id"],
|
|
759
|
+
"name": playlist["name"],
|
|
760
|
+
"description": playlist.get("description"),
|
|
761
|
+
"url": playlist["external_urls"]["spotify"],
|
|
762
|
+
"uri": playlist["uri"],
|
|
763
|
+
"tracks_added": playlist.get("tracks_added", 0),
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
return json.dumps(result, indent=2)
|
|
767
|
+
|
|
768
|
+
def add_tracks_to_playlist(
|
|
769
|
+
self,
|
|
770
|
+
playlist_id: str,
|
|
771
|
+
track_uris: List[str],
|
|
772
|
+
position: Optional[int] = None,
|
|
773
|
+
) -> str:
|
|
774
|
+
"""Add tracks to an existing playlist.
|
|
775
|
+
|
|
776
|
+
Args:
|
|
777
|
+
playlist_id: The Spotify ID of the playlist.
|
|
778
|
+
track_uris: List of Spotify track URIs to add.
|
|
779
|
+
Format: ["spotify:track:xxx", "spotify:track:yyy"]
|
|
780
|
+
position: Optional position to insert tracks (0-indexed). Appends to end if not specified.
|
|
781
|
+
|
|
782
|
+
Returns:
|
|
783
|
+
JSON string with success status and snapshot_id.
|
|
784
|
+
"""
|
|
785
|
+
log_debug(f"Adding {len(track_uris)} tracks to playlist {playlist_id}")
|
|
786
|
+
|
|
787
|
+
body: dict[str, Any] = {"uris": track_uris[:100]}
|
|
788
|
+
if position is not None:
|
|
789
|
+
body["position"] = position
|
|
790
|
+
|
|
791
|
+
result = self._make_request(f"playlists/{playlist_id}/tracks", method="POST", body=body)
|
|
792
|
+
|
|
793
|
+
if "snapshot_id" in result:
|
|
794
|
+
return json.dumps(
|
|
795
|
+
{
|
|
796
|
+
"success": True,
|
|
797
|
+
"tracks_added": len(track_uris[:100]),
|
|
798
|
+
"snapshot_id": result["snapshot_id"],
|
|
799
|
+
},
|
|
800
|
+
indent=2,
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
return json.dumps(result, indent=2)
|
|
804
|
+
|
|
805
|
+
def remove_tracks_from_playlist(
|
|
806
|
+
self,
|
|
807
|
+
playlist_id: str,
|
|
808
|
+
track_uris: List[str],
|
|
809
|
+
) -> str:
|
|
810
|
+
"""Remove tracks from a playlist.
|
|
811
|
+
|
|
812
|
+
Args:
|
|
813
|
+
playlist_id: The Spotify ID of the playlist.
|
|
814
|
+
track_uris: List of Spotify track URIs to remove.
|
|
815
|
+
Format: ["spotify:track:xxx", "spotify:track:yyy"]
|
|
816
|
+
|
|
817
|
+
Returns:
|
|
818
|
+
JSON string with success status and snapshot_id.
|
|
819
|
+
"""
|
|
820
|
+
log_debug(f"Removing {len(track_uris)} tracks from playlist {playlist_id}")
|
|
821
|
+
|
|
822
|
+
body = {"tracks": [{"uri": uri} for uri in track_uris]}
|
|
823
|
+
|
|
824
|
+
result = self._make_request(f"playlists/{playlist_id}/tracks", method="DELETE", body=body)
|
|
825
|
+
|
|
826
|
+
if "snapshot_id" in result:
|
|
827
|
+
return json.dumps(
|
|
828
|
+
{
|
|
829
|
+
"success": True,
|
|
830
|
+
"tracks_removed": len(track_uris),
|
|
831
|
+
"snapshot_id": result["snapshot_id"],
|
|
832
|
+
},
|
|
833
|
+
indent=2,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
return json.dumps(result, indent=2)
|
|
837
|
+
|
|
838
|
+
def get_playlist(
|
|
839
|
+
self,
|
|
840
|
+
playlist_id: str,
|
|
841
|
+
include_tracks: bool = True,
|
|
842
|
+
) -> str:
|
|
843
|
+
"""Get details of a playlist.
|
|
844
|
+
|
|
845
|
+
Args:
|
|
846
|
+
playlist_id: The Spotify ID of the playlist.
|
|
847
|
+
include_tracks: Whether to include track listing (default True).
|
|
848
|
+
|
|
849
|
+
Returns:
|
|
850
|
+
JSON string containing playlist details and optionally its tracks.
|
|
851
|
+
"""
|
|
852
|
+
log_debug(f"Fetching playlist: {playlist_id}")
|
|
853
|
+
|
|
854
|
+
fields = "id,name,description,public,owner(display_name),external_urls"
|
|
855
|
+
if include_tracks:
|
|
856
|
+
fields += ",tracks.items(track(id,name,artists(name),uri))"
|
|
857
|
+
|
|
858
|
+
result = self._make_request(f"playlists/{playlist_id}", params={"fields": fields})
|
|
859
|
+
|
|
860
|
+
if "error" in result:
|
|
861
|
+
return json.dumps(result, indent=2)
|
|
862
|
+
|
|
863
|
+
playlist_info = {
|
|
864
|
+
"id": result["id"],
|
|
865
|
+
"name": result["name"],
|
|
866
|
+
"description": result.get("description"),
|
|
867
|
+
"public": result.get("public"),
|
|
868
|
+
"owner": result.get("owner", {}).get("display_name"),
|
|
869
|
+
"url": result.get("external_urls", {}).get("spotify"),
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if include_tracks and "tracks" in result:
|
|
873
|
+
playlist_info["tracks"] = [
|
|
874
|
+
{
|
|
875
|
+
"id": item["track"]["id"],
|
|
876
|
+
"name": item["track"]["name"],
|
|
877
|
+
"artists": [a["name"] for a in item["track"]["artists"]],
|
|
878
|
+
"uri": item["track"]["uri"],
|
|
879
|
+
}
|
|
880
|
+
for item in result["tracks"]["items"]
|
|
881
|
+
if item.get("track")
|
|
882
|
+
]
|
|
883
|
+
|
|
884
|
+
return json.dumps(playlist_info, indent=2)
|
|
885
|
+
|
|
886
|
+
def update_playlist_details(
|
|
887
|
+
self,
|
|
888
|
+
playlist_id: str,
|
|
889
|
+
name: Optional[str] = None,
|
|
890
|
+
description: Optional[str] = None,
|
|
891
|
+
public: Optional[bool] = None,
|
|
892
|
+
) -> str:
|
|
893
|
+
"""Update a playlist's name, description, or visibility.
|
|
894
|
+
|
|
895
|
+
Args:
|
|
896
|
+
playlist_id: The Spotify ID of the playlist.
|
|
897
|
+
name: New name for the playlist (optional).
|
|
898
|
+
description: New description for the playlist (optional).
|
|
899
|
+
public: New visibility setting (optional).
|
|
900
|
+
|
|
901
|
+
Returns:
|
|
902
|
+
JSON string with success status.
|
|
903
|
+
"""
|
|
904
|
+
log_debug(f"Updating playlist details: {playlist_id}")
|
|
905
|
+
|
|
906
|
+
body: dict[str, Any] = {}
|
|
907
|
+
if name is not None:
|
|
908
|
+
body["name"] = name
|
|
909
|
+
if description is not None:
|
|
910
|
+
body["description"] = description
|
|
911
|
+
if public is not None:
|
|
912
|
+
body["public"] = public
|
|
913
|
+
|
|
914
|
+
if not body:
|
|
915
|
+
return json.dumps({"error": "No updates provided"}, indent=2)
|
|
916
|
+
|
|
917
|
+
result = self._make_request(f"playlists/{playlist_id}", method="PUT", body=body)
|
|
918
|
+
|
|
919
|
+
if result.get("success") or "error" not in result:
|
|
920
|
+
return json.dumps({"success": True, "updated_fields": list(body.keys())}, indent=2)
|
|
921
|
+
|
|
922
|
+
return json.dumps(result, indent=2)
|