Skill: dennisooki/lastfm-openclaw (version 1.0.0)

--- name: lastfm description: Access Last.fm user profile, now playing, top tracks/artists/albums by period, loved tracks, and optionally love/unlove tracks. metadata: { "openclaw": { "requires": { "env": ["LASTFM_API_KEY", "LASTFM_USERNAME"], "bins": ["curl", "jq"] }, "primaryEnv": "LASTFM_API_KEY", "emoji": "🎵" } } --- # Last.fm Profile Skill Retrieves Last.fm user listening data including now playing, top tracks/artists/albums by time period, and loved tracks. Optionally supports write operations (love/unlove tracks, scrobble) when `LASTFM_SESSION_KEY` is configured. ## Required Environment Variables - `LASTFM_API_KEY`: Your Last.fm API key (get one at https://www.last.fm/api/account/create) - `LASTFM_USERNAME`: Your Last.fm username ## Optional Environment Variables - `LASTFM_SESSION_KEY`: Required for write operations (love/unlove, scrobble) - `LASTFM_API_SECRET`: Required to sign write operations (love/unlove, scrobble) ## Workflow 1. Validate required environment variables are present 2. Ensure dependencies (`jq`, `curl`) are available 3. Determine which command the user is requesting 2. Determine which command the user is requesting 3. Construct API request to `ws.audioscrobbler.com/2.0/` 4. Execute HTTP GET request with appropriate method and parameters 5. Parse JSON response and format for user ## Supported Commands ### Read Operations (No Auth Required) | Command | Description | Example | |---------|-------------|---------| | `now-playing`, `np` | Current or most recent track | `/lastfm np` | | `top-tracks [period]` | Top tracks by period | `/lastfm top-tracks 7day` | | `top-artists [period]` | Top artists by period | `/lastfm top-artists 1month` | | `top-albums [period]` | Top albums by period | `/lastfm top-albums overall` | | `loved` | Loved tracks | `/lastfm loved` | | `recent [limit]` | Recent tracks (default 10) | `/lastfm recent 20` | | `profile` | User profile info | `/lastfm profile` | ### Time Periods - `7day` - Last 7 days - `1month` - Last 30 days - `3month` - Last 90 days - `6month` - Last 180 days - `12month` - Last year - `overall` - All time (default if not specified) ### Write Operations (Auth Required) | Command | Description | Example | |---------|-------------|---------| | `love <artist> <track>` | Love a track | `/lastfm love "Radiohead" "Creep"` | | `unlove <artist> <track>` | Unlove a track | `/lastfm unlove "Radiohead" "Creep"` | ## API Request Construction Base URL: `https://ws.audioscrobbler.com/2.0/` Required parameters for all requests: - `api_key`: Value from `LASTFM_API_KEY` - `format`: `json` - `method`: API method name User-specific requests also require: - `user`: Value from `LASTFM_USERNAME` ### Method Parameters | Method | Additional Parameters | |--------|----------------------| | `user.getInfo` | `user` | | `user.getRecentTracks` | `user`, `limit` (optional) | | `user.getTopTracks` | `user`, `period` (optional) | | `user.getTopArtists` | `user`, `period` (optional) | | `user.getTopAlbums` | `user`, `period` (optional) | | `user.getLovedTracks` | `user` | | `track.love` | `artist`, `track`, `sk` (session key) | | `track.unlove` | `artist`, `track`, `sk` (session key) | ## Response Parsing ### Now Playing Response Extract from `recenttracks.track[0]`: - If `@attr.nowplaying === "true"`: currently playing - `artist.#text` - Artist name - `name` - Track name - `album.#text` - Album name ### Top Items Response Extract array from: - `toptracks.track[]` for top tracks - `topartists.artist[]` for top artists - `topalbums.album[]` for top albums Each item includes: - `name` - Item name - `playcount` - Play count - `artist.name` - Artist (for tracks/albums) - `@attr.rank` - Position in chart ### Profile Response Extract from `user`: - `name` - Username - `realname` - Real name (if set) - `playcount` - Total scrobbles - `country` - Country - `registered` - Account creation date - `url` - Profile URL ## Guardrails - Never log or expose API keys or session keys in output - Rate limit: respect Last.fm's 5 requests/second limit - Write operations must fail gracefully if `LASTFM_SESSION_KEY` not set - All user inputs must be URL-encoded before API calls - Only connect to `ws.audioscrobbler.com` - no external endpoints - Handle missing data gracefully (e.g., no now playing, empty loved tracks) - Validate period parameter is one of: 7day, 1month, 3month, 6month, 12month, overall - Validate `recent` limit is numeric and within 1–200 ## Error Handling | Error Code | Meaning | Action | |------------|---------|--------| | 10 | Invalid API key | Tell user to check `LASTFM_API_KEY` | | 6 | Invalid parameters | Check required params are present | | 29 | Rate limit exceeded | Wait and retry, inform user | | 26 | Suspended API key | Direct user to Last.fm support | | 4 | Authentication failed | Check session key for write ops | ## Example Output Formats ### Now Playing ``` 🎵 Now Playing: "Track Name" by Artist Name from Album Name ``` Or if not currently playing: ``` 🎵 Last Played: "Track Name" by Artist Name Listened: [timestamp] ``` ### Top Tracks ``` 🎵 Top Tracks (7 days): 1. "Track One" by Artist One (42 plays) 2. "Track Two" by Artist Two (38 plays) 3. "Track Three" by Artist Three (31 plays) ... ``` ### Profile ``` 🎵 Last.fm Profile: username 📊 15,432 total scrobbles 🌍 United Kingdom 📅 Member since: Nov 2002 🔗 last.fm/user/username ``` ## Setup Instructions 1. Get a Last.fm API key at https://www.last.fm/api/account/create 2. Add to `~/.openclaw/openclaw.json`: ```json5 { skills: { entries: { lastfm: { enabled: true, env: { LASTFM_API_KEY: "your_api_key_here", LASTFM_USERNAME: "your_username" } } } } } ``` 3. For write operations, see `{baseDir}/references/auth-guide.md`

