/ plugins / spotify / tools.py
tools.py
  1  """Native Spotify tools for Hermes (registered via plugins/spotify)."""
  2  
  3  from __future__ import annotations
  4  
  5  from typing import Any, Dict, List
  6  
  7  from hermes_cli.auth import get_auth_status
  8  from plugins.spotify.client import (
  9      SpotifyAPIError,
 10      SpotifyAuthRequiredError,
 11      SpotifyClient,
 12      SpotifyError,
 13      normalize_spotify_id,
 14      normalize_spotify_uri,
 15      normalize_spotify_uris,
 16  )
 17  from tools.registry import tool_error, tool_result
 18  
 19  
 20  def _check_spotify_available() -> bool:
 21      try:
 22          return bool(get_auth_status("spotify").get("logged_in"))
 23      except Exception:
 24          return False
 25  
 26  
 27  def _spotify_client() -> SpotifyClient:
 28      return SpotifyClient()
 29  
 30  
 31  def _spotify_tool_error(exc: Exception) -> str:
 32      if isinstance(exc, (SpotifyError, SpotifyAuthRequiredError)):
 33          return tool_error(str(exc))
 34      if isinstance(exc, SpotifyAPIError):
 35          return tool_error(str(exc), status_code=exc.status_code)
 36      return tool_error(f"Spotify tool failed: {type(exc).__name__}: {exc}")
 37  
 38  
 39  def _coerce_limit(raw: Any, *, default: int = 20, minimum: int = 1, maximum: int = 50) -> int:
 40      try:
 41          value = int(raw)
 42      except Exception:
 43          value = default
 44      return max(minimum, min(maximum, value))
 45  
 46  
 47  def _coerce_bool(raw: Any, default: bool = False) -> bool:
 48      if isinstance(raw, bool):
 49          return raw
 50      if isinstance(raw, str):
 51          cleaned = raw.strip().lower()
 52          if cleaned in {"1", "true", "yes", "on"}:
 53              return True
 54          if cleaned in {"0", "false", "no", "off"}:
 55              return False
 56      return default
 57  
 58  
 59  def _as_list(raw: Any) -> List[str]:
 60      if raw is None:
 61          return []
 62      if isinstance(raw, list):
 63          return [str(item).strip() for item in raw if str(item).strip()]
 64      return [str(raw).strip()] if str(raw).strip() else []
 65  
 66  
 67  def _describe_empty_playback(payload: Any, *, action: str) -> dict | None:
 68      if not isinstance(payload, dict) or not payload.get("empty"):
 69          return None
 70      if action == "get_currently_playing":
 71          return {
 72              "success": True,
 73              "action": action,
 74              "is_playing": False,
 75              "status_code": payload.get("status_code", 204),
 76              "message": payload.get("message") or "Spotify is not currently playing anything.",
 77          }
 78      if action == "get_state":
 79          return {
 80              "success": True,
 81              "action": action,
 82              "has_active_device": False,
 83              "status_code": payload.get("status_code", 204),
 84              "message": payload.get("message") or "No active Spotify playback session was found.",
 85          }
 86      return None
 87  
 88  
 89  def _handle_spotify_playback(args: dict, **kw) -> str:
 90      action = str(args.get("action") or "get_state").strip().lower()
 91      client = _spotify_client()
 92      try:
 93          if action == "get_state":
 94              payload = client.get_playback_state(market=args.get("market"))
 95              empty_result = _describe_empty_playback(payload, action=action)
 96              return tool_result(empty_result or payload)
 97          if action == "get_currently_playing":
 98              payload = client.get_currently_playing(market=args.get("market"))
 99              empty_result = _describe_empty_playback(payload, action=action)
