merge: spotify skill implementation

This commit is contained in:
2026-04-12 02:17:32 -05:00
35 changed files with 3246 additions and 0 deletions

View File

@@ -22,6 +22,7 @@ This repository contains practical OpenClaw skills and companion integrations. I
| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |
| `property-assessor` | Assess a residential property from an address or listing URL with CAD/public-record enrichment, Zillow-first/HAR-fallback photo review, carry-cost/risk analysis, and fixed-template PDF output. | `skills/property-assessor` |
| `searxng` | Search through a local or self-hosted SearXNG instance for web, news, images, and more. | `skills/searxng` |
| `spotify` | Search Spotify tracks, manage playlists, and import songs from text files, M3U playlists, or music folders through the Spotify Web API. | `skills/spotify` |
| `us-cpa` | Federal individual 1040 workflow for tax questions, case intake, preparation, review, and draft e-file-ready export. | `skills/us-cpa` |
| `web-automation` | One-shot extraction plus broader browsing/scraping with CloakBrowser, including unit-aware Zillow/HAR discovery and dedicated listing-photo extractors. | `skills/web-automation` |

View File

@@ -10,6 +10,7 @@ This folder contains detailed docs for each skill in this repository.
- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)
- [`property-assessor`](property-assessor.md) — Residential property assessment with CAD/public-record enrichment, Zillow/HAR photo review, valuation workflow, and PDF delivery rules
- [`searxng`](searxng.md) — Privacy-respecting metasearch via a local or self-hosted SearXNG instance
- [`spotify`](spotify.md) — Spotify Web API helper for track search, playlist management, and text/M3U/folder imports
- [`us-cpa`](us-cpa.md) — Federal individual 1040 workflow for tax questions, case intake, preparation, review, and draft e-file-ready export
- [`web-automation`](web-automation.md) — One-shot extraction plus CloakBrowser automation, including unit-aware Zillow/HAR discovery and dedicated photo extraction

103
docs/spotify.md Normal file
View File

@@ -0,0 +1,103 @@
# Spotify
The Spotify skill adds a local helper for Spotify Web API playlist work from OpenClaw.
## Scope
- search Spotify tracks
- list the current user's playlists
- create private or public playlists
- add and remove track URIs
- search and add tracks
- import tracks from text lists, M3U/M3U8 playlists, and local folders
The skill uses OAuth2 Authorization Code with PKCE. It does not need a Spotify client secret and does not use browser automation for Spotify operations.
## Setup
Create the local credential directory:
```bash
mkdir -p ~/.openclaw/workspace/.clawdbot/credentials/spotify
chmod 700 ~/.openclaw/workspace/.clawdbot/credentials/spotify
$EDITOR ~/.openclaw/workspace/.clawdbot/credentials/spotify/config.json
```
Example `config.json`:
```json
{
"clientId": "your-spotify-client-id",
"redirectUri": "http://127.0.0.1:8888/callback"
}
```
Run auth from the active OpenClaw skill copy:
```bash
cd ~/.openclaw/workspace/skills/spotify
scripts/setup.sh
```
Or run only the OAuth login after dependencies are installed:
```bash
scripts/spotify auth
scripts/spotify status --json
```
Tokens are written to the local credentials directory as `token.json` with owner-only file mode when the filesystem supports it. Do not print token files.
## Commands
```bash
scripts/spotify status --json
scripts/spotify search "Radiohead Karma Police" --limit 3 --json
scripts/spotify list-playlists --limit 10 --json
scripts/spotify create-playlist "OpenClaw Mix" --description "Created by OpenClaw" --json
scripts/spotify add-to-playlist "<playlistId>" "spotify:track:..." --json
scripts/spotify remove-from-playlist "<playlistId>" "spotify:track:..." --json
scripts/spotify search-and-add "<playlistId>" "Radiohead Karma Police" --json
scripts/spotify import "/path/to/tracks.txt" --playlist "Imported Mix" --json
scripts/spotify import "/path/to/playlist.m3u8" --playlist-id "<playlistId>" --json
scripts/spotify import "/path/to/music-folder" --playlist "Folder Import" --json
```
`--playlist NAME` always creates a new playlist, private by default unless `--public` is provided. Spotify allows duplicate playlist names, so use `--playlist-id ID` when updating an existing playlist.
## Import Behavior
Text imports ignore blank lines and comment lines starting with `#` or `//`.
M3U/M3U8 imports use `#EXTINF` metadata when present and fall back to the filename otherwise.
Folder imports recursively scan supported audio filenames and ignore non-audio files.
The importer searches Spotify once per parsed candidate, adds the first match, reports misses, and skips duplicate Spotify URI matches.
## Endpoint Notes
This skill uses the current Spotify playlist endpoints:
```text
GET /v1/me
GET /v1/search?type=track&q=<query>&limit=<1-10>
GET /v1/me/playlists?limit=<n>&offset=<n>
POST /v1/me/playlists
POST /v1/playlists/{id}/items
DELETE /v1/playlists/{id}/items
POST https://accounts.spotify.com/api/token
```
Do not use the removed 2026 endpoints:
```text
POST /v1/users/{user_id}/playlists
GET /v1/users/{id}/playlists
POST /v1/playlists/{id}/tracks
DELETE /v1/playlists/{id}/tracks
```
## Live Smoke Caution
Spotify does not offer a normal delete-playlist Web API operation. Any live smoke that creates a playlist must be explicitly approved because the playlist can only be manually cleaned up later.

3
skills/spotify/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.log

58
skills/spotify/SKILL.md Normal file
View File

@@ -0,0 +1,58 @@
---
name: spotify
description: Search Spotify tracks, create and manage playlists, and import songs from text files, M3U playlists, or music folders.
metadata: {"clawdbot":{"emoji":"music","requires":{"bins":["node"]}}}
---
# Spotify
Use this skill when the user wants to search Spotify tracks, create playlists, add or remove playlist tracks, or import songs from a text list, M3U/M3U8 playlist, or local music folder.
Use the local helper from the installed skill directory:
```bash
cd ~/.openclaw/workspace/skills/spotify
scripts/spotify --help
```
This skill uses the Spotify Web API with OAuth2 Authorization Code + PKCE. It does not use browser automation for Spotify operations and does not need a Spotify client secret.
Do not print token files or OAuth callback data. Use `--json` for machine-readable command output.
## Setup
```bash
mkdir -p ~/.openclaw/workspace/.clawdbot/credentials/spotify
chmod 700 ~/.openclaw/workspace/.clawdbot/credentials/spotify
$EDITOR ~/.openclaw/workspace/.clawdbot/credentials/spotify/config.json
cd ~/.openclaw/workspace/skills/spotify
scripts/setup.sh
```
`config.json`:
```json
{
"clientId": "your-spotify-client-id",
"redirectUri": "http://127.0.0.1:8888/callback"
}
```
## Commands
```bash
scripts/spotify status --json
scripts/spotify search "Radiohead Karma Police" --limit 3 --json
scripts/spotify list-playlists --limit 10 --json
scripts/spotify create-playlist "OpenClaw Mix" --description "Created by OpenClaw" --public --json
scripts/spotify add-to-playlist "<playlistId>" "spotify:track:..." --json
scripts/spotify remove-from-playlist "<playlistId>" "spotify:track:..." --json
scripts/spotify search-and-add "<playlistId>" "Radiohead Karma Police" --json
scripts/spotify import "/path/to/tracks.txt" --playlist "Imported Mix" --json
```
`--playlist NAME` creates a new private playlist by default. Use `--playlist-id ID` to update an exact existing playlist.
Current Spotify endpoints used by this skill include `/v1/me/playlists` and `/v1/playlists/{id}/items`; do not use the removed `/tracks` playlist mutation endpoints.
Spotify has no normal delete-playlist Web API operation. Ask before running live smoke tests that create playlists.

759
skills/spotify/package-lock.json generated Normal file
View File

