More Spotify Tools (#140)
# PR Description This PR adds three new spotify tools that are natural language friendly. 1. `search` - Search Spotify Catalog information 2. `play_artist_by_name` - Gets 5 songs by the specified artist and plays them. Uses `search`, and `start_tracks_playback_by_id` under the hood 3. `play_track_by_name` - Plays the specified song, optionally provide the artist name who plays the song. Uses `search`, and `start_tracks_playback_by_id` under the hood
This commit is contained in:
parent
8b29407d2d
commit
efee9589fa
7 changed files with 252 additions and 4 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
SPOTIFY_BASE_URL = "https://api.spotify.com/v1"
|
SPOTIFY_BASE_URL = "https://api.spotify.com/v1"
|
||||||
|
|
||||||
ENDPOINTS = {
|
ENDPOINTS = {
|
||||||
|
"player_get_available_devices": "/me/player/devices",
|
||||||
"player_get_playback_state": "/me/player",
|
"player_get_playback_state": "/me/player",
|
||||||
"player_get_currently_playing": "/me/player/currently-playing",
|
"player_get_currently_playing": "/me/player/currently-playing",
|
||||||
"player_modify_playback": "/me/player/play",
|
"player_modify_playback": "/me/player/play",
|
||||||
|
|
@ -11,4 +12,5 @@ ENDPOINTS = {
|
||||||
"tracks_get_track": "/tracks/{track_id}",
|
"tracks_get_track": "/tracks/{track_id}",
|
||||||
"tracks_get_recommendations": "/recommendations",
|
"tracks_get_recommendations": "/recommendations",
|
||||||
"tracks_get_audio_features": "/audio-features",
|
"tracks_get_audio_features": "/audio-features",
|
||||||
|
"search": "/search",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from enum import Enum
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -27,3 +28,29 @@ class PlaybackState:
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert the PlaybackState instance to a dictionary, excluding None values."""
|
"""Convert the PlaybackState instance to a dictionary, excluding None values."""
|
||||||
return {k: v for k, v in asdict(self).items() if v is not None and v != []}
|
return {k: v for k, v in asdict(self).items() if v is not None and v != []}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Device:
|
||||||
|
id: str
|
||||||
|
is_active: bool
|
||||||
|
is_private_session: bool
|
||||||
|
is_restricted: bool
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
volume_percent: int
|
||||||
|
supports_volume: bool
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert the Device instance to a dictionary."""
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchType(str, Enum):
|
||||||
|
ALBUM = "album"
|
||||||
|
ARTIST = "artist"
|
||||||
|
PLAYLIST = "playlist"
|
||||||
|
TRACK = "track"
|
||||||
|
SHOW = "show"
|
||||||
|
EPISODE = "episode"
|
||||||
|
AUDIOBOOK = "audiobook"
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ from typing import Annotated, Optional
|
||||||
from arcade.sdk import ToolContext, tool
|
from arcade.sdk import ToolContext, tool
|
||||||
from arcade.sdk.auth import Spotify
|
from arcade.sdk.auth import Spotify
|
||||||
from arcade.sdk.errors import RetryableToolError
|
from arcade.sdk.errors import RetryableToolError
|
||||||
|
from arcade_spotify.tools.models import Device, SearchType
|
||||||
|
from arcade_spotify.tools.search import search
|
||||||
from arcade_spotify.tools.utils import (
|
from arcade_spotify.tools.utils import (
|
||||||
convert_to_playback_state,
|
convert_to_playback_state,
|
||||||
get_url,
|
get_url,
|
||||||
|
|
@ -183,13 +185,31 @@ async def start_tracks_playback_by_id(
|
||||||
] = 0,
|
] = 0,
|
||||||
) -> Annotated[dict, "The updated playback state"]:
|
) -> Annotated[dict, "The updated playback state"]:
|
||||||
"""Start playback of a list of tracks (songs)"""
|
"""Start playback of a list of tracks (songs)"""
|
||||||
|
|
||||||
|
devices = [
|
||||||
|
Device(**device) for device in (await get_available_devices(context)).get("devices", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
# If no active device is available, pick the first one. Otherwise, Spotify defaults to the active device.
|
||||||
|
device_id = None
|
||||||
|
if devices and not any(device.is_active for device in devices):
|
||||||
|
device_id = devices[0].id
|
||||||
|
|
||||||
|
params = {"device_id": device_id} if device_id else {}
|
||||||
|
|
||||||
url = get_url("player_modify_playback")
|
url = get_url("player_modify_playback")
|
||||||
body = {
|
body = {
|
||||||
"uris": [f"spotify:track:{track_id}" for track_id in track_ids],
|
"uris": [f"spotify:track:{track_id}" for track_id in track_ids],
|
||||||
"position_ms": position_ms,
|
"position_ms": position_ms,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await send_spotify_request(context, "PUT", url, json_data=body)
|
response = await send_spotify_request(context, "PUT", url, params=params, json_data=body)
|
||||||
|
playback_state = handle_404_playback_state(
|
||||||
|
response, "Cannot start playback because no active device is available", False
|
||||||
|
)
|
||||||
|
if playback_state:
|
||||||
|
return playback_state
|
||||||
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
playback_state = await get_playback_state(context)
|
playback_state = await get_playback_state(context)
|
||||||
|
|
@ -221,3 +241,74 @@ async def get_currently_playing(
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = {"is_playing": False} if response.status_code == 204 else response.json()
|
data = {"is_playing": False} if response.status_code == 204 else response.json()
|
||||||
return convert_to_playback_state(data).to_dict()
|
return convert_to_playback_state(data).to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: This tool only works for Spotify Premium users
|
||||||
|
@tool(
|
||||||
|
requires_auth=Spotify(
|
||||||
|
scopes=["user-read-playback-state", "user-modify-playback-state"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def play_artist_by_name(
|
||||||
|
context: ToolContext,
|
||||||
|
name: Annotated[str, "The name of the artist to play"],
|
||||||
|
) -> Annotated[dict, "The updated playback state"]:
|
||||||
|
"""Plays a song by an artist and queues four more songs by the same artist"""
|
||||||
|
q = f"artist:{name}"
|
||||||
|
search_results = await search(context, q, [SearchType.TRACK], 5)
|
||||||
|
if not search_results["tracks"]["items"]:
|
||||||
|
message = f"Artist '{name}' not found."
|
||||||
|
raise RetryableToolError(
|
||||||
|
message,
|
||||||
|
additional_prompt_content=f"{message} Try a different artist name.",
|
||||||
|
retry_after_ms=500,
|
||||||
|
)
|
||||||
|
track_ids = [item["id"] for item in search_results["tracks"]["items"]]
|
||||||
|
playback_state = await start_tracks_playback_by_id(context, track_ids)
|
||||||
|
|
||||||
|
return playback_state
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: This tool only works for Spotify Premium users
|
||||||
|
@tool(
|
||||||
|
requires_auth=Spotify(
|
||||||
|
scopes=["user-read-playback-state", "user-modify-playback-state"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def play_track_by_name(
|
||||||
|
context: ToolContext,
|
||||||
|
track_name: Annotated[str, "The name of the track to play"],
|
||||||
|
artist_name: Annotated[Optional[str], "The name of the artist of the track"] = None,
|
||||||
|
) -> Annotated[dict, "The updated playback state"]:
|
||||||
|
"""Plays a song by name"""
|
||||||
|
q = f"track:{track_name}"
|
||||||
|
if artist_name:
|
||||||
|
q += f" artist:{artist_name}"
|
||||||
|
search_results = await search(context, q, [SearchType.TRACK], 1)
|
||||||
|
|
||||||
|
if not search_results["tracks"]["items"]:
|
||||||
|
message = f"No track exists with name '{track_name}'"
|
||||||
|
if artist_name:
|
||||||
|
message += f" by '{artist_name}'"
|
||||||
|
raise RetryableToolError(
|
||||||
|
message,
|
||||||
|
additional_prompt_content=f"{message}. Try a different track name or artist name.",
|
||||||
|
retry_after_ms=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
track_id = search_results["tracks"]["items"][0]["id"]
|
||||||
|
playback_state = await start_tracks_playback_by_id(context, [track_id])
|
||||||
|
|
||||||
|
return playback_state
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: This tool only works for Spotify Premium users
|
||||||
|
@tool(requires_auth=Spotify(scopes=["user-read-playback-state"]))
|
||||||
|
async def get_available_devices(
|
||||||
|
context: ToolContext,
|
||||||
|
) -> Annotated[dict, "The available devices"]:
|
||||||
|
"""Get the available devices"""
|
||||||
|
url = get_url("player_get_available_devices")
|
||||||
|
response = await send_spotify_request(context, "GET", url)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
|
||||||
39
toolkits/spotify/arcade_spotify/tools/search.py
Normal file
39
toolkits/spotify/arcade_spotify/tools/search.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from arcade.sdk import ToolContext, tool
|
||||||
|
from arcade.sdk.auth import Spotify
|
||||||
|
from arcade_spotify.tools.models import SearchType
|
||||||
|
from arcade_spotify.tools.utils import (
|
||||||
|
get_url,
|
||||||
|
send_spotify_request,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@tool(requires_auth=Spotify())
|
||||||
|
async def search(
|
||||||
|
context: ToolContext,
|
||||||
|
q: Annotated[str, "The search query"],
|
||||||
|
types: Annotated[list[SearchType], "The types of results to return"],
|
||||||
|
limit: Annotated[int, "The maximum number of results to return"] = 1,
|
||||||
|
) -> Annotated[dict, "A list of artists matching the search query"]:
|
||||||
|
"""Search Spotify catalog information
|
||||||
|
|
||||||
|
Explanation of the q parameter:
|
||||||
|
You can narrow down your search using field filters. The available filters are album, artist, track, year, upc, tag:hipster, tag:new, isrc, and genre. Each field filter only applies to certain result types.
|
||||||
|
|
||||||
|
The artist and year filters can be used while searching albums, artists and tracks. You can filter on a single year or a range (e.g. 1955-1960).
|
||||||
|
The album filter can be used while searching albums and tracks.
|
||||||
|
The genre filter can be used while searching artists and tracks.
|
||||||
|
The isrc and track filters can be used while searching tracks.
|
||||||
|
The upc, tag:new and tag:hipster filters can only be used while searching albums. The tag:new filter will return albums released in the past two weeks and tag:hipster can be used to return only albums with the lowest 10% popularity.
|
||||||
|
|
||||||
|
Example: q="remaster track:Doxy artist:Miles Davis"
|
||||||
|
"""
|
||||||
|
|
||||||
|
url = get_url("search", q=q)
|
||||||
|
|
||||||
|
response = await send_spotify_request(
|
||||||
|
context, "GET", url, params={"q": q, "type": ",".join(types), "limit": limit}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
@ -3,6 +3,8 @@ from arcade_spotify.tools.player import (
|
||||||
get_currently_playing,
|
get_currently_playing,
|
||||||
get_playback_state,
|
get_playback_state,
|
||||||
pause_playback,
|
pause_playback,
|
||||||
|
play_artist_by_name,
|
||||||
|
play_track_by_name,
|
||||||
resume_playback,
|
resume_playback,
|
||||||
skip_to_next_track,
|
skip_to_next_track,
|
||||||
skip_to_previous_track,
|
skip_to_previous_track,
|
||||||
|
|
@ -15,7 +17,7 @@ from arcade.sdk.eval import (
|
||||||
EvalSuite,
|
EvalSuite,
|
||||||
tool_eval,
|
tool_eval,
|
||||||
)
|
)
|
||||||
from arcade.sdk.eval.critic import NumericCritic
|
from arcade.sdk.eval.critic import BinaryCritic, NumericCritic, SimilarityCritic
|
||||||
|
|
||||||
# Evaluation rubric
|
# Evaluation rubric
|
||||||
rubric = EvalRubric(
|
rubric = EvalRubric(
|
||||||
|
|
@ -32,10 +34,12 @@ catalog.add_tool(resume_playback, "Spotify")
|
||||||
catalog.add_tool(start_tracks_playback_by_id, "Spotify")
|
catalog.add_tool(start_tracks_playback_by_id, "Spotify")
|
||||||
catalog.add_tool(get_playback_state, "Spotify")
|
catalog.add_tool(get_playback_state, "Spotify")
|
||||||
catalog.add_tool(get_currently_playing, "Spotify")
|
catalog.add_tool(get_currently_playing, "Spotify")
|
||||||
|
catalog.add_tool(play_artist_by_name, "Spotify")
|
||||||
|
catalog.add_tool(play_track_by_name, "Spotify")
|
||||||
|
|
||||||
|
|
||||||
@tool_eval()
|
@tool_eval()
|
||||||
def spotify_eval_suite() -> EvalSuite:
|
def spotify_player_eval_suite() -> EvalSuite:
|
||||||
"""Create an evaluation suite for Spotify "player" tools."""
|
"""Create an evaluation suite for Spotify "player" tools."""
|
||||||
suite = EvalSuite(
|
suite = EvalSuite(
|
||||||
name="Spotify Tools Evaluation",
|
name="Spotify Tools Evaluation",
|
||||||
|
|
@ -118,6 +122,10 @@ def spotify_eval_suite() -> EvalSuite:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
critics=[
|
||||||
|
BinaryCritic(critic_field="track_ids", weight=0.5),
|
||||||
|
NumericCritic(critic_field="position_ms", weight=0.5, value_range=(9000, 11000)),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
suite.add_case(
|
suite.add_case(
|
||||||
|
|
@ -132,4 +140,33 @@ def spotify_eval_suite() -> EvalSuite:
|
||||||
expected_tool_calls=[(get_playback_state, {})],
|
expected_tool_calls=[(get_playback_state, {})],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Play artist by name",
|
||||||
|
user_message="play pearl jam",
|
||||||
|
expected_tool_calls=[
|
||||||
|
(
|
||||||
|
play_artist_by_name,
|
||||||
|
{"name": "Pearl Jam"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
SimilarityCritic(critic_field="name", weight=1.0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Play track by name",
|
||||||
|
user_message="it would be really great if I could listen to strobe by deadmau5 right now.",
|
||||||
|
expected_tool_calls=[
|
||||||
|
(
|
||||||
|
play_track_by_name,
|
||||||
|
{"track_name": "strobe", "artist_name": "deadmau5"},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
SimilarityCritic(critic_field="track_name", weight=0.5),
|
||||||
|
SimilarityCritic(critic_field="artist_name", weight=0.5),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
return suite
|
return suite
|
||||||
|
|
|
||||||
52
toolkits/spotify/evals/eval_search.py
Normal file
52
toolkits/spotify/evals/eval_search.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from arcade_spotify.tools.models import SearchType
|
||||||
|
from arcade_spotify.tools.search import search
|
||||||
|
|
||||||
|
from arcade.sdk import ToolCatalog
|
||||||
|
from arcade.sdk.eval import (
|
||||||
|
EvalRubric,
|
||||||
|
EvalSuite,
|
||||||
|
tool_eval,
|
||||||
|
)
|
||||||
|
from arcade.sdk.eval.critic import BinaryCritic, SimilarityCritic
|
||||||
|
|
||||||
|
# Evaluation rubric
|
||||||
|
rubric = EvalRubric(
|
||||||
|
fail_threshold=0.9,
|
||||||
|
warn_threshold=0.95,
|
||||||
|
)
|
||||||
|
|
||||||
|
catalog = ToolCatalog()
|
||||||
|
catalog.add_tool(search, "Spotify")
|
||||||
|
|
||||||
|
|
||||||
|
@tool_eval()
|
||||||
|
def spotify_search_eval_suite() -> EvalSuite:
|
||||||
|
"""Create an evaluation suite for Spotify "player" tools."""
|
||||||
|
suite = EvalSuite(
|
||||||
|
name="Spotify Tools Evaluation",
|
||||||
|
system_message="You are an AI assistant that can manage Spotify using the provided tools.",
|
||||||
|
catalog=catalog,
|
||||||
|
rubric=rubric,
|
||||||
|
)
|
||||||
|
|
||||||
|
suite.add_case(
|
||||||
|
name="Search Spotify catalog",
|
||||||
|
user_message="search for 3 songs in the the album 'American IV: The Man Comes Around' by Johnny Cash",
|
||||||
|
expected_tool_calls=[
|
||||||
|
(
|
||||||
|
search,
|
||||||
|
{
|
||||||
|
"q": "album:American IV: The Man Comes Around artist:Johnny Cash",
|
||||||
|
"types": [SearchType.TRACK],
|
||||||
|
"limit": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
critics=[
|
||||||
|
SimilarityCritic(critic_field="q", weight=0.5),
|
||||||
|
BinaryCritic(critic_field="limit", weight=0.25),
|
||||||
|
BinaryCritic(critic_field="types", weight=0.25),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
return suite
|
||||||
|
|
@ -130,7 +130,7 @@ history_3 = [
|
||||||
|
|
||||||
|
|
||||||
@tool_eval()
|
@tool_eval()
|
||||||
def spotify_eval_suite() -> EvalSuite:
|
def spotify_tracks_eval_suite() -> EvalSuite:
|
||||||
"""Create an evaluation suite for Spotify "track" tools."""
|
"""Create an evaluation suite for Spotify "track" tools."""
|
||||||
suite = EvalSuite(
|
suite = EvalSuite(
|
||||||
name="Spotify Tools Evaluation",
|
name="Spotify Tools Evaluation",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue