Skill: playoffp/music-identify (version 1.2.0)

--- name: music-identify description: Identify songs from audio clips using AudD API and optionally queue them to Spotify. Triggers on /songsearch command, voice messages with song identification intent, or when user asks "what song is this." Also handles recall queries like "what did I shazam" or "what was that song" by reading the music log. Works with any audio file (OGG, MP3, WAV, etc.). --- # Music Identify Identify songs from audio using AudD, return song info with Spotify link, and optionally queue to the user's active Spotify session. ## Runtime Dependencies Required on `$PATH`: - `curl` — HTTP requests to AudD and Spotify APIs - `jq` — JSON parsing for all API responses - `python3` — Spotify OAuth flow only (`scripts/spotify-auth.py`) ## Credentials | Credential | Location | Required | Purpose | |---|---|---|---| | AudD API key | `~/.config/audd/api_key` | Yes | Song identification | | Spotify OAuth tokens | `~/.config/spotify/tokens.json` | No (optional) | Queue to Spotify | | Spotify app credentials | `~/.config/spotify/credentials.json` | No (optional) | Token refresh | **Security notes:** - All credential files should be restricted: `chmod 600`. - This skill does not read, store, or access any platform tokens (Telegram, Discord, etc.). Audio file download from messaging platforms is handled by the host agent, not by this skill. ## Trigger - User sends `/songsearch` then follows with a voice message or audio file - User asks to identify a song from audio ("what song is this", "identify this song", "shazam this") - User asks to recall previously identified songs ("what was that song", "what did I shazam") ## Setup ### AudD API Key (required) 1. Sign up at <https://audd.io> (free tier: 300 requests, no credit card) 2. Save key: ```bash mkdir -p ~/.config/audd echo "YOUR_KEY" > ~/.config/audd/api_key chmod 600 ~/.config/audd/api_key ``` ### Spotify Integration (optional) 1. Create a Spotify Developer app at <https://developer.spotify.com/dashboard> 2. Set redirect URI to your callback URL 3. Save credentials: ```bash mkdir -p ~/.config/spotify cat > ~/.config/spotify/credentials.json << 'EOF' {"client_id": "...", "client_secret": "...", "redirect_uri": "https://your-host/callback"} EOF chmod 600 ~/.config/spotify/credentials.json ``` 4. Run the auth flow: `python3 scripts/spotify-auth.py` 5. Open the printed URL in a browser, authorize, tokens save automatically 6. `chmod 600 ~/.config/spotify/tokens.json` ## Workflow — Song Identification When a song identification is triggered: 1. Prompt user for a voice clip if not already provided. 2. Save the audio file to a temp location (e.g., `/tmp/music-identify-<timestamp>.ogg`). The host agent handles downloading audio from the messaging platform (Telegram, Discord, etc.) — this skill only needs the local file path. 3. Run identification: ```bash scripts/identify.sh /path/to/audio-file.ogg ``` 4. Parse the JSON result and respond. 5. If Spotify is configured and active, queue the track. 6. Log the identification to `memory/music-log.json`. **Supported formats:** OGG/Opus, MP3, WAV, FLAC, M4A. No transcoding needed — AudD accepts all common audio formats. ## Workflow — Spotify Queue After a successful identification, if Spotify tokens exist at `~/.config/spotify/tokens.json`: ```bash # Check if anything is playing scripts/spotify-queue.sh status # Queue the identified track scripts/spotify-queue.sh queue "spotify:track:<track_id>" ``` If no active Spotify session is found, fall back to returning the Spotify link only. Token refresh is handled automatically by `spotify-queue.sh` on 401 responses. ## Response Format - **Found**: `🎵 {title} by {artist}` + Spotify link on next line. If Spotify active, note it was queued. - **Found, no Spotify match**: `🎵 {title} by {artist}` (no link) - **Not found**: "Couldn't identify that one. Try a longer clip with more of the melody (5+ seconds works best)." - **Error**: "Something went wrong with the identification. Try again." - **Missing AudD key**: "AudD API key not configured. Get one at https://audd.io and save to ~/.config/audd/api_key" ## Memory Logging Append identified songs to `memory/music-log.json` (one JSON object per line): ```json {"date":"2026-03-22","time":"14:30","title":"Viva La Vida","artist":"Coldplay","album":"Viva la Vida","spotify":"https://open.spotify.com/track/..."} ``` Create the file if it doesn't exist. For recall queries, read and present this log. ## Scripts | Script | Purpose | Dependencies | |---|---|---| | `scripts/identify.sh <audio_file>` | POST audio to AudD, return JSON | curl, jq | | `scripts/spotify-auth.py [--url]` | One-time Spotify OAuth flow | python3 | | `scripts/spotify-queue.sh queue <uri>` | Queue track to active Spotify | curl, jq | | `scripts/spotify-queue.sh status` | Check current Spotify playback | curl, jq | Exit codes for `identify.sh`: 0=found, 1=not found, 2=error. ## API Reference See `references/audd-api.md` for AudD endpoint details, parameters, and response format.