@@ -0,0 +1,759 @@
{
"name": "spotify-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spotify-scripts",
"version": "1.0.0",
"dependencies": {
"minimist": "^1.2.8",
"open": "^10.2.0"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/node": "^24.8.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
"integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
"integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
"integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
"integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
"integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
"integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
"integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
"integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
"integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
"integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
"integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
"integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
"integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
"integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
"integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
"integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
"integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
"integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
"integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
"integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
"integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
"integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
"integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
"integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
"integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
"integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
"integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
}
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
"integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==",
"license": "MIT",
"dependencies": {
"run-applescript": "^7.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/default-browser": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz",
"integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==",
"license": "MIT",
"dependencies": {
"bundle-name": "^4.1.0",
"default-browser-id": "^5.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/default-browser-id": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz",
"integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/define-lazy-prop": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
"integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.27.7",
"@esbuild/android-arm": "0.27.7",
"@esbuild/android-arm64": "0.27.7",
"@esbuild/android-x64": "0.27.7",
"@esbuild/darwin-arm64": "0.27.7",
"@esbuild/darwin-x64": "0.27.7",
"@esbuild/freebsd-arm64": "0.27.7",
"@esbuild/freebsd-x64": "0.27.7",
"@esbuild/linux-arm": "0.27.7",
"@esbuild/linux-arm64": "0.27.7",
"@esbuild/linux-ia32": "0.27.7",
"@esbuild/linux-loong64": "0.27.7",
"@esbuild/linux-mips64el": "0.27.7",
"@esbuild/linux-ppc64": "0.27.7",
"@esbuild/linux-riscv64": "0.27.7",
"@esbuild/linux-s390x": "0.27.7",
"@esbuild/linux-x64": "0.27.7",
"@esbuild/netbsd-arm64": "0.27.7",
"@esbuild/netbsd-x64": "0.27.7",
"@esbuild/openbsd-arm64": "0.27.7",
"@esbuild/openbsd-x64": "0.27.7",
"@esbuild/openharmony-arm64": "0.27.7",
"@esbuild/sunos-x64": "0.27.7",
"@esbuild/win32-arm64": "0.27.7",
"@esbuild/win32-ia32": "0.27.7",
"@esbuild/win32-x64": "0.27.7"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/is-docker": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
"integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==",
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-inside-container": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz",
"integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==",
"license": "MIT",
"dependencies": {
"is-docker": "^3.0.0"
},
"bin": {
"is-inside-container": "cli.js"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-wsl": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz",
"integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==",
"license": "MIT",
"dependencies": {
"is-inside-container": "^1.0.0"
},
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/open": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz",
"integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==",
"license": "MIT",
"dependencies": {
"default-browser": "^5.2.1",
"define-lazy-prop": "^3.0.0",
"is-inside-container": "^1.0.0",
"wsl-utils": "^0.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/run-applescript": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
"integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/wsl-utils": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz",
"integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==",
"license": "MIT",
"dependencies": {
"is-wsl": "^3.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
}
}
}

View File

@@ -0,0 +1,21 @@
{
"name": "spotify-scripts",
"version": "1.0.0",
"description": "Spotify helper CLI for OpenClaw skills",
"type": "module",
"scripts": {
"spotify": "tsx src/cli.ts",
"test": "node --import tsx --test tests/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"minimist": "^1.2.8",
"open": "^10.2.0"
},
"devDependencies": {
"@types/minimist": "^1.2.5",
"@types/node": "^24.8.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${SKILL_DIR}"
npm install
scripts/spotify auth

26
skills/spotify/scripts/spotify Executable file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const skillDir = resolve(scriptDir, "..");
const tsxBin = join(skillDir, "node_modules", ".bin", "tsx");
if (!existsSync(tsxBin)) {
process.stderr.write(`Missing local Node dependencies for spotify. Run 'cd ${skillDir} && npm install' first.\n`);
process.exit(1);
}
const result = spawnSync(process.execPath, [tsxBin, join(skillDir, "src", "cli.ts"), ...process.argv.slice(2)], {
stdio: "inherit"
});
if (result.error) {
process.stderr.write(`${result.error.message}\n`);
process.exit(1);
}
process.exit(typeof result.status === "number" ? result.status : 1);

View File

@@ -0,0 +1,174 @@
import { refreshStoredToken } from "./auth.js";
import { loadToken, tokenNeedsRefresh } from "./token-store.js";
import type { SpotifyPlaylist, SpotifyToken, SpotifyTrack } from "./types.js";
type FetchLike = typeof fetch;
export interface SpotifyUser {
id: string;
display_name?: string | null;
}
export interface SpotifyApiClientOptions {
fetchImpl?: FetchLike;
loadToken?: () => Promise<SpotifyToken | undefined>;
refreshToken?: () => Promise<SpotifyToken>;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
baseUrl?: string;
}
export interface CreatePlaylistOptions {
description?: string;
public?: boolean;
}
export interface PlaylistMutationResult {
snapshot_id?: string;
}
const DEFAULT_BASE_URL = "https://api.spotify.com/v1";
function chunk<T>(items: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size));
}
return chunks;
}
async function noSleep(): Promise<void> {
return undefined;
}
export class SpotifyApiClient {
private readonly fetchImpl: FetchLike;
private readonly loadStoredToken: () => Promise<SpotifyToken | undefined>;
private readonly refreshStoredToken: () => Promise<SpotifyToken>;
private readonly sleep: (ms: number) => Promise<void>;
private readonly now: () => number;
private readonly baseUrl: string;
constructor(options: SpotifyApiClientOptions = {}) {
this.fetchImpl = options.fetchImpl ?? fetch;
this.loadStoredToken = options.loadToken ?? (() => loadToken());
this.refreshStoredToken = options.refreshToken ?? (() => refreshStoredToken());
this.sleep = options.sleep ?? noSleep;
this.now = options.now ?? Date.now;
this.baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
}
async getCurrentUser(): Promise<SpotifyUser> {
return this.request<SpotifyUser>("GET", "/me");
}
async searchTracks(query: string, limit: number): Promise<SpotifyTrack[]> {
const params = new URLSearchParams({ type: "track", q: query, limit: String(limit) });
const response = await this.request<{ tracks: { items: SpotifyTrack[] } }>("GET", `/search?${params.toString()}`);
return response.tracks.items;
}
async listPlaylists(limit: number, offset: number): Promise<SpotifyPlaylist[]> {
const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
const response = await this.request<{ items: SpotifyPlaylist[] }>("GET", `/me/playlists?${params.toString()}`);
return response.items;
}
async createPlaylist(name: string, options: CreatePlaylistOptions = {}): Promise<SpotifyPlaylist> {
return this.request<SpotifyPlaylist>("POST", "/me/playlists", {
name,
description: options.description,
public: Boolean(options.public)
});
}
async addItemsToPlaylist(playlistId: string, uris: string[]): Promise<PlaylistMutationResult[]> {
const results: PlaylistMutationResult[] = [];
for (const batch of chunk(uris, 100)) {
results.push(await this.request<PlaylistMutationResult>("POST", `/playlists/${encodeURIComponent(playlistId)}/items`, { uris: batch }));
}
return results;
}
async removeItemsFromPlaylist(playlistId: string, uris: string[]): Promise<PlaylistMutationResult[]> {
const results: PlaylistMutationResult[] = [];
for (const batch of chunk(uris, 100)) {
results.push(await this.request<PlaylistMutationResult>("DELETE", `/playlists/${encodeURIComponent(playlistId)}/items`, {
tracks: batch.map((uri) => ({ uri }))
}));
}
return results;
}
private async getAccessToken(): Promise<SpotifyToken> {
const token = await this.loadStoredToken();
if (!token) {
throw new Error("Spotify auth token not found. Run `scripts/spotify auth` first.");
}
if (tokenNeedsRefresh(token, this.now())) {
return this.refreshAccessTokenSafely();
}
return token;
}
private async request<T>(method: string, path: string, body?: unknown, authRetried = false): Promise<T> {
const token = await this.getAccessToken();
const response = await this.fetchWithTransientRetries(method, path, token.accessToken, body);
if (response.status === 401 && !authRetried) {
const refreshed = await this.refreshAccessTokenSafely();
const retryResponse = await this.fetchWithTransientRetries(method, path, refreshed.accessToken, body);
return this.parseResponse<T>(retryResponse);
}
return this.parseResponse<T>(response);
}
private async fetchWithTransientRetries(method: string, path: string, accessToken: string, body: unknown): Promise<Response> {
let retried429 = false;
let serverRetries = 0;
while (true) {
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
method,
headers: {
authorization: `Bearer ${accessToken}`,
...(body === undefined ? {} : { "content-type": "application/json" })
},
body: body === undefined ? undefined : JSON.stringify(body)
});
if (response.status === 429 && !retried429) {
retried429 = true;
const retryAfter = Number(response.headers.get("retry-after") ?? "1");
await this.sleep(Number.isFinite(retryAfter) ? retryAfter * 1000 : 1000);
continue;
}
if (response.status >= 500 && response.status < 600 && serverRetries < 2) {
serverRetries += 1;
await this.sleep(100 * serverRetries);
continue;
}
return response;
}
}
private async refreshAccessTokenSafely(): Promise<SpotifyToken> {
try {
return await this.refreshStoredToken();
} catch {
throw new Error("Spotify token refresh failed. Run `scripts/spotify auth` again.");
}
}
private async parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
throw new Error(`Spotify API request failed with status ${response.status}.`);
}
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
}
export function createSpotifyApiClient(options: SpotifyApiClientOptions = {}): SpotifyApiClient {
return new SpotifyApiClient(options);
}

230
skills/spotify/src/auth.ts Normal file
View File