100              return tool_result(empty_result or payload)
101          if action == "play":
102              offset = args.get("offset")
103              if isinstance(offset, dict):
104                  payload_offset = {k: v for k, v in offset.items() if v is not None}
105              else:
106                  payload_offset = None
107              uris = normalize_spotify_uris(_as_list(args.get("uris")), "track") if args.get("uris") else None
108              context_uri = None
109              if args.get("context_uri"):
110                  raw_context = str(args.get("context_uri"))
111                  context_type = None
112                  if raw_context.startswith("spotify:album:") or "/album/" in raw_context:
113                      context_type = "album"
114                  elif raw_context.startswith("spotify:playlist:") or "/playlist/" in raw_context:
115                      context_type = "playlist"
116                  elif raw_context.startswith("spotify:artist:") or "/artist/" in raw_context:
117                      context_type = "artist"
118                  context_uri = normalize_spotify_uri(raw_context, context_type)
119              result = client.start_playback(
120                  device_id=args.get("device_id"),
121                  context_uri=context_uri,
122                  uris=uris,
123                  offset=payload_offset,
124                  position_ms=args.get("position_ms"),
125              )
126              return tool_result({"success": True, "action": action, "result": result})
127          if action == "pause":
128              result = client.pause_playback(device_id=args.get("device_id"))
129              return tool_result({"success": True, "action": action, "result": result})
130          if action == "next":
131              result = client.skip_next(device_id=args.get("device_id"))
132              return tool_result({"success": True, "action": action, "result": result})
133          if action == "previous":
134              result = client.skip_previous(device_id=args.get("device_id"))
135              return tool_result({"success": True, "action": action, "result": result})
136          if action == "seek":
137              if args.get("position_ms") is None:
138                  return tool_error("position_ms is required for action='seek'")
139              result = client.seek(position_ms=int(args["position_ms"]), device_id=args.get("device_id"))
140              return tool_result({"success": True, "action": action, "result": result})
141          if action == "set_repeat":
142              state = str(args.get("state") or "").strip().lower()
143              if state not in {"track", "context", "off"}:
144                  return tool_error("state must be one of: track, context, off")
145              result = client.set_repeat(state=state, device_id=args.get("device_id"))
146              return tool_result({"success": True, "action": action, "result": result})
147          if action == "set_shuffle":
148              result = client.set_shuffle(state=_coerce_bool(args.get("state")), device_id=args.get("device_id"))
149              return tool_result({"success": True, "action": action, "result": result})
150          if action == "set_volume":
151              if args.get("volume_percent") is None:
152                  return tool_error("volume_percent is required for action='set_volume'")
153              result = client.set_volume(volume_percent=max(0, min(100, int(args["volume_percent"]))), device_id=args.get("device_id"))
154              return tool_result({"success": True, "action": action, "result": result})
155          if action == "recently_played":
156              after = args.get("after")
157              before = args.get("before")
158              if after and before:
159                  return tool_error("Provide only one of 'after' or 'before'")
160              return tool_result(client.get_recently_played(
161                  limit=_coerce_limit(args.get("limit"), default=20),
162                  after=int(after) if after is not None else None,
163                  before=int(before) if before is not None else None,
164              ))
165          return tool_error(f"Unknown spotify_playback action: {action}")
166      except Exception as exc:
167          return _spotify_tool_error(exc)
168  
169  
170  def _handle_spotify_devices(args: dict, **kw) -> str:
171      action = str(args.get("action") or "list").strip().lower()
172      client = _spotify_client()
173      try:
174          if action == "list":
175              return tool_result(client.get_devices())
176          if action == "transfer":
177              device_id = str(args.get("device_id") or "").strip()
178              if not device_id:
179                  return tool_error("device_id is required for action='transfer'")
180              result = client.transfer_playback(device_id=device_id, play=_coerce_bool(args.get("play")))
181              return tool_result({"success": True, "action": action, "result": result})
182          return tool_error(f"Unknown spotify_devices action: {action}")
183      except Exception as exc:
184          return _spotify_tool_error(exc)
185  
186  
187  def _handle_spotify_queue(args: dict, **kw) -> str:
188      action = str(args.get("action") or "get").strip().lower()
189      client = _spotify_client()
190      try:
191          if action == "get":
192              return tool_result(client.get_queue())
193          if action == "add":
194              uri = normalize_spotify_uri(str(args.get("uri") or ""), None)
195              result = client.add_to_queue(uri=uri, device_id=args.get("device_id"))
196              return tool_result({"success": True, "action": action, "uri": uri, "result": result})
197          return tool_error(f"Unknown spotify_queue action: {action}")
198      except Exception as exc:
199          return _spotify_tool_error(exc)
200  
201  
202  def _handle_spotify_search(args: dict, **kw) -> str:
203      client = _spotify_client()
204      query = str(args.get("query") or "").strip()
205      if not query:
206          return tool_error("query is required")
207      raw_types = _as_list(args.get("types") or args.get("type") or ["track"])
208      search_types = [value.lower() for value in raw_types if value.lower() in {"album", "artist", "playlist", "track", "show", "episode", "audiobook"}]
209      if not search_types:
210          return tool_error("types must contain one or more of: album, artist, playlist, track, show, episode, audiobook")
211      try:
212          return tool_result(client.search(
213              query=query,
214              search_types=search_types,
215              limit=_coerce_limit(args.get("limit"), default=10),
216              offset=max(0, int(args.get("offset") or 0)),
217              market=args.get("market"),
218              include_external=args.get("include_external"),
219          ))
220      except Exception as exc:
221          return _spotify_tool_error(exc)
222  
223  
224  def _handle_spotify_playlists(args: dict, **kw) -> str:
225      action = str(args.get("action") or "list").strip().lower()
226      client = _spotify_client()
227      try:
228          if action == "list":
229              return tool_result(client.get_my_playlists(
230                  limit=_coerce_limit(args.get("limit"), default=20),
231                  offset=max(0, int(args.get("offset") or 0)),
232              ))
233          if action == "get":
234              playlist_id = normalize_spotify_id(str(args.get("playlist_id") or ""), "playlist")
235              return tool_result(client.get_playlist(playlist_id=playlist_id, market=args.get("market")))
236          if action == "create":
237              name = str(args.get("name") or "").strip()
238              if not name:
239                  return tool_error("name is required for action='create'")
240              return tool_result(client.create_playlist(
241                  name=name,
242                  public=_coerce_bool(args.get("public")),
243                  collaborative=_coerce_bool(args.get("collaborative")),
244                  description=args.get("description"),
245              ))
246          if action == "add_items":
247              playlist_id = normalize_spotify_id(str(args.get("playlist_id") or ""), "playlist")
248              uris = normalize_spotify_uris(_as_list(args.get("uris")))
249              return tool_result(client.add_playlist_items(
250                  playlist_id=playlist_id,
251                  uris=uris,
252                  position=args.get("position"),
253              ))
254          if action == "remove_items":
255              playlist_id = normalize_spotify_id(str(args.get("playlist_id") or ""), "playlist")
256              uris = normalize_spotify_uris(_as_list(args.get("uris")))
257              return tool_result(client.remove_playlist_items(
258                  playlist_id=playlist_id,
259                  uris=uris,
260                  snapshot_id=args.get("snapshot_id"),
261              ))
262          if action == "update_details":
263              playlist_id = normalize_spotify_id(str(args.get("playlist_id") or ""), "playlist")
264              return tool_result(client.update_playlist_details(
265                  playlist_id=playlist_id,
266                  name=args.get("name"),
267                  public=args.get("public"),
268                  collaborative=args.get("collaborative"),
269                  description=args.get("description"),
270              ))
271          return tool_error(f"Unknown spotify_playlists action: {action}")
272      except Exception as exc:
273          return _spotify_tool_error(exc)
274  
275  
276  def _handle_spotify_albums(args: dict, **kw) -> str:
277      action = str(args.get("action") or "get").strip().lower()
278      client = _spotify_client()
279      try:
280          album_id = normalize_spotify_id(str(args.get("album_id") or args.get("id") or ""), "album")
281          if action == "get":
282              return tool_result(client.get_album(album_id=album_id, market=args.get("market")))
283          if action == "tracks":
284              return tool_result(client.get_album_tracks(
285                  album_id=album_id,
286                  limit=_coerce_limit(args.get("limit"), default=20),
287                  offset=max(0, int(args.get("offset") or 0)),
288                  market=args.get("market"),
289              ))
290          return tool_error(f"Unknown spotify_albums action: {action}")
291      except Exception as exc:
292          return _spotify_tool_error(exc)
293  
294  
295  def _handle_spotify_library(args: dict, **kw) -> str:
296      """Unified handler for saved tracks + saved albums (formerly two tools)."""
297      kind = str(args.get("kind") or "").strip().lower()
298      if kind not in {"tracks", "albums"}:
299          return tool_error("kind must be one of: tracks, albums")
300      action = str(args.get("action") or "list").strip().lower()
301      item_type = "track" if kind == "tracks" else "album"
302      client = _spotify_client()
303      try:
304          if action == "list":
305              limit = _coerce_limit(args.get("limit"), default=20)
306              offset = max(0, int(args.get("offset") or 0))
307              market = args.get("market")
308              if kind == "tracks":
309                  return tool_result(client.get_saved_tracks(limit=limit, offset=offset, market=market))
310              return tool_result(client.get_saved_albums(limit=limit, offset=offset, market=market))
311          if action == "save":
312              uris = normalize_spotify_uris(_as_list(args.get("uris") or args.get("items")), item_type)
313              return tool_result(client.save_library_items(uris=uris))
314          if action == "remove":
315              ids = [normalize_spotify_id(item, item_type) for item in _as_list(args.get("ids") or args.get("items"))]
316              if not ids:
317                  return tool_error("ids/items is required for action='remove'")
318              if kind == "tracks":
319                  return tool_result(client.remove_saved_tracks(track_ids=ids))
320              return tool_result(client.remove_saved_albums(album_ids=ids))
321          return tool_error(f"Unknown spotify_library action: {action}")
322      except Exception as exc:
323          return _spotify_tool_error(exc)
324  
325  
326  COMMON_STRING = {"type": "string"}
327  
328  SPOTIFY_PLAYBACK_SCHEMA = {
329      "name": "spotify_playback",
330      "description": "Control Spotify playback, inspect the active playback state, or fetch recently played tracks.",
331      "parameters": {
332          "type": "object",
333          "properties": {
334              "action": {"type": "string", "enum": ["get_state", "get_currently_playing", "play", "pause", "next", "previous", "seek", "set_repeat", "set_shuffle", "set_volume", "recently_played"]},
335              "device_id": COMMON_STRING,
336              "market": COMMON_STRING,
337              "context_uri": COMMON_STRING,
338              "uris": {"type": "array", "items": COMMON_STRING},
339              "offset": {"type": "object"},
340              "position_ms": {"type": "integer"},
341              "state": {"description": "For set_repeat use track/context/off. For set_shuffle use boolean-like true/false.", "oneOf": [{"type": "string"}, {"type": "boolean"}]},
342              "volume_percent": {"type": "integer"},
343              "limit": {"type": "integer", "description": "For recently_played: number of tracks (max 50)"},
344              "after": {"type": "integer", "description": "For recently_played: Unix ms cursor (after this timestamp)"},
345              "before": {"type": "integer", "description": "For recently_played: Unix ms cursor (before this timestamp)"},
346          },
347          "required": ["action"],
348      },
349  }
350  
351  SPOTIFY_DEVICES_SCHEMA = {
352      "name": "spotify_devices",
353      "description": "List Spotify Connect devices or transfer playback to a different device.",
354      "parameters": {
355          "type": "object",
356          "properties": {
357              "action": {"type": "string", "enum": ["list", "transfer"]},
358              "device_id": COMMON_STRING,
359              "play": {"type": "boolean"},
360          },
361          "required": ["action"],
362      },
363  }
364  
365  SPOTIFY_QUEUE_SCHEMA = {
366      "name": "spotify_queue",
367      "description": "Inspect the user's Spotify queue or add an item to it.",
368      "parameters": {
369          "type": "object",
370          "properties": {
371              "action": {"type": "string", "enum": ["get", "add"]},
372              "uri": COMMON_STRING,
373              "device_id": COMMON_STRING,
374          },
375          "required": ["action"],
376      },
377  }
378  
379  SPOTIFY_SEARCH_SCHEMA = {
380      "name": "spotify_search",
381      "description": "Search the Spotify catalog for tracks, albums, artists, playlists, shows, or episodes.",
382      "parameters": {
383          "type": "object",
384          "properties": {
385              "query": COMMON_STRING,
386              "types": {"type": "array", "items": COMMON_STRING},
387              "type": COMMON_STRING,
388              "limit": {"type": "integer"},
389              "offset": {"type": "integer"},
390              "market": COMMON_STRING,
391              "include_external": COMMON_STRING,
392          },
393          "required": ["query"],
394      },
395  }
396  
397  SPOTIFY_PLAYLISTS_SCHEMA = {
398      "name": "spotify_playlists",
399      "description": "List, inspect, create, update, and modify Spotify playlists.",
400      "parameters": {
401          "type": "object",
402          "properties": {
403              "action": {"type": "string", "enum": ["list", "get", "create", "add_items", "remove_items", "update_details"]},
404              "playlist_id": COMMON_STRING,
405              "market": COMMON_STRING,
406              "limit": {"type": "integer"},
407              "offset": {"type": "integer"},
408              "name": COMMON_STRING,
409              "description": COMMON_STRING,
410              "public": {"type": "boolean"},
411              "collaborative": {"type": "boolean"},
412              "uris": {"type": "array", "items": COMMON_STRING},
413              "position": {"type": "integer"},
414              "snapshot_id": COMMON_STRING,
415          },
416          "required": ["action"],
417      },
418  }
419  
420  SPOTIFY_ALBUMS_SCHEMA = {
421      "name": "spotify_albums",
422      "description": "Fetch Spotify album metadata or album tracks.",
423      "parameters": {
424          "type": "object",
425          "properties": {
426              "action": {"type": "string", "enum": ["get", "tracks"]},
427              "album_id": COMMON_STRING,
428              "id": COMMON_STRING,
429              "market": COMMON_STRING,
430              "limit": {"type": "integer"},
431              "offset": {"type": "integer"},
432          },
433          "required": ["action"],
434      },
435  }
436  
437  SPOTIFY_LIBRARY_SCHEMA = {
438      "name": "spotify_library",
439      "description": "List, save, or remove the user's saved Spotify tracks or albums. Use `kind` to select which.",
440      "parameters": {
441          "type": "object",
442          "properties": {
443              "kind": {"type": "string", "enum": ["tracks", "albums"], "description": "Which library to operate on"},
444              "action": {"type": "string", "enum": ["list", "save", "remove"]},
445              "limit": {"type": "integer"},
446              "offset": {"type": "integer"},
447              "market": COMMON_STRING,
448              "uris": {"type": "array", "items": COMMON_STRING},
449              "ids": {"type": "array", "items": COMMON_STRING},
450              "items": {"type": "array", "items": COMMON_STRING},
451          },
452          "required": ["kind", "action"],
453      },
454  }