read.ts•14.8 kB
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?.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>;
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;
const playlistTracks = await handleSpotifyRequest(async (spotifyApi) => {
return await spotifyApi.playlists.getPlaylistItems(
playlistId,
undefined,
undefined,
limit as MaxInt<50>,
offset,
);
});
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 `${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;
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}`,
},
],
};
},
};
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;
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}`,
},
],
};
},
};
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)
}`,
},
],
};
}
},
};
export const readTools = [
searchSpotify,
getNowPlaying,
getMyPlaylists,
getPlaylistTracks,
getRecentlyPlayed,
getUsersSavedTracks,
getQueue,
];