@@ -0,0 +1,230 @@
import { createHash, randomBytes } from "node:crypto";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import open from "open";
import { loadSpotifyConfig, type ResolveConfigOptions } from "./config.js";
import { loadToken, saveToken } from "./token-store.js";
import type { SpotifyConfig, SpotifyToken } from "./types.js";
export const SPOTIFY_SCOPES = [
"playlist-modify-public",
"playlist-modify-private",
"playlist-read-private",
"playlist-read-collaborative"
];
type FetchLike = typeof fetch;
export interface TokenEndpointResponse {
access_token: string;
refresh_token?: string;
expires_in: number;
}
export interface AuthFlowOptions extends ResolveConfigOptions {
fetchImpl?: FetchLike;
openUrl?: (url: string) => Promise<unknown>;
now?: () => number;
}
export interface AuthStatus {
configFound: boolean;
tokenFound: boolean;
tokenExpired: boolean | null;
expiresAt: number | null;
}
function base64Url(buffer: Buffer): string {
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/u, "");
}
export function createCodeVerifier(bytes = randomBytes(64)): string {
return base64Url(bytes);
}
export function createCodeChallenge(verifier: string): string {
return base64Url(createHash("sha256").update(verifier).digest());
}
export function createState(bytes = randomBytes(32)): string {
return base64Url(bytes);
}
export function buildAuthorizeUrl(config: SpotifyConfig, verifier: string, state: string, scopes = SPOTIFY_SCOPES): string {
const params = new URLSearchParams({
response_type: "code",
client_id: config.clientId,
scope: scopes.join(" "),
redirect_uri: config.redirectUri,
state,
code_challenge_method: "S256",
code_challenge: createCodeChallenge(verifier)
});
return `https://accounts.spotify.com/authorize?${params.toString()}`;
}
async function parseTokenResponse(response: Response, fallbackRefreshToken?: string, now = Date.now()): Promise<SpotifyToken> {
if (!response.ok) {
throw new Error(`Spotify token request failed with status ${response.status}.`);
}
const body = await response.json() as Partial<TokenEndpointResponse>;
if (typeof body.access_token !== "string" || typeof body.expires_in !== "number") {
throw new Error("Spotify token response was missing required fields.");
}
if (!body.refresh_token && !fallbackRefreshToken) {
throw new Error("Spotify token response did not include a refresh token.");
}
return {
accessToken: body.access_token,
refreshToken: body.refresh_token ?? fallbackRefreshToken ?? "",
expiresAt: now + body.expires_in * 1000
};
}
export async function exchangeCodeForToken(
config: SpotifyConfig,
code: string,
verifier: string,
fetchImpl: FetchLike = fetch,
now = Date.now()
): Promise<SpotifyToken> {
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
code_verifier: verifier
});
const response = await fetchImpl("https://accounts.spotify.com/api/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body
});
return parseTokenResponse(response, undefined, now);
}
export async function refreshAccessToken(
config: SpotifyConfig,
refreshToken: string,
fetchImpl: FetchLike = fetch,
now = Date.now()
): Promise<SpotifyToken> {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: config.clientId
});
const response = await fetchImpl("https://accounts.spotify.com/api/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body
});
return parseTokenResponse(response, refreshToken, now);
}
export async function refreshStoredToken(options: AuthFlowOptions = {}): Promise<SpotifyToken> {
const config = await loadSpotifyConfig(options);
const existing = await loadToken(options);
if (!existing) {
throw new Error("Spotify auth token not found. Run `scripts/spotify auth` first.");
}
const refreshed = await refreshAccessToken(config, existing.refreshToken, options.fetchImpl ?? fetch, options.now?.() ?? Date.now());
await saveToken(refreshed, options);
return refreshed;
}
export async function waitForAuthorizationCode(redirectUri: string, expectedState: string, timeoutMs = 300_000): Promise<string> {
const redirect = new URL(redirectUri);
const hostname = redirect.hostname || "127.0.0.1";
const port = Number(redirect.port);
if (!port || Number.isNaN(port)) {
throw new Error("Spotify redirectUri must include an explicit local port.");
}
return new Promise((resolvePromise, reject) => {
let settled = false;
const server = createServer((request, response) => {
const finish = (callback: () => void): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(timer);
server.close();
callback();
};
try {
const requestUrl = new URL(request.url ?? "/", redirectUri);
if (requestUrl.pathname !== redirect.pathname) {
response.writeHead(404).end("Not found");
return;
}
const error = requestUrl.searchParams.get("error");
if (error) {
throw new Error(`Spotify authorization failed: ${error}`);
}
const state = requestUrl.searchParams.get("state");
if (state !== expectedState) {
throw new Error("Spotify authorization state mismatch.");
}
const code = requestUrl.searchParams.get("code");
if (!code) {
throw new Error("Spotify authorization callback did not include a code.");
}
response.writeHead(200, { "content-type": "text/plain" }).end("Spotify authorization complete. You can close this tab.");
finish(() => resolvePromise(code));
} catch (error) {
response.writeHead(400, { "content-type": "text/plain" }).end("Spotify authorization failed.");
finish(() => reject(error));
}
});
const timer = setTimeout(() => {
if (settled) {
return;
}
settled = true;
server.close();
reject(new Error("Spotify authorization timed out waiting for the browser callback."));
}, timeoutMs);
server.once("error", reject);
server.listen(port, hostname, () => {
const address = server.address() as AddressInfo;
if (address.port !== port) {
server.close();
reject(new Error("Spotify authorization callback server bound to the wrong port."));
}
});
});
}
export async function runAuthorizationFlow(options: AuthFlowOptions = {}): Promise<SpotifyToken> {
const config = await loadSpotifyConfig(options);
const verifier = createCodeVerifier();
const state = createState();
const authUrl = buildAuthorizeUrl(config, verifier, state);
await (options.openUrl ?? open)(authUrl);
const code = await waitForAuthorizationCode(config.redirectUri, state);
const token = await exchangeCodeForToken(config, code, verifier, options.fetchImpl ?? fetch, options.now?.() ?? Date.now());
await saveToken(token, options);
return token;
}
export async function getAuthStatus(options: ResolveConfigOptions & { now?: () => number } = {}): Promise<AuthStatus> {
let configFound = false;
try {
await loadSpotifyConfig(options);
configFound = true;
} catch {
configFound = false;
}
const token = await loadToken(options);
return {
configFound,
tokenFound: Boolean(token),
tokenExpired: token ? token.expiresAt <= (options.now?.() ?? Date.now()) : null,
expiresAt: token?.expiresAt ?? null
};
}

143
skills/spotify/src/cli.ts Normal file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env node
import minimist from "minimist";
import { fileURLToPath } from "node:url";
import { getAuthStatus, runAuthorizationFlow } from "./auth.js";
import { runImportCommand } from "./importers/index.js";
import {
runAddToPlaylistCommand,
runCreatePlaylistCommand,
runRemoveFromPlaylistCommand,
runSearchAndAddCommand
} from "./playlists.js";
import { runListPlaylistsCommand, runSearchCommand } from "./search.js";
export interface CliDeps {
stdout: Pick<NodeJS.WriteStream, "write">;
stderr: Pick<NodeJS.WriteStream, "write">;
}
export interface ParsedCli {
command: string;
positional: string[];
json: boolean;
public?: boolean;
limit?: string;
offset?: string;
description?: string;
playlist?: string;
playlistId?: string;
}
export type CommandHandler = (args: ParsedCli, deps: CliDeps) => Promise<number> | number;
export type CommandHandlers = Record<string, CommandHandler>;
function notImplemented(command: string): CommandHandler {
return (args, deps) => {
const payload = { ok: false, error: `Command not implemented yet: ${command}` };
if (args.json) {
deps.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
} else {
deps.stderr.write(`${payload.error}\n`);
}
return 2;
};
}
export function createDefaultHandlers(): CommandHandlers {
return {
auth: async (_args, deps) => {
await runAuthorizationFlow();
deps.stdout.write("Spotify authorization complete.\n");
return 0;
},
status: async (args, deps) => {
const status = await getAuthStatus();
if (args.json) {
deps.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
} else {
deps.stdout.write(`Spotify config: ${status.configFound ? "found" : "missing"}\n`);
deps.stdout.write(`Spotify token: ${status.tokenFound ? "found" : "missing"}\n`);
if (status.tokenFound) {
deps.stdout.write(`Spotify token expired: ${status.tokenExpired ? "yes" : "no"}\n`);
}
}
return status.configFound && status.tokenFound ? 0 : 1;
},
search: runSearchCommand,
"list-playlists": runListPlaylistsCommand,
"create-playlist": runCreatePlaylistCommand,
"add-to-playlist": runAddToPlaylistCommand,
"remove-from-playlist": runRemoveFromPlaylistCommand,
"search-and-add": runSearchAndAddCommand,
import: runImportCommand
};
}
export function usage(): string {
return `spotify
Commands:
auth
status [--json]
search <query> [--limit N] [--json]
list-playlists [--limit N] [--offset N] [--json]
create-playlist <name> [--description TEXT] [--public] [--json]
add-to-playlist <playlistId> <spotify:track:...> [more uris...] [--json]
remove-from-playlist <playlistId> <spotify:track:...> [more uris...] [--json]
search-and-add <playlistId> <query> [more queries...] [--json]
import <path> [--playlist NAME | --playlist-id ID] [--public] [--json]
`;
}
export async function runCli(
argv: string[],
deps: CliDeps = { stdout: process.stdout, stderr: process.stderr },
handlers: CommandHandlers = createDefaultHandlers()
): Promise<number> {
const args = minimist(argv, {
boolean: ["help", "json", "public"],
string: ["limit", "offset", "description", "playlist", "playlist-id"],
alias: {
h: "help"
}
});
const [command] = args._;
if (!command || args.help) {
deps.stdout.write(usage());
return 0;
}
const handler = handlers[command];
if (!handler) {
deps.stderr.write(`Unknown command: ${command}\n\n${usage()}`);
return 1;
}
return handler(
{
command,
positional: args._.slice(1).map(String),
json: Boolean(args.json),
public: Boolean(args.public),
limit: args.limit,
offset: args.offset,
description: args.description,
playlist: args.playlist,
playlistId: args["playlist-id"]
},
deps
);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
runCli(process.argv.slice(2)).then((code) => {
process.exitCode = code;
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
process.stderr.write(`${message}\n`);
process.exitCode = 1;
});
}

View File

@@ -0,0 +1,116 @@
import { access, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import type { SpotifyConfig } from "./types.js";
export interface SpotifyPaths {
configDir: string;
configPath: string;
tokenPath: string;
}
export interface ResolveConfigOptions {
env?: NodeJS.ProcessEnv;
startDir?: string;
homeDir?: string;
}
const skillDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
function pathExists(path: string): Promise<boolean> {
return access(path, constants.F_OK).then(() => true, () => false);
}
function expandHome(path: string, homeDir: string): string {
if (path === "~") {
return homeDir;
}
if (path.startsWith("~/")) {
return join(homeDir, path.slice(2));
}
return path;
}
async function findUpwardCredentialDir(startDir: string): Promise<string | undefined> {
let current = resolve(startDir);
while (true) {
const candidate = join(current, ".clawdbot", "credentials", "spotify");
if (await pathExists(candidate)) {
return candidate;
}
const parent = dirname(current);
if (parent === current) {
return undefined;
}
current = parent;
}
}
export async function resolveSpotifyPaths(options: ResolveConfigOptions = {}): Promise<SpotifyPaths> {
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? homedir();
const candidates: Array<Promise<string | undefined>> = [];
if (env.SPOTIFY_CONFIG_DIR) {
candidates.push(Promise.resolve(resolve(expandHome(env.SPOTIFY_CONFIG_DIR, homeDir))));
}
candidates.push(Promise.resolve(join(homeDir, ".openclaw", "workspace", ".clawdbot", "credentials", "spotify")));
candidates.push(findUpwardCredentialDir(options.startDir ?? skillDir));
candidates.push(Promise.resolve(join(homeDir, ".clawdbot", "credentials", "spotify")));
for (const candidatePromise of candidates) {
const candidate = await candidatePromise;
if (candidate && await pathExists(candidate)) {
return {
configDir: candidate,
configPath: join(candidate, "config.json"),
tokenPath: join(candidate, "token.json")
};
}
}
const fallback = env.SPOTIFY_CONFIG_DIR
? resolve(expandHome(env.SPOTIFY_CONFIG_DIR, homeDir))
: join(homeDir, ".openclaw", "workspace", ".clawdbot", "credentials", "spotify");
return {
configDir: fallback,
configPath: join(fallback, "config.json"),
tokenPath: join(fallback, "token.json")
};
}
export async function loadSpotifyConfig(options: ResolveConfigOptions = {}): Promise<SpotifyConfig> {
const paths = await resolveSpotifyPaths(options);
let raw: string;
try {
raw = await readFile(paths.configPath, "utf8");
} catch {
throw new Error(`Spotify config not found. Create ${paths.configPath} with clientId and redirectUri.`);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Spotify config is not valid JSON.");
}
if (
!parsed ||
typeof parsed !== "object" ||
typeof (parsed as SpotifyConfig).clientId !== "string" ||
typeof (parsed as SpotifyConfig).redirectUri !== "string" ||
!(parsed as SpotifyConfig).clientId.trim() ||
!(parsed as SpotifyConfig).redirectUri.trim()
) {
throw new Error("Spotify config must include non-empty clientId and redirectUri.");
}
return {
clientId: (parsed as SpotifyConfig).clientId,
redirectUri: (parsed as SpotifyConfig).redirectUri
};
}

View File

@@ -0,0 +1,24 @@
import { readdir } from "node:fs/promises";
import { join } from "node:path";
import type { ParsedTrackRef } from "../types.js";
import { dedupeTrackRefs, isAudioFile, parseArtistTitle } from "./importer-utils.js";
async function walkAudioFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files: string[] = [];
for (const entry of entries) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...await walkAudioFiles(path));
} else if (entry.isFile() && isAudioFile(entry.name)) {
files.push(path);
}
}
return files.sort();
}
export async function readFolder(path: string): Promise<ParsedTrackRef[]> {
const files = await walkAudioFiles(path);
return dedupeTrackRefs(files.flatMap(parseArtistTitle));
}