path: README.md
content: # Last.fm OpenClaw Skill

A secure, configurable OpenClaw skill for accessing Last.fm user profile data.

## Features

- **Now Playing**: Get current or most recently played track
- **Top Tracks**: View top tracks by time period (7day, 1month, 3month, 6month, 12month, overall)
- **Top Artists**: View top artists by time period
- **Top Albums**: View top albums by time period
- **Loved Tracks**: See your loved tracks collection
- **Recent Tracks**: View recent listening history
- **Profile Info**: Get user profile statistics
- **Love/Unlove**: Mark tracks as loved (requires auth)

## Installation

### Prerequisites

- `jq` (for JSON parsing)
- `curl`

Install on Ubuntu/Debian:

```
sudo apt-get update && sudo apt-get install -y jq curl
```

### 1. Get a Last.fm API Key

1. Visit https://www.last.fm/api/account/create
2. Fill in the application details:
   - **Name**: Your preferred name (e.g., "OpenClaw Integration")
   - **Description**: "Personal OpenClaw skill for Last.fm"
   - **Application URL**: Leave blank or use your site
3. Save the **API Key** shown after creation

### 2. Configure OpenClaw

Add to `~/.openclaw/openclaw.json`:

```json5
{
  skills: {
    entries: {
      lastfm: {
        enabled: true,
        [REDACTED_SECRET]", // convenience for LASTFM_API_KEY
        env: {
          LASTFM_API_KEY: "YOUR_API_KEY_HERE",
          LASTFM_USERNAME: "YOUR_USERNAME"
        }
      }
    }
  }
}
```

**Note:** OpenClaw injects these env vars at runtime (not globally). Never commit secrets to git. If you run the agent in a sandboxed session, set the env vars under `agents.defaults.sandbox.docker.env` (or per-agent) because sandboxed skills do not inherit host env.

### 3. Install the Skill

**From ClawHub:**
```bash
clawhub install lastfm
```

**From local directory:**
```bash
# Copy skill to your workspace
cp -r lastfm ~/.openclaw/skills/

# Or place in workspace skills folder
cp -r lastfm ./skills/
```

### 4. Verify Installation

```
/lastfm profile
```

## Usage

### Read Commands (No Auth Required)

**Read-only endpoints require only `LASTFM_API_KEY` + `LASTFM_USERNAME`.**

| Command | Description | Example |
|---------|-------------|---------|
| `now-playing`, `np` | Current/last track | `/lastfm np` |
| `top-tracks [period]` | Top tracks by period | `/lastfm top-tracks 7day` |
| `top-artists [period]` | Top artists by period | `/lastfm top-artists 1month` |
| `top-albums [period]` | Top albums by period | `/lastfm top-albums overall` |
| `loved` | Loved tracks | `/lastfm loved` |
| `recent [limit]` | Recent tracks | `/lastfm recent 20` |
| `profile` | User profile | `/lastfm profile` |

### Time Periods

- `7day` - Last 7 days
- `1month` - Last 30 days  
- `3month` - Last 90 days
- `6month` - Last 180 days
- `12month` - Last year
- `overall` - All time

### Write Commands (Auth Required)

**Write endpoints require `LASTFM_SESSION_KEY` + `LASTFM_API_SECRET`.**
See [Authentication Guide](references/auth-guide.md) for setup.

| Command | Description | Example |
|---------|-------------|---------|
| `love` | Love a track | `/lastfm love "Artist" "Track"` |
| `unlove` | Unlove a track | `/lastfm unlove "Artist" "Track"` |

## Examples

### Check What's Playing

```
User: What am I listening to on Last.fm?

🎵 Now Playing:
"The Less I Know The Better" by Tame Impala
from Currents
```

### Weekly Top Tracks

```
User: Show me my top tracks this week

🎵 Top Tracks (7 days):

1. "Blinding Lights" by The Weeknd (23 plays)
2. "Levitating" by Dua Lipa (18 plays)
3. "Save Your Tears" by The Weeknd (15 plays)
...
```

### Profile Stats

```
User: Show my Last.fm profile

🎵 Last.fm Profile: musiclover

📊 42,156 total scrobbles
🌍 United States
📅 Member since: Mar 2015
🔗 last.fm/user/musiclover
```

## Security

This skill follows OpenClaw security best practices:

- **No hardcoded credentials** - All secrets via environment variables
- **No external endpoints** - Only connects to `ws.audioscrobbler.com`
- **No install hooks** - No lifecycle scripts or setup commands
- **No obfuscated code** - All files are human-readable
- **Minimal permissions** - Only requests what's needed

## Configuration Options

```json5
{
  skills: {
    entries: {
      lastfm: {
        enabled: true,
        env: {
          LASTFM_API_KEY: "required",
          LASTFM_USERNAME: "required",
          LASTFM_SESSION_KEY: "optional, for write ops",
          LASTFM_API_SECRET: "optional, for write ops"
        }
      }
    }
  }
}
```

## Troubleshooting

### "Missing LASTFM_API_KEY"

Ensure your API key is set in `~/.openclaw/openclaw.json` under `skills.entries.lastfm.env.LASTFM_API_KEY`.

### "Invalid API key"

1. Verify your API key at https://www.last.fm/api/accounts
2. Check for typos in your configuration
3. Ensure the API key wasn't revoked

### "Rate limit exceeded"

Last.fm allows 5 requests/second. Wait a moment and try again.

### "Authentication failed for write operations"

Write operations require a session key. See [Authentication Guide](references/auth-guide.md).

## API Reference

See [API Endpoints](references/api-endpoints.md) for detailed API documentation.

## License

MIT

## Contributing

Issues and pull requests welcome at the repository.


path: references/api-endpoints.md
content: # Last.fm API Endpoints Reference

This document describes the Last.fm API endpoints used by this skill.

## Base URL

```
https://ws.audioscrobbler.com/2.0/
```

## Authentication

All read operations require only an API key. Write operations require a session key obtained through the authentication flow.

See [auth-guide.md](auth-guide.md) for authentication setup.

## Required Parameters

All requests must include:

| Parameter | Description |
|-----------|-------------|
| `api_key` | Your Last.fm API key |
| `method` | API method name |
| `format` | Response format (`json` recommended) |

---

## User Methods

### user.getInfo

Get information about a user profile.

**URL:** `?method=user.getInfo&user=USERNAME&[REDACTED_SECRET]`

**Parameters:**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `user` | Yes | Last.fm username |
| `api_key` | Yes | API key |

**Response Fields:**

```json
{
  "user": {
    "name": "username",
    "realname": "Real Name",
    "url": "https://www.last.fm/user/username",
    "image": [...],
    "country": "Country",
    "age": "25",
    "gender": "m",
    "subscriber": "0",
    "playcount": "54189",
    "playlists": "4",
    "registered": {
      "unixtime": "1037793040",
      "#text": "2002-11-20 11:50"
    }
  }
}
```

---

### user.getRecentTracks

Get recent tracks, including now playing.

**URL:** `?method=user.getRecentTracks&user=USERNAME&[REDACTED_SECRET]`

