Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions src/albums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,30 @@ 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>]>;
albumIds: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodArray<z.ZodString>]>>;
ids: z.ZodOptional<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)])
.optional()
.describe('A single album ID or array of album IDs (max 20)'),
ids: z
.union([z.string(), z.array(z.string()).max(20)])
.optional()
.describe('Alias for albumIds'),
},
handler: async (args, _extra: SpotifyHandlerExtra) => {
const { albumIds } = args;
const raw = args.albumIds ?? args.ids;
if (!raw) {
return {
content: [{ type: 'text', text: 'Error: albumIds is required' }],
};
}
const albumIds = raw;
const ids = Array.isArray(albumIds) ? albumIds : [albumIds];

if (ids.length === 0) {
Expand Down Expand Up @@ -98,13 +110,15 @@ const getAlbums: tool<{

const getAlbumTracks: tool<{
albumId: z.ZodString;
album_id: z.ZodOptional<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'),
album_id: z.string().optional().describe('Alias for albumId'),
limit: z
.number()
.min(1)
Expand All @@ -118,7 +132,13 @@ const getAlbumTracks: tool<{
.describe('Offset for pagination (0-based index)'),
},
handler: async (args, _extra: SpotifyHandlerExtra) => {
const { albumId, limit = 20, offset = 0 } = args;
const albumId = args.albumId ?? args.album_id;
const { limit = 20, offset = 0 } = args;
if (!albumId) {
return {
content: [{ type: 'text', text: 'Error: albumId is required' }],
};
}

try {
const tracks = await handleSpotifyRequest(async (spotifyApi) => {
Expand Down
180 changes: 137 additions & 43 deletions src/play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,74 +2,162 @@ import { z } from 'zod';
import type { SpotifyHandlerExtra, tool } from './types.js';
import { handleSpotifyRequest, spotifyFetch } from './utils.js';

/**
* Ensures there is an active Spotify device before attempting playback.
* If no device is currently active, transfers playback to the first available
* device and waits briefly for it to become ready.
* Returns the device_id to use, or empty string if none found.
*/
async function ensureActiveDevice(preferredDeviceId?: string): Promise<string> {
const data = await spotifyFetch<{
devices: Array<{ id: string; is_active: boolean; name: string }>;
}>('me/player/devices');
const devices = data?.devices ?? [];

if (devices.length === 0) {
throw new Error(
'No Spotify devices found. Open Spotify on any device first.',
);
}

// If a preferred device was specified and exists, use it
if (preferredDeviceId) {
const preferred = devices.find((d) => d.id === preferredDeviceId);
if (preferred) {
if (!preferred.is_active) {
await spotifyFetch('me/player', {
method: 'PUT',
body: { device_ids: [preferredDeviceId], play: false },
});
await new Promise((r) => setTimeout(r, 500));
}
return preferredDeviceId;
}
}

// Use the already-active device if there is one
const active = devices.find((d) => d.is_active);
if (active) return active.id;

// No active device — transfer to the first available one
const target = devices[0];
await spotifyFetch('me/player', {
method: 'PUT',
body: { device_ids: [target.id], play: false },
});
// Give Spotify a moment to register the transfer before we start playback
await new Promise((r) => setTimeout(r, 600));
return target.id;
}

const playMusic: tool<{
uri: z.ZodOptional<z.ZodString>;
context_uri: z.ZodOptional<z.ZodString>;
type: z.ZodOptional<z.ZodEnum<['track', 'album', 'artist', 'playlist']>>;
id: z.ZodOptional<z.ZodString>;
deviceId: z.ZodOptional<z.ZodString>;
device_id: z.ZodOptional<z.ZodString>;
offset: z.ZodOptional<z.ZodNumber>;
}> = {
name: 'playMusic',
description: 'Start playing a Spotify track, album, artist, or playlist',
description:
'Start playing a Spotify track, album, artist, or playlist. ' +
'Pass a Spotify URI (e.g. spotify:track:xxx, spotify:album:xxx) via "uri". ' +
'For albums/playlists you can also use "context_uri". ' +
'Device is selected automatically — "deviceId" or "device_id" are optional.',
schema: {
uri: z
.string()
.optional()
.describe('The Spotify URI to play (overrides type and id)'),
.describe(
'Spotify URI to play (e.g. spotify:track:xxx, spotify:album:xxx)',
),
context_uri: z
.string()
.optional()
.describe('Alias for uri — Spotify context URI for albums/playlists'),
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'),
.describe('Type of item (only needed with id)'),
id: z
.string()
.optional()
.describe('Spotify ID of the item (use with type)'),
deviceId: z
.string()
.optional()
.describe('The Spotify device ID to play on'),
.describe('Spotify device ID to play on (auto-selected if omitted)'),
device_id: z.string().optional().describe('Alias for deviceId'),
offset: z
.number()
.optional()
.describe('Track offset within album/playlist (0-based)'),
},
handler: async (args, _extra: SpotifyHandlerExtra) => {
const { uri, type, id, deviceId } = args;
// Normalize aliases
const deviceId = args.deviceId ?? args.device_id;
const resolvedUri = args.uri ?? args.context_uri;
const { type, id, offset } = args;

if (!(uri || (type && id))) {
if (!(resolvedUri || (type && id))) {
return {
content: [
{
type: 'text',
text: 'Error: Must provide either a URI or both a type and ID',
text: 'Error: Must provide a "uri" (e.g. spotify:track:xxx) or both "type" and "id"',
isError: true,
},
],
};
}

let spotifyUri = uri;
let spotifyUri = resolvedUri;
if (!spotifyUri && type && id) {
spotifyUri = `spotify:${type}:${id}`;
}

await handleSpotifyRequest(async (spotifyApi) => {
const device = deviceId || '';
// Infer type from URI if not provided
const resolvedType = type ?? spotifyUri?.split(':')?.[1];

if (!spotifyUri) {
await spotifyApi.player.startResumePlayback(device);
return;
}
try {
const activeDeviceId = await ensureActiveDevice(deviceId);

if (type === 'track') {
await spotifyApi.player.startResumePlayback(device, undefined, [
spotifyUri,
]);
} else {
await spotifyApi.player.startResumePlayback(device, spotifyUri);
}
});
await handleSpotifyRequest(async (spotifyApi) => {
if (!spotifyUri) {
await spotifyApi.player.startResumePlayback(activeDeviceId);
return;
}
if (resolvedType === 'track') {
await spotifyApi.player.startResumePlayback(
activeDeviceId,
undefined,
[spotifyUri],
undefined,
offset,
);
} else {
// album, playlist, artist — use context_uri + optional offset
await spotifyApi.player.startResumePlayback(
activeDeviceId,
spotifyUri,
undefined,
offset !== undefined ? { position: offset } : undefined,
);
}
});

return {
content: [
{
type: 'text',
text: `Started playing ${type || 'music'} ${id ? `(ID: ${id})` : ''}`,
},
],
};
return {
content: [{ type: 'text', text: `Now playing: ${spotifyUri}` }],
};
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
content: [
{ type: 'text', text: `Error playing music: ${msg}`, isError: true },
],
};
}
},
};

Expand Down Expand Up @@ -289,18 +377,24 @@ const resumePlayback: tool<{
handler: async (args, _extra: SpotifyHandlerExtra) => {
const { deviceId } = args;

await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.startResumePlayback(deviceId || '');
});

return {
content: [
{
type: 'text',
text: 'Playback resumed',
},
],
};
try {
const activeDeviceId = await ensureActiveDevice(deviceId);
await handleSpotifyRequest(async (spotifyApi) => {
await spotifyApi.player.startResumePlayback(activeDeviceId);
});
return { content: [{ type: 'text', text: 'Playback resumed' }] };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: 'text',
text: `Error resuming playback: ${msg}`,
isError: true,
},
],
};
}
},
};

Expand Down
Loading