View File

@@ -0,0 +1,90 @@
import { basename, extname } from "node:path";
import type { ParsedTrackRef } from "../types.js";
const audioExtensions = new Set([".aac", ".aiff", ".alac", ".flac", ".m4a", ".mp3", ".ogg", ".opus", ".wav", ".wma"]);
export function normalizeText(value: string): string {
return value.normalize("NFKC").replace(/[_\t]+/g, " ").replace(/\s+/g, " ").trim();
}
export function stripAudioExtension(filename: string): string {
const extension = extname(filename).toLowerCase();
const base = basename(filename);
return audioExtensions.has(extension) ? base.slice(0, -extension.length) : base;
}
export function isAudioFile(filename: string): boolean {
return audioExtensions.has(extname(filename).toLowerCase());
}
export function stripTrackNumberPrefix(value: string): string {
return normalizeText(value)
.replace(/^\d{1,3}\s*[-._)]\s*/u, "")
.replace(/^\d{1,3}\s+/u, "");
}
function ref(source: string, artist: string | undefined, title: string | undefined, query?: string): ParsedTrackRef {
const cleanedArtist = artist ? normalizeText(artist) : undefined;
const cleanedTitle = title ? normalizeText(title) : undefined;
return {
source,
query: normalizeText(query ?? [cleanedArtist, cleanedTitle].filter(Boolean).join(" ")),
...(cleanedArtist ? { artist: cleanedArtist } : {}),
...(cleanedTitle ? { title: cleanedTitle } : {})
};
}
export function parseArtistTitle(value: string): ParsedTrackRef[] {
const source = normalizeText(stripTrackNumberPrefix(stripAudioExtension(value)));
if (!source) {
return [];
}
const colon = source.match(/^(.+?)\s*:\s*(.+)$/u);
if (colon) {
return [ref(source, colon[1], colon[2])];
}
const dash = source.match(/^(.+?)\s+-\s+(.+)$/u);
if (dash) {
return [ref(source, dash[1], dash[2]), ref(source, dash[2], dash[1])];
}
const underscore = value.match(/^(.+?)_(.+)$/u);
if (underscore) {
return [ref(source, underscore[1], underscore[2])];
}
return [ref(source, undefined, source, source)];
}
export function dedupeTrackRefs(refs: ParsedTrackRef[]): ParsedTrackRef[] {
const seen = new Set<string>();
const output: ParsedTrackRef[] = [];
for (const item of refs) {
const key = `${normalizeText(item.artist ?? "").toLowerCase()}|${normalizeText(item.title ?? item.query).toLowerCase()}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(item);
}
return output;
}
export function buildSearchQueries(ref: ParsedTrackRef): string[] {
const queries = new Set<string>();
if (ref.artist && ref.title) {
queries.add(`${ref.artist} ${ref.title}`);
queries.add(`track:${ref.title} artist:${ref.artist}`);
}
queries.add(ref.query);
return Array.from(queries).map(normalizeText).filter(Boolean);
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export const DEFAULT_IMPORT_SEARCH_DELAY_MS = 100;

View File

@@ -0,0 +1,108 @@
import { stat } from "node:fs/promises";
import { extname } from "node:path";
import { createSpotifyApiClient, type SpotifyApiClient } from "../api-client.js";
import type { CliDeps, ParsedCli } from "../cli.js";
import type { ImportResult, ParsedTrackRef, SpotifyTrack } from "../types.js";
import { DEFAULT_IMPORT_SEARCH_DELAY_MS, buildSearchQueries, sleep as defaultSleep } from "./importer-utils.js";
import { readFolder } from "./folder.js";
import { readM3u } from "./m3u.js";
import { readTextList } from "./text-list.js";
type ImportClient = Pick<SpotifyApiClient, "searchTracks" | "createPlaylist" | "addItemsToPlaylist">;
export interface ImportOptions {
playlist?: string;
playlistId?: string;
public?: boolean;
delayMs?: number;
sleep?: (ms: number) => Promise<void>;
}
export async function readImportSource(path: string): Promise<ParsedTrackRef[]> {
const info = await stat(path);
if (info.isDirectory()) {
return readFolder(path);
}
const extension = extname(path).toLowerCase();
if (extension === ".m3u" || extension === ".m3u8") {
return readM3u(path);
}
return readTextList(path);
}
async function findTrack(ref: ParsedTrackRef, client: Pick<ImportClient, "searchTracks">): Promise<SpotifyTrack | undefined> {
for (const query of buildSearchQueries(ref)) {
const tracks = await client.searchTracks(query, 1);
if (tracks[0]) {
return tracks[0];
}
}
return undefined;
}
export async function importTracks(
path: string,
options: ImportOptions,
client: ImportClient = createSpotifyApiClient()
): Promise<ImportResult> {
if (Boolean(options.playlist) === Boolean(options.playlistId)) {
throw new Error("Specify exactly one of --playlist or --playlist-id.");
}
const refs = await readImportSource(path);
const wait = options.sleep ?? defaultSleep;
const found: ImportResult["found"] = [];
const missed: ImportResult["missed"] = [];
const foundUris = new Set<string>();
for (const [index, ref] of refs.entries()) {
const track = await findTrack(ref, client);
if (!track) {
missed.push({ ...ref, reason: "No Spotify match found" });
} else if (!foundUris.has(track.uri)) {
foundUris.add(track.uri);
found.push({ ...ref, uri: track.uri, matchedName: track.name, matchedArtists: track.artists.map((artist) => artist.name) });
}
if (options.delayMs !== 0 && index < refs.length - 1) {
await wait(options.delayMs ?? DEFAULT_IMPORT_SEARCH_DELAY_MS);
}
}
const playlistId = options.playlistId ?? (await client.createPlaylist(options.playlist ?? "", { public: Boolean(options.public) })).id;
const mutationResults = found.length > 0
? await client.addItemsToPlaylist(playlistId, found.map((item) => item.uri))
: [];
return {
found,
missed,
added: {
playlistId,
count: found.length,
snapshotIds: mutationResults.map((result) => result.snapshot_id).filter((id): id is string => Boolean(id))
}
};
}
export async function runImportCommand(
args: ParsedCli,
deps: CliDeps,
client: ImportClient = createSpotifyApiClient()
): Promise<number> {
const [path] = args.positional;
if (!path) {
throw new Error("Missing import path.");
}
const result = await importTracks(path, {
playlist: args.playlist,
playlistId: args.playlistId,
public: args.public
}, client);
if (args.json) {
deps.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
} else {
deps.stdout.write(`Imported ${result.found.length} track(s); missed ${result.missed.length}; playlist ${result.added?.playlistId}.\n`);
}
return 0;
}

View File

@@ -0,0 +1,38 @@
import { readFile } from "node:fs/promises";
import { basename } from "node:path";
import type { ParsedTrackRef } from "../types.js";
import { dedupeTrackRefs, parseArtistTitle } from "./importer-utils.js";
function parseExtInf(line: string): string | undefined {
const comma = line.indexOf(",");
if (comma === -1 || comma === line.length - 1) {
return undefined;
}
return line.slice(comma + 1).trim();
}
export function parseM3u(content: string): ParsedTrackRef[] {
const refs: ParsedTrackRef[] = [];
let pendingExtInf: string | undefined;
for (const rawLine of content.split(/\r?\n/u)) {
const line = rawLine.trim();
if (!line) {
continue;
}
if (line.startsWith("#EXTINF:")) {
pendingExtInf = parseExtInf(line);
continue;
}
if (line.startsWith("#")) {
continue;
}
refs.push(...parseArtistTitle(pendingExtInf ?? basename(line)));
pendingExtInf = undefined;
}
return dedupeTrackRefs(refs);
}
export async function readM3u(path: string): Promise<ParsedTrackRef[]> {
return parseM3u(await readFile(path, "utf8"));
}

View File

@@ -0,0 +1,17 @@
import { readFile } from "node:fs/promises";
import type { ParsedTrackRef } from "../types.js";
import { dedupeTrackRefs, parseArtistTitle } from "./importer-utils.js";
export function parseTextList(content: string): ParsedTrackRef[] {
const refs = content
.split(/\r?\n/u)
.map((line) => line.trim())
.filter((line) => line && !line.startsWith("#") && !line.startsWith("//"))
.flatMap(parseArtistTitle);
return dedupeTrackRefs(refs);
}
export async function readTextList(path: string): Promise<ParsedTrackRef[]> {
return parseTextList(await readFile(path, "utf8"));
}

View File

@@ -0,0 +1,118 @@
import { createSpotifyApiClient, type SpotifyApiClient } from "./api-client.js";
import type { CliDeps, ParsedCli } from "./cli.js";
import { mapPlaylist, mapTrack } from "./search.js";
import type { SpotifyTrack } from "./types.js";
type PlaylistClient = Pick<SpotifyApiClient, "createPlaylist" | "addItemsToPlaylist" | "removeItemsFromPlaylist" | "searchTracks">;
export function validateTrackUris(uris: string[]): string[] {
if (uris.length === 0) {
throw new Error("At least one spotify:track URI is required.");
}
for (const uri of uris) {
if (!uri.startsWith("spotify:track:")) {
throw new Error(`Invalid Spotify track URI: ${uri}`);
}
}
return uris;
}
function snapshotIds(results: Array<{ snapshot_id?: string }>): string[] {
return results.map((result) => result.snapshot_id).filter((id): id is string => Boolean(id));
}
export async function runCreatePlaylistCommand(
args: ParsedCli,
deps: CliDeps,
client: PlaylistClient = createSpotifyApiClient()
): Promise<number> {
const name = args.positional.join(" ").trim();
if (!name) {
throw new Error("Missing playlist name.");
}
const playlist = mapPlaylist(await client.createPlaylist(name, { description: args.description, public: Boolean(args.public) }));
if (args.json) {
deps.stdout.write(`${JSON.stringify({ playlist }, null, 2)}\n`);
} else {
deps.stdout.write(`Created playlist ${playlist.id}: ${playlist.name}\n`);
}
return 0;
}
export async function runAddToPlaylistCommand(
args: ParsedCli,
deps: CliDeps,
client: PlaylistClient = createSpotifyApiClient()
): Promise<number> {
const [playlistId, ...uris] = args.positional;
if (!playlistId) {
throw new Error("Missing playlist id.");
}
const validUris = validateTrackUris(uris);
const results = await client.addItemsToPlaylist(playlistId, validUris);
const output = { playlistId, count: validUris.length, snapshotIds: snapshotIds(results) };
deps.stdout.write(args.json ? `${JSON.stringify(output, null, 2)}\n` : `Added ${output.count} track(s) to ${playlistId}.\n`);
return 0;
}
export async function runRemoveFromPlaylistCommand(
args: ParsedCli,
deps: CliDeps,
client: PlaylistClient = createSpotifyApiClient()
): Promise<number> {
const [playlistId, ...uris] = args.positional;
if (!playlistId) {
throw new Error("Missing playlist id.");
}
const validUris = validateTrackUris(uris);
const results = await client.removeItemsFromPlaylist(playlistId, validUris);
const output = { playlistId, count: validUris.length, snapshotIds: snapshotIds(results) };
deps.stdout.write(args.json ? `${JSON.stringify(output, null, 2)}\n` : `Removed ${output.count} track(s) from ${playlistId}.\n`);
return 0;
}
export async function searchAndAdd(
playlistId: string,
queries: string[],
client: Pick<PlaylistClient, "searchTracks" | "addItemsToPlaylist">
): Promise<{ added: Array<{ query: string; uri: string; name: string; artists: string[] }>; missed: string[]; snapshotIds: string[] }> {
if (!playlistId) {
throw new Error("Missing playlist id.");
}
if (queries.length === 0) {
throw new Error("At least one search query is required.");
}
const added: Array<{ query: string; uri: string; name: string; artists: string[] }> = [];
const missed: string[] = [];
for (const query of queries) {
const tracks: SpotifyTrack[] = await client.searchTracks(query, 1);
const first = tracks[0];
if (!first) {
missed.push(query);
continue;
}
const output = mapTrack(first);
added.push({ query, uri: output.uri, name: output.name, artists: output.artists });
}
const mutationResults = added.length > 0
? await client.addItemsToPlaylist(playlistId, added.map((entry) => entry.uri))
: [];
return { added, missed, snapshotIds: snapshotIds(mutationResults) };
}
export async function runSearchAndAddCommand(
args: ParsedCli,
deps: CliDeps,
client: PlaylistClient = createSpotifyApiClient()
): Promise<number> {
const [playlistId, ...queries] = args.positional;
const output = await searchAndAdd(playlistId, queries, client);
if (args.json) {
deps.stdout.write(`${JSON.stringify(output, null, 2)}\n`);
} else {
deps.stdout.write(`Added ${output.added.length} track(s) to ${playlistId}; missed ${output.missed.length}.\n`);
}
return 0;
}

View File

@@ -0,0 +1,98 @@
import { createSpotifyApiClient, type SpotifyApiClient } from "./api-client.js";
import type { CliDeps, ParsedCli } from "./cli.js";
import type { SpotifyPlaylist, SpotifyTrack } from "./types.js";
export interface TrackOutput {
id: string;
uri: string;
name: string;
artists: string[];
album?: string;
externalUrl?: string;
}
export interface PlaylistOutput {
id: string;
name: string;
public: boolean | null;
owner: string;
externalUrl?: string;
}
function numberOption(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, Math.trunc(value)));
}
export function mapTrack(track: SpotifyTrack): TrackOutput {
return {
id: track.id,
uri: track.uri,
name: track.name,
artists: track.artists.map((artist) => artist.name),
album: track.album?.name,
externalUrl: track.external_urls?.spotify
};
}
export function mapPlaylist(playlist: SpotifyPlaylist): PlaylistOutput {
return {
id: playlist.id,
name: playlist.name,
public: playlist.public,
owner: playlist.owner.display_name ?? playlist.owner.id,
externalUrl: playlist.external_urls?.spotify
};
}
export function formatTrack(track: TrackOutput): string {
const album = track.album ? ` (${track.album})` : "";
return `${track.uri} | ${track.name} | ${track.artists.join(", ")}${album}`;
}
export function formatPlaylist(playlist: PlaylistOutput): string {
const visibility = playlist.public === true ? "public" : playlist.public === false ? "private" : "unknown";
return `${playlist.id} | ${visibility} | ${playlist.owner} | ${playlist.name}`;
}
export async function runSearchCommand(
args: ParsedCli,
deps: CliDeps,
client: Pick<SpotifyApiClient, "searchTracks"> = createSpotifyApiClient()
): Promise<number> {
const query = args.positional.join(" ").trim();
if (!query) {
throw new Error("Missing search query.");
}
const limit = clamp(numberOption(args.limit, 5), 1, 10);
const tracks = (await client.searchTracks(query, limit)).map(mapTrack);
if (args.json) {
deps.stdout.write(`${JSON.stringify({ tracks }, null, 2)}\n`);
} else {
deps.stdout.write(`${tracks.map(formatTrack).join("\n")}${tracks.length ? "\n" : ""}`);
}
return 0;
}
export async function runListPlaylistsCommand(
args: ParsedCli,
deps: CliDeps,
client: Pick<SpotifyApiClient, "listPlaylists"> = createSpotifyApiClient()
): Promise<number> {
const limit = clamp(numberOption(args.limit, 50), 1, 50);
const offset = Math.max(0, numberOption(args.offset, 0));
const playlists = (await client.listPlaylists(limit, offset)).map(mapPlaylist);
if (args.json) {
deps.stdout.write(`${JSON.stringify({ playlists }, null, 2)}\n`);
} else {
deps.stdout.write(`${playlists.map(formatPlaylist).join("\n")}${playlists.length ? "\n" : ""}`);
}
return 0;
}

View File

@@ -0,0 +1,80 @@
import { chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { resolveSpotifyPaths, type ResolveConfigOptions } from "./config.js";
import type { SpotifyToken } from "./types.js";
export interface TokenStoreOptions extends ResolveConfigOptions {
tokenPath?: string;
}
function isSpotifyToken(value: unknown): value is SpotifyToken {
return Boolean(
value &&
typeof value === "object" &&
typeof (value as SpotifyToken).accessToken === "string" &&
typeof (value as SpotifyToken).refreshToken === "string" &&
typeof (value as SpotifyToken).expiresAt === "number"
);
}
export async function resolveTokenPath(options: TokenStoreOptions = {}): Promise<string> {
if (options.tokenPath) {
return options.tokenPath;
}
return (await resolveSpotifyPaths(options)).tokenPath;
}
export async function loadToken(options: TokenStoreOptions = {}): Promise<SpotifyToken | undefined> {
const tokenPath = await resolveTokenPath(options);
let raw: string;
try {
raw = await readFile(tokenPath, "utf8");
} catch {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Spotify token store is not valid JSON.");
}
if (!isSpotifyToken(parsed)) {
throw new Error("Spotify token store has an invalid token shape.");
}
return parsed;
}
export async function saveToken(token: SpotifyToken, options: TokenStoreOptions = {}): Promise<string> {
const tokenPath = await resolveTokenPath(options);
await mkdir(dirname(tokenPath), { recursive: true, mode: 0o700 });
const tempPath = `${tokenPath}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tempPath, `${JSON.stringify(token, null, 2)}\n`, { mode: 0o600 });
try {
await chmod(tempPath, 0o600);
} catch {
// chmod can fail on non-POSIX filesystems; the initial mode still keeps normal macOS writes private.
}
await rename(tempPath, tokenPath);
try {
await chmod(tokenPath, 0o600);
} catch {
// Best effort only; token values are never printed.
}
return tokenPath;
}
export function tokenNeedsRefresh(token: SpotifyToken, now = Date.now(), skewMs = 60_000): boolean {
return token.expiresAt <= now + skewMs;
}
export async function tokenFileMode(options: TokenStoreOptions = {}): Promise<number | undefined> {
const tokenPath = await resolveTokenPath(options);
try {
return (await stat(tokenPath)).mode & 0o777;
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,41 @@
export interface SpotifyConfig {
clientId: string;
redirectUri: string;
}
export interface SpotifyToken {
accessToken: string;
refreshToken: string;
expiresAt: number;
}
export interface SpotifyTrack {
id: string;
uri: string;
name: string;
artists: Array<{ name: string }>;
album?: { name: string };
external_urls?: { spotify?: string };
}
export interface SpotifyPlaylist {
id: string;
uri: string;
name: string;
public: boolean | null;
owner: { id: string; display_name?: string | null };
external_urls?: { spotify?: string };
}
export interface ParsedTrackRef {
source: string;
query: string;
artist?: string;
title?: string;
}
export interface ImportResult {
found: Array<ParsedTrackRef & { uri: string; matchedName: string; matchedArtists: string[] }>;
missed: Array<ParsedTrackRef & { reason: string }>;
added?: { playlistId: string; count: number; snapshotIds: string[] };
}

View File

@@ -0,0 +1,181 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createSpotifyApiClient } from "../src/api-client.js";
import type { SpotifyToken } from "../src/types.js";
function jsonResponse(body: unknown, status = 200, headers?: HeadersInit): Response {
return new Response(JSON.stringify(body), { status, headers });
}
function token(expiresAt = Date.now() + 3_600_000): SpotifyToken {
return { accessToken: "access-token", refreshToken: "refresh-token", expiresAt };
}
test("searchTracks uses current search endpoint", async () => {
const urls: string[] = [];
const client = createSpotifyApiClient({
loadToken: async () => token(),
fetchImpl: (async (url) => {
urls.push(String(url));
return jsonResponse({ tracks: { items: [{ id: "1", uri: "spotify:track:1", name: "Song", artists: [] }] } });
}) as typeof fetch
});
const tracks = await client.searchTracks("Karma Police", 3);
assert.equal(tracks[0].uri, "spotify:track:1");
assert.equal(urls[0], "https://api.spotify.com/v1/search?type=track&q=Karma+Police&limit=3");
});
test("proactively refreshes expired token before request", async () => {
const accessTokens: string[] = [];
const client = createSpotifyApiClient({
now: () => 1_000,
loadToken: async () => token(1_000),
refreshToken: async () => ({ accessToken: "fresh-token", refreshToken: "refresh-token", expiresAt: 10_000 }),
fetchImpl: (async (_url, init) => {
accessTokens.push(String(init?.headers && (init.headers as Record<string, string>).authorization));
return jsonResponse({ id: "me" });
}) as typeof fetch
});
await client.getCurrentUser();
assert.deepEqual(accessTokens, ["Bearer fresh-token"]);
});
test("reactively refreshes once on 401 and retries original request", async () => {
const accessTokens: string[] = [];
const client = createSpotifyApiClient({
loadToken: async () => token(),
refreshToken: async () => ({ accessToken: "fresh-token", refreshToken: "refresh-token", expiresAt: 20_000 }),
fetchImpl: (async (_url, init) => {
accessTokens.push(String(init?.headers && (init.headers as Record<string, string>).authorization));
if (accessTokens.length === 1) {
return jsonResponse({ error: "expired" }, 401);
}
return jsonResponse({ id: "me" });
}) as typeof fetch
});
await client.getCurrentUser();
assert.deepEqual(accessTokens, ["Bearer access-token", "Bearer fresh-token"]);
});
test("proactive refresh failure is sanitized", async () => {
const client = createSpotifyApiClient({
now: () => 1_000,
loadToken: async () => ({ accessToken: "access-secret", refreshToken: "refresh-secret", expiresAt: 1_000 }),
refreshToken: async () => {
throw new Error("refresh-secret access-secret credential-path");
},
fetchImpl: (async () => jsonResponse({ id: "me" })) as typeof fetch
});
await assert.rejects(
() => client.getCurrentUser(),
(error) => error instanceof Error &&
error.message === "Spotify token refresh failed. Run `scripts/spotify auth` again." &&
!error.message.includes("secret") &&
!error.message.includes("credential-path")
);
});
test("reactive refresh failure is sanitized", async () => {
const client = createSpotifyApiClient({
loadToken: async () => token(),
refreshToken: async () => {
throw new Error("refresh-secret access-secret credential-path");
},
fetchImpl: (async () => jsonResponse({ error: "expired" }, 401)) as typeof fetch
});
await assert.rejects(
() => client.getCurrentUser(),
(error) => error instanceof Error &&
error.message === "Spotify token refresh failed. Run `scripts/spotify auth` again." &&
!error.message.includes("secret") &&
!error.message.includes("credential-path")
);
});
test("retries 429 using retry-after", async () => {
const sleeps: number[] = [];
let attempts = 0;
const client = createSpotifyApiClient({
loadToken: async () => token(),
sleep: async (ms) => { sleeps.push(ms); },
fetchImpl: (async () => {
attempts += 1;
if (attempts === 1) {
return jsonResponse({ error: "rate" }, 429, { "retry-after": "2" });
}
return jsonResponse({ id: "me" });
}) as typeof fetch
});
await client.getCurrentUser();
assert.equal(attempts, 2);
assert.deepEqual(sleeps, [2000]);
});
test("429 retry has a separate budget from 5xx retry", async () => {
const statuses = [500, 429, 200];
const client = createSpotifyApiClient({
loadToken: async () => token(),
sleep: async () => undefined,
fetchImpl: (async () => {
const status = statuses.shift() ?? 200;
return status === 200 ? jsonResponse({ id: "me" }) : jsonResponse({ error: status }, status);
}) as typeof fetch
});
await client.getCurrentUser();
assert.deepEqual(statuses, []);
});
test("retries 5xx up to bounded attempts", async () => {
let attempts = 0;
const client = createSpotifyApiClient({
loadToken: async () => token(),
sleep: async () => undefined,
fetchImpl: (async () => {
attempts += 1;
if (attempts < 3) {
return jsonResponse({ error: "server" }, 500);
}
return jsonResponse({ id: "me" });
}) as typeof fetch
});
await client.getCurrentUser();
assert.equal(attempts, 3);
});
test("playlist mutations use current items endpoints and chunk batches", async () => {
const calls: Array<{ url: string; method?: string; body?: string }> = [];
const client = createSpotifyApiClient({
loadToken: async () => token(),
fetchImpl: (async (url, init) => {
calls.push({ url: String(url), method: init?.method, body: String(init?.body) });
return jsonResponse({ snapshot_id: `snap-${calls.length}` });
}) as typeof fetch
});
const uris = Array.from({ length: 101 }, (_, index) => `spotify:track:${index}`);
const addResults = await client.addItemsToPlaylist("playlist id", uris);
const removeResults = await client.removeItemsFromPlaylist("playlist id", uris);
assert.deepEqual(addResults.map((result) => result.snapshot_id), ["snap-1", "snap-2"]);
assert.deepEqual(removeResults.map((result) => result.snapshot_id), ["snap-3", "snap-4"]);
assert.equal(calls[0].url, "https://api.spotify.com/v1/playlists/playlist%20id/items");
assert.equal(calls[0].method, "POST");
assert.equal(JSON.parse(calls[0].body ?? "{}").uris.length, 100);
assert.equal(calls[2].method, "DELETE");
assert.equal(JSON.parse(calls[2].body ?? "{}").tracks.length, 100);
});

View File

@@ -0,0 +1,126 @@
import assert from "node:assert/strict";
import { randomBytes } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { createServer } from "node:net";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import {
buildAuthorizeUrl,
createCodeChallenge,
createCodeVerifier,
exchangeCodeForToken,
getAuthStatus,
refreshAccessToken,
refreshStoredToken,
waitForAuthorizationCode
} from "../src/auth.js";
import { loadToken, saveToken } from "../src/token-store.js";
import type { SpotifyConfig } from "../src/types.js";
const config: SpotifyConfig = {
clientId: "client-id",
redirectUri: "http://127.0.0.1:8888/callback"
};
async function getFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
server.close(() => {
if (!address || typeof address === "string") {
reject(new Error("Unable to reserve a test port."));
return;
}
resolve(address.port);
});
});
});
}
test("creates RFC7636 S256 code challenge", () => {
assert.equal(
createCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
);
});
test("creates url-safe verifier", () => {
const verifier = createCodeVerifier(randomBytes(64));
assert.match(verifier, /^[A-Za-z0-9_-]+$/);
});
test("builds authorize url with PKCE parameters and scopes", () => {
const url = new URL(buildAuthorizeUrl(config, "verifier", "state", ["playlist-read-private"]));
assert.equal(url.origin + url.pathname, "https://accounts.spotify.com/authorize");
assert.equal(url.searchParams.get("response_type"), "code");
assert.equal(url.searchParams.get("client_id"), "client-id");
assert.equal(url.searchParams.get("redirect_uri"), config.redirectUri);
assert.equal(url.searchParams.get("state"), "state");
assert.equal(url.searchParams.get("code_challenge_method"), "S256");
assert.equal(url.searchParams.get("scope"), "playlist-read-private");
});
test("exchanges code for token with PKCE body", async () => {
const calls: Array<{ url: string; body: URLSearchParams }> = [];
const fetchImpl = async (url: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(url), body: init?.body as URLSearchParams });
return new Response(JSON.stringify({ access_token: "access", refresh_token: "refresh", expires_in: 10 }), { status: 200 });
};
const token = await exchangeCodeForToken(config, "code", "verifier", fetchImpl as typeof fetch, 1_000);
assert.deepEqual(token, { accessToken: "access", refreshToken: "refresh", expiresAt: 11_000 });
assert.equal(calls[0].url, "https://accounts.spotify.com/api/token");
assert.equal(calls[0].body.get("grant_type"), "authorization_code");
assert.equal(calls[0].body.get("code_verifier"), "verifier");
});
test("refresh preserves old refresh token when response omits one", async () => {
const fetchImpl = async () => new Response(JSON.stringify({ access_token: "new-access", expires_in: 10 }), { status: 200 });
const token = await refreshAccessToken(config, "old-refresh", fetchImpl as typeof fetch, 1_000);
assert.deepEqual(token, { accessToken: "new-access", refreshToken: "old-refresh", expiresAt: 11_000 });
});
test("refreshStoredToken persists refreshed token", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-auth-"));
await writeFile(join(root, "config.json"), JSON.stringify(config));
await saveToken({ accessToken: "old", refreshToken: "refresh", expiresAt: 1 }, { tokenPath: join(root, "token.json") });
const fetchImpl = async () => new Response(JSON.stringify({ access_token: "new", expires_in: 5 }), { status: 200 });
const token = await refreshStoredToken({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing", fetchImpl: fetchImpl as typeof fetch, now: () => 100 });
assert.deepEqual(token, { accessToken: "new", refreshToken: "refresh", expiresAt: 5_100 });
assert.deepEqual(await loadToken({ tokenPath: join(root, "token.json") }), token);
});
test("getAuthStatus reports config and token state without token values", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-auth-"));
await writeFile(join(root, "config.json"), JSON.stringify(config));
await saveToken({ accessToken: "secret-access", refreshToken: "secret-refresh", expiresAt: 200 }, { tokenPath: join(root, "token.json") });
const status = await getAuthStatus({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing", now: () => 100 });
assert.deepEqual(status, {
configFound: true,
tokenFound: true,
tokenExpired: false,
expiresAt: 200
});
assert.equal(JSON.stringify(status).includes("secret"), false);
});
test("authorization callback wait has a bounded timeout", async () => {
const port = await getFreePort();
await assert.rejects(
() => waitForAuthorizationCode(`http://127.0.0.1:${port}/callback`, "state", 1),
/timed out/
);
});

View File

@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { runCli } from "../src/cli.js";
function createBuffer() {
let output = "";
return {
stream: {
write(chunk: string) {
output += chunk;
return true;
}
},
output: () => output
};
}
test("prints usage", async () => {
const stdout = createBuffer();
const stderr = createBuffer();
const code = await runCli(["--help"], { stdout: stdout.stream, stderr: stderr.stream });
assert.equal(code, 0);
assert.match(stdout.output(), /Commands:/);
assert.equal(stderr.output(), "");
});
test("prints usage for bare invocation", async () => {
const stdout = createBuffer();
const stderr = createBuffer();
const code = await runCli([], { stdout: stdout.stream, stderr: stderr.stream });
assert.equal(code, 0);
assert.match(stdout.output(), /Commands:/);
assert.equal(stderr.output(), "");
});
test("rejects unknown command", async () => {
const stdout = createBuffer();
const stderr = createBuffer();
const code = await runCli(["bogus"], { stdout: stdout.stream, stderr: stderr.stream });
assert.equal(code, 1);
assert.equal(stdout.output(), "");
assert.match(stderr.output(), /Unknown command: bogus/);
});
test("dispatches known command with json flag", async () => {
const stdout = createBuffer();
const stderr = createBuffer();
const code = await runCli(
["search", "Karma Police", "--limit", "3", "--json"],
{ stdout: stdout.stream, stderr: stderr.stream },
{
search(args, deps) {
deps.stdout.write(JSON.stringify(args));
return 0;
}
}
);
assert.equal(code, 0);
assert.deepEqual(JSON.parse(stdout.output()), {
command: "search",
positional: ["Karma Police"],
json: true,
public: false,
limit: "3"
});
assert.equal(stderr.output(), "");
});

View File

@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { loadSpotifyConfig, resolveSpotifyPaths } from "../src/config.js";
test("uses SPOTIFY_CONFIG_DIR when it exists", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-config-"));
await writeFile(join(root, "config.json"), JSON.stringify({ clientId: "id", redirectUri: "http://127.0.0.1:8888/callback" }));
const paths = await resolveSpotifyPaths({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing-home" });
assert.equal(paths.configDir, root);
assert.equal(paths.configPath, join(root, "config.json"));
assert.equal(paths.tokenPath, join(root, "token.json"));
});
test("loads and validates config json", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-config-"));
await writeFile(join(root, "config.json"), JSON.stringify({ clientId: "client", redirectUri: "http://127.0.0.1:8888/callback" }));
const config = await loadSpotifyConfig({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing-home" });
assert.deepEqual(config, {
clientId: "client",
redirectUri: "http://127.0.0.1:8888/callback"
});
});
test("finds upward clawdbot credentials", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-upward-"));
const configDir = join(root, ".clawdbot", "credentials", "spotify");
const nested = join(root, "a", "b");
await mkdir(configDir, { recursive: true });
await mkdir(nested, { recursive: true });
const paths = await resolveSpotifyPaths({ env: {}, startDir: nested, homeDir: "/missing-home" });
assert.equal(paths.configDir, configDir);
});
test("rejects missing config file", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-config-"));
await assert.rejects(
() => loadSpotifyConfig({ env: { SPOTIFY_CONFIG_DIR: root }, homeDir: "/missing-home" }),
/Spotify config not found/
);
});

View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { readFolder } from "../src/importers/folder.js";
test("recursively reads audio filenames and ignores non-audio files", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-folder-"));
await mkdir(join(root, "nested"));
await writeFile(join(root, "01 - Radiohead - Karma Police.mp3"), "");
await writeFile(join(root, "cover.jpg"), "");
await writeFile(join(root, "nested", "02 - Massive Attack - Teardrop.flac"), "");
const refs = await readFolder(root);
assert.equal(refs.some((ref) => ref.artist === "Radiohead" && ref.title === "Karma Police"), true);
assert.equal(refs.some((ref) => ref.artist === "Massive Attack" && ref.title === "Teardrop"), true);
assert.equal(refs.some((ref) => ref.source.includes("cover")), false);
});

View File

@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { importTracks, readImportSource, runImportCommand } from "../src/importers/index.js";
import type { CliDeps } from "../src/cli.js";
function createDeps(): { deps: CliDeps; stdout: () => string; stderr: () => string } {
let stdout = "";
let stderr = "";
return {
deps: {
stdout: { write: (chunk: string) => { stdout += chunk; return true; } },
stderr: { write: (chunk: string) => { stderr += chunk; return true; } }
},
stdout: () => stdout,
stderr: () => stderr
};
}
test("auto-detects text imports by extension fallback", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-import-"));
const path = join(root, "tracks.csv");
await writeFile(path, "Radiohead - Karma Police\n");
const refs = await readImportSource(path);
assert.equal(refs[0].artist, "Radiohead");
assert.equal(refs[0].title, "Karma Police");
});
test("import creates a new private playlist for --playlist and records misses", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-import-"));
const path = join(root, "tracks.txt");
await writeFile(path, "Radiohead - Karma Police\nMissing Song\n");
let createdPublic: boolean | undefined;
let addedUris: string[] = [];
const result = await importTracks(path, { playlist: "New Mix", delayMs: 0 }, {
createPlaylist: async (_name, options) => {
createdPublic = options?.public;
return { id: "playlist-id", uri: "spotify:playlist:playlist-id", name: "New Mix", public: false, owner: { id: "owner" } };
},
searchTracks: async (query) => query.includes("Missing")
? []
: [{ id: "track-id", uri: "spotify:track:track-id", name: "Karma Police", artists: [{ name: "Radiohead" }] }],
addItemsToPlaylist: async (_playlistId, uris) => {
addedUris = uris;
return [{ snapshot_id: "snap" }];
}
});
assert.equal(createdPublic, false);
assert.deepEqual(addedUris, ["spotify:track:track-id"]);
assert.equal(result.found.length, 1);
assert.equal(result.missed.length, 1);
assert.deepEqual(result.added, { playlistId: "playlist-id", count: 1, snapshotIds: ["snap"] });
});
test("import updates explicit playlist id without creating a new playlist", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-import-"));
const path = join(root, "tracks.txt");
await writeFile(path, "Radiohead - Karma Police\n");
let created = false;
const result = await importTracks(path, { playlistId: "existing", delayMs: 0 }, {
createPlaylist: async () => {
created = true;
throw new Error("should not create");
},
searchTracks: async () => [{ id: "track-id", uri: "spotify:track:track-id", name: "Karma Police", artists: [{ name: "Radiohead" }] }],
addItemsToPlaylist: async () => [{ snapshot_id: "snap" }]
});
assert.equal(created, false);
assert.equal(result.added?.playlistId, "existing");
});
test("import command writes JSON result", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-import-"));
const path = join(root, "tracks.txt");
await writeFile(path, "Radiohead - Karma Police\n");
const io = createDeps();
await runImportCommand(
{ command: "import", positional: [path], playlistId: "existing", json: true },
io.deps,
{
createPlaylist: async () => { throw new Error("should not create"); },
searchTracks: async () => [{ id: "track-id", uri: "spotify:track:track-id", name: "Karma Police", artists: [{ name: "Radiohead" }] }],
addItemsToPlaylist: async () => [{ snapshot_id: "snap" }]
}
);
assert.equal(JSON.parse(io.stdout()).added.playlistId, "existing");
});