**Parameters:**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `user` | Yes | Last.fm username |
| `api_key` | Yes | API key |
| `limit` | No | Number of results (default: 50, max: 200) |
| `page` | No | Page number |
| `from` | No | Unix timestamp start |
| `to` | No | Unix timestamp end |
| `extended` | No | Include extended data (0 or 1) |

**Now Playing Detection:**

The first track in `recenttracks.track[]` has `@attr.nowplaying="true"` if currently playing.

**Response Fields:**

```json
{
  "recenttracks": {
    "user": "username",
    "track": [
      {
        "@attr": { "nowplaying": "true" },
        "artist": { "mbid": "...", "#text": "Artist Name" },
        "name": "Track Name",
        "album": { "mbid": "", "#text": "Album Name" },
        "url": "https://www.last.fm/music/...",
        "date": { "uts": "1213031819", "#text": "9 Jun 2008, 17:16" }
      }
    ]
  }
}
```

---

### user.getTopTracks

Get top tracks for a user.

**URL:** `?method=user.getTopTracks&user=USERNAME&[REDACTED_SECRET]`

**Parameters:**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `user` | Yes | Last.fm username |
| `api_key` | Yes | API key |
| `period` | No | Time period (see below) |
| `limit` | No | Number of results (default: 50) |
| `page` | No | Page number |

**Period Values:**

| Value | Description |
|-------|-------------|
| `overall` | All time (default) |
| `7day` | Last 7 days |
| `1month` | Last 30 days |
| `3month` | Last 90 days |
| `6month` | Last 180 days |
| `12month` | Last year |

**Response Fields:**

```json
{
  "toptracks": {
    "user": "username",
    "track": [
      {
        "@attr": { "rank": "1" },
        "name": "Track Name",
        "playcount": "42",
        "artist": { "name": "Artist Name", "mbid": "..." },
        "album": { "mbid": "..." },
        "url": "https://www.last.fm/music/..."
      }
    ]
  }
}
```

---

### user.getTopArtists

Get top artists for a user.

**URL:** `?method=user.getTopArtists&user=USERNAME&[REDACTED_SECRET]`

**Parameters:**

Same as `user.getTopTracks` (minus `period` variation).

**Response Fields:**

```json
{
  "topartists": {
    "user": "username",
    "artist": [
      {
        "@attr": { "rank": "1" },
        "name": "Artist Name",
        "playcount": "156",
        "mbid": "...",
        "url": "https://www.last.fm/music/..."
      }
    ]
  }
}
```

---

### user.getTopAlbums

Get top albums for a user.

**URL:** `?method=user.getTopAlbums&user=USERNAME&[REDACTED_SECRET]`

**Parameters:**

Same as `user.getTopTracks`.

**Response Fields:**

```json
{
  "topalbums": {
    "user": "username",
    "album": [
      {
        "@attr": { "rank": "1" },
        "name": "Album Name",
        "playcount": "38",
        "artist": { "name": "Artist Name", "mbid": "..." },
        "mbid": "...",
        "url": "https://www.last.fm/music/..."
      }
    ]
  }
}
```

---

### user.getLovedTracks

Get loved tracks for a user.

**URL:** `?method=user.getLovedTracks&user=USERNAME&[REDACTED_SECRET]`

**Parameters:**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `user` | Yes | Last.fm username |
| `api_key` | Yes | API key |
| `limit` | No | Number of results (default: 50) |
| `page` | No | Page number |

**Response Fields:**

```json
{
  "lovedtracks": {
    "user": "username",
    "track": [
      {
        "artist": { "name": "Artist Name", "mbid": "..." },
        "name": "Track Name",
        "mbid": "...",
        "url": "https://www.last.fm/music/...",
        "date": { "uts": "1213031819", "#text": "9 Jun 2008, 17:16" }
      }
    ]
  }
}
```

---

## Track Methods

### track.love

Love a track for a user session. **Requires authentication.**

**URL:** POST to `https://ws.audioscrobbler.com/2.0/`

**Parameters:**

| Parameter | Required | Description |
|-----------|----------|-------------|
| `method` | Yes | `track.love` |
| `api_key` | Yes | API key |
| `sk` | Yes | Session key |
| `artist` | Yes | Artist name |
| `track` | Yes | Track name |
| `api_sig` | Yes | Method signature (see auth guide) |

**Response:**

```json
{
  "lfm": {
    "status": "ok"
  }
}
```

---

### track.unlove

Remove a loved track. **Requires authentication.**

