mirror of
https://github.com/NousResearch/atropos.git
synced 2026-04-19 12:57:58 +00:00
Reorganize community environments - Move lean_proof_env, router_env, and philosophical_rlaif_env.py to environments/community/ - Add comprehensive README for community environments - This organizes community-contributed environments into a dedicated community folder for better maintainability and discoverability
This commit is contained in:
parent
945ea30c3a
commit
e85a170c34
53 changed files with 85 additions and 0 deletions
|
|
@ -0,0 +1,272 @@
|
|||
<div align="center" style="display: flex; align-items: center; justify-content: center; gap: 10px;">
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/8/84/Spotify_icon.svg" width="30" height="30">
|
||||
<h1>Spotify MCP Server</h1>
|
||||
</div>
|
||||
|
||||
A lightweight [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that enables AI assistants like Cursor & Claude to control Spotify playback and manage playlists.
|
||||
|
||||
<details>
|
||||
<summary>Contents</summary>
|
||||
|
||||
- [Example Interactions](#example-interactions)
|
||||
- [Tools](#tools)
|
||||
- [Read Operations](#read-operations)
|
||||
- [Play / Create Operations](#play--create-operations)
|
||||
- [Setup](#setup)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Installation](#installation)
|
||||
- [Creating a Spotify Developer Application](#creating-a-spotify-developer-application)
|
||||
- [Spotify API Configuration](#spotify-api-configuration)
|
||||
- [Authentication Process](#authentication-process)
|
||||
- [Integrating with Claude Desktop, Cursor, and VsCode (Cline)](#integrating-with-claude-desktop-and-cursor)
|
||||
</details>
|
||||
|
||||
## Example Interactions
|
||||
|
||||
- _"Play Elvis's first song"_
|
||||
- _"Create a Taylor Swift / Slipknot fusion playlist"_
|
||||
- _"Copy all the techno tracks from my workout playlist to my work playlist"_
|
||||
|
||||
## Tools
|
||||
|
||||
### Read Operations
|
||||
|
||||
1. **searchSpotify**
|
||||
|
||||
- **Description**: Search for tracks, albums, artists, or playlists on Spotify
|
||||
- **Parameters**:
|
||||
- `query` (string): The search term
|
||||
- `type` (string): Type of item to search for (track, album, artist, playlist)
|
||||
- `limit` (number, optional): Maximum number of results to return (10-50)
|
||||
- **Returns**: List of matching items with their IDs, names, and additional details
|
||||
- **Example**: `searchSpotify("bohemian rhapsody", "track", 20)`
|
||||
|
||||
2. **getNowPlaying**
|
||||
|
||||
- **Description**: Get information about the currently playing track on Spotify
|
||||
- **Parameters**: None
|
||||
- **Returns**: Object containing track name, artist, album, playback progress, duration, and playback state
|
||||
- **Example**: `getNowPlaying()`
|
||||
|
||||
3. **getMyPlaylists**
|
||||
|
||||
- **Description**: Get a list of the current user's playlists on Spotify
|
||||
- **Parameters**:
|
||||
- `limit` (number, optional): Maximum number of playlists to return (default: 20)
|
||||
- `offset` (number, optional): Index of the first playlist to return (default: 0)
|
||||
- **Returns**: Array of playlists with their IDs, names, track counts, and public status
|
||||
- **Example**: `getMyPlaylists(10, 0)`
|
||||
|
||||
4. **getPlaylistTracks**
|
||||
|
||||
- **Description**: Get a list of tracks in a specific Spotify playlist
|
||||
- **Parameters**:
|
||||
- `playlistId` (string): The Spotify ID of the playlist
|
||||
- `limit` (number, optional): Maximum number of tracks to return (default: 100)
|
||||
- `offset` (number, optional): Index of the first track to return (default: 0)
|
||||
- **Returns**: Array of tracks with their IDs, names, artists, album, duration, and added date
|
||||
- **Example**: `getPlaylistTracks("37i9dQZEVXcJZyENOWUFo7")`
|
||||
|
||||
5. **getRecentlyPlayed**
|
||||
|
||||
- **Description**: Retrieves a list of recently played tracks from Spotify.
|
||||
- **Parameters**:
|
||||
- `limit` (number, optional): A number specifying the maximum number of tracks to return.
|
||||
- **Returns**: If tracks are found it returns a formatted list of recently played tracks else a message stating: "You don't have any recently played tracks on Spotify".
|
||||
- **Example**: `getRecentlyPlayed({ limit: 10 })`
|
||||
|
||||
6. **getRecentlyPlayed**
|
||||
|
||||
- **Description**: Retrieves a list of recently played tracks from Spotify.
|
||||
- **Parameters**:
|
||||
- `limit` (number, optional): A number specifying the maximum number of tracks to return.
|
||||
- **Returns**: If tracks are found it returns a formatted list of recently played tracks else a message stating: "You don't have any recently played tracks on Spotify".
|
||||
- **Example**: `getRecentlyPlayed({ limit: 10 })`
|
||||
|
||||
### Play / Create Operations
|
||||
|
||||
1. **playMusic**
|
||||
|
||||
- **Description**: Start playing a track, album, artist, or playlist on Spotify
|
||||
- **Parameters**:
|
||||
- `uri` (string, optional): Spotify URI of the item to play (overrides type and id)
|
||||
- `type` (string, optional): Type of item to play (track, album, artist, playlist)
|
||||
- `id` (string, optional): Spotify ID of the item to play
|
||||
- `deviceId` (string, optional): ID of the device to play on
|
||||
- **Returns**: Success status
|
||||
- **Example**: `playMusic({ uri: "spotify:track:6rqhFgbbKwnb9MLmUQDhG6" })`
|
||||
- **Alternative**: `playMusic({ type: "track", id: "6rqhFgbbKwnb9MLmUQDhG6" })`
|
||||
|
||||
2. **pausePlayback**
|
||||
|
||||
- **Description**: Pause the currently playing track on Spotify
|
||||
- **Parameters**:
|
||||
- `deviceId` (string, optional): ID of the device to pause
|
||||
- **Returns**: Success status
|
||||
- **Example**: `pausePlayback()`
|
||||
|
||||
3. **skipToNext**
|
||||
|
||||
- **Description**: Skip to the next track in the current playback queue
|
||||
- **Parameters**:
|
||||
- `deviceId` (string, optional): ID of the device
|
||||
- **Returns**: Success status
|
||||
- **Example**: `skipToNext()`
|
||||
|
||||
4. **skipToPrevious**
|
||||
|
||||
- **Description**: Skip to the previous track in the current playback queue
|
||||
- **Parameters**:
|
||||
- `deviceId` (string, optional): ID of the device
|
||||
- **Returns**: Success status
|
||||
- **Example**: `skipToPrevious()`
|
||||
|
||||
5. **createPlaylist**
|
||||
|
||||
- **Description**: Create a new playlist on Spotify
|
||||
- **Parameters**:
|
||||
- `name` (string): Name for the new playlist
|
||||
- `description` (string, optional): Description for the playlist
|
||||
- `public` (boolean, optional): Whether the playlist should be public (default: false)
|
||||
- **Returns**: Object with the new playlist's ID and URL
|
||||
- **Example**: `createPlaylist({ name: "Workout Mix", description: "Songs to get pumped up", public: false })`
|
||||
|
||||
6. **addTracksToPlaylist**
|
||||
|
||||
- **Description**: Add tracks to an existing Spotify playlist
|
||||
- **Parameters**:
|
||||
- `playlistId` (string): ID of the playlist
|
||||
- `trackUris` (array): Array of track URIs or IDs to add
|
||||
- `position` (number, optional): Position to insert tracks
|
||||
- **Returns**: Success status and snapshot ID
|
||||
- **Example**: `addTracksToPlaylist({ playlistId: "3cEYpjA9oz9GiPac4AsH4n", trackUris: ["spotify:track:4iV5W9uYEdYUVa79Axb7Rh"] })`
|
||||
|
||||
7. **addToQueue**
|
||||
|
||||
- **Description**: Adds a track, album, artist or playlist to the current playback queue
|
||||
- - **Parameters**:
|
||||
- `uri` (string, optional): Spotify URI of the item to add to queue (overrides type and id)
|
||||
- `type` (string, optional): Type of item to queue (track, album, artist, playlist)
|
||||
- `id` (string, optional): Spotify ID of the item to queue
|
||||
- `deviceId` (string, optional): ID of the device to queue on
|
||||
- **Returns**: Success status
|
||||
- **Example**: `addToQueue({ uri: "spotify:track:6rqhFgbbKwnb9MLmUQDhG6" })`
|
||||
- **Alternative**: `addToQueue({ type: "track", id: "6rqhFgbbKwnb9MLmUQDhG6" })`
|
||||
|
||||
## Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js v16+
|
||||
- A Spotify Premium account
|
||||
- A registered Spotify Developer application
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/marcelmarais/spotify-mcp-server.git
|
||||
cd spotify-mcp-server
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Creating a Spotify Developer Application
|
||||
|
||||
1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/)
|
||||
2. Log in with your Spotify account
|
||||
3. Click the "Create an App" button
|
||||
4. Fill in the app name and description
|
||||
5. Accept the Terms of Service and click "Create"
|
||||
6. In your new app's dashboard, you'll see your **Client ID**
|
||||
7. Click "Show Client Secret" to reveal your **Client Secret**
|
||||
8. Click "Edit Settings" and add a Redirect URI (e.g., `http://localhost:8888/callback`)
|
||||
9. Save your changes
|
||||
|
||||
### Spotify API Configuration
|
||||
|
||||
Create a `spotify-config.json` file in the project root (you can copy and modify the provided example):
|
||||
|
||||
```bash
|
||||
# Copy the example config file
|
||||
cp spotify-config.example.json spotify-config.json
|
||||
```
|
||||
|
||||
Then edit the file with your credentials:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"redirectUri": "http://localhost:8888/callback"
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication Process
|
||||
|
||||
The Spotify API uses OAuth 2.0 for authentication. Follow these steps to authenticate your application:
|
||||
|
||||
1. Run the authentication script:
|
||||
|
||||
```bash
|
||||
npm run auth
|
||||
```
|
||||
|
||||
2. The script will generate an authorization URL. Open this URL in your web browser.
|
||||
|
||||
3. You'll be prompted to log in to Spotify and authorize your application.
|
||||
|
||||
4. After authorization, Spotify will redirect you to your specified redirect URI with a code parameter in the URL.
|
||||
|
||||
5. The authentication script will automatically exchange this code for access and refresh tokens.
|
||||
|
||||
6. These tokens will be saved to your `spotify-config.json` file, which will now look something like:
|
||||
|
||||
```json
|
||||
{
|
||||
"clientId": "your-client-id",
|
||||
"clientSecret": "your-client-secret",
|
||||
"redirectUri": "http://localhost:8888/callback",
|
||||
"accessToken": "BQAi9Pn...kKQ",
|
||||
"refreshToken": "AQDQcj...7w",
|
||||
"expiresAt": 1677889354671
|
||||
}
|
||||
```
|
||||
|
||||
7. The server will automatically refresh the access token when needed, using the refresh token.
|
||||
|
||||
## Integrating with Claude Desktop, Cursor, and VsCode [Via Cline model extension](https://marketplace.visualstudio.com/items/?itemName=saoudrizwan.claude-dev)
|
||||
|
||||
To use your MCP server with Claude Desktop, add it to your Claude configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"spotify": {
|
||||
"command": "node",
|
||||
"args": ["spotify-mcp-server/build/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Cursor, go to the MCP tab in `Cursor Settings` (command + shift + J). Add a server with this command:
|
||||
|
||||
```bash
|
||||
node path/to/spotify-mcp-server/build/index.js
|
||||
```
|
||||
|
||||
To set up your MCP correctly with Cline ensure you have the following file configuration set `cline_mcp_settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"spotify": {
|
||||
"command": "node",
|
||||
"args": ["~/../spotify-mcp-server/build/index.js"],
|
||||
"autoApprove": ["getListeningHistory", "getNowPlaying"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can add additional tools to the auto approval array to run the tools without intervention.
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": [
|
||||
"**/pnpm-lock.yaml",
|
||||
"lib/db/migrations",
|
||||
"lib/editor/react-renderer.tsx",
|
||||
"node_modules",
|
||||
".next",
|
||||
"public",
|
||||
".vercel"
|
||||
]
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"defaultBranch": "main",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 80,
|
||||
"attributePosition": "auto"
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"useHtmlLang": "warn", // Not in recommended ruleset, turning on manually
|
||||
"noHeaderScope": "warn", // Not in recommended ruleset, turning on manually
|
||||
"useValidAriaRole": {
|
||||
"level": "warn",
|
||||
"options": {
|
||||
"ignoreNonDom": false,
|
||||
"allowInvalidRoles": ["none", "text"]
|
||||
}
|
||||
},
|
||||
"useSemanticElements": "off", // Rule is buggy, revisit later
|
||||
"noSvgWithoutTitle": "off", // We do not intend to adhere to this rule
|
||||
"useMediaCaption": "off", // We would need a cultural change to turn this on
|
||||
"noAutofocus": "off", // We're highly intentional about when we use autofocus
|
||||
"noBlankTarget": "off", // Covered by Conformance
|
||||
"useFocusableInteractive": "off", // Disable focusable interactive element requirement
|
||||
"useAriaPropsForRole": "off", // Disable required ARIA attributes check
|
||||
"useKeyWithClickEvents": "off" // Disable keyboard event requirement with click events
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessStringConcat": "warn", // Not in recommended ruleset, turning on manually
|
||||
"noForEach": "off", // forEach is too familiar to ban
|
||||
"noUselessSwitchCase": "off", // Turned off due to developer preferences
|
||||
"noUselessThisAlias": "off" // Turned off due to developer preferences
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedImports": "warn", // Not in recommended ruleset, turning on manually
|
||||
"useArrayLiterals": "warn", // Not in recommended ruleset, turning on manually
|
||||
"noNewSymbol": "warn", // Not in recommended ruleset, turning on manually
|
||||
"useJsxKeyInIterable": "off", // Rule is buggy, revisit later
|
||||
"useExhaustiveDependencies": "off", // Community feedback on this rule has been poor, we will continue with ESLint
|
||||
"noUnnecessaryContinue": "off" // Turned off due to developer preferences
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "off" // Covered by Conformance
|
||||
},
|
||||
"style": {
|
||||
"useFragmentSyntax": "warn", // Not in recommended ruleset, turning on manually
|
||||
"noYodaExpression": "warn", // Not in recommended ruleset, turning on manually
|
||||
"useDefaultParameterLast": "warn", // Not in recommended ruleset, turning on manually
|
||||
"useExponentiationOperator": "off", // Obscure and arguably not easily readable
|
||||
"noUnusedTemplateLiteral": "off", // Stylistic opinion
|
||||
"noUselessElse": "off" // Stylistic opinion
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "off" // We trust Vercelians to use any only when necessary
|
||||
},
|
||||
"nursery": {
|
||||
"noStaticElementInteractions": "warn",
|
||||
"noHeadImportInDocument": "warn",
|
||||
"noDocumentImportInPage": "warn",
|
||||
"noDuplicateElseIf": "warn",
|
||||
"noIrregularWhitespace": "warn",
|
||||
"useValidAutocomplete": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"jsxRuntime": "reactClassic",
|
||||
"formatter": {
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all",
|
||||
"semicolons": "always",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"quoteStyle": "single",
|
||||
"attributePosition": "auto"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"trailingCommas": "none"
|
||||
},
|
||||
"parser": {
|
||||
"allowComments": true,
|
||||
"allowTrailingCommas": false
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"formatter": { "enabled": false },
|
||||
"linter": { "enabled": false }
|
||||
},
|
||||
"organizeImports": { "enabled": false },
|
||||
"overrides": [
|
||||
// Playwright requires an object destructure, even if empty
|
||||
// https://github.com/microsoft/playwright/issues/30007
|
||||
{
|
||||
"include": ["playwright/**"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"correctness": {
|
||||
"noEmptyPattern": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { authorizeSpotify } from './utils.js';
|
||||
|
||||
console.log('Starting Spotify authentication flow...');
|
||||
authorizeSpotify()
|
||||
.then(() => {
|
||||
console.log('Authentication completed successfully!');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Authentication failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { playTools } from './play.js';
|
||||
import { readTools } from './read.js';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'spotify-controller',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
[...readTools, ...playTools].forEach((tool) => {
|
||||
server.tool(tool.name, tool.description, tool.schema, tool.handler);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Fatal error in main():', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
import { handleSpotifyRequest } from './utils.js';
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
|
||||
const playMusic: tool<{
|
||||
uri: z.ZodOptional<z.ZodString>;
|
||||
type: z.ZodOptional<z.ZodEnum<['track', 'album', 'artist', 'playlist']>>;
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'playMusic',
|
||||
description: 'Start playing a Spotify track, album, artist, or playlist',
|
||||
schema: {
|
||||
uri: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify URI to play (overrides type and id)'),
|
||||
type: z
|
||||
.enum(['track', 'album', 'artist', 'playlist'])
|
||||
.optional()
|
||||
.describe('The type of item to play'),
|
||||
id: z.string().optional().describe('The Spotify ID of the item to play'),
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to play on'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { uri, type, id, deviceId } = args;
|
||||
|
||||
if (!uri && (!type || !id)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Must provide either a URI or both a type and ID',
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let spotifyUri = uri;
|
||||
if (!spotifyUri && type && id) {
|
||||
spotifyUri = `spotify:${type}:${id}`;
|
||||
}
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
const device = deviceId || '';
|
||||
|
||||
if (!spotifyUri) {
|
||||
await spotifyApi.player.startResumePlayback(device);
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'track') {
|
||||
await spotifyApi.player.startResumePlayback(device, undefined, [
|
||||
spotifyUri,
|
||||
]);
|
||||
} else {
|
||||
await spotifyApi.player.startResumePlayback(device, spotifyUri);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Started playing ${type || 'music'} ${id ? `(ID: ${id})` : ''}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const pausePlayback: tool<{
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'pausePlayback',
|
||||
description: 'Pause Spotify playback on the active device',
|
||||
schema: {
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to pause playback on'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { deviceId } = args;
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.pausePlayback(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Playback paused',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const skipToNext: tool<{
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'skipToNext',
|
||||
description: 'Skip to the next track in the current Spotify playback queue',
|
||||
schema: {
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to skip on'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { deviceId } = args;
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.skipToNext(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Skipped to next track',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const skipToPrevious: tool<{
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'skipToPrevious',
|
||||
description:
|
||||
'Skip to the previous track in the current Spotify playback queue',
|
||||
schema: {
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to skip on'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { deviceId } = args;
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.skipToPrevious(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Skipped to previous track',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const createPlaylist: tool<{
|
||||
name: z.ZodString;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
public: z.ZodOptional<z.ZodBoolean>;
|
||||
}> = {
|
||||
name: 'createPlaylist',
|
||||
description: 'Create a new playlist on Spotify',
|
||||
schema: {
|
||||
name: z.string().describe('The name of the playlist'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The description of the playlist'),
|
||||
public: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the playlist should be public'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { name, description, public: isPublic = false } = args;
|
||||
|
||||
const result = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
const me = await spotifyApi.currentUser.profile();
|
||||
|
||||
return await spotifyApi.playlists.createPlaylist(me.id, {
|
||||
name,
|
||||
description,
|
||||
public: isPublic,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully created playlist "${name}"\nPlaylist ID: ${result.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const addTracksToPlaylist: tool<{
|
||||
playlistId: z.ZodString;
|
||||
trackIds: z.ZodArray<z.ZodString>;
|
||||
position: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'addTracksToPlaylist',
|
||||
description: 'Add tracks to a Spotify playlist',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
trackIds: z.array(z.string()).describe('Array of Spotify track IDs to add'),
|
||||
position: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe('Position to insert the tracks (0-based index)'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId, trackIds, position } = args;
|
||||
|
||||
if (trackIds.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: No track IDs provided',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const trackUris = trackIds.map((id) => `spotify:track:${id}`);
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.playlists.addItemsToPlaylist(
|
||||
playlistId,
|
||||
trackUris,
|
||||
position,
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully added ${trackIds.length} track${
|
||||
trackIds.length === 1 ? '' : 's'
|
||||
} to playlist (ID: ${playlistId})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error adding tracks to playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const resumePlayback: tool<{
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'resumePlayback',
|
||||
description: 'Resume Spotify playback on the active device',
|
||||
schema: {
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to resume playback on'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { deviceId } = args;
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.startResumePlayback(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Playback resumed',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const addToQueue: tool<{
|
||||
uri: z.ZodOptional<z.ZodString>;
|
||||
type: z.ZodOptional<z.ZodEnum<['track', 'album', 'artist', 'playlist']>>;
|
||||
id: z.ZodOptional<z.ZodString>;
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'addToQueue',
|
||||
description: 'Adds a track, album, artist or playlist to the playback queue',
|
||||
schema: {
|
||||
uri: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify URI to play (overrides type and id)'),
|
||||
type: z
|
||||
.enum(['track', 'album', 'artist', 'playlist'])
|
||||
.optional()
|
||||
.describe('The type of item to play'),
|
||||
id: z.string().optional().describe('The Spotify ID of the item to play'),
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to add the track to'),
|
||||
},
|
||||
handler: async (args) => {
|
||||
const { uri, type, id, deviceId } = args;
|
||||
|
||||
let spotifyUri = uri;
|
||||
if (!spotifyUri && type && id) {
|
||||
spotifyUri = `spotify:${type}:${id}`;
|
||||
}
|
||||
|
||||
if (!spotifyUri) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: Must provide either a URI or both a type and ID',
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.addItemToPlaybackQueue(
|
||||
spotifyUri,
|
||||
deviceId || '',
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Added item ${spotifyUri} to queue`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export const playTools = [
|
||||
playMusic,
|
||||
pausePlayback,
|
||||
skipToNext,
|
||||
skipToPrevious,
|
||||
createPlaylist,
|
||||
addTracksToPlaylist,
|
||||
resumePlayback,
|
||||
addToQueue,
|
||||
];
|
||||
|
|
@ -0,0 +1,366 @@
|
|||
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import type { MaxInt } from '@spotify/web-api-ts-sdk';
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, SpotifyTrack, tool } from './types.js';
|
||||
import { formatDuration, handleSpotifyRequest } from './utils.js';
|
||||
|
||||
function isTrack(item: any): item is SpotifyTrack {
|
||||
return (
|
||||
item &&
|
||||
item.type === 'track' &&
|
||||
Array.isArray(item.artists) &&
|
||||
item.album &&
|
||||
typeof item.album.name === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
const searchSpotify: tool<{
|
||||
query: z.ZodString;
|
||||
type: z.ZodEnum<['track', 'album', 'artist', 'playlist']>;
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'searchSpotify',
|
||||
description: 'Search for tracks, albums, artists, or playlists on Spotify',
|
||||
schema: {
|
||||
query: z.string().describe('The search query'),
|
||||
type: z
|
||||
.enum(['track', 'album', 'artist', 'playlist'])
|
||||
.describe(
|
||||
'The type of item to search for either track, album, artist, or playlist',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of results to return (10-50)'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { query, type, limit } = args;
|
||||
const limitValue = limit ?? 10;
|
||||
|
||||
try {
|
||||
const results = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.search(
|
||||
query,
|
||||
[type],
|
||||
undefined,
|
||||
limitValue as MaxInt<50>,
|
||||
);
|
||||
});
|
||||
|
||||
let formattedResults = '';
|
||||
|
||||
if (type === 'track' && results.tracks) {
|
||||
formattedResults = results.tracks.items
|
||||
.map((track, i) => {
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
return `${i + 1}. "${
|
||||
track.name
|
||||
}" by ${artists} (${duration}) - ID: ${track.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
} else if (type === 'album' && results.albums) {
|
||||
formattedResults = results.albums.items
|
||||
.map((album, i) => {
|
||||
const artists = album.artists.map((a) => a.name).join(', ');
|
||||
return `${i + 1}. "${album.name}" by ${artists} - ID: ${album.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
} else if (type === 'artist' && results.artists) {
|
||||
formattedResults = results.artists.items
|
||||
.map((artist, i) => {
|
||||
return `${i + 1}. ${artist.name} - ID: ${artist.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
} else if (type === 'playlist' && results.playlists) {
|
||||
formattedResults = results.playlists.items
|
||||
.map((playlist, i) => {
|
||||
return `${i + 1}. "${playlist?.name ?? 'Unknown Playlist'} (${
|
||||
playlist?.description ?? 'No description'
|
||||
} tracks)" by ${playlist?.owner?.display_name} - ID: ${
|
||||
playlist?.id
|
||||
}`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
formattedResults.length > 0
|
||||
? `# Search results for "${query}" (type: ${type})\n\n${formattedResults}`
|
||||
: `No ${type} results found for "${query}"`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error searching for ${type}s: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getNowPlaying: tool<Record<string, never>> = {
|
||||
name: 'getNowPlaying',
|
||||
description: 'Get information about the currently playing track on Spotify',
|
||||
schema: {},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
try {
|
||||
const currentTrack = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getCurrentlyPlayingTrack();
|
||||
});
|
||||
|
||||
if (!currentTrack || !currentTrack.item) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Nothing is currently playing on Spotify',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const item = currentTrack.item;
|
||||
|
||||
if (!isTrack(item)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Currently playing item is not a track (might be a podcast episode)',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const artists = item.artists.map((a) => a.name).join(', ');
|
||||
const album = item.album.name;
|
||||
const duration = formatDuration(item.duration_ms);
|
||||
const progress = formatDuration(currentTrack.progress_ms || 0);
|
||||
const isPlaying = currentTrack.is_playing;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
`# Currently ${isPlaying ? 'Playing' : 'Paused'}\n\n` +
|
||||
`**Track**: "${item.name}"\n` +
|
||||
`**Artist**: ${artists}\n` +
|
||||
`**Album**: ${album}\n` +
|
||||
`**Progress**: ${progress} / ${duration}\n` +
|
||||
`**ID**: ${item.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting current track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getMyPlaylists: tool<{
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getMyPlaylists',
|
||||
description: "Get a list of the current user's playlists on Spotify",
|
||||
schema: {
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of playlists to return (1-50)'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { limit = 50 } = args;
|
||||
|
||||
const playlists = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.currentUser.playlists.playlists(
|
||||
limit as MaxInt<50>,
|
||||
);
|
||||
});
|
||||
|
||||
if (playlists.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You don't have any playlists on Spotify",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedPlaylists = playlists.items
|
||||
.map((playlist, i) => {
|
||||
const tracksTotal = playlist.tracks?.total ? playlist.tracks.total : 0;
|
||||
return `${i + 1}. "${playlist.name}" (${tracksTotal} tracks) - ID: ${
|
||||
playlist.id
|
||||
}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Your Spotify Playlists\n\n${formattedPlaylists}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getPlaylistTracks: tool<{
|
||||
playlistId: z.ZodString;
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getPlaylistTracks',
|
||||
description: 'Get a list of tracks in a Spotify playlist',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of tracks to return (1-50)'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId, limit = 50 } = args;
|
||||
|
||||
const playlistTracks = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.playlists.getPlaylistItems(
|
||||
playlistId,
|
||||
undefined,
|
||||
undefined,
|
||||
limit as MaxInt<50>,
|
||||
);
|
||||
});
|
||||
|
||||
if ((playlistTracks.items?.length ?? 0) === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "This playlist doesn't have any tracks",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedTracks = playlistTracks.items
|
||||
.map((item, i) => {
|
||||
const { track } = item;
|
||||
if (!track) return `${i + 1}. [Removed track]`;
|
||||
|
||||
if (isTrack(track)) {
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
return `${i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
|
||||
}
|
||||
|
||||
return `${i + 1}. Unknown item`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Tracks in Playlist\n\n${formattedTracks}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const getRecentlyPlayed: tool<{
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getRecentlyPlayed',
|
||||
description: 'Get a list of recently played tracks on Spotify',
|
||||
schema: {
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of tracks to return (1-50)'),
|
||||
},
|
||||
handler: async (args, extra: SpotifyHandlerExtra) => {
|
||||
const { limit = 50 } = args;
|
||||
|
||||
const history = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getRecentlyPlayedTracks(
|
||||
limit as MaxInt<50>,
|
||||
);
|
||||
});
|
||||
|
||||
if (history.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You don't have any recently played tracks on Spotify",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedHistory = history.items
|
||||
.map((item, i) => {
|
||||
const track = item.track;
|
||||
if (!track) return `${i + 1}. [Removed track]`;
|
||||
|
||||
if (isTrack(track)) {
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
return `${i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
|
||||
}
|
||||
|
||||
return `${i + 1}. Unknown item`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Recently Played Tracks\n\n${formattedHistory}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const readTools = [
|
||||
searchSpotify,
|
||||
getNowPlaying,
|
||||
getMyPlaylists,
|
||||
getPlaylistTracks,
|
||||
getRecentlyPlayed,
|
||||
];
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import { ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
import type { z } from 'zod';
|
||||
|
||||
export type SpotifyHandlerExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
||||
|
||||
export type tool<Args extends z.ZodRawShape> = {
|
||||
name: string;
|
||||
description: string;
|
||||
schema: Args;
|
||||
handler: (
|
||||
args: z.infer<z.ZodObject<Args>>,
|
||||
extra: SpotifyHandlerExtra,
|
||||
) =>
|
||||
| Promise<{
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
}>
|
||||
| {
|
||||
content: Array<{
|
||||
type: 'text';
|
||||
text: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export interface SpotifyArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SpotifyAlbum {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: SpotifyArtist[];
|
||||
}
|
||||
|
||||
export interface SpotifyTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
duration_ms: number;
|
||||
artists: SpotifyArtist[];
|
||||
album: SpotifyAlbum;
|
||||
}
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
import { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import open from 'open';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_FILE = path.join(__dirname, '../spotify-config.json');
|
||||
|
||||
export interface SpotifyConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export function loadSpotifyConfig(): SpotifyConfig {
|
||||
if (!fs.existsSync(CONFIG_FILE)) {
|
||||
throw new Error(
|
||||
`Spotify configuration file not found at ${CONFIG_FILE}. Please create one with clientId, clientSecret, and redirectUri.`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
if (!config.clientId || !config.clientSecret || !config.redirectUri) {
|
||||
throw new Error(
|
||||
'Spotify configuration must include clientId, clientSecret, and redirectUri.',
|
||||
);
|
||||
}
|
||||
return config;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse Spotify configuration: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSpotifyConfig(config: SpotifyConfig): void {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
let cachedSpotifyApi: SpotifyApi | null = null;
|
||||
|
||||
export function createSpotifyApi(): SpotifyApi {
|
||||
if (cachedSpotifyApi) {
|
||||
return cachedSpotifyApi;
|
||||
}
|
||||
|
||||
const config = loadSpotifyConfig();
|
||||
|
||||
if (config.accessToken && config.refreshToken) {
|
||||
const accessToken = {
|
||||
access_token: config.accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600 * 24 * 30, // Default to 1 month
|
||||
refresh_token: config.refreshToken,
|
||||
};
|
||||
|
||||
cachedSpotifyApi = SpotifyApi.withAccessToken(config.clientId, accessToken);
|
||||
return cachedSpotifyApi;
|
||||
}
|
||||
|
||||
cachedSpotifyApi = SpotifyApi.withClientCredentials(
|
||||
config.clientId,
|
||||
config.clientSecret,
|
||||
);
|
||||
|
||||
return cachedSpotifyApi;
|
||||
}
|
||||
|
||||
function generateRandomString(length: number): string {
|
||||
const array = new Uint8Array(length);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array)
|
||||
.map((b) =>
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.charAt(
|
||||
b % 62,
|
||||
),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function base64Encode(str: string): string {
|
||||
return Buffer.from(str).toString('base64');
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(
|
||||
code: string,
|
||||
config: SpotifyConfig,
|
||||
): Promise<{ access_token: string; refresh_token: string }> {
|
||||
const tokenUrl = 'https://accounts.spotify.com/api/token';
|
||||
const authHeader = `Basic ${base64Encode(`${config.clientId}:${config.clientSecret}`)}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('grant_type', 'authorization_code');
|
||||
params.append('code', code);
|
||||
params.append('redirect_uri', config.redirectUri);
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
throw new Error(`Failed to exchange code for token: ${errorData}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token,
|
||||
};
|
||||
}
|
||||
|
||||
export async function authorizeSpotify(): Promise<void> {
|
||||
const config = loadSpotifyConfig();
|
||||
|
||||
const redirectUri = new URL(config.redirectUri);
|
||||
if (
|
||||
redirectUri.hostname !== 'localhost' &&
|
||||
redirectUri.hostname !== '127.0.0.1'
|
||||
) {
|
||||
console.error(
|
||||
'Error: Redirect URI must use localhost for automatic token exchange',
|
||||
);
|
||||
console.error(
|
||||
'Please update your spotify-config.json with a localhost redirect URI',
|
||||
);
|
||||
console.error('Example: http://127.0.0.1:8888/callback');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = redirectUri.port || '80';
|
||||
const callbackPath = redirectUri.pathname || '/callback';
|
||||
|
||||
const state = generateRandomString(16);
|
||||
|
||||
const scopes = [
|
||||
'user-read-private',
|
||||
'user-read-email',
|
||||
'user-read-playback-state',
|
||||
'user-modify-playback-state',
|
||||
'user-read-currently-playing',
|
||||
'playlist-read-private',
|
||||
'playlist-modify-private',
|
||||
'playlist-modify-public',
|
||||
'user-library-read',
|
||||
'user-library-modify',
|
||||
'user-read-recently-played',
|
||||
'user-modify-playback-state',
|
||||
'user-read-playback-state',
|
||||
'user-read-currently-playing'
|
||||
];
|
||||
|
||||
const authParams = new URLSearchParams({
|
||||
client_id: config.clientId,
|
||||
response_type: 'code',
|
||||
redirect_uri: config.redirectUri,
|
||||
scope: scopes.join(' '),
|
||||
state: state,
|
||||
show_dialog: 'true',
|
||||
});
|
||||
|
||||
const authorizationUrl = `https://accounts.spotify.com/authorize?${authParams.toString()}`;
|
||||
|
||||
const authPromise = new Promise<void>((resolve, reject) => {
|
||||
// Create HTTP server to handle the callback
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (!req.url) {
|
||||
return res.end('No URL provided');
|
||||
}
|
||||
|
||||
const reqUrl = new URL(req.url, `http://localhost:${port}`);
|
||||
|
||||
if (reqUrl.pathname === callbackPath) {
|
||||
const code = reqUrl.searchParams.get('code');
|
||||
const returnedState = reqUrl.searchParams.get('state');
|
||||
const error = reqUrl.searchParams.get('error');
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
|
||||
if (error) {
|
||||
console.error(`Authorization error: ${error}`);
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Failed</h1><p>Please close this window and try again.</p></body></html>',
|
||||
);
|
||||
server.close();
|
||||
reject(new Error(`Authorization failed: ${error}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (returnedState !== state) {
|
||||
console.error('State mismatch error');
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Failed</h1><p>State verification failed. Please close this window and try again.</p></body></html>',
|
||||
);
|
||||
server.close();
|
||||
reject(new Error('State mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
console.error('No authorization code received');
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Failed</h1><p>No authorization code received. Please close this window and try again.</p></body></html>',
|
||||
);
|
||||
server.close();
|
||||
reject(new Error('No authorization code received'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForToken(code, config);
|
||||
|
||||
config.accessToken = tokens.access_token;
|
||||
config.refreshToken = tokens.refresh_token;
|
||||
saveSpotifyConfig(config);
|
||||
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Successful!</h1><p>You can now close this window and return to the application.</p></body></html>',
|
||||
);
|
||||
console.log(
|
||||
'Authentication successful! Access token has been saved.',
|
||||
);
|
||||
|
||||
server.close();
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('Token exchange error:', error);
|
||||
res.end(
|
||||
'<html><body><h1>Authentication Failed</h1><p>Failed to exchange authorization code for tokens. Please close this window and try again.</p></body></html>',
|
||||
);
|
||||
server.close();
|
||||
reject(error);
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(Number.parseInt(port), '127.0.0.1', () => {
|
||||
console.log(
|
||||
`Listening for Spotify authentication callback on port ${port}`,
|
||||
);
|
||||
console.log('Opening browser for authorization...');
|
||||
|
||||
open(authorizationUrl).catch((error: Error) => {
|
||||
console.log(
|
||||
'Failed to open browser automatically. Please visit this URL to authorize:',
|
||||
);
|
||||
console.log(authorizationUrl);
|
||||
});
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
console.error(`Server error: ${error.message}`);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
await authPromise;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
||||
return `${minutes}:${seconds.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export async function handleSpotifyRequest<T>(
|
||||
action: (spotifyApi: SpotifyApi) => Promise<T>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const spotifyApi = createSpotifyApi();
|
||||
return await action(spotifyApi);
|
||||
} catch (error) {
|
||||
// Skip JSON parsing errors as these are actually successful operations
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (
|
||||
errorMessage.includes('Unexpected token') ||
|
||||
errorMessage.includes('Unexpected non-whitespace character') ||
|
||||
errorMessage.includes('Exponent part is missing a number in JSON')
|
||||
) {
|
||||
return undefined as T;
|
||||
}
|
||||
// Rethrow other errors
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue