Skill: codeaholicman/spotify-playlist (version 1.0.0)

--- name: spotify-playlist description: Build and manage Spotify playlists from natural language requests. Search tracks/artists/albums, create playlists, manage tracks, view listening history. Use when the user asks to create a playlist, find music, check what they've been listening to, or any Spotify-related request. Examples - "make me a playlist for a rainy Sunday", "what have I been listening to lately", "find songs like Bonobo". --- # Spotify Playlist Builder Build playlists from natural language using the Spotify Web API. Updated for the February 2026 API changes (Dev Mode). ## Prerequisites - Spotify Premium account (required for Dev Mode apps since Feb 2026) - Python 3 with `requests` library ## Setup 1. Create an app at https://developer.spotify.com/dashboard 2. Set redirect URI to `http://127.0.0.1:8765/callback` (must be `127.0.0.1`, not `localhost` - Spotify blocks `localhost`) 3. Run auth: ```bash python3 scripts/auth.py --client-id <ID> --client-secret <SECRET> ``` 4. Open the printed URL in a browser and authorize 5. If the callback page loads automatically, done. Otherwise copy the full redirect URL and run: ```bash python3 scripts/auth.py --client-id <ID> --client-secret <SECRET> --code-url "<FULL_REDIRECT_URL>" ``` Tokens are saved to `~/.openclaw/workspace/config/.spotify-tokens.json` and auto-refresh on 401. ## API Script Reference All commands in `scripts/spotify.py`. All output JSON. ```bash # Search (tracks, artists, or albums) python3 scripts/spotify.py search "bohemian rhapsody" --limit 5 python3 scripts/spotify.py search "Bonobo" --type artist --limit 3 python3 scripts/spotify.py search "Moon Safari" --type album --limit 3 # Create playlist python3 scripts/spotify.py create "Rainy Sunday" --description "Chill vibes" --private # Add/remove tracks (by Spotify URI) python3 scripts/spotify.py add <playlist_id> spotify:track:xxx spotify:track:yyy python3 scripts/spotify.py remove <playlist_id> spotify:track:xxx # List playlists python3 scripts/spotify.py my-playlists --limit 10 # View playlist contents python3 scripts/spotify.py playlist-tracks <playlist_id> --limit 50 # Listening history python3 scripts/spotify.py top-tracks --time-range short --limit 20 python3 scripts/spotify.py recently-played --limit 20 # Profile python3 scripts/spotify.py me ``` ## Playlist Building Workflow When a user asks for a playlist: 1. **Interpret the request** - extract mood, genre, era, activity, or specific artists/songs 2. **Search for tracks** - use multiple targeted searches to find tracks matching the vibe 3. **Use listening history** - check `top-tracks` and `recently-played` to personalize if relevant 4. **Create and populate** - create the playlist with a creative name and description, add 15-30 tracks 5. **Share the link** - return the Spotify URL ### Tips - Run multiple searches with different angles (artist names, genre keywords, era + mood combos) - Mix well-known tracks with deeper cuts for a good playlist feel - `top-tracks --time-range short` shows what the user's been into recently (last 4 weeks) - `top-tracks --time-range long` shows all-time favourites - Aim for 15-30 tracks unless the user specifies otherwise - Name playlists creatively based on the vibe, not just the literal request ## Feb 2026 API Limitations (Dev Mode) - **Recommendations endpoint removed** - build playlists via search + curation instead - **No popularity field** on some responses - may return 0 - **Playlist endpoints renamed** from `/tracks` to `/items` (already handled in scripts) - **Premium required** for the app owner - **Max 5 users** per app, 1 app per developer - **No batch endpoints** for albums/artists/tracks (use individual lookups) ## Cron Ideas Combine with OpenClaw cron for automated playlists: - **Weekly refresh** - build a new playlist every Monday based on a rotating theme - **Time-of-day playlists** - morning focus, afternoon energy, evening wind-down - **Commute playlist** - auto-build a playlist matched to journey duration - **Weather-reactive** - check weather, adjust playlist mood accordingly

path: scripts/auth.py
content: #!/usr/bin/env python3
"""
Spotify OAuth Authorization Flow.
Supports both local callback server and manual code exchange.

Usage:
  1. Start auth:  python3 auth.py --client-id <ID> --client-secret <SECRET>
  2. Exchange code: python3 auth.py --client-id <ID> --client-secret <SECRET> --code-url <URL>
  3. Refresh [REDACTED_SECRET] auth.py --refresh

Tokens stored in ~/.openclaw/workspace/config/.spotify-tokens.json
"""

import argparse
import json
import http.server
import urllib.parse
import requests
import sys
import os
import base64

TOKEN_PATH = os.path.expanduser("~/.openclaw/workspace/config/.spotify-tokens.json")
REDIRECT_URI = "http://127.0.0.1:8765/callback"
SCOPES = (
    "playlist-modify-public playlist-modify-private playlist-read-private "
    "user-library-read user-read-recently-played user-top-read"
)


def save_tokens(data):
    os.makedirs(os.path.dirname(TOKEN_PATH), exist_ok=True)
    existing = {}
    if os.path.exists(TOKEN_PATH):
        with open(TOKEN_PATH) as f:
            existing = json.load(f)
    existing.update(data)
    with open(TOKEN_PATH, "w") as f:
        json.dump(existing, f, indent=2)
    os.chmod(TOKEN_PATH, 0o600)
    print(f"Tokens saved to {TOKEN_PATH}")


def load_tokens():
    if not os.path.exists(TOKEN_PATH):
        print("No tokens found. Run auth.py with --client-id and --client-secret first.", file=sys.stderr)
        sys.exit(1)
    with open(TOKEN_PATH) as f:
        return json.load(f)


def exchange_code(code, client_id, client_secret):
    auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
    resp = requests.post("https://accounts.spotify.com/api/token", data={
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": REDIRECT_URI,
    }, headers={"Authorization": f"Basic {auth_header}"})
    resp.raise_for_status()
    tokens = resp.json()
    save_tokens({
        "access_token": tokens["access_token"],
        "refresh_token": tokens.get("refresh_token"),
        "client_id": client_id,
        "client_secret": client_secret,
    })
    print("Authorization successful!")


def refresh_token():
    tokens = load_tokens()
    if not tokens.get("refresh_token"):
        print("No refresh token found. Re-authorize.", file=sys.stderr)
        sys.exit(1)
    auth_header = base64.b64encode(
        f"{tokens['client_id']}:{tokens['client_secret']}".encode()
    ).decode()
    resp = requests.post("https://accounts.spotify.com/api/token", data={
        "grant_type": "refresh_token",
        "refresh_token": tokens["refresh_token"],
    }, headers={"Authorization": f"Basic {auth_header}"})
    resp.raise_for_status()
    new_tokens = resp.json()
    save_tokens({
        "access_token": new_tokens["access_token"],
        "refresh_token": new_tokens.get("refresh_token", tokens["refresh_token"]),
    })
    print("Token refreshed successfully!")
    print(json.dumps({"access_token": new_tokens["access_token"]}))


def get_auth_url(client_id):
    return (
        "https://accounts.spotify.com/authorize?"
        + urllib.parse.urlencode({
            "client_id": client_id,
            "response_type": "code",
            "redirect_uri": REDIRECT_URI,
            "scope": SCOPES,
            "show_dialog": "true",
        })
    )


def authorize(client_id, client_secret, code=None):
    if code:
        exchange_code(code, client_id, client_secret)
        return

    auth_url = get_auth_url(client_id)
    print(f"\nOpen this URL to authorize:\n\n{auth_url}\n")
    print("After authorizing, you'll be redirected to a URL like:")
    print("  http://127.0.0.1:8765/callback?code=AQ...")
    print("\nIf the page loads, you're done. If it doesn't load,")
    print("copy the full URL and re-run with --code-url <URL>\n")

    # Try local callback server
    code_holder = {}

    class Handler(http.server.BaseHTTPRequestHandler):
        def do_GET(self):
            query = urllib.parse.urlparse(self.path).query
            params = urllib.parse.parse_qs(query)
            if "code" in params:
                code_holder["code"] = params["code"][0]
                self.send_response(200)
                self.send_header("Content-Type", "text/html")
                self.end_headers()
                self.wfile.write(
                    b"<html><body><h2>Authorization successful!</h2>"
                    b"<p>You can close this tab.</p></body></html>"
                )
            else:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b"Authorization failed - no code received.")

        def log_message(self, *args):
            pass

    try:
        server = http.server.HTTPServer(("127.0.0.1", 8765), Handler)
        print("Listening on http://127.0.0.1:8765 for callback...\n")
        server.timeout = 300
        server.handle_request()
        server.server_close()
        if "code" in code_holder:
            exchange_code(code_holder["code"], client_id, client_secret)
        else:
            print("No code received. Use --code-url to exchange manually.")
    except OSError as e:
        print(f"Could not start local server ({e}). Use --code-url to exchange manually.")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Spotify OAuth")
    parser.add_argument("--client-id", help="Spotify Client ID")
    parser.add_argument("--client-secret", help="Spotify Client Secret")
    parser.add_argument("--refresh", action="store_true", help="Refresh access token")
    parser.add_argument("--code", help="Authorization code (extracted from URL)")
    parser.add_argument("--code-url", help="Full callback URL (code extracted automatically)")
    args = parser.parse_args()

    code = args.code
    if args.code_url:
        parsed = urllib.parse.urlparse(args.code_url)
        params = urllib.parse.parse_qs(parsed.query)
        code = params.get("code", [None])[0]
        if not code:
            print("Could not extract code from URL.", file=sys.stderr)
            sys.exit(1)

    if args.refresh:
        refresh_token()
    elif args.client_id and args.client_secret:
        authorize(args.client_id, args.client_secret, code=code)
    else:
        parser.print_help()
        sys.exit(1)