Same parameters and response as `track.love`.

---

## Error Codes

| Code | Description |
|------|-------------|
| 2 | Invalid service |
| 3 | Invalid method |
| 4 | Authentication failed |
| 5 | Invalid format |
| 6 | Invalid parameters |
| 7 | Invalid resource |
| 8 | Operation failed |
| 9 | Invalid session key |
| 10 | Invalid API key |
| 11 | Service offline |
| 13 | Invalid method signature |
| 16 | Temporary error |
| 26 | Suspended API key |
| 29 | Rate limit exceeded |

---

## Rate Limits

- **5 requests per second** per IP address
- Implement delays between requests if making multiple calls
- Error 29 indicates rate limit exceeded

---

## References

- Official API docs: https://www.last.fm/api
- Authentication: https://www.last.fm/api/webauth


path: references/auth-guide.md
content: # Last.fm Authentication Guide

This guide explains how to set up authentication for Last.fm write operations (love/unlove tracks, scrobble).

## Overview

Read operations only require an API key. Write operations require a session key obtained through Last.fm's authentication flow.

## Authentication Types

| Type | Use Case | Requires |
|------|----------|----------|
| API Key Only | Read operations | API key |
| Desktop Auth | Write operations (local app) | API key + secret |
| Web Auth | Write operations (web app) | API key + secret + callback URL |

This skill uses **Desktop Auth** for write operations.

---

## Step 1: Get API Key and Secret

1. Visit https://www.last.fm/api/account/create
2. Fill in application details:
   - **Name**: Your preferred name
   - **Description**: Personal OpenClaw skill
   - **Application URL**: Leave blank or use your site
3. After creation, note both:
   - **API Key** - Used for all requests
   - **Shared Secret** - Used for signing write requests

## Step 2: Store Credentials

Add to `~/.openclaw/openclaw.json`:

```json5
{
  skills: {
    entries: {
      lastfm: {
        enabled: true,
        env: {
          LASTFM_API_KEY: "your_api_key",
          LASTFM_API_SECRET: "your_shared_secret",
          LASTFM_USERNAME: "your_username",
          LASTFM_SESSION_KEY: ""  // Will be filled after auth
        }
      }
    }
  }
}
```

**Security Note:** Never commit your API secret to a public repository.

---

## Step 3: Get Authentication Token

Request a token from Last.fm:

```bash
curl "https://ws.audioscrobbler.com/2.0/?method=auth.gettoken&[REDACTED_SECRET]"
```

Response:
```json
{
  "token": "your_auth_token"
}
```

**Note:** Tokens expire after 60 minutes if not used.

---

## Step 4: Authorize the Token

1. Construct authorization URL:
   ```
   https://www.last.fm/api/auth/?[REDACTED_SECRET]
   ```

2. Open this URL in your browser

3. Log in to Last.fm and click "Yes, allow access"

4. You'll be redirected to a confirmation page

---

## Step 5: Get Session Key

After authorizing, exchange the token for a session key:

```bash
# First, create the method signature
# Signature = md5(api_key + method + token + secret)

# Example using common tools:
[REDACTED_SECRET]"
API_SECRET="your_secret"
[REDACTED_SECRET]"

# Create signature string (params in alphabetical order, without format)
SIG_STRING="api_key${API_KEY}methodauth.getSessiontoken${TOKEN}${API_SECRET}"
SIGNATURE=$(echo -n "$SIG_STRING" | md5sum | cut -d' ' -f1)

# Request session key
curl "https://ws.audioscrobbler.com/2.0/?method=auth.getSession&[REDACTED_SECRET]"
```

Response:
```json
{
  "session": {
    "name": "your_username",
    "key": "your_session_key",
    "subscriber": "0"
  }
}
```

---

## Step 6: Store Session Key

Add the session key to your configuration:

```json5
{
  skills: {
    entries: {
      lastfm: {
        enabled: true,
        env: {
          LASTFM_API_KEY: "your_api_key",
          LASTFM_API_SECRET: "your_secret",
          LASTFM_USERNAME: "your_username",
          LASTFM_SESSION_KEY: "your_session_key"
        }
      }
    }
  }
}
```

**Important:** Session keys do not expire unless the user revokes access.

---

## Creating Method Signatures

For write operations, you must create an API signature:

### Rules

1. Sort all parameters alphabetically (excluding `format`)
2. Concatenate: `param1value1param2value2...`
3. Append your API secret
4. Calculate MD5 hash of the string

