Skill: ledzgio/spoticlaw (version 1.0.7)

--- name: spoticlaw description: "Spotify Web API client for Nyx agents. Use when interacting with Spotify: search, playback, playlists, library, tracks, artists, albums, shows, podcasts. Requires SPOTIFY_CLIENT_ID, SPOTIFY_CLIENT_SECRET, SPOTIFY_REDIRECT_URI, and a local .spotify_cache token file." homepage: https://github.com/ledzgio/spoticlaw metadata: {"clawdbot":{"emoji":"🎵","requires":{"env":["SPOTIFY_CLIENT_ID","SPOTIFY_CLIENT_SECRET","SPOTIFY_REDIRECT_URI"],"files":[".spotify_cache"]},"primaryEnv":"SPOTIFY_CLIENT_ID"}} --- # Spoticlaw - Spotify Web API Client A lightweight Spotify Web API client using direct HTTP requests. No Spotipy dependency. ## Quick Start ```bash # Install dependencies pip install requests python-dotenv # Add to path import sys sys.path.insert(0, "skills/spoticlaw/scripts") from spoticlaw import player, search, playlists, library # Search for music results = search().query("coldplay", types=["track"], limit=10) # Play a track player().play(uris=["spotify:track:..."]) # Manage playlists playlists().create("My Playlist") playlists().add_items("playlist_id", ["spotify:track:..."]) # Save to library library().save(["spotify:track:..."]) ``` Or run from the scripts directory: ```bash cd skills/spoticlaw/scripts python -c "from spoticlaw import player; player().play(...)" ``` ## Required Configuration Required env vars in agent runtime: - `SPOTIFY_CLIENT_ID` - `SPOTIFY_CLIENT_SECRET` - `SPOTIFY_REDIRECT_URI` (recommended: `http://127.0.0.1:8888/callback`) Required file: - `.spotify_cache` (OAuth token cache) ## Authentication **Security Note:** Tokens never pass through the AI model. Authentication is done locally, and the token file is copied manually to the agent. ### Setup 1. Create a Spotify app at https://developer.spotify.com/dashboard 2. Get `CLIENT_ID` and `CLIENT_SECRET` 3. Add `http://127.0.0.1:8888/callback` as Redirect URI 4. Create `.env` file in your LOCAL machine: ``` SPOTIFY_CLIENT_ID=your_client_id SPOTIFY_CLIENT_SECRET=your_client_secret SPOTIFY_REDIRECT_URI=http://127.0.0.1:8888/callback ``` 5. Run authentication on your LOCAL machine: ```bash cd skills/spoticlaw/scripts pip install -r requirements.txt python auth.py ``` 6. Open the displayed URL in your browser, authorize 7. **Copy the token file to your agent:** ```bash # Linux/Mac - copy to agent's skill folder cp .spotify_cache /path/to/agent/skills/spoticlaw/.spotify_cache # Or if agent is remote, copy via scp, USB, etc. scp .spotify_cache user@agent:/path/to/skills/spoticlaw/.spotify_cache ``` **That's it!** No token ever touches the AI. The agent just reads the file. ### Token Auto-Refresh The library automatically handles token refresh **only if the agent has the same app credentials in `.env`**: - Access token expires after ~1 hour - On first API call after expiry, it uses `refresh_token` + client credentials to request a new access token - Requires `SPOTIFY_CLIENT_ID` and `SPOTIFY_CLIENT_SECRET` in the agent environment - If `.spotify_cache` exists but `.env` is missing/mismatched, refresh fails (`invalid_client`) - If you get an error, run `python auth.py` locally again and copy updated `.spotify_cache` For more on Spotify's OAuth flow, see: https://developer.spotify.com/documentation/web-api/tutorials/code-flow ### Required Scopes The auth.py script requests these scopes: - `user-read-playback-state` - Read playback state - `user-modify-playback-state` - Control playback - `playlist-read-private` - Read private playlists - `playlist-modify-public` - Modify public playlists - `playlist-modify-private` - Modify private playlists - `user-library-read` - Read user library - `user-library-modify` - Modify user library - `user-read-recently-played` - Recently played tracks - `user-top-read` - Top tracks/artists - `user-follow-read` - Followed artists --- ## Primitives **Important:** Add the module path and install deps first: ```bash pip install requests python-dotenv # Install dependencies # For agent execution, add to path: import sys sys.path.insert(0, "skills/spoticlaw/scripts") ``` ### User ```python from spoticlaw import user user().me() # Get current user profile ``` Returns: `{id, display_name, email, country, ...}` --- ### Search ```python from spoticlaw import search # Search for tracks search().query("song name", types=["track"], limit=10) # Search for artists search().query("artist name", types=["artist"], limit=10) # Search multiple types search().query("coldplay", types=["track", "artist", "album"], limit=10) ``` **Parameters:** - `q`: Search query string - `types`: List of types: `track`, `artist`, `album`, `playlist`, `show`, `episode`, `audiobook` - `limit`: Max 10 results (Spotify limit) - `offset`: Pagination offset --- ### Tracks ```python from spoticlaw import tracks tracks().get("track_id") # Get single track tracks().get_multiple(["id1", "id2"]) # Get multiple tracks ``` Returns track metadata: `{name, artists, album, duration_ms, uri, ...}` --- ### Artists ```python from spoticlaw import artists artists().get("artist_id") # Get artist details artists().get_albums("artist_id", limit=10) # Get artist albums ``` **Album filters (include_groups):** - `album`, `single`, `compilation`, `appears_on` --- ### Albums ```python from spoticlaw import albums albums().get("album_id") # Get album details albums().get_tracks("album_id") # Get album tracks ``` --- ### Shows (Podcasts) ```python from spoticlaw import shows shows().get("show_id") # Get show details shows().get_episodes("show_id", limit=10) # Get show episodes ``` --- ### Episodes ```python from spoticlaw import episodes episodes().get("episode_id") # Get episode details ``` --- ### Playlists ```python from spoticlaw import playlists, user_playlists # Get user's playlists user_playlists().get(limit=50) # Get playlist details playlists().get("playlist_id") # Get playlist tracks # Note: Each item has 'item' key (not 'track'), e.g. item['item']['name'] # Also can contain episodes (podcasts), not just tracks playlists().get_items("playlist_id", limit=50) # Create playlist playlists().create(name="My Playlist", description="...", public=False) # Update playlist playlists().update("playlist_id", name="New Name") # Add tracks playlists().add_items("playlist_id", ["spotify:track:...", "spotify:track:..."]) # Remove tracks playlists().remove_items("playlist_id", ["spotify:track:..."]) # Delete playlist (unfollow) playlists().delete("playlist_id") ``` --- ### Library ```python from spoticlaw import library library().save(["spotify:track:..."]) # Save to library library().remove(["spotify:track:..."]) # Remove from library library().check(["spotify:track:..."]) # Check if saved, returns [True, False] ``` --- ### Player ```python from spoticlaw import player # Get playback state player().get_playback_state() # Returns {} if nothing playing player().get_currently_playing() # Get devices player().get_devices() # Returns list of devices with IDs # Transfer playback to device player().transfer("device_id", play=True) # Playback control player().play(uris=["spotify:track:..."]) # Start playing player().play(context_uri="spotify:album:...") # Play album/playlist player().pause() player().next() player().previous() player().seek(60000) # Seek to 1:00 (milliseconds) player().set_volume(80) # Set volume 0-100 # Queue player().add_to_queue("spotify:track:...") player().get_queue() # Modes player().set_shuffle(True) player().set_repeat("off") # off, track, context # Recently played player().get_recently_played(limit=50) ``` --- ### Personalisation ```python from spoticlaw import personalisation personalisation().get_top("tracks", time_range="medium_term", limit=20) personalisation().get_top("artists", time_range="long_term", limit=20) # time_range: short_term (4 weeks), medium_term (6 months), long_term (years) ``` --- ### Follow ```python from spoticlaw import follow follow().get_followed(limit=50) # Get followed artists ``` --- ## Composite Workflows The primitives can be mixed and matched to create powerful automations. Here are practical examples: ### Workflow 1: Play a Specific Song ```python from spoticlaw import search, player # 1. Search for the song results = search().query("stairway to heaven", types=["track"], limit=5) song = results["tracks"]["items"][0] song_uri = song["uri"] print(f"Playing: {song['name']} by {song['artists'][0]['name']}") # 2. Play it player().play(uris=[song_uri]) ``` ### Workflow 2: Create Playlist from Search Results ```python from spoticlaw import search, playlists # 1. Search for songs results = search().query("led zeppelin", types=["track"], limit=10) track_uris = [t["uri"] for t in results["tracks"]["items"][:5]] # 2. Create playlist pl = playlists().create("Led Zeppelin Mix", public=False) playlist_id = pl["id"] # 3. Add tracks playlists().add_items(playlist_id, track_uris) print(f"Created playlist: {pl['name']}") ``` ### Workflow 3: Save Album to Library ```python from spoticlaw import artists, albums, library # 1. Find artist artist = search().query("the weeknd", types=["artist"], limit=1)["artists"]["items"][0] # 2. Get albums albums_list = artists().get_albums(artist["id"], include_groups="album", limit=5) # 3. Save first album album = albums_list["items"][0] library().save([album["uri"]]) print(f"Saved album: {album['name']}") ``` ### Workflow 4: Play Podcast Episode ```python from spoticlaw import search, shows, player # 1. Find podcast podcast = search().query("joe rogan", types=["show"], limit=1)["shows"]["items"][0] show_id = podcast["id"] # 2. Get latest episode episodes = shows().get_episodes(show_id, limit=1) episode = episodes["items"][0] episode_uri = episode["uri"] # 3. Get device and play devices = player().get_devices() if devices.get("devices"): device_id = devices["devices"][0]["id"] player().transfer(device_id, play=True) player().play(uris=[episode_uri]) print(f"Playing: {episode['name']}") ``` ### Workflow 5: Transfer Playback and Play ```python from spoticlaw import player, search # 1. Get available devices devices = player().get_devices() print("Available devices:", [d["name"] for d in devices.get("devices", [])]) # 2. Transfer to phone if devices.get("devices"): device_id = devices["devices"][0]["id"] player().transfer(device_id, play=True) # 3. Search and play results = search().query("dream on", types=["track"], limit=1) track_uri = results["tracks"]["items"][0]["uri"] player().play(uris=[track_uri]) ``` ### Workflow 6: Get User's Top Artists and Follow One ```python from spoticlaw import personalisation, search, library # 1. Get top artists top = personalisation().get_top("artists", limit=10) print("Your top artists:") for i, a in enumerate(top["items"], 1): print(f" {i}. {a['name']}") # 2. Search for a new artist new_artist = search().query("tame impala", types=["artist"], limit=1)["artists"]["items"][0] print(f"\nFound: {new_artist['name']}") # Note: Follow functionality requires additional scope (not in current scope list) # Use Spotify app to follow manually ``` ### Workflow 7: Build Queue from Album ```python from spoticlaw import albums, player # 1. Get album tracks album_id = "4aawyAB9vmqN3uQ7FjRGTy" # Example album tracks = albums().get_tracks(album_id) # 2. Add all tracks to queue for track in tracks["items"][:5]: # First 5 tracks player().add_to_queue(track["uri"]) print("Added 5 tracks to queue") ``` ### Workflow 8: Check Library for Multiple Tracks ```python from spoticlaw import library, search # 1. Search for tracks results = search().query("classic rock", types=["track"], limit=20) track_uris = [t["uri"] for t in results["tracks"]["items"]] # 2. Check which are in library saved = library().check(track_uris) # 3. Show results for i, (track, is_saved) in enumerate(zip(results["tracks"]["items"], saved)): status = "✓ saved" if is_saved else "○ not saved" print(f"{i+1}. {track['name']} - {status}") ``` ### Workflow 9: Get Recently Played and Save One ```python from spoticlaw import player, library # 1. Get recently played recent = player().get_recently_played(limit=10) print("Recently played:") for i, item in enumerate(recent["items"], 1): track = item["track"] print(f" {i}. {track['name']} - {track['artists'][0]['name']}") # 2. Save the first one if recent["items"]: track_uri = recent["items"][0]["track"]["uri"] library().save([track_uri]) print(f"\nSaved: {recent['items'][0]['track']['name']}") ``` ### Workflow 10: Play from Playlist ```python from spoticlaw import playlists, player # 1. Get user's playlists my_playlists = user_playlists().get(limit=10) print("Your playlists:") for p in my_playlists["items"]: print(f" - {p['name']} ({p['tracks']['total']} tracks)") # 2. Pick first playlist and play if my_playlists["items"]: playlist_id = my_playlists["items"][0]["id"] # Get device first devices = player().get_devices() if devices.get("devices"): player().transfer(devices["devices"][0]["id"], play=True) # Play playlist player().play(context_uri=f"spotify:playlist:{playlist_id}") print(f"Playing: {my_playlists['items'][0]['name']}") ``` --- ## Error Handling ```python from spoticlaw import player, SpotifyException try: player().play(uris=["spotify:track:..."]) except SpotifyException as e: if "NO_ACTIVE_DEVICE" in str(e): print("No device found. Open Spotify and try again.") elif "Invalid token" in str(e): print("Token expired. Re-authenticate: python auth.py") else: print(f"Error: {e}") ``` --- ## Common Issues | Error | Solution | |-------|----------| | `No token. Re-authenticate.` | Run `python auth.py` | | `The access token expired` | Should auto-refresh. If not, run `python auth.py` | | `Insufficient client scope` | Re-auth with more scopes | | `NO_ACTIVE_DEVICE` | Open Spotify app, then retry | | `Invalid limit` | Use max 10 for search, 50 for playlists | | `Resource not found` | Invalid ID or item unavailable | --- ## API Limits - **Search:** max 10 results - **Playlist items:** max 50 - **Pagination:** Use `offset` parameter - **Player:** Requires active Spotify session --- ## Files - `scripts/spoticlaw.py` - Main API client - `scripts/auth.py` - Authentication helper - `scripts/requirements.txt` - Dependencies --- ## More Info For setup, troubleshooting, and contributions: https://github.com/your-org/spoticlaw

