merge: spotify skill implementation
This commit is contained in:
@@ -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` |
|
||||
|
||||
|
||||
@@ -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
103
docs/spotify.md
Normal 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
3
skills/spotify/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
58
skills/spotify/SKILL.md
Normal file
58
skills/spotify/SKILL.md
Normal 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
759
skills/spotify/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
skills/spotify/package.json
Normal file
21
skills/spotify/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
9
skills/spotify/scripts/setup.sh
Executable file
9
skills/spotify/scripts/setup.sh
Executable 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
26
skills/spotify/scripts/spotify
Executable 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);
|
||||
174
skills/spotify/src/api-client.ts
Normal file
174
skills/spotify/src/api-client.ts
Normal 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
230
skills/spotify/src/auth.ts
Normal 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
143
skills/spotify/src/cli.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
116
skills/spotify/src/config.ts
Normal file
116
skills/spotify/src/config.ts
Normal 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
|
||||
};
|
||||
}
|
||||
24
skills/spotify/src/importers/folder.ts
Normal file
24
skills/spotify/src/importers/folder.ts
Normal 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));
|
||||
}
|
||||
90
skills/spotify/src/importers/importer-utils.ts
Normal file
90
skills/spotify/src/importers/importer-utils.ts
Normal 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;
|
||||
108
skills/spotify/src/importers/index.ts
Normal file
108
skills/spotify/src/importers/index.ts
Normal 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;
|
||||
}
|
||||
38
skills/spotify/src/importers/m3u.ts
Normal file
38
skills/spotify/src/importers/m3u.ts
Normal 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"));
|
||||
}
|
||||
17
skills/spotify/src/importers/text-list.ts
Normal file
17
skills/spotify/src/importers/text-list.ts
Normal 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"));
|
||||
}
|
||||
118
skills/spotify/src/playlists.ts
Normal file
118
skills/spotify/src/playlists.ts
Normal 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;
|
||||
}
|
||||
98
skills/spotify/src/search.ts
Normal file
98
skills/spotify/src/search.ts
Normal 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;
|
||||
}
|
||||
80
skills/spotify/src/token-store.ts
Normal file
80
skills/spotify/src/token-store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
41
skills/spotify/src/types.ts
Normal file
41
skills/spotify/src/types.ts
Normal 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[] };
|
||||
}
|
||||
181
skills/spotify/tests/api-client.test.ts
Normal file
181
skills/spotify/tests/api-client.test.ts
Normal 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);
|
||||
});
|
||||
126
skills/spotify/tests/auth.test.ts
Normal file
126
skills/spotify/tests/auth.test.ts
Normal 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/
|
||||
);
|
||||
});
|
||||
72
skills/spotify/tests/cli.test.ts
Normal file
72
skills/spotify/tests/cli.test.ts
Normal 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(), "");
|
||||
});
|
||||
51
skills/spotify/tests/config.test.ts
Normal file
51
skills/spotify/tests/config.test.ts
Normal 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/
|
||||
);
|
||||
});
|
||||
21
skills/spotify/tests/folder.test.ts
Normal file
21
skills/spotify/tests/folder.test.ts
Normal 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);
|
||||
});
|
||||
98
skills/spotify/tests/import.test.ts
Normal file
98
skills/spotify/tests/import.test.ts
Normal 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");
|
||||
});
|
||||
52
skills/spotify/tests/importer-utils.test.ts
Normal file
52
skills/spotify/tests/importer-utils.test.ts
Normal 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"
|
||||
]);
|
||||
});
|
||||
38
skills/spotify/tests/m3u.test.ts
Normal file
38
skills/spotify/tests/m3u.test.ts
Normal 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");
|
||||
});
|
||||
158
skills/spotify/tests/playlists.test.ts
Normal file
158
skills/spotify/tests/playlists.test.ts
Normal 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");
|
||||
});
|
||||
97
skills/spotify/tests/search.test.ts
Normal file
97
skills/spotify/tests/search.test.ts
Normal 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/);
|
||||
});
|
||||
33
skills/spotify/tests/text-list.test.ts
Normal file
33
skills/spotify/tests/text-list.test.ts
Normal 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");
|
||||
});
|
||||
44
skills/spotify/tests/token-store.test.ts
Normal file
44
skills/spotify/tests/token-store.test.ts
Normal 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);
|
||||
});
|
||||
17
skills/spotify/tsconfig.json
Normal file
17
skills/spotify/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user