### Example

For `track.love`:

```
Parameters: api_key=XXX, artist=Radiohead, method=track.love, sk=YYY, track=Creep

Sorted: api_keyXXXartistRadioheadmethodtrack.loveskYYYtrackCreep

With [REDACTED_SECRET]

Signature: md5("api_keyXXXartistRadioheadmethodtrack.loveskYYYtrackCreepSECRET")
```

### Code Example (Bash)

```bash
generate_signature() {
    local params=("$@")
    local [REDACTED_SECRET]"
    
    # Sort params alphabetically
    IFS=$'\n' sorted=($(sort <<<"${params[*]}"))
    unset IFS
    
    # Concatenate
    local sig_string=""
    for param in "${sorted[@]}"; do
        sig_string+="${param}"
    done
    sig_string+="${secret}"
    
    # MD5 hash
    echo -n "$sig_string" | md5sum | cut -d' ' -f1
}

# Usage
signature=$(generate_signature "api_key${LASTFM_API_KEY}" "artistRadiohead" "methodtrack.love" "sk${LASTFM_SESSION_KEY}" "trackCreep")
```

---

## Revoking Access

To revoke session key access:

1. Visit https://www.last.fm/settings/applications
2. Find your application
3. Click "Revoke access"

After revoking, you'll need to repeat the authentication flow.

---

## Troubleshooting

### "Invalid session key"

- Token expired before authorization (60 min limit)
- User revoked access
- Session key is incorrect in config

### "Invalid method signature"

- Parameters not sorted alphabetically
- Secret incorrect
- Parameter names/values concatenated incorrectly

### "Token has expired"

Tokens are single-use and expire after 60 minutes. Request a new token and start over.

---

## Security Notes

1. **Never share** your API secret or session key
2. **Never commit** secrets to version control
3. **Use environment variables** for all credentials
4. **Revoke access** if you suspect compromise
5. **Session keys are long-lived** - store them securely


path: scripts/lastfm-api.sh
content: #!/bin/bash

set -euo pipefail

LASTFM_API_ROOT="https://ws.audioscrobbler.com/2.0/"

print_usage() {
    cat <<EOF
Usage: lastfm-api.sh <command> [options]

Commands:
    now-playing, np              Get currently playing track
    top-tracks [period]          Get top tracks (period: 7day, 1month, 3month, 6month, 12month, overall)
    top-artists [period]         Get top artists
    top-albums [period]          Get top albums
    loved                        Get loved tracks
    recent [limit]               Get recent tracks (default: 10)
    profile                      Get user profile info
    love <artist> <track>        Love a track (requires session key)
    unlove <artist> <track>      Unlove a track (requires session key)

Environment Variables Required:
    LASTFM_API_KEY               Your Last.fm API key
    LASTFM_USERNAME              Your Last.fm username

Optional for Write Operations:
    LASTFM_SESSION_KEY           Session key for authenticated requests
    LASTFM_API_SECRET            API secret for signing requests

Examples:
    LASTFM_API_KEY=xxx LASTFM_USERNAME=user ./lastfm-api.sh now-playing
    LASTFM_API_KEY=xxx LASTFM_USERNAME=user ./lastfm-api.sh top-tracks 7day
    LASTFM_API_KEY=xxx LASTFM_USERNAME=user LASTFM_SESSION_KEY=yyy LASTFM_API_SECRET=zzz ./lastfm-api.sh love "Radiohead" "Creep"
EOF
}

check_required_vars() {
    if [[ -z "${LASTFM_API_KEY:-}" ]]; then
        echo "Error: LASTFM_API_KEY not set" >&2
        exit 1
    fi
    if [[ -z "${LASTFM_USERNAME:-}" ]]; then
        echo "Error: LASTFM_USERNAME not set" >&2
        exit 1
    fi
}

check_bins() {
    local bins=("curl" "jq")
    for b in "${bins[@]}"; do
        if ! command -v "$b" >/dev/null 2>&1; then
            echo "Error: missing dependency '$b'" >&2
            exit 1
        fi
    done
}

check_write_vars() {
    if [[ -z "${LASTFM_SESSION_KEY:-}" ]]; then
        echo "Error: LASTFM_SESSION_KEY not set (required for write operations)" >&2
        echo "See references/auth-guide.md for authentication instructions" >&2
        exit 1
    fi
    if [[ -z "${LASTFM_API_SECRET:-}" ]]; then
        echo "Error: LASTFM_API_SECRET not set (required for signing write requests)" >&2
        exit 1
    fi
}