path: references/audd-api.md
content: # AudD API Reference (Summary)

## Endpoint
- POST https://api.audd.io/

## Parameters (multipart/form-data)
- `file`: Audio file to identify (OGG/Opus accepted)
- `api_token`: AudD API key
- `return`: Optional; set to `spotify` to include Spotify metadata

## Response Format (JSON)
- Success with match:
  - `status`: "success"
  - `result`: object with fields such as `title`, `artist`, `album`, and `spotify`
  - `result.spotify.external_urls.spotify`: Spotify URL
- Success with no match:
  - `status`: "success"
  - `result`: null
- Error:
  - `status`: "error"
  - `error`: object with `error_message` (and optionally `error_code`)


path: scripts/identify.sh
content: #!/usr/bin/env bash
set -euo pipefail

audio_path="${1:-}"
if [[ -z "$audio_path" ]]; then
  echo '{"status":"error","message":"missing audio file path"}'
  exit 2
fi

if [[ ! -f "$audio_path" ]]; then
  echo '{"status":"error","message":"audio file not found"}'
  exit 2
fi

api_key_file="${AUDD_API_KEY_FILE:-$HOME/.config/audd/api_key}"
if [[ ! -f "$api_key_file" ]]; then
  echo '{"status":"error","message":"missing api key"}'
  exit 2
fi

api_key="$(tr -d '\n' < "$api_key_file" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [[ -z "$api_key" ]]; then
  echo '{"status":"error","message":"empty api key"}'
  exit 2
fi

response="$(curl -sS -F "file=@${audio_path}" -F "api_token=${api_key}" -F "return=spotify" https://api.audd.io/ || true)"

if [[ -z "$response" ]]; then
  echo '{"status":"error","message":"empty api response"}'
  exit 2
fi

status="$(echo "$response" | jq -r '.status // empty' 2>/dev/null || true)"
if [[ -z "$status" ]]; then
  echo '{"status":"error","message":"invalid api response"}'
  exit 2
fi

if [[ "$status" == "error" ]]; then
  message="$(echo "$response" | jq -r '.error.error_message // .error // "api error"' 2>/dev/null || true)"
  if [[ -z "$message" ]]; then
    message="api error"
  fi
  printf '{"status":"error","message":%s}\n' "$(jq -Rn --arg msg "$message" '$msg')"
  exit 2
fi

result_is_null="$(echo "$response" | jq -r '.result == null' 2>/dev/null || true)"
if [[ "$result_is_null" == "true" ]]; then
  echo '{"status":"not_found"}'
  exit 1
fi

title="$(echo "$response" | jq -r '.result.title // empty')"
artist="$(echo "$response" | jq -r '.result.artist // empty')"
album="$(echo "$response" | jq -r '.result.album // empty')"
spotify_url="$(echo "$response" | jq -r '.result.spotify.external_urls.spotify // empty')"

if [[ -z "$title" && -z "$artist" ]]; then
  echo '{"status":"not_found"}'
  exit 1
fi

jq -n --arg title "$title" --arg artist "$artist" --arg album "$album" --arg spotify_url "$spotify_url" \
  '{status:"found",title:$title,artist:$artist,album:$album,spotify_url:$spotify_url}'
exit 0


path: scripts/spotify-auth.py
content: #!/usr/bin/env python3
"""Spotify OAuth callback server. Run once to authorize, then tokens auto-refresh."""

