pharmaia commited on
Commit
cbabc21
·
verified ·
1 Parent(s): 4711709

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +174 -6
app.py CHANGED
@@ -254,6 +254,21 @@ def _spotify_request(
254
  payload = response.json()
255
  except ValueError:
256
  payload = {"error": response.text}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  raise SpotifyAuthError(f"Spotify API error {response.status_code}: {payload}")
258
 
259
  if not response.content:
@@ -265,7 +280,7 @@ def _spotify_request(
265
  return {"raw": response.text}
266
 
267
 
268
- def _build_auth_url(state: str) -> str:
269
  _require_spotify_config()
270
  params = {
271
  "response_type": "code",
@@ -273,7 +288,7 @@ def _build_auth_url(state: str) -> str:
273
  "scope": SPOTIFY_SCOPES,
274
  "redirect_uri": SPOTIFY_REDIRECT_URI,
275
  "state": state,
276
- "show_dialog": "false",
277
  }
278
  return f"{SPOTIFY_ACCOUNTS_BASE}/authorize?{urlencode(params)}"
279
 
@@ -289,6 +304,24 @@ def _token_status() -> dict[str, Any]:
289
  }
290
 
291
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
292
  def _short_playlist(playlist: dict[str, Any]) -> dict[str, Any]:
293
  return {
294
  "id": playlist.get("id"),
@@ -344,6 +377,20 @@ def _normalize_track_ids(track_ids: list[str], max_items: int = 500) -> list[str
344
  return normalized
345
 
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  def _chunked(items: list[str], size: int) -> list[list[str]]:
348
  return [items[i : i + size] for i in range(0, len(items), size)]
349
 
@@ -596,6 +643,8 @@ def spotify_create_playlist(
596
  """Create a playlist in the current user's account."""
597
  if not name.strip():
598
  raise SpotifyAuthError("Playlist name cannot be empty.")
 
 
599
 
600
  me = _spotify_request("GET", "/me")
601
  user_id = str(me.get("id", "")).strip()
@@ -624,6 +673,10 @@ def spotify_create_playlist(
624
  @mcp.tool()
625
  def spotify_add_items_to_playlist(playlist_id: str, uris: list[str]) -> dict[str, Any]:
626
  """Add Spotify item URIs to a playlist (/playlists/{id}/items)."""
 
 
 
 
627
  clean_uris = [u.strip() for u in uris if u and u.strip()]
628
 
629
  if not playlist_id.strip():
@@ -635,16 +688,115 @@ def spotify_add_items_to_playlist(playlist_id: str, uris: list[str]) -> dict[str
635
 
636
  payload = _spotify_request(
637
  "POST",
638
- f"/playlists/{playlist_id.strip()}/items",
639
  json_body={"uris": clean_uris},
640
  )
641
  return {
642
- "playlist_id": playlist_id.strip(),
643
  "added": len(clean_uris),
644
  "snapshot_id": payload.get("snapshot_id"),
645
  }
646
 
647
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  app = FastAPI(title="Spotify MCP Server", version="1.0.0")
649
  app.mount("/gradio_api/mcp", mcp.sse_app("/gradio_api/mcp"))
650
 
@@ -654,6 +806,7 @@ def root() -> dict[str, Any]:
654
  return {
655
  "service": "spotify-mcp-server",
656
  "auth_login_url": "/auth/login",
 
657
  "auth_status_url": "/auth/status",
658
  "mcp_sse_path": "/gradio_api/mcp/sse",
659
  "public_mcp_sse_url": MCP_SSE_URL,
@@ -670,11 +823,26 @@ def auth_status() -> dict[str, Any]:
670
  return _token_status()
671
 
672
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
673
  @app.get("/auth/login")
674
- def auth_login() -> RedirectResponse:
675
  try:
676
  state = _new_oauth_state()
677
- url = _build_auth_url(state)
678
  except SpotifyAuthError as exc:
679
  raise HTTPException(status_code=500, detail=str(exc)) from exc
680
  return RedirectResponse(url=url, status_code=307)
 
254
  payload = response.json()
255
  except ValueError:
256
  payload = {"error": response.text}
257
+ if response.status_code == 403:
258
+ error_message = ""
259
+ if isinstance(payload, dict):
260
+ err = payload.get("error")
261
+ if isinstance(err, dict):
262
+ error_message = str(err.get("message", "")).strip()
263
+ elif isinstance(err, str):
264
+ error_message = err.strip()
265
+ if "Insufficient client scope" in error_message:
266
+ raise SpotifyAuthError(
267
+ "Spotify token without required scopes. Re-authenticate at "
268
+ "/auth/reset then /auth/login (force consent), and confirm "
269
+ "SPOTIFY_SCOPES includes playlist-modify-private and "
270
+ "playlist-modify-public."
271
+ )
272
  raise SpotifyAuthError(f"Spotify API error {response.status_code}: {payload}")
273
 
274
  if not response.content:
 
280
  return {"raw": response.text}
281
 
282
 
283
+ def _build_auth_url(state: str, show_dialog: bool = True) -> str:
284
  _require_spotify_config()
285
  params = {
286
  "response_type": "code",
 
288
  "scope": SPOTIFY_SCOPES,
289
  "redirect_uri": SPOTIFY_REDIRECT_URI,
290
  "state": state,
291
+ "show_dialog": "true" if show_dialog else "false",
292
  }
293
  return f"{SPOTIFY_ACCOUNTS_BASE}/authorize?{urlencode(params)}"
294
 
 
304
  }
305
 
306
 
307
+ def _token_scope_set() -> set[str]:
308
+ tokens = _load_tokens() or {}
309
+ scope_str = str(tokens.get("scope", "")).strip()
310
+ return {scope for scope in scope_str.split() if scope}
311
+
312
+
313
+ def _require_any_scope(required_scopes: list[str], action: str) -> None:
314
+ token_scopes = _token_scope_set()
315
+ if not token_scopes:
316
+ return
317
+ if any(scope in token_scopes for scope in required_scopes):
318
+ return
319
+ raise SpotifyAuthError(
320
+ f"Missing scope for {action}. Need one of: {', '.join(required_scopes)}. "
321
+ "Run /auth/reset and /auth/login to re-authorize with updated scopes."
322
+ )
323
+
324
+
325
  def _short_playlist(playlist: dict[str, Any]) -> dict[str, Any]:
326
  return {
327
  "id": playlist.get("id"),
 
377
  return normalized
378
 
379
 
380
+ def _normalize_playlist_id(playlist_ref: str) -> str:
381
+ value = playlist_ref.strip()
382
+ if not value:
383
+ return ""
384
+
385
+ if value.startswith("spotify:playlist:"):
386
+ return value.split(":")[-1].strip()
387
+
388
+ if "open.spotify.com/playlist/" in value:
389
+ return value.split("open.spotify.com/playlist/")[-1].split("?")[0].strip().strip("/")
390
+
391
+ return value
392
+
393
+
394
  def _chunked(items: list[str], size: int) -> list[list[str]]:
395
  return [items[i : i + size] for i in range(0, len(items), size)]
396
 
 
643
  """Create a playlist in the current user's account."""
644
  if not name.strip():
645
  raise SpotifyAuthError("Playlist name cannot be empty.")
646
+ required_scope = "playlist-modify-public" if public else "playlist-modify-private"
647
+ _require_any_scope([required_scope], "create playlist")
648
 
649
  me = _spotify_request("GET", "/me")
650
  user_id = str(me.get("id", "")).strip()
 
673
  @mcp.tool()
674
  def spotify_add_items_to_playlist(playlist_id: str, uris: list[str]) -> dict[str, Any]:
675
  """Add Spotify item URIs to a playlist (/playlists/{id}/items)."""
676
+ _require_any_scope(
677
+ ["playlist-modify-private", "playlist-modify-public"],
678
+ "add items to playlist",
679
+ )
680
  clean_uris = [u.strip() for u in uris if u and u.strip()]
681
 
682
  if not playlist_id.strip():
 
688
 
689
  payload = _spotify_request(
690
  "POST",
691
+ f"/playlists/{_normalize_playlist_id(playlist_id)}/items",
692
  json_body={"uris": clean_uris},
693
  )
694
  return {
695
+ "playlist_id": _normalize_playlist_id(playlist_id),
696
  "added": len(clean_uris),
697
  "snapshot_id": payload.get("snapshot_id"),
698
  }
699
 
700
 
701
+ @mcp.tool()
702
+ def spotify_update_playlist_details(
703
+ playlist_id: str,
704
+ name: str | None = None,
705
+ description: str | None = None,
706
+ public: bool | None = None,
707
+ collaborative: bool | None = None,
708
+ ) -> dict[str, Any]:
709
+ """Update playlist metadata (PUT /playlists/{id})."""
710
+ _require_any_scope(
711
+ ["playlist-modify-private", "playlist-modify-public"],
712
+ "update playlist details",
713
+ )
714
+ pid = _normalize_playlist_id(playlist_id)
715
+ if not pid:
716
+ raise SpotifyAuthError("playlist_id is required.")
717
+
718
+ body: dict[str, Any] = {}
719
+ if name is not None:
720
+ body["name"] = name.strip()
721
+ if description is not None:
722
+ body["description"] = description
723
+ if public is not None:
724
+ body["public"] = public
725
+ if collaborative is not None:
726
+ body["collaborative"] = collaborative
727
+
728
+ if not body:
729
+ raise SpotifyAuthError("Provide at least one field to update.")
730
+
731
+ # Spotify collaborative playlists must be private.
732
+ if body.get("collaborative") is True and body.get("public") is True:
733
+ raise SpotifyAuthError("Collaborative playlists cannot be public.")
734
+ if body.get("collaborative") is True and "public" not in body:
735
+ body["public"] = False
736
+
737
+ _spotify_request(
738
+ "PUT",
739
+ f"/playlists/{pid}",
740
+ json_body=body,
741
+ )
742
+
743
+ return {
744
+ "playlist_id": pid,
745
+ "updated_fields": list(body.keys()),
746
+ }
747
+
748
+
749
+ @mcp.tool()
750
+ def spotify_replace_playlist_items(playlist_id: str, uris: list[str]) -> dict[str, Any]:
751
+ """Replace all items in playlist (PUT /playlists/{id}/tracks)."""
752
+ _require_any_scope(
753
+ ["playlist-modify-private", "playlist-modify-public"],
754
+ "replace playlist items",
755
+ )
756
+ pid = _normalize_playlist_id(playlist_id)
757
+ if not pid:
758
+ raise SpotifyAuthError("playlist_id is required.")
759
+
760
+ clean_uris = [u.strip() for u in uris if u and u.strip()]
761
+ if not clean_uris:
762
+ raise SpotifyAuthError("Provide at least one URI.")
763
+ if len(clean_uris) > 100:
764
+ raise SpotifyAuthError("Spotify allows up to 100 URIs per request.")
765
+
766
+ payload = _spotify_request(
767
+ "PUT",
768
+ f"/playlists/{pid}/tracks",
769
+ json_body={"uris": clean_uris},
770
+ )
771
+ return {
772
+ "playlist_id": pid,
773
+ "replaced_with": len(clean_uris),
774
+ "snapshot_id": payload.get("snapshot_id"),
775
+ }
776
+
777
+
778
+ @mcp.tool()
779
+ def spotify_delete_playlist(playlist_id: str) -> dict[str, Any]:
780
+ """Delete playlist from library (DELETE /playlists/{id}/followers)."""
781
+ _require_any_scope(
782
+ ["playlist-modify-private", "playlist-modify-public"],
783
+ "delete playlist",
784
+ )
785
+ pid = _normalize_playlist_id(playlist_id)
786
+ if not pid:
787
+ raise SpotifyAuthError("playlist_id is required.")
788
+
789
+ _spotify_request(
790
+ "DELETE",
791
+ f"/playlists/{pid}/followers",
792
+ )
793
+ return {
794
+ "playlist_id": pid,
795
+ "deleted": True,
796
+ "note": "Spotify performs unfollow; own playlists are removed from your profile.",
797
+ }
798
+
799
+
800
  app = FastAPI(title="Spotify MCP Server", version="1.0.0")
801
  app.mount("/gradio_api/mcp", mcp.sse_app("/gradio_api/mcp"))
802
 
 
806
  return {
807
  "service": "spotify-mcp-server",
808
  "auth_login_url": "/auth/login",
809
+ "auth_reset_url": "/auth/reset",
810
  "auth_status_url": "/auth/status",
811
  "mcp_sse_path": "/gradio_api/mcp/sse",
812
  "public_mcp_sse_url": MCP_SSE_URL,
 
823
  return _token_status()
824
 
825
 
826
+ @app.get("/auth/reset")
827
+ def auth_reset() -> dict[str, Any]:
828
+ removed = False
829
+ if SPOTIFY_TOKEN_FILE.exists():
830
+ try:
831
+ SPOTIFY_TOKEN_FILE.unlink(missing_ok=True)
832
+ removed = True
833
+ except OSError as exc:
834
+ raise HTTPException(
835
+ status_code=500,
836
+ detail=f"Could not remove token file: {exc}",
837
+ ) from exc
838
+ return {"reset": True, "removed_token_file": removed}
839
+
840
+
841
  @app.get("/auth/login")
842
+ def auth_login(force: bool = Query(default=True)) -> RedirectResponse:
843
  try:
844
  state = _new_oauth_state()
845
+ url = _build_auth_url(state, show_dialog=force)
846
  except SpotifyAuthError as exc:
847
  raise HTTPException(status_code=500, detail=str(exc)) from exc
848
  return RedirectResponse(url=url, status_code=307)