Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
@@ -0,0 +1,304 @@
|
||||
import type { MaxInt } from '@spotify/web-api-ts-sdk';
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
import { formatDuration, handleSpotifyRequest } from './utils.js';
|
||||
|
||||
const getAlbums: tool<{
|
||||
albumIds: z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString>]>;
|
||||
}> = {
|
||||
name: 'getAlbums',
|
||||
description:
|
||||
'Get detailed information about one or more albums by their Spotify IDs',
|
||||
schema: {
|
||||
albumIds: z
|
||||
.union([z.string(), z.array(z.string()).max(20)])
|
||||
.describe('A single album ID or array of album IDs (max 20)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { albumIds } = args;
|
||||
const ids = Array.isArray(albumIds) ? albumIds : [albumIds];
|
||||
|
||||
if (ids.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: No album IDs provided',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const albums = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return ids.length === 1
|
||||
? [await spotifyApi.albums.get(ids[0])]
|
||||
: await spotifyApi.albums.get(ids);
|
||||
});
|
||||
|
||||
if (albums.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No albums found for the provided IDs',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (albums.length === 1) {
|
||||
const album = albums[0];
|
||||
const artists = album.artists.map((a) => a.name).join(', ');
|
||||
const releaseDate = album.release_date;
|
||||
const totalTracks = album.total_tracks;
|
||||
const albumType = album.album_type;
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Album Details\n\n**Name**: "${album.name}"\n**Artists**: ${artists}\n**Release Date**: ${releaseDate}\n**Type**: ${albumType}\n**Total Tracks**: ${totalTracks}\n**ID**: ${album.id}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedAlbums = albums
|
||||
.map((album, i) => {
|
||||
if (!album) return `${i + 1}. [Album not found]`;
|
||||
|
||||
const artists = album.artists.map((a) => a.name).join(', ');
|
||||
return `${i + 1}. "${album.name}" by ${artists} (${album.release_date}) - ${album.total_tracks} tracks - ID: ${album.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Multiple Albums\n\n${formattedAlbums}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting albums: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getAlbumTracks: tool<{
|
||||
albumId: z.ZodString;
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
offset: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getAlbumTracks',
|
||||
description: 'Get tracks from a specific album with pagination support',
|
||||
schema: {
|
||||
albumId: z.string().describe('The Spotify ID of the album'),
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of tracks to return (1-50)'),
|
||||
offset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe('Offset for pagination (0-based index)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { albumId, limit = 20, offset = 0 } = args;
|
||||
|
||||
try {
|
||||
const tracks = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.albums.tracks(
|
||||
albumId,
|
||||
undefined,
|
||||
limit as MaxInt<50>,
|
||||
offset,
|
||||
);
|
||||
});
|
||||
|
||||
if (tracks.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No tracks found in this album',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedTracks = tracks.items
|
||||
.map((track, i) => {
|
||||
if (!track) return `${i + 1}. [Track not found]`;
|
||||
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
return `${offset + i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Album Tracks (${offset + 1}-${offset + tracks.items.length} of ${tracks.total})\n\n${formattedTracks}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting album tracks: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const saveOrRemoveAlbumForUser: tool<{
|
||||
albumIds: z.ZodArray<z.ZodString>;
|
||||
action: z.ZodEnum<['save', 'remove']>;
|
||||
}> = {
|
||||
name: 'saveOrRemoveAlbumForUser',
|
||||
description: 'Save or remove albums from the user\'s "Your Music" library',
|
||||
schema: {
|
||||
albumIds: z
|
||||
.array(z.string())
|
||||
.max(20)
|
||||
.describe('Array of Spotify album IDs (max 20)'),
|
||||
action: z
|
||||
.enum(['save', 'remove'])
|
||||
.describe('Action to perform: save or remove albums'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { albumIds, action } = args;
|
||||
|
||||
if (albumIds.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: No album IDs provided',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return action === 'save'
|
||||
? await spotifyApi.currentUser.albums.saveAlbums(albumIds)
|
||||
: await spotifyApi.currentUser.albums.removeSavedAlbums(albumIds);
|
||||
});
|
||||
|
||||
const actionPastTense = action === 'save' ? 'saved' : 'removed';
|
||||
const preposition = action === 'save' ? 'to' : 'from';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully ${actionPastTense} ${albumIds.length} album${albumIds.length === 1 ? '' : 's'} ${preposition} your library`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error ${action === 'save' ? 'saving' : 'removing'} albums: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const checkUsersSavedAlbums: tool<{
|
||||
albumIds: z.ZodArray<z.ZodString>;
|
||||
}> = {
|
||||
name: 'checkUsersSavedAlbums',
|
||||
description: 'Check if albums are saved in the user\'s "Your Music" library',
|
||||
schema: {
|
||||
albumIds: z
|
||||
.array(z.string())
|
||||
.max(20)
|
||||
.describe('Array of Spotify album IDs to check (max 20)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { albumIds } = args;
|
||||
|
||||
if (albumIds.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: No album IDs provided',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const savedStatus = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.currentUser.albums.hasSavedAlbums(albumIds);
|
||||
});
|
||||
|
||||
const formattedResults = albumIds
|
||||
.map((albumId, i) => {
|
||||
const isSaved = savedStatus[i];
|
||||
return `${i + 1}. ${albumId}: ${isSaved ? 'Saved' : 'Not saved'}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Album Save Status\n\n${formattedResults}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error checking saved albums: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const albumTools = [
|
||||
getAlbums,
|
||||
getAlbumTracks,
|
||||
saveOrRemoveAlbumForUser,
|
||||
checkUsersSavedAlbums,
|
||||
];
|
||||
@@ -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,27 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { albumTools } from './albums.js';
|
||||
import { playTools } from './play.js';
|
||||
import { playlistTools } from './playlist.js';
|
||||
import { readTools } from './read.js';
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'spotify-controller',
|
||||
version: '1.0.0',
|
||||
});
|
||||
|
||||
[...readTools, ...playTools, ...albumTools, ...playlistTools].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,586 @@
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
import { handleSpotifyRequest, spotifyFetch } from './utils.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',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
let spotifyUri = uri;
|
||||
if (!spotifyUri && type && id) {
|
||||
spotifyUri = `spotify:${type}:${id}`;
|
||||
}
|
||||
|
||||
try {
|
||||
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})` : ''}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error starting playback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.pausePlayback(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Playback paused' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error pausing playback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.skipToNext(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Skipped to next track' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error skipping to next track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.skipToPrevious(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Skipped to previous track' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error skipping to previous track: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
// Get user ID via raw fetch (SDK playlists.createPlaylist hit a 403 after
|
||||
// the March 2026 Spotify API changes — use spotifyFetch to avoid the SDK
|
||||
// OAuth signing layer and hit the endpoint directly with Bearer token).
|
||||
const me = await spotifyFetch<{ id: string }>('me');
|
||||
const playlist = await spotifyFetch<{
|
||||
id: string;
|
||||
external_urls: { spotify: string };
|
||||
}>(`users/${me.id}/playlists`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name,
|
||||
...(description !== undefined ? { description } : {}),
|
||||
public: isPublic,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully created playlist "${name}"\nPlaylist ID: ${playlist.id}\nPlaylist URL: ${playlist.external_urls.spotify}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error creating playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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}`);
|
||||
|
||||
// Hit /items directly: see spotifyFetch JSDoc for context.
|
||||
await spotifyFetch(`playlists/${playlistId}/items`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
uris: trackUris,
|
||||
...(position !== undefined ? { 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;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.startResumePlayback(deviceId || '');
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Playback resumed' }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error resuming playback: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.addItemToPlaybackQueue(
|
||||
spotifyUri,
|
||||
deviceId || '',
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Added item ${spotifyUri} to queue`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error adding to queue: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const setVolume: tool<{
|
||||
volumePercent: z.ZodNumber;
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'setVolume',
|
||||
description:
|
||||
'Set the playback volume to a specific percentage (0-100). Requires Spotify Premium.',
|
||||
schema: {
|
||||
volumePercent: z
|
||||
.number()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.describe('The volume to set (0-100)'),
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to set volume on'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { volumePercent, deviceId } = args;
|
||||
|
||||
try {
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.setPlaybackVolume(
|
||||
Math.round(volumePercent),
|
||||
deviceId || '',
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Volume set to ${Math.round(volumePercent)}%`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error setting volume: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const adjustVolume: tool<{
|
||||
adjustment: z.ZodNumber;
|
||||
deviceId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'adjustVolume',
|
||||
description:
|
||||
'Adjust the playback volume up or down by a relative amount. Use positive values to increase, negative to decrease. Requires Spotify Premium.',
|
||||
schema: {
|
||||
adjustment: z
|
||||
.number()
|
||||
.min(-100)
|
||||
.max(100)
|
||||
.describe(
|
||||
'The amount to adjust volume by (-100 to 100). Positive increases, negative decreases.',
|
||||
),
|
||||
deviceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Spotify device ID to adjust volume on'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { adjustment, deviceId } = args;
|
||||
|
||||
try {
|
||||
// First get the current playback state to find current volume
|
||||
const playback = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getPlaybackState();
|
||||
});
|
||||
|
||||
if (!playback?.device) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No active device found. Make sure Spotify is open and playing on a device.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const currentVolume = playback.device.volume_percent;
|
||||
if (currentVolume === null || currentVolume === undefined) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Unable to get current volume from device.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const newVolume = Math.min(100, Math.max(0, currentVolume + adjustment));
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.player.setPlaybackVolume(
|
||||
Math.round(newVolume),
|
||||
deviceId || '',
|
||||
);
|
||||
});
|
||||
|
||||
const direction = adjustment > 0 ? 'increased' : 'decreased';
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Volume ${direction} from ${currentVolume}% to ${Math.round(newVolume)}%`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error adjusting volume: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const playTools = [
|
||||
playMusic,
|
||||
pausePlayback,
|
||||
skipToNext,
|
||||
skipToPrevious,
|
||||
createPlaylist,
|
||||
addTracksToPlaylist,
|
||||
resumePlayback,
|
||||
addToQueue,
|
||||
setVolume,
|
||||
adjustVolume,
|
||||
];
|
||||
@@ -0,0 +1,294 @@
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, tool } from './types.js';
|
||||
import { handleSpotifyRequest, spotifyFetch } from './utils.js';
|
||||
|
||||
const getPlaylist: tool<{
|
||||
playlistId: z.ZodString;
|
||||
}> = {
|
||||
name: 'getPlaylist',
|
||||
description:
|
||||
'Get details of a specific Spotify playlist including tracks count, description and owner',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId } = args;
|
||||
|
||||
try {
|
||||
const playlist = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.playlists.getPlaylist(playlistId);
|
||||
});
|
||||
|
||||
const owner =
|
||||
playlist.owner?.display_name ?? playlist.owner?.id ?? 'Unknown';
|
||||
const tracksTotal = playlist.tracks?.total ?? 0;
|
||||
const isPublic = playlist.public ? 'Public' : 'Private';
|
||||
const isCollaborative = playlist.collaborative ? ' | Collaborative' : '';
|
||||
const description = playlist.description
|
||||
? `\n**Description**: ${playlist.description}`
|
||||
: '';
|
||||
const url = playlist.external_urls?.spotify ?? '';
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text:
|
||||
`# Playlist: "${playlist.name}"\n\n` +
|
||||
`**Owner**: ${owner}\n` +
|
||||
`**Tracks**: ${tracksTotal}\n` +
|
||||
`**Visibility**: ${isPublic}${isCollaborative}` +
|
||||
`${description}\n` +
|
||||
`**ID**: ${playlist.id}\n` +
|
||||
`**URL**: ${url}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const updatePlaylist: tool<{
|
||||
playlistId: z.ZodString;
|
||||
name: z.ZodOptional<z.ZodString>;
|
||||
description: z.ZodOptional<z.ZodString>;
|
||||
public: z.ZodOptional<z.ZodBoolean>;
|
||||
collaborative: z.ZodOptional<z.ZodBoolean>;
|
||||
}> = {
|
||||
name: 'updatePlaylist',
|
||||
description:
|
||||
'Update the details of a Spotify playlist (name, description, public/private, collaborative)',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
name: z.string().optional().describe('New name for the playlist'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('New description for the playlist'),
|
||||
public: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the playlist should be public'),
|
||||
collaborative: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Whether the playlist should be collaborative (requires public to be false)',
|
||||
),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const {
|
||||
playlistId,
|
||||
name,
|
||||
description,
|
||||
public: isPublic,
|
||||
collaborative,
|
||||
} = args;
|
||||
|
||||
if (
|
||||
!name &&
|
||||
description === undefined &&
|
||||
isPublic === undefined &&
|
||||
collaborative === undefined
|
||||
) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Error: At least one field to update must be provided (name, description, public, collaborative)',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const body: Record<string, string | boolean> = {};
|
||||
if (name) body.name = name;
|
||||
if (description !== undefined) body.description = description;
|
||||
if (isPublic !== undefined) body.public = isPublic;
|
||||
if (collaborative !== undefined) body.collaborative = collaborative;
|
||||
|
||||
await handleSpotifyRequest(async (spotifyApi) => {
|
||||
await spotifyApi.playlists.changePlaylistDetails(playlistId, body);
|
||||
});
|
||||
|
||||
const changes = Object.keys(body).join(', ');
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully updated playlist (ID: ${playlistId})\nFields updated: ${changes}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error updating playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const removeTracksFromPlaylist: tool<{
|
||||
playlistId: z.ZodString;
|
||||
trackIds: z.ZodArray<z.ZodString>;
|
||||
snapshotId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'removeTracksFromPlaylist',
|
||||
description:
|
||||
'Remove one or more tracks from a Spotify playlist (max 100 tracks per request)',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
trackIds: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.max(100)
|
||||
.describe('Array of Spotify track IDs to remove (max 100)'),
|
||||
snapshotId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The playlist snapshot ID to target a specific version (optional)',
|
||||
),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId, trackIds, snapshotId } = args;
|
||||
|
||||
try {
|
||||
const items = trackIds.map((id) => ({ uri: `spotify:track:${id}` }));
|
||||
|
||||
// Hit /items directly: SDK targets the deprecated /tracks endpoint
|
||||
// (see spotifyFetch JSDoc for context on the March 2026 migration).
|
||||
await spotifyFetch(`playlists/${playlistId}/items`, {
|
||||
method: 'DELETE',
|
||||
body: {
|
||||
items,
|
||||
...(snapshotId ? { snapshot_id: snapshotId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully removed ${trackIds.length} track${
|
||||
trackIds.length === 1 ? '' : 's'
|
||||
} from playlist (ID: ${playlistId})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error removing tracks from playlist: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const reorderPlaylistItems: tool<{
|
||||
playlistId: z.ZodString;
|
||||
rangeStart: z.ZodNumber;
|
||||
insertBefore: z.ZodNumber;
|
||||
rangeLength: z.ZodOptional<z.ZodNumber>;
|
||||
snapshotId: z.ZodOptional<z.ZodString>;
|
||||
}> = {
|
||||
name: 'reorderPlaylistItems',
|
||||
description:
|
||||
'Reorder a range of tracks within a Spotify playlist by moving them to a new position',
|
||||
schema: {
|
||||
playlistId: z.string().describe('The Spotify ID of the playlist'),
|
||||
rangeStart: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.describe('The position of the first item to move (0-based index)'),
|
||||
insertBefore: z
|
||||
.number()
|
||||
.nonnegative()
|
||||
.describe(
|
||||
'The position where the items should be inserted (0-based index)',
|
||||
),
|
||||
rangeLength: z
|
||||
.number()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe('Number of consecutive items to move (defaults to 1)'),
|
||||
snapshotId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The playlist snapshot ID to target a specific version (optional)',
|
||||
),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId, rangeStart, insertBefore, rangeLength, snapshotId } =
|
||||
args;
|
||||
|
||||
try {
|
||||
// Hit /items directly: see spotifyFetch JSDoc for context.
|
||||
await spotifyFetch(`playlists/${playlistId}/items`, {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
range_start: rangeStart,
|
||||
insert_before: insertBefore,
|
||||
...(rangeLength !== undefined ? { range_length: rangeLength } : {}),
|
||||
...(snapshotId ? { snapshot_id: snapshotId } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
const count = rangeLength ?? 1;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully moved ${count} track${
|
||||
count === 1 ? '' : 's'
|
||||
} from position ${rangeStart} to before position ${insertBefore} in playlist (ID: ${playlistId})`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error reordering playlist items: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const playlistTools = [
|
||||
getPlaylist,
|
||||
updatePlaylist,
|
||||
removeTracksFromPlaylist,
|
||||
reorderPlaylistItems,
|
||||
];
|
||||
@@ -0,0 +1,766 @@
|
||||
import type { MaxInt } from '@spotify/web-api-ts-sdk';
|
||||
import { z } from 'zod';
|
||||
import type { SpotifyHandlerExtra, SpotifyTrack, tool } from './types.js';
|
||||
import {
|
||||
formatDuration,
|
||||
handleSpotifyRequest,
|
||||
spotifyFetch,
|
||||
} 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, including device and volume info',
|
||||
schema: {},
|
||||
handler: async (_args, _extra: SpotifyHandlerExtra) => {
|
||||
try {
|
||||
const playback = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getPlaybackState();
|
||||
});
|
||||
|
||||
if (!playback?.item) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Nothing is currently playing on Spotify',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const item = playback.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(playback.progress_ms || 0);
|
||||
const isPlaying = playback.is_playing;
|
||||
|
||||
const device = playback.device;
|
||||
const deviceInfo = device
|
||||
? `${device.name} (${device.type})`
|
||||
: 'Unknown device';
|
||||
const volume =
|
||||
device?.volume_percent !== null && device?.volume_percent !== undefined
|
||||
? `${device.volume_percent}%`
|
||||
: 'N/A';
|
||||
const shuffle = playback.shuffle_state ? 'On' : 'Off';
|
||||
const repeat = playback.repeat_state || 'off';
|
||||
|
||||
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}\n\n` +
|
||||
`**Device**: ${deviceInfo}\n` +
|
||||
`**Volume**: ${volume}\n` +
|
||||
`**Shuffle**: ${shuffle} | **Repeat**: ${repeat}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} 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;
|
||||
|
||||
try {
|
||||
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}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting playlists: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getPlaylistTracks: tool<{
|
||||
playlistId: z.ZodString;
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
offset: 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)'),
|
||||
offset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe('Offset for pagination (0-based index)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { playlistId, limit = 50, offset = 0 } = args;
|
||||
|
||||
// Hit /items directly: see spotifyFetch JSDoc for context.
|
||||
// Response wraps each entry's track under `item` (new) or `track` (legacy).
|
||||
type PlaylistItemEntry = {
|
||||
item?: SpotifyTrack | null;
|
||||
track?: SpotifyTrack | null;
|
||||
};
|
||||
const playlistTracks = await spotifyFetch<{
|
||||
items: PlaylistItemEntry[];
|
||||
total: number;
|
||||
}>(`playlists/${playlistId}/items`, {
|
||||
query: { limit, offset },
|
||||
});
|
||||
|
||||
if ((playlistTracks.items?.length ?? 0) === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "This playlist doesn't have any tracks",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedTracks = playlistTracks.items
|
||||
.map((entry, i) => {
|
||||
const track = entry.item ?? entry.track;
|
||||
if (!track) return `${offset + i + 1}. [Removed track]`;
|
||||
|
||||
if (isTrack(track)) {
|
||||
const artists = track.artists.map((a) => a.name).join(', ');
|
||||
const duration = formatDuration(track.duration_ms);
|
||||
return `${offset + i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id}`;
|
||||
}
|
||||
|
||||
return `${offset + i + 1}. Unknown item`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Tracks in Playlist (${offset + 1}-${offset + playlistTracks.items.length} of ${playlistTracks.total})\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;
|
||||
|
||||
try {
|
||||
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);
|
||||
const playedAt = item.played_at
|
||||
? new Date(item.played_at).toLocaleString()
|
||||
: 'Unknown time';
|
||||
return `${i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id} - Played at: ${playedAt}`;
|
||||
}
|
||||
|
||||
return `${i + 1}. Unknown item`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Recently Played Tracks\n\n${formattedHistory}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting recently played tracks: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getUsersSavedTracks: tool<{
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
offset: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getUsersSavedTracks',
|
||||
description:
|
||||
'Get a list of tracks saved in the user\'s "Liked Songs" library',
|
||||
schema: {
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of tracks to return (1-50)'),
|
||||
offset: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe('Offset for pagination (0-based index)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { limit = 50, offset = 0 } = args;
|
||||
|
||||
try {
|
||||
const savedTracks = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.currentUser.tracks.savedTracks(
|
||||
limit as MaxInt<50>,
|
||||
offset,
|
||||
);
|
||||
});
|
||||
|
||||
if (savedTracks.items.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "You don't have any saved tracks in your Liked Songs",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedTracks = savedTracks.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);
|
||||
const addedDate = new Date(item.added_at).toLocaleDateString();
|
||||
return `${offset + i + 1}. "${track.name}" by ${artists} (${duration}) - ID: ${track.id} - Added: ${addedDate}`;
|
||||
}
|
||||
|
||||
return `${i + 1}. Unknown item`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Your Liked Songs (${offset + 1}-${offset + savedTracks.items.length} of ${savedTracks.total})\n\n${formattedTracks}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting saved tracks: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getQueue: tool<{
|
||||
limit: z.ZodOptional<z.ZodNumber>;
|
||||
}> = {
|
||||
name: 'getQueue',
|
||||
description:
|
||||
'Get a list of the currently playing track and the next items in your Spotify queue',
|
||||
schema: {
|
||||
limit: z
|
||||
.number()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe('Maximum number of upcoming items to show (1-50)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { limit = 10 } = args;
|
||||
|
||||
try {
|
||||
const queue = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getUsersQueue();
|
||||
});
|
||||
|
||||
const current = (queue as any)?.currently_playing;
|
||||
const upcoming = ((queue as any)?.queue ?? []) as any[];
|
||||
|
||||
const header = '# Spotify Queue\n\n';
|
||||
|
||||
let currentText = 'Nothing is currently playing';
|
||||
if (current) {
|
||||
const name = current?.name ?? 'Unknown';
|
||||
const artists = Array.isArray(current?.artists)
|
||||
? (current.artists as Array<{ name: string }>)
|
||||
.map((a) => a.name)
|
||||
.join(', ')
|
||||
: 'Unknown';
|
||||
const duration =
|
||||
typeof current?.duration_ms === 'number'
|
||||
? formatDuration(current.duration_ms)
|
||||
: 'Unknown';
|
||||
currentText = `Currently Playing: "${name}" by ${artists} (${duration})`;
|
||||
}
|
||||
|
||||
if (upcoming.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `${header}${currentText}\n\nNo upcoming items in the queue`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const toShow = upcoming.slice(0, limit);
|
||||
const formatted = toShow
|
||||
.map((track, i) => {
|
||||
const name = track?.name ?? 'Unknown';
|
||||
const artists = Array.isArray(track?.artists)
|
||||
? (track.artists as Array<{ name: string }>)
|
||||
.map((a) => a.name)
|
||||
.join(', ')
|
||||
: 'Unknown';
|
||||
const duration =
|
||||
typeof track?.duration_ms === 'number'
|
||||
? formatDuration(track.duration_ms)
|
||||
: 'Unknown';
|
||||
const id = track?.id ?? 'Unknown';
|
||||
return `${i + 1}. "${name}" by ${artists} (${duration}) - ID: ${id}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `${header}${currentText}\n\nNext ${toShow.length} in queue:\n\n${formatted}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error fetching queue: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const getAvailableDevices: tool<Record<string, never>> = {
|
||||
name: 'getAvailableDevices',
|
||||
description:
|
||||
"Get information about the user's available Spotify Connect devices",
|
||||
schema: {},
|
||||
handler: async (_args, _extra: SpotifyHandlerExtra) => {
|
||||
try {
|
||||
const devices = await handleSpotifyRequest(async (spotifyApi) => {
|
||||
return await spotifyApi.player.getAvailableDevices();
|
||||
});
|
||||
|
||||
if (!devices.devices || devices.devices.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No available devices found. Make sure Spotify is open on at least one device.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const formattedDevices = devices.devices
|
||||
.map((device, i) => {
|
||||
const status = device.is_active ? '▶ Active' : '○ Inactive';
|
||||
const volume =
|
||||
device.volume_percent !== null
|
||||
? `${device.volume_percent}%`
|
||||
: 'N/A';
|
||||
const restricted = device.is_restricted ? ' (Restricted)' : '';
|
||||
return `${i + 1}. ${device.name} (${device.type})${restricted}\n Status: ${status} | Volume: ${volume} | ID: ${device.id}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `# Available Spotify Devices\n\n${formattedDevices}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error getting available devices: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const saveUsersTracks: tool<{
|
||||
trackIds: z.ZodArray<z.ZodString>;
|
||||
}> = {
|
||||
name: 'saveUsersTracks',
|
||||
description:
|
||||
'Save (like) one or more tracks to the user\'s "Liked Songs" library (max 50 per request)',
|
||||
schema: {
|
||||
trackIds: z
|
||||
.array(z.string())
|
||||
.max(50)
|
||||
.describe('Array of Spotify track IDs to save/like (max 50)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { trackIds } = args;
|
||||
|
||||
if (trackIds.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: No track IDs provided' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// PUT /me/tracks with body {"ids": [...]}
|
||||
await spotifyFetch('me/tracks', {
|
||||
method: 'PUT',
|
||||
body: { ids: trackIds },
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully saved ${trackIds.length} track${
|
||||
trackIds.length === 1 ? '' : 's'
|
||||
} to your Liked Songs`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error saving tracks to Liked Songs: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const removeUsersSavedTracks: tool<{
|
||||
trackIds: z.ZodArray<z.ZodString>;
|
||||
}> = {
|
||||
name: 'removeUsersSavedTracks',
|
||||
description:
|
||||
'Remove one or more tracks from the user\'s "Liked Songs" library (max 40 per request)',
|
||||
schema: {
|
||||
trackIds: z
|
||||
.array(z.string())
|
||||
.max(40)
|
||||
.describe('Array of Spotify track IDs to remove (max 40)'),
|
||||
},
|
||||
handler: async (args, _extra: SpotifyHandlerExtra) => {
|
||||
const { trackIds } = args;
|
||||
|
||||
if (trackIds.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text', text: 'Error: No track IDs provided' }],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// DELETE /me/tracks with body {"ids": [...]} (plain track IDs, not URIs)
|
||||
await spotifyFetch('me/tracks', {
|
||||
method: 'DELETE',
|
||||
body: { ids: trackIds },
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Successfully removed ${trackIds.length} track${
|
||||
trackIds.length === 1 ? '' : 's'
|
||||
} from your Liked Songs`,
|
||||
},
|
||||
],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `Error removing tracks from Liked Songs: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const readTools = [
|
||||
searchSpotify,
|
||||
getNowPlaying,
|
||||
getMyPlaylists,
|
||||
getPlaylistTracks,
|
||||
getRecentlyPlayed,
|
||||
getUsersSavedTracks,
|
||||
saveUsersTracks,
|
||||
removeUsersSavedTracks,
|
||||
getQueue,
|
||||
getAvailableDevices,
|
||||
];
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
|
||||
import type {
|
||||
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,439 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { URL, fileURLToPath } from 'node:url';
|
||||
import { SpotifyApi } from '@spotify/web-api-ts-sdk';
|
||||
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;
|
||||
expiresAt?: number; // Unix timestamp in milliseconds
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* Direct Spotify Web API fetch helper.
|
||||
* Used to bypass @spotify/web-api-ts-sdk methods that hit deprecated endpoints
|
||||
* (e.g. /playlists/{id}/tracks which was retired in the March 2026 API migration
|
||||
* for new Development Mode apps; replacement is /playlists/{id}/items).
|
||||
*
|
||||
* Handles token loading and refresh transparently.
|
||||
*/
|
||||
export async function spotifyFetch<T = unknown>(
|
||||
endpoint: string,
|
||||
options: {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | undefined>;
|
||||
} = {},
|
||||
): Promise<T> {
|
||||
const { method = 'GET', body, query } = options;
|
||||
const config = loadSpotifyConfig();
|
||||
|
||||
// Refresh token if expired
|
||||
if (config.accessToken && config.refreshToken) {
|
||||
const now = Date.now();
|
||||
if (!config.expiresAt || config.expiresAt <= now) {
|
||||
const tokens = await refreshAccessToken(config);
|
||||
config.accessToken = tokens.access_token;
|
||||
config.expiresAt = now + tokens.expires_in * 1000;
|
||||
saveSpotifyConfig(config);
|
||||
cachedSpotifyApi = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.accessToken) {
|
||||
throw new Error(
|
||||
'No access token available. Run "npm run auth" to authenticate.',
|
||||
);
|
||||
}
|
||||
|
||||
// Build URL with query string
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint.slice(1) : endpoint;
|
||||
let url = `https://api.spotify.com/v1/${cleanEndpoint}`;
|
||||
if (query) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(query)) {
|
||||
if (v !== undefined && v !== null) qs.append(k, String(v));
|
||||
}
|
||||
const qsStr = qs.toString();
|
||||
if (qsStr) url += `?${qsStr}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.accessToken}`,
|
||||
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errBody = await response.text();
|
||||
throw new Error(
|
||||
`Spotify API ${method} ${url} failed (${response.status}): ${errBody}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Some endpoints (DELETE, PUT) return empty body
|
||||
const text = await response.text();
|
||||
if (!text) return undefined as T;
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
export async function createSpotifyApi(): Promise<SpotifyApi> {
|
||||
const config = loadSpotifyConfig();
|
||||
|
||||
if (config.accessToken && config.refreshToken) {
|
||||
const now = Date.now();
|
||||
const shouldRefresh = !config.expiresAt || config.expiresAt <= now;
|
||||
|
||||
if (shouldRefresh) {
|
||||
console.error(
|
||||
'Access token expired or missing expiration time, refreshing...',
|
||||
);
|
||||
try {
|
||||
const tokens = await refreshAccessToken(config);
|
||||
config.accessToken = tokens.access_token;
|
||||
config.expiresAt = now + tokens.expires_in * 1000; // Convert seconds to milliseconds
|
||||
saveSpotifyConfig(config);
|
||||
console.error('Access token refreshed successfully');
|
||||
|
||||
// Clear cached API instance to force recreation with new token
|
||||
cachedSpotifyApi = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token:', error);
|
||||
throw new Error(
|
||||
'Failed to refresh access token. Please run "npm run auth" to re-authenticate.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedSpotifyApi) {
|
||||
return cachedSpotifyApi;
|
||||
}
|
||||
|
||||
const accessToken = {
|
||||
access_token: config.accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: Math.floor(
|
||||
((config.expiresAt ?? now + 3600000) - now) / 1000,
|
||||
),
|
||||
refresh_token: config.refreshToken,
|
||||
};
|
||||
|
||||
cachedSpotifyApi = SpotifyApi.withAccessToken(config.clientId, accessToken);
|
||||
return cachedSpotifyApi;
|
||||
}
|
||||
|
||||
// Fallback to client credentials if no user tokens available
|
||||
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;
|
||||
expires_in: number;
|
||||
}> {
|
||||
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,
|
||||
expires_in: data.expires_in || 3600,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshAccessToken(
|
||||
config: SpotifyConfig,
|
||||
): Promise<{ access_token: string; expires_in: number }> {
|
||||
if (!config.refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const tokenUrl = 'https://accounts.spotify.com/api/token';
|
||||
const authHeader = `Basic ${base64Encode(`${config.clientId}:${config.clientSecret}`)}`;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.append('grant_type', 'refresh_token');
|
||||
params.append('refresh_token', config.refreshToken);
|
||||
|
||||
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 refresh access token: ${errorData}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
expires_in: data.expires_in || 3600,
|
||||
};
|
||||
}
|
||||
|
||||
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-read-collaborative',
|
||||
'playlist-modify-private',
|
||||
'playlist-modify-public',
|
||||
'user-library-read',
|
||||
'user-library-modify',
|
||||
'user-read-recently-played',
|
||||
];
|
||||
|
||||
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;
|
||||
config.expiresAt = Date.now() + tokens.expires_in * 1000; // Convert seconds to milliseconds
|
||||
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 = await 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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user