import json
import os
import sys
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.request

CREDS_FILE = os.path.expanduser("~/.config/spotify/credentials.json")
TOKEN_FILE = os.path.expanduser("~/.config/spotify/tokens.json")
SCOPES = "user-modify-playback-state user-read-playback-state"

with open(CREDS_FILE) as f:
    creds = json.load(f)

CLIENT_ID = creds["client_id"]
CLIENT_SECRET = creds["client_secret"]
REDIRECT_URI = creds["redirect_uri"]


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


def exchange_code(code):
    data = urllib.parse.urlencode({
        "grant_type": "authorization_code",
        "code": code,
        "redirect_uri": REDIRECT_URI,
        "client_id": CLIENT_ID,
        "client_secret": CLIENT_SECRET,
    }).encode()
    req = urllib.request.Request("https://accounts.spotify.com/api/token", data=data,
                                 headers={"Content-Type": "application/x-www-form-urlencoded"})
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())


class CallbackHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        parsed = urllib.parse.urlparse(self.path)
        # Accept both /callback and / (Tailscale Serve may strip the path)
        if parsed.path not in ("/callback", "/"):
            self.send_response(404)
            self.end_headers()
            return

        params = urllib.parse.parse_qs(parsed.query)
        if "error" in params:
            self.send_response(400)
            self.end_headers()
            self.wfile.write(f"Error: {params['error'][0]}".encode())
            return

        code = params.get("code", [None])[0]
        if not code:
            self.send_response(400)
            self.end_headers()
            self.wfile.write(b"Missing code parameter")
            return

        try:
            tokens = exchange_code(code)
            with open(TOKEN_FILE, "w") as f:
                json.dump(tokens, f, indent=2)

            self.send_response(200)
            self.send_header("Content-Type", "text/html")
            self.end_headers()
            self.wfile.write(b"""
            <html><body style="background:#0d0d1a;color:#e2e8f0;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0">
            <div style="text-align:center">
                <h1 style="color:#22c55e">Connected!</h1>
                <p>Spotify is linked. You can close this tab.</p>
            </div>
            </body></html>
            """)
            print(f"\n✅ Tokens saved to {TOKEN_FILE}")
            print("You can close this server now.")
        except Exception as e:
            self.send_response(500)
            self.end_headers()
            self.wfile.write(f"Token exchange failed: {e}".encode())
            print(f"❌ Error: {e}")

    def log_message(self, format, *args):
        pass  # suppress request logs


if __name__ == "__main__":
    if "--url" in sys.argv:
        print(get_auth_url())
        sys.exit(0)

    port = 8888
    print(f"Auth URL:\n{get_auth_url()}\n")
    print(f"Waiting for callback on port {port}...")
    server = HTTPServer(("0.0.0.0", port), CallbackHandler)
    try:
        # Handle up to 5 requests (health checks, favicon, then actual callback)
        for _ in range(5):
            server.handle_request()
            if os.path.exists(TOKEN_FILE):
                break
    except KeyboardInterrupt:
        pass
    server.server_close()


path: scripts/spotify-queue.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Queue a track to Spotify or check playback state
# Usage: spotify-queue.sh queue <spotify_uri>
#        spotify-queue.sh status

TOKEN_FILE="$HOME/.config/spotify/tokens.json"
CREDS_FILE="$HOME/.config/spotify/credentials.json"

if [[ ! -f "$TOKEN_FILE" ]]; then
  echo '{"status":"error","message":"not authenticated. run spotify-auth.py first"}'
  exit 2
fi

if [[ ! -f "$CREDS_FILE" ]]; then
  echo '{"status":"error","message":"missing credentials file"}'
  exit 2
fi

# Read tokens
access_token=$(jq -r '.access_token' "$TOKEN_FILE")
refresh_token=$(jq -r '.refresh_token' "$TOKEN_FILE")
client_id=$(jq -r '.client_id' "$CREDS_FILE")
client_secret=$(jq -r '.client_secret' "$CREDS_FILE")