path: README.md
content: # Spoticlaw
<p align="center">
  <img src="assets/logo.jpg" alt="SpotiClaw logo" width="400">
</p>

<p align="center"><b>Lightweight Spotify Web API client for AI agents.</b></p>

## SpotiClaw Features

- **No external dependencies** (uses direct HTTP requests)
- **Automatic token refresh** — authenticate once, works forever
- **Composable primitives** — mix and match to build complex workflows
- Full Spotify Web API coverage
- Lightweight and fast

## Composable Primitives

Combine primitives to create powerful workflows:

```python
# Example: Find an artist → get their albums → create playlist → add top tracks
artist = search().query("metallica", types=["artist"])[0]
albums = artists().get_albums(artist["id"], limit=10)
track_uris = [albums["items"][0]["tracks"]["items"][0]["uri"]]
pl = playlists().create(f"{artist['name']} Mix")
playlists().add_items(pl["id"], track_uris)
```

The primitives are designed to be combined in endless ways for any Spotify automation task.

## Quick Start

Clone and install:

**Manual step-by-step:**

1. Install dependencies (recommended: use virtual environment):
```bash
cd skills/spoticlaw

# Create venv (recommended)
python -m venv .venv
source .venv/bin/activate  # Linux/Mac
# .venv\Scripts\activate   # Windows

# Install dependencies
pip install -r scripts/requirements.txt
```