path: scripts/spotify.py
content: #!/usr/bin/env python3
"""
Spotify API client for playlist management.
Updated for February 2026 Web API changes (Dev Mode).

Commands:
  search <query> [--type track|artist|album] [--limit N]
  create <name> [--description DESC] [--private]
  add <playlist_id> <track_uris...>
  remove <playlist_id> <track_uris...>
  my-playlists [--limit N]
  playlist-tracks <playlist_id> [--limit N]
  top-tracks [--time-range short|medium|long] [--limit N]
  recently-played [--limit N]
  me

All commands output JSON for easy parsing by agents.
"""

import argparse
import json
import sys
import os
import subprocess

TOKEN_PATH = os.path.expanduser("~/.openclaw/workspace/config/.spotify-tokens.json")
SKILL_DIR = os.path.dirname(os.path.abspath(__file__))
API_BASE = "https://api.spotify.com/v1"


def get_token():
    if not os.path.exists(TOKEN_PATH):
        print(json.dumps({"error": "Not authenticated. Run auth.py first."}))
        sys.exit(1)
    with open(TOKEN_PATH) as f:
        tokens = json.load(f)
    return tokens.get("access_token")


def refresh_and_retry():
    """Refresh token and return new access token."""
    try:
        subprocess.run(
            [sys.executable, os.path.join(SKILL_DIR, "auth.py"), "--refresh"],
            capture_output=True, check=True
        )
        with open(TOKEN_PATH) as f:
            return json.load(f).get("access_token")
    except Exception:
        return None


def api(method, path, token, data=None, params=None):
    import requests
    url = f"{API_BASE}{path}" if path.startswith("/") else path
    headers = {"Authorization": f"Bearer {token}"}
    if data:
        headers["Content-Type"] = "application/json"

    resp = requests.request(method, url, headers=headers, json=data, params=params)

    # Auto-refresh on 401
    if resp.status_code == 401:
        new_token = refresh_and_retry()
        if new_token:
            headers["Authorization"] = f"Bearer {new_token}"
            resp = requests.request(method, url, headers=headers, json=data, params=params)

    if resp.status_code == 204:
        return {}

    if not resp.ok:
        try:
            err = resp.json()
        except Exception:
            err = {"status": resp.status_code, "message": resp.text}
        print(json.dumps({"error": err}, indent=2))
        sys.exit(1)

    return resp.json()


# --- Commands ---

def cmd_search(args):
    [REDACTED_SECRET])
    params = {"q": args.query, "type": args.type, "limit": args.limit}
    result = api("GET", "/search", token, params=params)

    if args.type == "track":
        items = []
        for t in result.get("tracks", {}).get("items", []):
            items.append({
                "name": t["name"],
                "artist": ", ".join(a["name"] for a in t["artists"]),
                "album": t.get("album", {}).get("name", ""),
                "uri": t["uri"],
                "duration_ms": t.get("duration_ms", 0),
                "popularity": t.get("popularity", 0),
            })
        print(json.dumps(items, indent=2))
    elif args.type == "artist":
        items = []
        for a in result.get("artists", {}).get("items", []):
            items.append({
                "name": a["name"],
                "uri": a["uri"],
                "genres": a.get("genres", []),
                "popularity": a.get("popularity", 0),
                "followers": a.get("followers", {}).get("total", 0),
            })
        print(json.dumps(items, indent=2))
    elif args.type == "album":
        items = []
        for a in result.get("albums", {}).get("items", []):
            items.append({
                "name": a["name"],
                "artist": ", ".join(ar["name"] for ar in a["artists"]),
                "uri": a["uri"],
                "release_date": a.get("release_date", ""),
                "total_tracks": a.get("total_tracks", 0),
            })
        print(json.dumps(items, indent=2))
    else:
        print(json.dumps(result, indent=2))


def cmd_create(args):
    [REDACTED_SECRET])
    data = {
        "name": args.name,
        "description": args.description or "",
        "public": not args.private,
    }
    result = api("POST", "/me/playlists", token, data=data)
    print(json.dumps({
        "id": result["id"],
        "name": result["name"],
        "url": result["external_urls"]["spotify"],
        "uri": result["uri"],
    }, indent=2))


def cmd_add(args):
    [REDACTED_SECRET])
    uris = args.track_uris
    for i in range(0, len(uris), 100):
        batch = uris[i:i + 100]
        api("POST", f"/playlists/{args.playlist_id}/items", token, data={"uris": batch})
    print(json.dumps({"added": len(uris), "playlist_id": args.playlist_id}))


