diff --git a/README.md b/README.md index 3e4dfde..e9def20 100644 --- a/README.md +++ b/README.md @@ -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` | diff --git a/docs/README.md b/docs/README.md index 09b105f..7c2055a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 diff --git a/docs/spotify.md b/docs/spotify.md new file mode 100644 index 0000000..f4c959a --- /dev/null +++ b/docs/spotify.md @@ -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 "" "spotify:track:..." --json +scripts/spotify remove-from-playlist "" "spotify:track:..." --json +scripts/spotify search-and-add "" "Radiohead Karma Police" --json +scripts/spotify import "/path/to/tracks.txt" --playlist "Imported Mix" --json +scripts/spotify import "/path/to/playlist.m3u8" --playlist-id "" --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=&limit=<1-10> +GET /v1/me/playlists?limit=&offset= +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. diff --git a/skills/spotify/.gitignore b/skills/spotify/.gitignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/skills/spotify/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/skills/spotify/SKILL.md b/skills/spotify/SKILL.md new file mode 100644 index 0000000..f53965b --- /dev/null +++ b/skills/spotify/SKILL.md @@ -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 "" "spotify:track:..." --json +scripts/spotify remove-from-playlist "" "spotify:track:..." --json +scripts/spotify search-and-add "" "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. diff --git a/skills/spotify/package-lock.json b/skills/spotify/package-lock.json new file mode 100644 index 0000000..9bbfdf3 --- /dev/null +++ b/skills/spotify/package-lock.json @@ -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" + } + } + } +} diff --git a/skills/spotify/package.json b/skills/spotify/package.json new file mode 100644 index 0000000..4a685da --- /dev/null +++ b/skills/spotify/package.json @@ -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" + } +} diff --git a/skills/spotify/scripts/setup.sh b/skills/spotify/scripts/setup.sh new file mode 100755 index 0000000..aaa2633 --- /dev/null +++ b/skills/spotify/scripts/setup.sh @@ -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 diff --git a/skills/spotify/scripts/spotify b/skills/spotify/scripts/spotify new file mode 100755 index 0000000..ce5875f --- /dev/null +++ b/skills/spotify/scripts/spotify @@ -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); diff --git a/skills/spotify/src/api-client.ts b/skills/spotify/src/api-client.ts new file mode 100644 index 0000000..9c305fd --- /dev/null +++ b/skills/spotify/src/api-client.ts @@ -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; + refreshToken?: () => Promise; + sleep?: (ms: number) => Promise; + 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(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 { + return undefined; +} + +export class SpotifyApiClient { + private readonly fetchImpl: FetchLike; + private readonly loadStoredToken: () => Promise; + private readonly refreshStoredToken: () => Promise; + private readonly sleep: (ms: number) => Promise; + 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 { + return this.request("GET", "/me"); + } + + async searchTracks(query: string, limit: number): Promise { + 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 { + 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 { + return this.request("POST", "/me/playlists", { + name, + description: options.description, + public: Boolean(options.public) + }); + } + + async addItemsToPlaylist(playlistId: string, uris: string[]): Promise { + const results: PlaylistMutationResult[] = []; + for (const batch of chunk(uris, 100)) { + results.push(await this.request("POST", `/playlists/${encodeURIComponent(playlistId)}/items`, { uris: batch })); + } + return results; + } + + async removeItemsFromPlaylist(playlistId: string, uris: string[]): Promise { + const results: PlaylistMutationResult[] = []; + for (const batch of chunk(uris, 100)) { + results.push(await this.request("DELETE", `/playlists/${encodeURIComponent(playlistId)}/items`, { + tracks: batch.map((uri) => ({ uri })) + })); + } + return results; + } + + private async getAccessToken(): Promise { + 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(method: string, path: string, body?: unknown, authRetried = false): Promise { + 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(retryResponse); + } + return this.parseResponse(response); + } + + private async fetchWithTransientRetries(method: string, path: string, accessToken: string, body: unknown): Promise { + 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 { + try { + return await this.refreshStoredToken(); + } catch { + throw new Error("Spotify token refresh failed. Run `scripts/spotify auth` again."); + } + } + + private async parseResponse(response: Response): Promise { + 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; + } +} + +export function createSpotifyApiClient(options: SpotifyApiClientOptions = {}): SpotifyApiClient { + return new SpotifyApiClient(options); +} diff --git a/skills/spotify/src/auth.ts b/skills/spotify/src/auth.ts new file mode 100644 index 0000000..a37e9a3 --- /dev/null +++ b/skills/spotify/src/auth.ts @@ -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; + 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 { + if (!response.ok) { + throw new Error(`Spotify token request failed with status ${response.status}.`); + } + const body = await response.json() as Partial; + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + }; +} diff --git a/skills/spotify/src/cli.ts b/skills/spotify/src/cli.ts new file mode 100644 index 0000000..75baaef --- /dev/null +++ b/skills/spotify/src/cli.ts @@ -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; + stderr: Pick; +} + +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; +export type CommandHandlers = Record; + +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 [--limit N] [--json] + list-playlists [--limit N] [--offset N] [--json] + create-playlist [--description TEXT] [--public] [--json] + add-to-playlist [more uris...] [--json] + remove-from-playlist [more uris...] [--json] + search-and-add [more queries...] [--json] + import [--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 { + 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; + }); +} diff --git a/skills/spotify/src/config.ts b/skills/spotify/src/config.ts new file mode 100644 index 0000000..bda2cb5 --- /dev/null +++ b/skills/spotify/src/config.ts @@ -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 { + 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 { + 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 { + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? homedir(); + + const candidates: Array> = []; + 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 { + 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 + }; +} diff --git a/skills/spotify/src/importers/folder.ts b/skills/spotify/src/importers/folder.ts new file mode 100644 index 0000000..16759e8 --- /dev/null +++ b/skills/spotify/src/importers/folder.ts @@ -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 { + 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 { + const files = await walkAudioFiles(path); + return dedupeTrackRefs(files.flatMap(parseArtistTitle)); +} diff --git a/skills/spotify/src/importers/importer-utils.ts b/skills/spotify/src/importers/importer-utils.ts new file mode 100644 index 0000000..4dc2d70 --- /dev/null +++ b/skills/spotify/src/importers/importer-utils.ts @@ -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(); + 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(); + 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export const DEFAULT_IMPORT_SEARCH_DELAY_MS = 100; diff --git a/skills/spotify/src/importers/index.ts b/skills/spotify/src/importers/index.ts new file mode 100644 index 0000000..7fee195 --- /dev/null +++ b/skills/spotify/src/importers/index.ts @@ -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; + +export interface ImportOptions { + playlist?: string; + playlistId?: string; + public?: boolean; + delayMs?: number; + sleep?: (ms: number) => Promise; +} + +export async function readImportSource(path: string): Promise { + 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): Promise { + 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 { + 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(); + + 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 { + 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; +} diff --git a/skills/spotify/src/importers/m3u.ts b/skills/spotify/src/importers/m3u.ts new file mode 100644 index 0000000..a6d5665 --- /dev/null +++ b/skills/spotify/src/importers/m3u.ts @@ -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 { + return parseM3u(await readFile(path, "utf8")); +} diff --git a/skills/spotify/src/importers/text-list.ts b/skills/spotify/src/importers/text-list.ts new file mode 100644 index 0000000..2c62255 --- /dev/null +++ b/skills/spotify/src/importers/text-list.ts @@ -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 { + return parseTextList(await readFile(path, "utf8")); +} diff --git a/skills/spotify/src/playlists.ts b/skills/spotify/src/playlists.ts new file mode 100644 index 0000000..d731848 --- /dev/null +++ b/skills/spotify/src/playlists.ts @@ -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; + +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 { + 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 { + 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 { + 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 +): 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 { + 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; +} diff --git a/skills/spotify/src/search.ts b/skills/spotify/src/search.ts new file mode 100644 index 0000000..4d7c13f --- /dev/null +++ b/skills/spotify/src/search.ts @@ -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 = createSpotifyApiClient() +): Promise { + 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 = createSpotifyApiClient() +): Promise { + 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; +} diff --git a/skills/spotify/src/token-store.ts b/skills/spotify/src/token-store.ts new file mode 100644 index 0000000..6c8879c --- /dev/null +++ b/skills/spotify/src/token-store.ts @@ -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 { + if (options.tokenPath) { + return options.tokenPath; + } + return (await resolveSpotifyPaths(options)).tokenPath; +} + +export async function loadToken(options: TokenStoreOptions = {}): Promise { + 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 { + 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 { + const tokenPath = await resolveTokenPath(options); + try { + return (await stat(tokenPath)).mode & 0o777; + } catch { + return undefined; + } +} diff --git a/skills/spotify/src/types.ts b/skills/spotify/src/types.ts new file mode 100644 index 0000000..91980ac --- /dev/null +++ b/skills/spotify/src/types.ts @@ -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; + missed: Array; + added?: { playlistId: string; count: number; snapshotIds: string[] }; +} diff --git a/skills/spotify/tests/api-client.test.ts b/skills/spotify/tests/api-client.test.ts new file mode 100644 index 0000000..d77041c --- /dev/null +++ b/skills/spotify/tests/api-client.test.ts @@ -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).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).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); +}); diff --git a/skills/spotify/tests/auth.test.ts b/skills/spotify/tests/auth.test.ts new file mode 100644 index 0000000..bbee650 --- /dev/null +++ b/skills/spotify/tests/auth.test.ts @@ -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 { + 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/ + ); +}); diff --git a/skills/spotify/tests/cli.test.ts b/skills/spotify/tests/cli.test.ts new file mode 100644 index 0000000..56c869a --- /dev/null +++ b/skills/spotify/tests/cli.test.ts @@ -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(), ""); +}); diff --git a/skills/spotify/tests/config.test.ts b/skills/spotify/tests/config.test.ts new file mode 100644 index 0000000..deae347 --- /dev/null +++ b/skills/spotify/tests/config.test.ts @@ -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/ + ); +}); diff --git a/skills/spotify/tests/folder.test.ts b/skills/spotify/tests/folder.test.ts new file mode 100644 index 0000000..b6cfe13 --- /dev/null +++ b/skills/spotify/tests/folder.test.ts @@ -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); +}); diff --git a/skills/spotify/tests/import.test.ts b/skills/spotify/tests/import.test.ts new file mode 100644 index 0000000..4713bce --- /dev/null +++ b/skills/spotify/tests/import.test.ts @@ -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"); +}); diff --git a/skills/spotify/tests/importer-utils.test.ts b/skills/spotify/tests/importer-utils.test.ts new file mode 100644 index 0000000..24098bd --- /dev/null +++ b/skills/spotify/tests/importer-utils.test.ts @@ -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" + ]); +}); diff --git a/skills/spotify/tests/m3u.test.ts b/skills/spotify/tests/m3u.test.ts new file mode 100644 index 0000000..1d4883a --- /dev/null +++ b/skills/spotify/tests/m3u.test.ts @@ -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"); +}); diff --git a/skills/spotify/tests/playlists.test.ts b/skills/spotify/tests/playlists.test.ts new file mode 100644 index 0000000..f149387 --- /dev/null +++ b/skills/spotify/tests/playlists.test.ts @@ -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"); +}); diff --git a/skills/spotify/tests/search.test.ts b/skills/spotify/tests/search.test.ts new file mode 100644 index 0000000..842fc93 --- /dev/null +++ b/skills/spotify/tests/search.test.ts @@ -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/); +}); diff --git a/skills/spotify/tests/text-list.test.ts b/skills/spotify/tests/text-list.test.ts new file mode 100644 index 0000000..623dad6 --- /dev/null +++ b/skills/spotify/tests/text-list.test.ts @@ -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"); +}); diff --git a/skills/spotify/tests/token-store.test.ts b/skills/spotify/tests/token-store.test.ts new file mode 100644 index 0000000..66773dd --- /dev/null +++ b/skills/spotify/tests/token-store.test.ts @@ -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); +}); diff --git a/skills/spotify/tsconfig.json b/skills/spotify/tsconfig.json new file mode 100644 index 0000000..4f5d71e --- /dev/null +++ b/skills/spotify/tsconfig.json @@ -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"] +}