url_encode() {
    local string="$1"
    jq -nr --arg str "$string" '$str | @uri'
}

generate_signature() {
    local params=("$@")
    local [REDACTED_SECRET]"
    
    local sorted_params
    sorted_params=$(printf '%s\n' "${params[@]}" | sort)
    
    local sig_string=""
    while IFS= read -r param; do
        sig_string+="${param}"
    done <<< "$sorted_params"
    sig_string+="${secret}"
    
    echo -n "$sig_string" | md5sum | cut -d' ' -f1
}

make_request() {
    local method="$1"
    shift
    local extra_params=("$@")
    
    local url="${LASTFM_API_ROOT}?method=${method}&user=$(url_encode "$LASTFM_USERNAME")&[REDACTED_SECRET]"
    
    for param in "${extra_params[@]}"; do
        url+="&${param}"
    done
    
    curl -s "$url"
}

make_write_request() {
    local method="$1"
    local artist="$2"
    local track="$3"
    
    check_write_vars
    
    local artist_enc
    artist_enc=$(url_encode "$artist")
    local track_enc
    track_enc=$(url_encode "$track")
    
    local api_sig
    api_sig=$(generate_signature \
        "api_key${LASTFM_API_KEY}" \
        "artist${artist}" \
        "method${method}" \
        "sk${LASTFM_SESSION_KEY}" \
        "track${track}"
    )
    
    local url="${LASTFM_API_ROOT}"
    local data="method=${method}&[REDACTED_SECRET]"
    
    curl -s -X POST -d "$data" "$url"
}

format_now_playing() {
    local json="$1"
    
    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi
    
    local track
    track=$(echo "$json" | jq -r '.recenttracks.track[0]')
    
    if [[ -z "$track" || "$track" == "null" ]]; then
        echo "No recent tracks found"
        exit 0
    fi
    
    local is_now_playing
    is_now_playing=$(echo "$json" | jq -r '.recenttracks.track[0]."@attr".nowplaying // "false"')
    local artist
    artist=$(echo "$json" | jq -r '.recenttracks.track[0].artist."#text" // .recenttracks.track[0].artist')
    local name
    name=$(echo "$json" | jq -r '.recenttracks.track[0].name')
    local album
    album=$(echo "$json" | jq -r '.recenttracks.track[0].album."#text" // "Unknown Album"')
    
    if [[ "$is_now_playing" == "true" ]]; then
        echo "🎵 Now Playing:"
    else
        local date
        date=$(echo "$json" | jq -r '.recenttracks.track[0].date."#text" // "Unknown time"')
        echo "🎵 Last Played:"
    fi
    
    echo "\"${name}\" by ${artist}"
    echo "from ${album}"
    
    if [[ "$is_now_playing" != "true" ]]; then
        echo "Listened: ${date}"
    fi
}

format_top_tracks() {
    local json="$1"
    local period="${2:-overall}"
    
    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi
    
    echo "🎵 Top Tracks (${period}):"
    echo ""
    
    echo "$json" | jq -r '.toptracks.track[:10][] | "\(.["@attr"].rank). \"\(.name)\" by \(.artist.name) (\(.playcount) plays)"'
}

format_top_artists() {
    local json="$1"
    local period="${2:-overall}"
    
    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi
    
    echo "🎵 Top Artists (${period}):"
    echo ""
    
    echo "$json" | jq -r '.topartists.artist[:10][] | "\(.["@attr"].rank). \(.name) (\(.playcount) plays)"'
}

format_top_albums() {
    local json="$1"
    local period="${2:-overall}"
    
    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi
    
    echo "🎵 Top Albums (${period}):"
    echo ""
    
    echo "$json" | jq -r '.topalbums.album[:10][] | "\(.["@attr"].rank). \"\(.name)\" by \(.artist.name) (\(.playcount) plays)"'
}

format_loved() {
    local json="$1"
    
    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi
    
    local count
    count=$(echo "$json" | jq -r '.lovedtracks."@attr".total // 0')
    
    echo "🎵 Loved Tracks (${count} total):"
    echo ""
    
    if [[ "$count" == "0" ]]; then
        echo "No loved tracks found"
        return
    fi
    
    echo "$json" | jq -r '.lovedtracks.track[:10][] | "- \"\(.name)\" by \(.artist.name)"'
}