2. Configure Spotify credentials:
```bash
cp .env.example .env
# Edit .env with your Spotify app credentials
```

3. Get credentials from https://developer.spotify.com/dashboard
   - Create a new app
   - Get `Client ID` and `Client Secret`
   - Add `http://127.0.0.1:8888/callback` as Redirect URI in app settings

4. Authenticate (run on your local machine/desktop):
```bash
python scripts/auth.py
```

5. Copy the token file to the skill folder:

**Important:** The token file never passes through the AI - it's copied manually for security.

```bash
# If running locally (same machine):
cp .spotify_cache /path/to/skills/spoticlaw/.spotify_cache

# If running on a remote server, do auth first on your desktop, then copy via scp, USB, etc.
```

**That's it!** Token auto-refreshes — no need to re-authenticate.

## Quick Usage

```python
import sys
sys.path.insert(0, "skills/spoticlaw/scripts")

from spoticlaw import player, search, playlists, library

# Search
search().query("coldplay", types=["track"], limit=10)

# Play
player().play(uris=["spotify:track:..."])

# Playlists
playlists().create("My Playlist")
playlists().add_items("playlist_id", ["spotify:track:..."])

# Library
library().save(["spotify:track:..."])
```

## Token Refresh

The library automatically refreshes your access token when it expires (~60 minutes), **but only if the agent has matching app credentials** (`SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`) in `.env`.