# Try a request; if 401, refresh token
try_refresh() {
  local response
  response=$(curl -sS -X POST "https://accounts.spotify.com/api/token" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "grant_type=refresh_token&refresh_token=${refresh_token}&client_id=${client_id}&client_secret=${client_secret}")

  local new_access
  new_access=$(echo "$response" | jq -r '.access_token // empty')
  if [[ -z "$new_access" ]]; then
    echo '{"status":"error","message":"token refresh failed"}'
    exit 2
  fi

  # Update token file (preserve refresh_token if not returned)
  local new_refresh
  new_refresh=$(echo "$response" | jq -r '.refresh_token // empty')
  if [[ -z "$new_refresh" ]]; then
    new_refresh="$refresh_token"
  fi

  jq --arg at "$new_access" --arg rt "$new_refresh" \
    '. + {access_token: $at, refresh_token: $rt}' "$TOKEN_FILE" > "${TOKEN_FILE}.tmp" \
    && mv "${TOKEN_FILE}.tmp" "$TOKEN_FILE"

  access_token="$new_access"
}

spotify_api() {
  local method="$1"
  local url="$2"
  local data="${3:-}"
  local http_code body

  if [[ -n "$data" ]]; then
    response=$(curl -sS -w "\n%{http_code}" -X "$method" "$url" \
      -H "Authorization: Bearer ${access_token}" \
      -H "Content-Type: application/json" \
      -d "$data")
  else
    response=$(curl -sS -w "\n%{http_code}" -X "$method" "$url" \
      -H "Authorization: Bearer ${access_token}")
  fi

  http_code=$(echo "$response" | tail -1)
  body=$(echo "$response" | sed '$d')

  if [[ "$http_code" == "401" ]]; then
    try_refresh
    # Retry
    if [[ -n "$data" ]]; then
      response=$(curl -sS -w "\n%{http_code}" -X "$method" "$url" \
        -H "Authorization: Bearer ${access_token}" \
        -H "Content-Type: application/json" \
        -d "$data")
    else
      response=$(curl -sS -w "\n%{http_code}" -X "$method" "$url" \
        -H "Authorization: Bearer ${access_token}")
    fi
    http_code=$(echo "$response" | tail -1)
    body=$(echo "$response" | sed '$d')
  fi

  echo "$http_code"
  echo "$body"
}

action="${1:-}"

case "$action" in
  queue)
    spotify_uri="${2:-}"
    if [[ -z "$spotify_uri" ]]; then
      echo '{"status":"error","message":"missing spotify URI"}'
      exit 1
    fi

    encoded_uri=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${spotify_uri}', safe=''))")
    result=$(spotify_api POST "https://api.spotify.com/v1/me/player/queue?uri=${encoded_uri}")
    code=$(echo "$result" | head -1)
    body=$(echo "$result" | tail -n +2)

    if [[ "$code" == "204" || "$code" == "200" ]]; then
      echo '{"status":"queued","uri":"'"$spotify_uri"'"}'
      exit 0
    elif [[ "$code" == "404" ]]; then
      echo '{"status":"no_device","message":"no active Spotify session found"}'
      exit 1
    else
      msg=$(echo "$body" | jq -r '.error.message // "unknown error"' 2>/dev/null || echo "unknown error")
      echo '{"status":"error","message":"'"$msg"'"}'
      exit 1
    fi
    ;;

  status)
    result=$(spotify_api GET "https://api.spotify.com/v1/me/player")
    code=$(echo "$result" | head -1)
    body=$(echo "$result" | tail -n +2)

    if [[ "$code" == "200" ]]; then
      is_playing=$(echo "$body" | jq -r '.is_playing')
      device=$(echo "$body" | jq -r '.device.name // "unknown"')
      track=$(echo "$body" | jq -r '.item.name // "nothing"')
      artist=$(echo "$body" | jq -r '.item.artists[0].name // "unknown"')
      echo '{"status":"active","is_playing":'"$is_playing"',"device":"'"$device"'","track":"'"$track"'","artist":"'"$artist"'"}'
      exit 0
    elif [[ "$code" == "204" ]]; then
      echo '{"status":"no_device","message":"no active Spotify session"}'
      exit 1
    else
      echo '{"status":"error","message":"could not check playback"}'
      exit 1
    fi
    ;;

  *)
    echo '{"status":"error","message":"usage: spotify-queue.sh queue <uri> | status"}'
    exit 2
    ;;
esac


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