format_recent() {
    local json="$1"
    local limit="${2:-10}"
    
    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi
    
    echo "🎵 Recent Tracks:"
    echo ""
    
    echo "$json" | jq -r --argjson limit "$limit" '.recenttracks.track[:$limit][] | "- \"\(.name)\" by \(.artist."#text" // .artist) [\(.date."#text" // "now playing")]"'
}

format_profile() {
    local json="$1"
    
    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi
    
    local name
    name=$(echo "$json" | jq -r '.user.name')
    local realname
    realname=$(echo "$json" | jq -r '.user.realname // ""')
    local playcount
    playcount=$(echo "$json" | jq -r '.user.playcount')
    local country
    country=$(echo "$json" | jq -r '.user.country // "Unknown"')
    local registered
    registered=$(echo "$json" | jq -r '.user.registered."#text" // "Unknown"')
    local url
    url=$(echo "$json" | jq -r '.user.url')
    
    echo "🎵 Last.fm Profile: ${name}"
    [[ -n "$realname" && "$realname" != "null" ]] && echo "   ($realname)"
    echo ""
    echo "📊 ${playcount} total scrobbles"
    echo "🌍 ${country}"
    echo "📅 Member since: ${registered}"
    echo "🔗 ${url}"
}

format_write_response() {
    local json="$1"
    local action="$2"
    
    if echo "$json" | jq -e '.error' > /dev/null 2>&1; then
        echo "Error: $(echo "$json" | jq -r '.message // .error')"
        exit 1
    fi
    
    echo "✓ Track ${action}!"
}

validate_period() {
    local period="$1"
    local valid="7day 1month 3month 6month 12month overall"
    
    if [[ ! " $valid " =~ " $period " ]]; then
        echo "Error: Invalid period '$period'" >&2
        echo "Valid periods: 7day, 1month, 3month, 6month, 12month, overall" >&2
        exit 1
    fi
}

validate_limit() {
    local limit="$1"
    if [[ ! "$limit" =~ ^[0-9]+$ ]]; then
        echo "Error: limit must be a number" >&2
        exit 1
    fi
    if (( limit < 1 || limit > 200 )); then
        echo "Error: limit must be between 1 and 200" >&2
        exit 1
    fi
}

main() {
    if [[ $# -lt 1 ]]; then
        print_usage
        exit 1
    fi
    
    local command="$1"
    shift
    
    case "$command" in
        now-playing|np)
            check_required_vars
            check_bins
            format_now_playing "$(make_request "user.getRecentTracks" "limit=1")"
            ;;
        top-tracks)
            check_required_vars
            check_bins
            local period="${1:-overall}"
            validate_period "$period"
            format_top_tracks "$(make_request "user.getTopTracks" "period=${period}")" "$period"
            ;;
        top-artists)
            check_required_vars
            check_bins
            local period="${1:-overall}"
            validate_period "$period"
            format_top_artists "$(make_request "user.getTopArtists" "period=${period}")" "$period"
            ;;
        top-albums)
            check_required_vars
            check_bins
            local period="${1:-overall}"
            validate_period "$period"
            format_top_albums "$(make_request "user.getTopAlbums" "period=${period}")" "$period"
            ;;
        loved)
            check_required_vars
            check_bins
            format_loved "$(make_request "user.getLovedTracks")"
            ;;
        recent)
            check_required_vars
            check_bins
            local limit="${1:-10}"
            validate_limit "$limit"
            format_recent "$(make_request "user.getRecentTracks" "limit=${limit}")" "$limit"
            ;;
        profile)
            check_required_vars
            check_bins
            format_profile "$(make_request "user.getInfo")"
            ;;
        love)
            if [[ $# -lt 2 ]]; then
                echo "Usage: lastfm-api.sh love <artist> <track>" >&2
                exit 1
            fi
            check_required_vars
            check_bins
            format_write_response "$(make_write_request "track.love" "$1" "$2")" "loved"
            ;;
        unlove)
            if [[ $# -lt 2 ]]; then
                echo "Usage: lastfm-api.sh unlove <artist> <track>" >&2
                exit 1
            fi
            check_required_vars
            check_bins
            format_write_response "$(make_write_request "track.unlove" "$1" "$2")" "unloved"
            ;;
        help|--help|-h)
            print_usage
            ;;
        *)
            echo "Error: Unknown command '$command'" >&2
            echo "Run 'lastfm-api.sh help' for usage" >&2
            exit 1
            ;;
    esac
}

main "$@"


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