View File

@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
buildSearchQueries,
dedupeTrackRefs,
isAudioFile,
normalizeText,
parseArtistTitle,
stripAudioExtension,
stripTrackNumberPrefix
} from "../src/importers/importer-utils.js";
test("normalizes whitespace and underscores", () => {
assert.equal(normalizeText(" Radiohead__Karma\tPolice "), "Radiohead Karma Police");
});
test("strips audio extensions and track number prefixes", () => {
assert.equal(stripAudioExtension("01 - Radiohead - Karma Police.mp3"), "01 - Radiohead - Karma Police");
assert.equal(stripTrackNumberPrefix("01 - Radiohead - Karma Police"), "Radiohead - Karma Police");
assert.equal(isAudioFile("song.FLAC"), true);
assert.equal(isAudioFile("cover.jpg"), false);
});
test("parses artist title patterns", () => {
assert.deepEqual(parseArtistTitle("Radiohead - Karma Police"), [
{ source: "Radiohead - Karma Police", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" },
{ source: "Radiohead - Karma Police", query: "Karma Police Radiohead", artist: "Karma Police", title: "Radiohead" }
]);
assert.deepEqual(parseArtistTitle("Radiohead: Karma Police"), [
{ source: "Radiohead: Karma Police", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" }
]);
assert.deepEqual(parseArtistTitle("Radiohead_Karma Police"), [
{ source: "Radiohead Karma Police", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" }
]);
});
test("dedupes normalized artist title refs", () => {
const refs = dedupeTrackRefs([
{ source: "a", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" },
{ source: "b", query: "radiohead karma police", artist: " radiohead ", title: "karma police" }
]);
assert.equal(refs.length, 1);
});
test("builds fallback search queries", () => {
assert.deepEqual(buildSearchQueries({ source: "x", query: "Radiohead Karma Police", artist: "Radiohead", title: "Karma Police" }), [
"Radiohead Karma Police",
"track:Karma Police artist:Radiohead"
]);
});

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { parseM3u, readM3u } from "../src/importers/m3u.js";
test("parses EXTINF metadata and ignores comments", () => {
const refs = parseM3u(`#EXTM3U
#EXTINF:123,Radiohead - Karma Police
/music/01 - ignored filename.mp3
# comment
#EXTINF:123,Massive Attack - Teardrop
/music/02 - fallback.mp3
`);
assert.equal(refs.some((ref) => ref.artist === "Radiohead" && ref.title === "Karma Police"), true);
assert.equal(refs.some((ref) => ref.artist === "Massive Attack" && ref.title === "Teardrop"), true);
});
test("falls back to filename when EXTINF is absent", () => {
const refs = parseM3u("/music/01 - Radiohead - Karma Police.flac\n");
assert.equal(refs[0].artist, "Radiohead");
assert.equal(refs[0].title, "Karma Police");
});
test("reads m3u from file", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-m3u-"));
const path = join(root, "playlist.m3u8");
await writeFile(path, "#EXTINF:123,Radiohead - Karma Police\n/music/track.mp3\n");
const refs = await readM3u(path);
assert.equal(refs[0].artist, "Radiohead");
assert.equal(refs[0].title, "Karma Police");
});

View File

@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
runAddToPlaylistCommand,
runCreatePlaylistCommand,
runRemoveFromPlaylistCommand,
runSearchAndAddCommand,
searchAndAdd,
validateTrackUris
} from "../src/playlists.js";
import type { CliDeps } from "../src/cli.js";
import type { SpotifyPlaylist } from "../src/types.js";
function createDeps(): { deps: CliDeps; stdout: () => string; stderr: () => string } {
let stdout = "";
let stderr = "";
return {
deps: {
stdout: { write: (chunk: string) => { stdout += chunk; return true; } },
stderr: { write: (chunk: string) => { stderr += chunk; return true; } }
},
stdout: () => stdout,
stderr: () => stderr
};
}
function playlist(name: string): SpotifyPlaylist {
return {
id: "playlist-id",
uri: "spotify:playlist:playlist-id",
name,
public: false,
owner: { id: "owner-id" },
external_urls: { spotify: "https://open.spotify.com/playlist/playlist-id" }
};
}
test("create playlist defaults private unless public flag is present", async () => {
const io = createDeps();
let observed: { name: string; public?: boolean; description?: string } | undefined;
await runCreatePlaylistCommand(
{ command: "create-playlist", positional: ["My", "Mix"], json: true, public: false, description: "desc" },
io.deps,
{
createPlaylist: async (name, options) => {
observed = { name, ...options };
return playlist(name);
},
addItemsToPlaylist: async () => [],
removeItemsFromPlaylist: async () => [],
searchTracks: async () => []
}
);
assert.deepEqual(observed, { name: "My Mix", public: false, description: "desc" });
assert.equal(JSON.parse(io.stdout()).playlist.id, "playlist-id");
});
test("create playlist treats omitted public flag as private", async () => {
const io = createDeps();
let observedPublic: boolean | undefined;
await runCreatePlaylistCommand(
{ command: "create-playlist", positional: ["My Mix"], json: true },
io.deps,
{
createPlaylist: async (name, options) => {
observedPublic = options?.public;
return playlist(name);
},
addItemsToPlaylist: async () => [],
removeItemsFromPlaylist: async () => [],
searchTracks: async () => []
}
);
assert.equal(observedPublic, false);
});
test("validates spotify track uris", () => {
assert.deepEqual(validateTrackUris(["spotify:track:1"]), ["spotify:track:1"]);
assert.throws(() => validateTrackUris(["spotify:album:1"]), /Invalid Spotify track URI/);
});
test("add to playlist outputs mutation summary", async () => {
const io = createDeps();
let observedUris: string[] = [];
await runAddToPlaylistCommand(
{ command: "add-to-playlist", positional: ["playlist-id", "spotify:track:1"], json: true, public: false },
io.deps,
{
createPlaylist: async () => playlist("unused"),
addItemsToPlaylist: async (_playlistId, uris) => {
observedUris = uris;
return [{ snapshot_id: "snap" }];
},
removeItemsFromPlaylist: async () => [],
searchTracks: async () => []
}
);
assert.deepEqual(observedUris, ["spotify:track:1"]);
assert.deepEqual(JSON.parse(io.stdout()), { playlistId: "playlist-id", count: 1, snapshotIds: ["snap"] });
});
test("remove from playlist outputs mutation summary", async () => {
const io = createDeps();
await runRemoveFromPlaylistCommand(
{ command: "remove-from-playlist", positional: ["playlist-id", "spotify:track:1"], json: false, public: false },
io.deps,
{
createPlaylist: async () => playlist("unused"),
addItemsToPlaylist: async () => [],
removeItemsFromPlaylist: async () => [{ snapshot_id: "snap" }],
searchTracks: async () => []
}
);
assert.equal(io.stdout(), "Removed 1 track(s) from playlist-id.\n");
});
test("searchAndAdd adds first matches and records misses", async () => {
let observedUris: string[] = [];
const result = await searchAndAdd("playlist-id", ["found", "missing"], {
searchTracks: async (query) => query === "found"
? [{ id: "track-id", uri: "spotify:track:track-id", name: "Song", artists: [{ name: "Artist" }] }]
: [],
addItemsToPlaylist: async (_playlistId, uris) => {
observedUris = uris;
return [{ snapshot_id: "snap" }];
}
});
assert.deepEqual(observedUris, ["spotify:track:track-id"]);
assert.deepEqual(result, {
added: [{ query: "found", uri: "spotify:track:track-id", name: "Song", artists: ["Artist"] }],
missed: ["missing"],
snapshotIds: ["snap"]
});
});
test("search-and-add command writes JSON summary", async () => {
const io = createDeps();
await runSearchAndAddCommand(
{ command: "search-and-add", positional: ["playlist-id", "found"], json: true, public: false },
io.deps,
{
createPlaylist: async () => playlist("unused"),
addItemsToPlaylist: async () => [{ snapshot_id: "snap" }],
removeItemsFromPlaylist: async () => [],
searchTracks: async () => [{ id: "track-id", uri: "spotify:track:track-id", name: "Song", artists: [{ name: "Artist" }] }]
}
);
assert.equal(JSON.parse(io.stdout()).added[0].uri, "spotify:track:track-id");
});

View File

@@ -0,0 +1,97 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { mapPlaylist, mapTrack, runListPlaylistsCommand, runSearchCommand } from "../src/search.js";
import type { CliDeps } from "../src/cli.js";
import type { SpotifyPlaylist, SpotifyTrack } from "../src/types.js";
function createDeps(): { deps: CliDeps; stdout: () => string; stderr: () => string } {
let stdout = "";
let stderr = "";
return {
deps: {
stdout: { write: (chunk: string) => { stdout += chunk; return true; } },
stderr: { write: (chunk: string) => { stderr += chunk; return true; } }
},
stdout: () => stdout,
stderr: () => stderr
};
}
const track: SpotifyTrack = {
id: "track-id",
uri: "spotify:track:track-id",
name: "Karma Police",
artists: [{ name: "Radiohead" }],
album: { name: "OK Computer" },
external_urls: { spotify: "https://open.spotify.com/track/track-id" }
};
const playlist: SpotifyPlaylist = {
id: "playlist-id",
uri: "spotify:playlist:playlist-id",
name: "Private Mix",
public: false,
owner: { id: "owner-id", display_name: "Owner" },
external_urls: { spotify: "https://open.spotify.com/playlist/playlist-id" }
};
test("maps raw track to flattened output DTO", () => {
assert.deepEqual(mapTrack(track), {
id: "track-id",
uri: "spotify:track:track-id",
name: "Karma Police",
artists: ["Radiohead"],
album: "OK Computer",
externalUrl: "https://open.spotify.com/track/track-id"
});
});
test("search command clamps limit and writes JSON", async () => {
const io = createDeps();
let observedLimit = 0;
const code = await runSearchCommand(
{ command: "search", positional: ["Karma", "Police"], json: true, public: false, limit: "99" },
io.deps,
{
searchTracks: async (_query, limit) => {
observedLimit = limit;
return [track];
}
}
);
assert.equal(code, 0);
assert.equal(observedLimit, 10);
assert.equal(JSON.parse(io.stdout()).tracks[0].externalUrl, "https://open.spotify.com/track/track-id");
assert.equal(io.stderr(), "");
});
test("maps playlist to flattened output DTO", () => {
assert.deepEqual(mapPlaylist(playlist), {
id: "playlist-id",
name: "Private Mix",
public: false,
owner: "Owner",
externalUrl: "https://open.spotify.com/playlist/playlist-id"
});
});
test("list playlists command clamps limit and writes human output", async () => {
const io = createDeps();
let observed = { limit: 0, offset: -1 };
const code = await runListPlaylistsCommand(
{ command: "list-playlists", positional: [], json: false, public: false, limit: "200", offset: "-5" },
io.deps,
{
listPlaylists: async (limit, offset) => {
observed = { limit, offset };
return [playlist];
}
}
);
assert.equal(code, 0);
assert.deepEqual(observed, { limit: 50, offset: 0 });
assert.match(io.stdout(), /playlist-id \\| private \\| Owner \\| Private Mix/);
});

View File

@@ -0,0 +1,33 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { parseTextList, readTextList } from "../src/importers/text-list.js";
test("parses text list with comments, blanks, and dedupe", () => {
const refs = parseTextList(`
# favorites
Radiohead - Karma Police
Radiohead: Karma Police
// ignored
Massive Attack - Teardrop
`);
assert.equal(refs.some((ref) => ref.artist === "Radiohead" && ref.title === "Karma Police"), true);
assert.equal(refs.some((ref) => ref.artist === "Massive Attack" && ref.title === "Teardrop"), true);
assert.equal(refs.filter((ref) => ref.artist === "Radiohead" && ref.title === "Karma Police").length, 1);
});
test("reads text list from file", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-text-"));
const path = join(root, "tracks.txt");
await writeFile(path, "Radiohead - Karma Police\n");
const refs = await readTextList(path);
assert.equal(refs[0].artist, "Radiohead");
assert.equal(refs[0].title, "Karma Police");
});

View File

@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { test } from "node:test";
import { loadToken, saveToken, tokenFileMode, tokenNeedsRefresh } from "../src/token-store.js";
test("saves and loads token without changing values", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-token-"));
const tokenPath = join(root, "token.json");
const token = {
accessToken: "access-secret",
refreshToken: "refresh-secret",
expiresAt: 123456
};
await saveToken(token, { tokenPath });
assert.deepEqual(await loadToken({ tokenPath }), token);
});
test("writes token file with owner-only mode when supported", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-token-"));
const tokenPath = join(root, "token.json");
await saveToken({ accessToken: "a", refreshToken: "r", expiresAt: 123 }, { tokenPath });
const mode = await tokenFileMode({ tokenPath });
assert.equal(mode, 0o600);
});
test("rejects invalid token shape", async () => {
const root = await mkdtemp(join(tmpdir(), "spotify-token-"));
const tokenPath = join(root, "token.json");
await saveToken({ accessToken: "a", refreshToken: "r", expiresAt: 123 }, { tokenPath });
await writeFile(tokenPath, JSON.stringify({ accessToken: "a" }));
await assert.rejects(() => loadToken({ tokenPath }), /invalid token shape/);
});
test("identifies tokens needing refresh with skew", () => {
assert.equal(tokenNeedsRefresh({ accessToken: "a", refreshToken: "r", expiresAt: 1_050 }, 1_000, 100), true);
assert.equal(tokenNeedsRefresh({ accessToken: "a", refreshToken: "r", expiresAt: 1_200 }, 1_000, 100), false);
});

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"types": ["node"],
"outDir": "./dist",
"rootDir": "."
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["node_modules", "dist"]
}