def cmd_remove(args):
    [REDACTED_SECRET])
    items = [{"uri": uri} for uri in args.track_uris]
    for i in range(0, len(items), 100):
        batch = items[i:i + 100]
        api("DELETE", f"/playlists/{args.playlist_id}/items", token, data={"tracks": batch})
    print(json.dumps({"removed": len(items), "playlist_id": args.playlist_id}))


def cmd_my_playlists(args):
    [REDACTED_SECRET])
    result = api("GET", "/me/playlists", token, params={"limit": args.limit})
    playlists = []
    for p in result.get("items", []):
        playlists.append({
            "id": p["id"],
            "name": p["name"],
            "tracks": p["tracks"]["total"],
            "url": p["external_urls"]["spotify"],
        })
    print(json.dumps(playlists, indent=2))


def cmd_playlist_tracks(args):
    [REDACTED_SECRET])
    result = api("GET", f"/playlists/{args.playlist_id}/items", token, params={"limit": args.limit})
    tracks = []
    for item in result.get("items", []):
        t = item.get("track")
        if not t:
            continue
        tracks.append({
            "name": t["name"],
            "artist": ", ".join(a["name"] for a in t.get("artists", [])),
            "album": t.get("album", {}).get("name", ""),
            "uri": t["uri"],
            "duration_ms": t.get("duration_ms", 0),
        })
    print(json.dumps(tracks, indent=2))


def cmd_top_tracks(args):
    [REDACTED_SECRET])
    params = {"time_range": args.time_range + "_term", "limit": args.limit}
    result = api("GET", "/me/top/tracks", token, params=params)
    tracks = []
    for t in result.get("items", []):
        tracks.append({
            "name": t["name"],
            "artist": ", ".join(a["name"] for a in t["artists"]),
            "uri": t["uri"],
            "popularity": t.get("popularity", 0),
        })
    print(json.dumps(tracks, indent=2))


def cmd_recently_played(args):
    [REDACTED_SECRET])
    result = api("GET", "/me/player/recently-played", token, params={"limit": args.limit})
    tracks = []
    for item in result.get("items", []):
        t = item.get("track", {})
        tracks.append({
            "name": t["name"],
            "artist": ", ".join(a["name"] for a in t.get("artists", [])),
            "uri": t["uri"],
            "played_at": item.get("played_at", ""),
        })
    print(json.dumps(tracks, indent=2))


def cmd_me(args):
    [REDACTED_SECRET])
    result = api("GET", "/me", token)
    print(json.dumps({
        "id": result["id"],
        "display_name": result.get("display_name"),
        "email": result.get("email"),
        "country": result.get("country"),
        "product": result.get("product"),
    }, indent=2))


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Spotify Playlist Manager")
    sub = parser.add_subparsers(dest="command")

    p_search = sub.add_parser("search")
    p_search.add_argument("query")
    p_search.add_argument("--type", choices=["track", "artist", "album"], default="track")
    p_search.add_argument("--limit", type=int, default=20)
    p_search.set_defaults(func=cmd_search)

    p_create = sub.add_parser("create")
    p_create.add_argument("name")
    p_create.add_argument("--description", default="")
    p_create.add_argument("--private", action="store_true")
    p_create.set_defaults(func=cmd_create)

    p_add = sub.add_parser("add")
    p_add.add_argument("playlist_id")
    p_add.add_argument("track_uris", nargs="+")
    p_add.set_defaults(func=cmd_add)

    p_remove = sub.add_parser("remove")
    p_remove.add_argument("playlist_id")
    p_remove.add_argument("track_uris", nargs="+")
    p_remove.set_defaults(func=cmd_remove)

    p_playlists = sub.add_parser("my-playlists")
    p_playlists.add_argument("--limit", type=int, default=20)
    p_playlists.set_defaults(func=cmd_my_playlists)

    p_ptracks = sub.add_parser("playlist-tracks")
    p_ptracks.add_argument("playlist_id")
    p_ptracks.add_argument("--limit", type=int, default=50)
    p_ptracks.set_defaults(func=cmd_playlist_tracks)

    p_top = sub.add_parser("top-tracks")
    p_top.add_argument("--time-range", choices=["short", "medium", "long"], default="medium")
    p_top.add_argument("--limit", type=int, default=20)
    p_top.set_defaults(func=cmd_top_tracks)

    p_recent = sub.add_parser("recently-played")
    p_recent.add_argument("--limit", type=int, default=20)
    p_recent.set_defaults(func=cmd_recently_played)

    p_me = sub.add_parser("me")
    p_me.set_defaults(func=cmd_me)

    args = parser.parse_args()
    if not args.command:
        parser.print_help()
        sys.exit(1)
    args.func(args)


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)