If `.spotify_cache` exists but `.env` is missing or mismatched, refresh will fail with `invalid_client`.

If you get a token error, re-run locally:
```bash
python scripts/auth.py
```
and copy the updated `.spotify_cache` to the agent skill folder.

## Requirements

- Python 3.8+
- requests
- python-dotenv

## License

MIT


path: scripts/__init__.py
content: """Spoticlaw package."""

from .spoticlaw import (
    SpotifyException,
    user,
    library,
    playlists,
    user_playlists,
    player,
    search,
    tracks,
    artists,
    albums,
    shows,
    episodes,
    follow,
    personalisation,
)

__all__ = [
    "SpotifyException",
    "user",
    "library",
    "playlists",
    "user_playlists",
    "player",
    "search",
    "tracks",
    "artists",
    "albums",
    "shows",
    "episodes",
    "follow",
    "personalisation",
]


path: scripts/auth.py
content: #!/usr/bin/env python3
"""
Spotify Authentication Helper (Manual Token Transfer Mode)

This script generates an authorization URL. After user authorizes,
the token is saved locally. User then copies .spotify_cache to the agent.

This keeps tokens secure - never passes through AI model.

Usage:
    1. Run: python auth.py
    2. Open the displayed URL in browser
    3. Authorize the app
    4. Copy .spotify_cache file to your agent's folder:
       - Linux/Mac: cp .spotify_cache /path/to/agent/skills/spoticlaw/
       - Windows: copy .spotify_cache C:\path\to\agent\skills\spoticlaw\

For remote agents: run auth locally, then copy the cache file over.
"""

import os
import json
import socket
import webbrowser
from urllib.parse import urlencode
from http.server import HTTPServer, BaseHTTPRequestHandler
from dotenv import load_dotenv

# Load config
load_dotenv()

CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET", "")
REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI", "http://127.0.0.1:8888/callback")

# OAuth scopes (space-separated)
SCOPES = [
    "user-read-playback-state",
    "user-modify-playback-state",
    "playlist-read-private",
    "playlist-modify-public",
    "playlist-modify-private",
    "user-library-read",
    "user-library-modify",
    "user-read-recently-played",
    "user-top-read",
    "user-follow-read",
]

SCOPE = " ".join(SCOPES)


class AuthHandler(BaseHTTPRequestHandler):
    """Handle OAuth callback."""
    
    def do_GET(self):
        if "/callback" in self.path:
            # Parse code from query
            query = self.path.split("?")[1] if "?" in self.path else ""
            params = dict(p.split("=") for p in query.split("&") if "=" in p)
            
            if "code" in params:
                self.send_response(200)
                self.send_header("Content-Type", "text/html")
                self.end_headers()
                self.wfile.write(b"<h1>Authentication successful!</h1><p>You can close this window.</p>")
                global auth_code
                auth_code = params["code"]
            else:
                self.send_response(400)
                self.end_headers()
                self.wfile.write(b"<h1>Authentication failed.</h1>")
        else:
            self.send_response(404)
            self.end_headers()
    
    def log_message(self, format, *args):
        pass  # Suppress logging


def get_token():
    """Exchange code for token."""
    import requests
    
    # Start local server
    server = HTTPServer(("localhost", 8888), AuthHandler)
    
    # Build auth URL
    auth_url = "https://accounts.spotify.com/authorize?" + urlencode({
        "client_id": CLIENT_ID,
        "response_type": "code",
        "redirect_uri": REDIRECT_URI,
        "scope": SCOPE,
    })
    
    print(f"\nOpening browser for authentication...")
    print(f"If browser doesn't open, go to:\n{auth_url}\n")
    webbrowser.open(auth_url)
    
    # Wait for callback
    print("Waiting for callback...")
    server.handle_request()
    
    if not auth_code:
        print("No auth code received.")
        return
    
    # Exchange for token
    print("Exchanging code for token...")
    resp = requests.post(
        "https://accounts.spotify.com/api/token",
        data={
            "grant_type": "authorization_code",
            "code": auth_code,
            "redirect_uri": REDIRECT_URI,
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
        }
    )
    
    if resp.status_code == 200:
        [REDACTED_SECRET])
        # Calculate expires_at timestamp
        import time
        token["expires_at"] = time.time() + token.get("expires_in", 3600)
        
        # Save to cache
        cache_file = ".spotify_cache"
        with open(cache_file, "w") as f:
            json.dump(token, f)
        print(f"Token saved to {cache_file}")
        print(f"Scopes: {token.get('scope', '')}")
        print(f"Expires in: {token.get('expires_in')} seconds")
    else:
        print(f"Error: {resp.text}")


if __name__ == "__main__":
    if not CLIENT_ID or not CLIENT_SECRET:
        print("Error: SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET not set in .env")
        print("See: https://github.com/your-repo/spoticlaw#setup")
    else:
        get_token()


path: scripts/requirements.txt
content: requests
python-dotenv


path: scripts/spoticlaw.py
content: """
Spoticlaw v2 - Spotify Web API Client using direct HTTP requests.

Implements all endpoints available as of Spotify's February 2026 API changes.
Reference: https://developer.spotify.com/documentation/web-api
"""

import os
import json
import time
from pathlib import Path
from functools import lru_cache
from typing import Any, Optional

import requests
from dotenv import load_dotenv

# Load .env
_env_path = Path(__file__).parent.parent / ".env"
load_dotenv(_env_path)

# Configuration
CLIENT_ID = os.getenv("SPOTIFY_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("SPOTIFY_CLIENT_SECRET", "")
REDIRECT_URI = os.getenv("SPOTIFY_REDIRECT_URI", "http://127.0.0.1:8888/callback")
CACHE_PATH = os.getenv("SPOTIFY_CACHE_PATH", ".spotify_cache")

BASE_URL = "https://api.spotify.com/v1"


class SpotifyException(Exception):
    """Spotify API exception."""
    pass


# ============================================================================
# Token Management with Auto-Refresh
# ============================================================================

def _get_cache_path() -> Path:
    """Get path to cache file."""
    return Path(__file__).parent.parent / CACHE_PATH


def _load_token_data() -> dict:
    """Load token data from cache."""
    cache_file = _get_cache_path()
    if not cache_file.exists():
        raise SpotifyException("No token. Re-authenticate: run auth.py")
    with open(cache_file) as f:
        return json.load(f)


def _save_token_data(data: dict) -> None:
    """Save token data to cache."""
    cache_file = _get_cache_path()
    with open(cache_file, "w") as f:
        json.dump(data, f)


def _is_token_expired(token_data: dict) -> bool:
    """Check if token is expired."""
    expires_at = token_data.get("expires_at", 0)
    # Add 60 second buffer to be safe
    return time.time() >= (expires_at - 60)


def _refresh_token() -> str:
    """Refresh access token using refresh_token."""
    token_data = _load_token_data()
    refresh_token = token_data.get("refresh_token")
    
    if not refresh_token:
        raise SpotifyException("No refresh_token. Re-authenticate: run auth.py")
    
    # Encode client credentials
    import base64
    auth_header = base64.b64encode(f"{CLIENT_ID}:{CLIENT_SECRET}".encode()).decode()
    
    # Request new token
    resp = requests.post(
        "https://accounts.spotify.com/api/token",
        data={
            "grant_type": "refresh_token",
            "refresh_token": refresh_token,
        },
        headers={
            "Authorization": f"Basic {auth_header}",
            "Content-Type": "application/x-www-form-urlencoded",
        }
    )
    
    if resp.status_code != 200:
        raise SpotifyException(f"Token refresh failed: {resp.status_code} - {resp.text}")
    
    new_token_data = resp.json()
    
    # Keep the old refresh_token if not returned
    if "refresh_token" not in new_token_data:
        new_token_data["refresh_token"] = refresh_token
    
    # Calculate expires_at
    new_token_data["expires_at"] = time.time() + new_token_data.get("expires_in", 3600)
    
    # Save new token data
    _save_token_data(new_token_data)
    
    print("🔄 Token refreshed automatically")
    return new_token_data.get("access_token")


def _get_token() -> str:
    """Get valid access token (auto-refreshes if expired)."""
    token_data = _load_token_data()
    
    # Check if token is expired and refresh if needed
    if _is_token_expired(token_data):
        return _refresh_token()
    
    return token_data.get("access_token")


def _headers() -> dict:
    """Get headers with auth."""
    return {
        "Authorization": f"Bearer {_get_token()}",
        "Content-Type": "application/json",
    }


def get(endpoint: str, **params) -> dict:
    """GET request to Spotify API."""
    url = f"{BASE_URL}{endpoint}"
    resp = requests.get(url, headers=_headers(), params=params)
    
    # If unauthorized, try refreshing token once
    if resp.status_code == 401:
        _refresh_token()
        resp = requests.get(url, headers=_headers(), params=params)
    
    if resp.status_code == 204:
        return {}  # No content
    if resp.status_code not in (200, 201):
        raise SpotifyException(f"GET {endpoint}: {resp.status_code} - {resp.text}")
    return resp.json() if resp.text else {}


def post(endpoint: str, **payload) -> dict:
    """POST request to Spotify API."""
    url = f"{BASE_URL}{endpoint}"
    resp = requests.post(url, headers=_headers(), json=payload if payload else None)
    
    # If unauthorized, try refreshing token once
    if resp.status_code == 401:
        _refresh_token()
        resp = requests.post(url, headers=_headers(), json=payload if payload else None)
    
    if resp.status_code == 204 or resp.status_code == 200:
        return {}  # No content or empty success response
    if resp.status_code not in (200, 201):
        raise SpotifyException(f"POST {endpoint}: {resp.status_code} - {resp.text}")
    return resp.json() if resp.text else {}


def put(endpoint: str, **payload) -> dict:
    """PUT request to Spotify API."""
    url = f"{BASE_URL}{endpoint}"
    resp = requests.put(url, headers=_headers(), json=payload if payload else None)
    
    # If unauthorized, try refreshing token once
    if resp.status_code == 401:
        _refresh_token()
        resp = requests.put(url, headers=_headers(), json=payload if payload else None)
    
    if resp.status_code == 204 or resp.status_code == 200:
        return {}  # No content or empty success response
    if resp.status_code not in (200, 204):
        raise SpotifyException(f"PUT {endpoint}: {resp.status_code} - {resp.text}")
    return resp.json() if resp.text else {}


def delete(endpoint: str, **payload) -> dict:
    """DELETE request to Spotify API."""
    url = f"{BASE_URL}{endpoint}"
    resp = requests.delete(url, headers=_headers(), json=payload)
    
    # If unauthorized, try refreshing token once
    if resp.status_code == 401:
        _refresh_token()
        resp = requests.delete(url, headers=_headers(), json=payload)
    
    if resp.status_code not in (200, 204):
        raise SpotifyException(f"DELETE {endpoint}: {resp.status_code} - {resp.text}")
    return resp.json() if resp.text else {}


# ============================================================================
# User Endpoints
# ============================================================================

class User:
    """User endpoints."""
    
    @staticmethod
    def me() -> dict:
        """Get current user's profile."""
        return get("/me")


# ============================================================================
# Library Endpoints (NEW /me/library API)
# ============================================================================

class Library:
    """Library endpoints."""
    
    @staticmethod
    def save(uris: list[str]) -> dict:
        """Save items to user's library."""
        url = f"{BASE_URL}/me/library"
        resp = requests.put(url, headers=_headers(), params={"uris": ",".join(uris)})
        if resp.status_code not in (200, 204):
            raise SpotifyException(f"Library save: {resp.status_code} - {resp.text}")
        return {}
    
    @staticmethod
    def remove(uris: list[str]) -> dict:
        """Remove items from user's library."""
        url = f"{BASE_URL}/me/library"
        resp = requests.delete(url, headers=_headers(), params={"uris": ",".join(uris)})
        if resp.status_code not in (200, 204):
            raise SpotifyException(f"Library remove: {resp.status_code} - {resp.text}")
        return {}
    
    @staticmethod
    def check(uris: list[str]) -> list[bool]:
        """Check if items are in user's library."""
        url = f"{BASE_URL}/me/library/contains?uris={','.join(uris)}"
        resp = requests.get(url, headers=_headers())
        if resp.status_code != 200:
            raise SpotifyException(f"Library check: {resp.status_code} - {resp.text}")
        return resp.json()


# ============================================================================
# Playlist Endpoints
# ============================================================================

class Playlists:
    """Playlist endpoints."""
    
    @staticmethod
    def get(playlist_id: str) -> dict:
        """Get playlist details."""
        return get(f"/playlists/{playlist_id}")
    
    @staticmethod
    def get_items(playlist_id: str, limit: int = 50, offset: int = 0) -> dict:
        """Get playlist items (tracks/episodes)."""
        return get(f"/playlists/{playlist_id}/items", limit=limit, offset=offset)
    
    @staticmethod
    def get_cover(playlist_id: str) -> list[dict]:
        """Get playlist cover image."""
        return get(f"/playlists/{playlist_id}/images")
    
    @staticmethod
    def create(name: str, description: str = "", public: bool = False) -> dict:
        """Create new playlist."""
        return post("/me/playlists", name=name, description=description, public=public)
    
    @staticmethod
    def update(playlist_id: str, name: str = None, description: str = None, public: bool = None) -> dict:
        """Update playlist details."""
        payload = {}
        if name is not None:
            payload["name"] = name
        if description is not None:
            payload["description"] = description
        if public is not None:
            payload["public"] = public
        return put(f"/playlists/{playlist_id}", **payload)
    
    @staticmethod
    def add_items(playlist_id: str, uris: list[str], position: int = None) -> dict:
        """Add items to playlist."""
        payload = {"uris": uris}
        if position is not None:
            payload["position"] = position
        return post(f"/playlists/{playlist_id}/items", **payload)
    
    @staticmethod
    def remove_items(playlist_id: str, uris: list[str], snapshot_id: str = None) -> dict:
        """Remove items from playlist."""
        tracks = [{"uri": uri} for uri in uris]
        payload = {"tracks": tracks}
        if snapshot_id:
            payload["snapshot_id"] = snapshot_id
        return delete(f"/playlists/{playlist_id}/items", **payload)
    
    @staticmethod
    def update_items(playlist_id: str, range_start: int, insert_before: int, range_length: int = 1, snapshot_id: str = None) -> dict:
        """Reorder items in playlist."""
        payload = {
            "range_start": range_start,
            "insert_before": insert_before,
            "range_length": range_length
        }
        if snapshot_id:
            payload["snapshot_id"] = snapshot_id
        return put(f"/playlists/{playlist_id}/items", **payload)
    
    @staticmethod
    def replace_items(playlist_id: str, uris: list[str]) -> dict:
        """Replace all items in playlist."""
        return put(f"/playlists/{playlist_id}/items", uris=uris)
    
    @staticmethod
    def upload_cover(playlist_id: str, image_base64: str) -> dict:
        """Upload custom playlist cover image."""
        url = f"{BASE_URL}/playlists/{playlist_id}/images"
        resp = requests.put(url, headers={"Authorization": f"Bearer {_get_token()}", "Content-Type": "image/jpeg"}, data=image_base64)
        if resp.status_code not in (200, 201):
            raise SpotifyException(f"Upload cover: {resp.status_code} - {resp.text}")
        return {}


class UserPlaylists:
    """User's playlists endpoints."""
    
    @staticmethod
    def get(limit: int = 50, offset: int = 0) -> dict:
        """Get current user's playlists."""
        return get("/me/playlists", limit=limit, offset=offset)


# ============================================================================
# Player Endpoints
# ============================================================================

class Player:
    """Player endpoints."""
    
    @staticmethod
    def get_playback_state() -> dict:
        """Get playback state."""
        return get("/me/player")
    
    @staticmethod
    def get_currently_playing() -> dict:
        """Get currently playing track."""
        return get("/me/player/currently-playing")
    
    @staticmethod
    def get_devices() -> dict:
        """Get available devices."""
        return get("/me/player/devices")
    
    @staticmethod
    def get_recently_played(limit: int = 50) -> dict:
        """Get recently played tracks."""
        return get("/me/player/recently-played", limit=limit)
    
    @staticmethod
    def get_queue() -> dict:
        """Get playback queue."""
        return get("/me/player/queue")
    
    @staticmethod
    def play(device_id: str = None, context_uri: str = None, uris: list[str] = None, offset: int = 0, position_ms: int = 0) -> dict:
        """Start/resume playback."""
        payload = {}
        if context_uri:
            payload["context_uri"] = context_uri
            payload["offset"] = {"position": offset}
        if uris:
            payload["uris"] = uris
        if position_ms:
            payload["position_ms"] = position_ms
        
        params = f"?device_id={device_id}" if device_id else ""
        return put(f"/me/player/play{params}", **payload)
    
    @staticmethod
    def pause(device_id: str = None) -> dict:
        """Pause playback."""
        params = f"?device_id={device_id}" if device_id else ""
        return put(f"/me/player/pause{params}")
    
    @staticmethod
    def seek(position_ms: int, device_id: str = None) -> dict:
        """Seek to position."""
        params = f"position_ms={position_ms}"
        if device_id:
            params += f"&device_id={device_id}"
        return put(f"/me/player/seek?{params}")
    
    @staticmethod
    def set_volume(volume_percent: int, device_id: str = None) -> dict:
        """Set volume."""
        params = f"volume_percent={volume_percent}"
        if device_id:
            params += f"&device_id={device_id}"
        return put(f"/me/player/volume?{params}")
    
    @staticmethod
    def set_repeat(state: str, device_id: str = None) -> dict:
        """Set repeat mode (off, track, context)."""
        params = f"state={state}"
        if device_id:
            params += f"&device_id={device_id}"
        return put(f"/me/player/repeat?{params}")
    
    @staticmethod
    def set_shuffle(state: bool, device_id: str = None) -> dict:
        """Set shuffle mode."""
        params = f"state={str(state).lower()}"
        if device_id:
            params += f"&device_id={device_id}"
        return put(f"/me/player/shuffle?{params}")
    
    @staticmethod
    def next(device_id: str = None) -> dict:
        """Skip to next track."""
        params = f"?device_id={device_id}" if device_id else ""
        return post(f"/me/player/next{params}")
    
    @staticmethod
    def previous(device_id: str = None) -> dict:
        """Skip to previous track."""
        params = f"?device_id={device_id}" if device_id else ""
        return post(f"/me/player/previous{params}")
    
    @staticmethod
    def add_to_queue(uri: str, device_id: str = None) -> dict:
        """Add item to queue."""
        params = f"uri={uri}"
        if device_id:
            params += f"&device_id={device_id}"
        
        # Queue endpoint returns 204 with empty body - use direct request
        url = f"{BASE_URL}/me/player/queue?{params}"
        resp = requests.post(url, headers=_headers())
        
        # If unauthorized, try refreshing token once
        if resp.status_code == 401:
            _refresh_token()
            resp = requests.post(url, headers=_headers())
        
        if resp.status_code == 204 or resp.status_code == 200:
            return {}
        if resp.status_code not in (200, 201):
            raise SpotifyException(f"add_to_queue: {resp.status_code} - {resp.text}")
        return resp.json() if resp.text else {}
    
    @staticmethod
    def transfer(device_id: str, play: bool = True) -> dict:
        """Transfer playback to device."""
        return put("/me/player", device_ids=[device_id], play=play)


# ============================================================================
# Search Endpoints
# ============================================================================

class Search:
    """Search endpoint."""
    
    @staticmethod
    def query(q: str, types: list[str], market: str = None, limit: int = 10, offset: int = 0) -> dict:
        """
        Search for items.
        
        Args:
            q: Search query.
            types: List of types: track, artist, album, playlist, show, episode, audiobook.
            market: Country code.
            limit: Max results (1-10).
            offset: Pagination offset.
        """
        return get("/search", q=q, type=",".join(types), market=market, limit=limit, offset=offset)


# ============================================================================
# Track Endpoints
# ============================================================================

class Tracks:
    """Track endpoints."""
    
    @staticmethod
    def get(track_id: str) -> dict:
        """Get track details."""
        return get(f"/tracks/{track_id}")
    
    @staticmethod
    def get_multiple(ids: list[str]) -> dict:
        """Get multiple tracks."""
        return get("/tracks", ids=",".join(ids))


# ============================================================================
# Artist Endpoints
# ============================================================================

class Artists:
    """Artist endpoints."""
    
    @staticmethod
    def get(artist_id: str) -> dict:
        """Get artist details."""
        return get(f"/artists/{artist_id}")
    
    @staticmethod
    def get_multiple(ids: list[str]) -> dict:
        """Get multiple artists."""
        return get("/artists", ids=",".join(ids))
    
    @staticmethod
    def get_albums(artist_id: str, include_groups: str = None, market: str = None, limit: int = 20, offset: int = 0) -> dict:
        """Get artist's albums."""
        params = {"limit": limit, "offset": offset}
        if include_groups:
            params["include_groups"] = include_groups
        if market:
            params["market"] = market
        return get(f"/artists/{artist_id}/albums", **params)


# ============================================================================
# Album Endpoints
# ============================================================================

class Albums:
    """Album endpoints."""
    
    @staticmethod
    def get(album_id: str) -> dict:
        """Get album details."""
        return get(f"/albums/{album_id}")
    
    @staticmethod
    def get_multiple(ids: list[str]) -> dict:
        """Get multiple albums."""
        return get("/albums", ids=",".join(ids))
    
    @staticmethod
    def get_tracks(album_id: str, limit: int = 50, offset: int = 0) -> dict:
        """Get album tracks."""
        return get(f"/albums/{album_id}/tracks", limit=limit, offset=offset)


# ============================================================================
# Show Endpoints (Podcasts)
# ============================================================================

class Shows:
    """Podcast show endpoints."""
    
    @staticmethod
    def get(show_id: str) -> dict:
        """Get show details."""
        return get(f"/shows/{show_id}")
    
    @staticmethod
    def get_multiple(ids: list[str]) -> dict:
        """Get multiple shows."""
        return get("/shows", ids=",".join(ids))
    
    @staticmethod
    def get_episodes(show_id: str, limit: int = 50, offset: int = 0) -> dict:
        """Get show episodes."""
        return get(f"/shows/{show_id}/episodes", limit=limit, offset=offset)


# ============================================================================
# Episode Endpoints (Podcasts)
# ============================================================================

class Episodes:
    """Podcast episode endpoints."""
    
    @staticmethod
    def get(episode_id: str) -> dict:
        """Get episode details."""
        return get(f"/episodes/{episode_id}")
    
    @staticmethod
    def get_multiple(ids: list[str]) -> dict:
        """Get multiple episodes."""
        return get("/episodes", ids=",".join(ids))


# ============================================================================
# Audiobook Endpoints
# ============================================================================

class Audiobooks:
    """Audiobook endpoints."""
    
    @staticmethod
    def get(audiobook_id: str) -> dict:
        """Get audiobook details."""
        return get(f"/audiobooks/{audiobook_id}")
    
    @staticmethod
    def get_multiple(ids: list[str]) -> dict:
        """Get multiple audiobooks."""
        return get("/audiobooks", ids=",".join(ids))
    
    @staticmethod
    def get_chapters(audiobook_id: str, limit: int = 50, offset: int = 0) -> dict:
        """Get audiobook chapters."""
        return get(f"/audiobooks/{audiobook_id}/chapters", limit=limit, offset=offset)


# ============================================================================
# Chapter Endpoints
# ============================================================================

class Chapters:
    """Chapter endpoints."""
    
    @staticmethod
    def get(chapter_id: str) -> dict:
        """Get chapter details."""
        return get(f"/chapters/{chapter_id}")


# ============================================================================
# Follow Endpoints
# ============================================================================

class Follow:
    """Follow endpoints."""
    
    @staticmethod
    def get_followed(type: str = "artist", limit: int = 50, after: str = None) -> dict:
        """Get followed artists."""
        params = {"type": type, "limit": limit}
        if after:
            params["after"] = after
        return get("/me/following", **params)


# ============================================================================
# Personalisation Endpoints
# ============================================================================

class Personalisation:
    """Personalisation endpoints."""
    
    @staticmethod
    def get_top(type: str, time_range: str = "medium_term", limit: int = 20, offset: int = 0) -> dict:
        """Get user's top artists or tracks."""
        return get(f"/me/top/{type}", time_range=time_range, limit=limit, offset=offset)


# ============================================================================
# Convenience Functions
# ============================================================================

@lru_cache(maxsize=1)
def user() -> User:
    return User()


@lru_cache(maxsize=1)
def library() -> Library:
    return Library()


@lru_cache(maxsize=1)
def playlists() -> Playlists:
    return Playlists()


@lru_cache(maxsize=1)
def user_playlists() -> UserPlaylists:
    return UserPlaylists()


@lru_cache(maxsize=1)
def player() -> Player:
    return Player()


@lru_cache(maxsize=1)
def search() -> Search:
    return Search()


@lru_cache(maxsize=1)
def tracks() -> Tracks:
    return Tracks()


@lru_cache(maxsize=1)
def artists() -> Artists:
    return Artists()


@lru_cache(maxsize=1)
def albums() -> Albums:
    return Albums()


@lru_cache(maxsize=1)
def shows() -> Shows:
    return Shows()


@lru_cache(maxsize=1)
def episodes() -> Episodes:
    return Episodes()


@lru_cache(maxsize=1)
def audiobooks() -> Audiobooks:
    return Audiobooks()


@lru_cache(maxsize=1)
def chapters() -> Chapters:
    return Chapters()


@lru_cache(maxsize=1)
def follow() -> Follow:
    return Follow()


@lru_cache(maxsize=1)
def personalisation() -> Personalisation:
    return Personalisation()


# Export all
__all__ = [
    "SpotifyException",
    "user",
    "library",
    "playlists",
    "user_playlists",
    "player",
    "search",
    "tracks",
    "artists",
    "albums",
    "shows",
    "episodes",
    "audiobooks",
    "chapters",
    "follow",
    "personalisation",
]


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