import {
connectOauthUrl,
resolveSpotifyIntegration,
type SpotifyIntegrationInput,
} from './accounts.ts'
import {
buildDeviceMatchError,
hasDeviceSelector,
matchDevice,
readDeviceTarget,
type DeviceTargetParams,
} from './device-target.ts'
import {
chunkArray,
dryRunPreview,
isDryRun,
normalizeArtistNames,
normalizeIdList,
normalizeSpotifyPlaylistUri,
normalizeSpotifyTrackId,
normalizeSpotifyTrackUri,
normalizeTrackUris,
parseLimit,
requireNonEmptyString,
} from './normalize.ts'
import * as spotify from './openapi-client.ts'
import type { ScaffoldOptions } from './openapi-client.ts'
export type IntegrationParams = SpotifyIntegrationInput
type CallOptions = ScaffoldOptions
function providerOptions(integration: string): CallOptions {
return { provider: integration }
}
function getSpotifyAuthErrorText(error: any): string {
const values = [
error?.error,
error?.error_description,
error?.message,
error?.detail,
error?.detail?.error,
error?.detail?.error_description,
error?.detail?.message,
error?.cause?.error,
error?.cause?.error_description,
error?.cause?.message,
]
return values
.map((value) => (typeof value === 'string' ? value : ''))
.join(' ')
.toLowerCase()
}
function isSpotifyInvalidGrantError(error: unknown): boolean {
return getSpotifyAuthErrorText(error).includes('invalid_grant')
}
function normalizeSpotifyAuthError(error: unknown, integration: string): unknown {
if (!isSpotifyInvalidGrantError(error)) return error
const authError = new Error(
'Spotify authorization expired for integration "' +
integration +
'". Reconnect at ' +
connectOauthUrl(integration) +
', then retry this action. Refresh tokens expire six months after the original authorization.',
)
authError.name = 'SpotifyReauthorizationRequiredError'
;(authError as any).cause = error
return authError
}
async function parseResponseBody(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return text
}
}
function getSpotifyErrorMessage(detail: any): string | null {
if (!detail) return null
if (typeof detail === 'string') return detail
if (typeof detail?.error?.message === 'string') return detail.error.message
if (typeof detail?.error_description === 'string') return detail.error_description
if (typeof detail?.message === 'string') return detail.message
if (typeof detail?.error === 'string') return detail.error
return null
}
function buildSpotifyError(message: string, status: number, detail: unknown): Error {
const spotifyMessage = getSpotifyErrorMessage(detail)
const suffix = spotifyMessage ? ': ' + spotifyMessage : '.'
const error = new Error(message + ' Spotify API responded with status ' + status + suffix)
error.name = 'SpotifyApiError'
;(error as any).status = status
;(error as any).detail = detail
return error
}
export function assertSpotifyOk(
response: Response,
data: unknown,
message: string,
successStatuses: number[] = [],
): void {
if (response.ok || successStatuses.includes(response.status)) return
throw buildSpotifyError(message, response.status, data)
}
async function callAndParse(
integration: string,
invoke: (options: CallOptions) => Promise<Response>,
message: string,
opts: { allowNoContent?: boolean; successStatuses?: number[] } = {},
): Promise<{ response: Response; data: any; integration: string }> {
let response: Response
try {
response = await invoke(providerOptions(integration))
} catch (error) {
throw normalizeSpotifyAuthError(error, integration)
}
if (opts.allowNoContent && (response.status === 202 || response.status === 204)) {
return { response, data: null, integration }
}
const data = await parseResponseBody(response)
assertSpotifyOk(response, data, message, opts.successStatuses ?? [])
return { response, data, integration }
}
export function normalizeDevice(device: any) {
if (!device) return null
return {
id: device.id ?? null,
isActive: Boolean(device.is_active),
isRestricted: Boolean(device.is_restricted),
name: device.name ?? 'Unknown device',
supportsVolume: Boolean(device.supports_volume),
type: device.type ?? 'Unknown device',
volumePercent: Number.isFinite(device.volume_percent) ? device.volume_percent : null,
}
}
export function normalizeDeviceSummary(device: any) {
const normalized = normalizeDevice(device)
if (!normalized) return null
return {
id: normalized.id,
name: normalized.name,
type: normalized.type,
isActive: normalized.isActive,
volumePercent: normalized.volumePercent,
}
}
export function normalizeTrackSummary(track: any) {
if (!track) return null
return {
id: track.id ?? null,
uri: track.uri ?? null,
name: track.name ?? 'Unknown track',
artists: normalizeArtistNames(track.artists),
url: track.external_urls?.spotify ?? null,
}
}
export function normalizeTrackDetails(track: any) {
if (!track) return null
return {
id: track.id ?? null,
uri: track.uri ?? null,
name: track.name ?? 'Unknown track',
artists: normalizeArtistNames(track.artists),
album: track.album?.name ?? null,
durationMs: Number.isFinite(track.duration_ms) ? track.duration_ms : null,
popularity: Number.isFinite(track.popularity) ? track.popularity : null,
url: track.external_urls?.spotify ?? null,
}
}
export function normalizeArtistDetails(artist: any) {
if (!artist) return null
return {
id: artist.id ?? null,
uri: artist.uri ?? null,
name: artist.name ?? 'Unknown artist',
genres: Array.isArray(artist.genres) ? artist.genres : [],
popularity: Number.isFinite(artist.popularity) ? artist.popularity : null,
url: artist.external_urls?.spotify ?? null,
}
}
export function normalizePlaybackItem(item: any) {
if (!item) return null
return {
album: item.album?.name ?? null,
artists: normalizeArtistNames(item.artists),
durationMs: Number.isFinite(item.duration_ms) ? item.duration_ms : null,
externalUrl: item.external_urls?.spotify ?? null,
name: item.name ?? 'Unknown track',
type: item.type ?? 'track',
uri: item.uri ?? null,
}
}
export function normalizePlaybackState(playback: any) {
if (!playback) return null
return {
contextUri: playback.context?.uri ?? null,
device: normalizeDevice(playback.device),
isPlaying: Boolean(playback.is_playing),
item: normalizePlaybackItem(playback.item),
progressMs: Number.isFinite(playback.progress_ms) ? playback.progress_ms : null,
repeatState: playback.repeat_state ?? 'off',
shuffleState: Boolean(playback.shuffle_state),
}
}
export function normalizePlaylistTrackItem(item: any) {
const track = item?.item ?? item?.track
if (!track) return null
return {
id: track.id ?? null,
uri: track.uri ?? null,
name: track.name ?? 'Unknown track',
artists: normalizeArtistNames(track.artists).join(', '),
album: track.album?.name ?? '',
}
}
export function normalizeProfile(profile: any) {
if (!profile) return null
return {
id: profile.id ?? null,
displayName: profile.display_name ?? null,
uri: profile.uri ?? null,
url: profile.external_urls?.spotify ?? null,
product: profile.product ?? null,
country: profile.country ?? null,
followers: Number.isFinite(profile.followers?.total) ? profile.followers.total : null,
}
}
export async function getCurrentProfile(params: IntegrationParams = {}) {
const integration = resolveSpotifyIntegration(params)
const { data } = await callAndParse(
integration,
(options) => spotify.getCurrentUsersProfile({}, options),
'Failed to read the Spotify profile.',
)
const profile = normalizeProfile(data)
if (!profile?.id) {
throw new Error('Spotify profile response did not include a user id.')
}
return { integration, ...profile }
}
export async function getPlaybackState(params: IntegrationParams = {}) {
const integration = resolveSpotifyIntegration(params)
let response: Response
try {
response = await spotify.getInformationAboutTheUsersCurrentPlayback(
{},
providerOptions(integration),
)
} catch (error) {
throw normalizeSpotifyAuthError(error, integration)
}
if (response.status === 204 || response.status === 404) {
return { nothingPlaying: true, playback: null, status: response.status, integration }
}
const data = await parseResponseBody(response)
assertSpotifyOk(response, data, 'Failed to read Spotify playback state.')
return { nothingPlaying: false, playback: data, status: response.status, integration }
}
export async function getAvailableDevices(params: IntegrationParams = {}) {
const integration = resolveSpotifyIntegration(params)
const { data } = await callAndParse(
integration,
(options) => spotify.getAUsersAvailableDevices({}, options),
'Failed to read available Spotify devices.',
)
return Array.isArray(data?.devices) ? data.devices : []
}
export async function resolveOptionalDeviceId(
params: DeviceTargetParams = {},
): Promise<string | undefined> {
const target = readDeviceTarget(params)
if (!target.deviceName && !target.deviceType) {
return target.deviceId ?? undefined
}
const devices = await getAvailableDevices(params)
const matched = matchDevice(devices, target)
if (!matched?.id) {
throw new Error(buildDeviceMatchError(devices, target))
}
return matched.id
}
export async function requireResolvedDeviceId(
params: DeviceTargetParams & { requiredMessage?: string } = {},
): Promise<string> {
const deviceId = await resolveOptionalDeviceId(params)
if (deviceId) return deviceId
throw new Error(
params.requiredMessage ??
'A deviceName (preferred) or deviceId is required to target a Spotify device.',
)
}
export function resolveDeviceId(input: any = {}) {
return (
input.requestedDeviceId ??
input.playback?.device?.id ??
(Array.isArray(input.devices)
? (input.devices.find((device: any) => device?.is_active)?.id ??
input.devices[0]?.id ??
null)
: null) ??
null
)
}
export async function requireDeviceId(input: any = {}) {
const deviceId = resolveDeviceId(input)
if (!deviceId) {
throw new Error(
'No Spotify device is currently available. Open Spotify on a device, or pass deviceName.',
)
}
return deviceId
}
export { hasDeviceSelector }
export async function startOrResumePlayback(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const playbackState = await getPlaybackState(params)
const devices = await getAvailableDevices(params)
const requestedDeviceId = await resolveOptionalDeviceId(params)
const deviceId = await requireDeviceId({
requestedDeviceId,
playback: playbackState.playback,
devices,
})
const contextUri =
typeof params.contextUri === 'string' && params.contextUri.trim()
? params.contextUri.trim()
: null
const shouldStartContext =
(playbackState.nothingPlaying || params.forceCollection === true) && contextUri != null
if (isDryRun(params)) {
return dryRunPreview({
action: shouldStartContext ? 'started' : 'resumed',
deviceId,
contextUri,
integration,
})
}
const body = shouldStartContext ? { context_uri: contextUri, offset: { position: 0 } } : undefined
const { response } = await callAndParse(
integration,
(options) =>
spotify.startAUsersPlayback({ query: { device_id: deviceId }, body }, options),
'Failed to start or resume Spotify playback.',
{ allowNoContent: true, successStatuses: [204] },
)
return {
ok: true,
action: shouldStartContext ? 'started' : 'resumed',
deviceId,
status: response.status,
integration,
}
}
export async function pausePlayback(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const deviceId = await resolveOptionalDeviceId(params)
if (isDryRun(params)) {
return dryRunPreview({ action: 'paused', deviceId: deviceId ?? null, integration })
}
const { response } = await callAndParse(
integration,
(options) =>
spotify.pauseAUsersPlayback(
{ query: deviceId ? { device_id: deviceId } : undefined },
options,
),
'Failed to pause Spotify playback.',
{ allowNoContent: true, successStatuses: [204] },
)
return { ok: true, action: 'paused', deviceId: deviceId ?? null, status: response.status, integration }
}
export async function skipToNext(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const deviceId = await resolveOptionalDeviceId(params)
if (isDryRun(params)) {
return dryRunPreview({ action: 'next', deviceId: deviceId ?? null, integration })
}
const { response } = await callAndParse(
integration,
(options) =>
spotify.skipUsersPlaybackToNextTrack(
{ query: deviceId ? { device_id: deviceId } : undefined },
options,
),
'Failed to skip to the next Spotify track.',
{ allowNoContent: true, successStatuses: [204] },
)
return { ok: true, action: 'next', status: response.status, integration }
}
export async function skipToPrevious(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const deviceId = await resolveOptionalDeviceId(params)
if (isDryRun(params)) {
return dryRunPreview({ action: 'previous', deviceId: deviceId ?? null, integration })
}
const { response } = await callAndParse(
integration,
(options) =>
spotify.skipUsersPlaybackToPreviousTrack(
{ query: deviceId ? { device_id: deviceId } : undefined },
options,
),
'Failed to skip to the previous Spotify track.',
{ allowNoContent: true, successStatuses: [204] },
)
return { ok: true, action: 'previous', status: response.status, integration }
}
export async function skip(params: any = {}) {
const direction = params.direction ?? 'next'
if (direction === 'next') return await skipToNext(params)
if (direction === 'previous') return await skipToPrevious(params)
throw new Error('direction must be "next" or "previous".')
}
export async function transferPlayback(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const deviceId = await requireResolvedDeviceId({
...params,
requiredMessage:
'A deviceName (preferred) or deviceId is required to transfer Spotify playback.',
})
const play = params.play !== false
if (isDryRun(params)) {
return dryRunPreview({ action: 'transferred', deviceId, play, integration })
}
const { response } = await callAndParse(
integration,
(options) =>
spotify.transferAUsersPlayback({ body: { device_ids: [deviceId], play } }, options),
'Failed to transfer Spotify playback.',
{ allowNoContent: true, successStatuses: [204] },
)
return { ok: true, action: 'transferred', deviceId, play, status: response.status, integration }
}
export async function setVolume(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const volumePercent = Math.round(Number(params.volumePercent ?? params.volume ?? NaN))
if (!Number.isFinite(volumePercent) || volumePercent < 0 || volumePercent > 100) {
throw new Error('Volume must be a number between 0 and 100.')
}
const playbackState = await getPlaybackState(params)
const devices = await getAvailableDevices(params)
const requestedDeviceId = await resolveOptionalDeviceId(params)
const deviceId = await requireDeviceId({
requestedDeviceId,
playback: playbackState.playback,
devices,
})
if (isDryRun(params)) {
return dryRunPreview({ action: 'volume-set', deviceId, volumePercent, integration })
}
const { response } = await callAndParse(
integration,
(options) =>
spotify.setVolumeForUsersPlayback(
{ query: { device_id: deviceId, volume_percent: volumePercent } },
options,
),
'Failed to set Spotify volume.',
{ allowNoContent: true, successStatuses: [204] },
)
return { ok: true, action: 'volume-set', deviceId, volumePercent, status: response.status, integration }
}
export async function seek(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const positionMs = Math.round(Number(params.positionMs ?? params.position_ms))
if (!Number.isFinite(positionMs) || positionMs < 0) {
throw new Error('positionMs must be a non-negative number.')
}
const deviceId = await resolveOptionalDeviceId(params)
if (isDryRun(params)) {
return dryRunPreview({ action: 'seek', positionMs, deviceId: deviceId ?? null, integration })
}
const { response } = await callAndParse(
integration,
(options) =>
spotify.seekToPositionInCurrentlyPlayingTrack(
{ query: { position_ms: positionMs, device_id: deviceId } },
options,
),
'Failed to seek Spotify playback.',
{ allowNoContent: true, successStatuses: [204] },
)
return { ok: true, positionMs, deviceId: deviceId ?? null, status: response.status, integration }
}
const VALID_REPEAT_STATES = new Set(['track', 'context', 'off'])
export async function setRepeat(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const state = params.state
if (!VALID_REPEAT_STATES.has(state)) {
throw new Error('Repeat state must be "track", "context", or "off".')
}
const deviceId = await resolveOptionalDeviceId(params)
if (isDryRun(params)) {
return dryRunPreview({ action: 'repeat', state, deviceId: deviceId ?? null, integration })
}
const { response } = await callAndParse(
integration,
(options) =>
spotify.setRepeatModeOnUsersPlayback({ query: { state, device_id: deviceId } }, options),
'Failed to set Spotify repeat state.',
{ allowNoContent: true, successStatuses: [204] },
)
return { ok: true, state, deviceId: deviceId ?? null, status: response.status, integration }
}
export async function setShuffle(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
if (typeof params.state !== 'boolean') {
throw new Error('Shuffle state must be a boolean.')
}
const deviceId = await resolveOptionalDeviceId(params)
if (isDryRun(params)) {
return dryRunPreview({
action: 'shuffle',
state: params.state,
deviceId: deviceId ?? null,
integration,
})
}
const { response } = await callAndParse(
integration,
(options) =>
spotify.toggleShuffleForUsersPlayback(
{ query: { state: params.state, device_id: deviceId } },
options,
),
'Failed to set Spotify shuffle state.',
{ allowNoContent: true, successStatuses: [204] },
)
return { ok: true, state: params.state, deviceId: deviceId ?? null, status: response.status, integration }
}
export async function playContext(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const contextUri = requireNonEmptyString(
params.contextUri ?? params.context_uri,
'A Spotify contextUri is required.',
)
const deviceId = await resolveOptionalDeviceId(params)
if (isDryRun(params)) {
return dryRunPreview({
action: 'play-context',
contextUri,
deviceId: deviceId ?? null,
offsetIndex: params.offsetIndex ?? null,
shuffled: typeof params.shuffled === 'boolean' ? params.shuffled : null,
integration,
})
}
if (typeof params.shuffled === 'boolean') {
await setShuffle({
integration,
state: params.shuffled,
deviceId,
})
}
const body: any = { context_uri: contextUri }
if (params.offsetIndex !== undefined && params.offsetIndex !== null) {
const offsetIndex = Math.round(Number(params.offsetIndex))
if (!Number.isFinite(offsetIndex) || offsetIndex < 0) {
throw new Error('offsetIndex must be a non-negative number.')
}
body.offset = { position: offsetIndex }
}
const { response } = await callAndParse(
integration,
(options) =>
spotify.startAUsersPlayback(
{
query: deviceId ? { device_id: deviceId } : undefined,
body,
},
options,
),
'Failed to start Spotify context playback.',
{ allowNoContent: true, successStatuses: [204] },
)
return {
ok: true,
contextUri,
deviceId: deviceId ?? null,
offsetIndex: body.offset?.position ?? null,
shuffled: typeof params.shuffled === 'boolean' ? params.shuffled : null,
status: response.status,
integration,
}
}
export async function addToQueue(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const uri = params.uri ?? params.spotifyUri ?? null
if (!uri) {
throw new Error('A Spotify track or episode URI is required.')
}
const deviceId = await resolveOptionalDeviceId(params)
if (isDryRun(params)) {
return dryRunPreview({ action: 'queued', uri, deviceId: deviceId ?? null, integration })
}
const { response } = await callAndParse(
integration,
(options) => spotify.addToQueue({ query: { uri, device_id: deviceId } }, options),
'Failed to add the item to the Spotify queue.',
{ allowNoContent: true, successStatuses: [204] },
)
return { ok: true, action: 'queued', uri, status: response.status, integration }
}
export async function getQueue(params: IntegrationParams = {}) {
const integration = resolveSpotifyIntegration(params)
const { data } = await callAndParse(
integration,
(options) => spotify.getQueue({}, options),
'Failed to read the Spotify queue.',
)
return data ?? { currently_playing: null, queue: [] }
}
export async function createPlaylist(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const name = requireNonEmptyString(params.name, 'A playlist name is required.')
const description = params.description ?? ''
const isPublic = params.public === true
if (isDryRun(params)) {
return dryRunPreview({
action: 'create-playlist',
name,
description,
public: isPublic,
integration,
})
}
const { data } = await callAndParse(
integration,
(options) =>
spotify.createPlaylist(
{ body: { name, description, public: isPublic } },
options,
),
'Failed to create Spotify playlist.',
)
return {
playlistId: data?.id ?? null,
uri: data?.uri ?? null,
name: data?.name ?? name,
url: data?.external_urls?.spotify ?? null,
integration,
}
}
function playlistIdFromParams(params: any): { playlistId: string; playlistUri: string } {
const playlistIdOrUri = requireNonEmptyString(
params.playlistId ?? params.playlist_id,
'A playlistId is required.',
)
const playlistUri = normalizeSpotifyPlaylistUri(playlistIdOrUri)
return { playlistId: playlistUri.split(':')[2] ?? playlistIdOrUri, playlistUri }
}
export async function addTracksToPlaylist(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const { playlistId, playlistUri } = playlistIdFromParams(params)
const uris = normalizeTrackUris(params.uris)
if (isDryRun(params)) {
return dryRunPreview({
action: 'add-tracks-to-playlist',
playlistId,
uri: playlistUri,
uris,
added: uris.length,
integration,
})
}
let snapshotId = null
for (const batch of chunkArray(uris, 100)) {
const { data } = await callAndParse(
integration,
(options) =>
spotify.addItemsToPlaylist(
{ params: { playlist_id: playlistId }, body: { uris: batch } },
options,
),
'Failed to add tracks to Spotify playlist.',
)
snapshotId = data?.snapshot_id ?? snapshotId
}
return { added: uris.length, snapshotId, integration }
}
export async function removeTracksFromPlaylist(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const { playlistId, playlistUri } = playlistIdFromParams(params)
const uris = normalizeTrackUris(params.uris)
if (isDryRun(params)) {
return dryRunPreview({
action: 'remove-tracks-from-playlist',
playlistId,
uri: playlistUri,
uris,
removed: uris.length,
integration,
})
}
const batches = []
for (const batch of chunkArray(uris, 100)) {
const { response, data } = await callAndParse(
integration,
(options) =>
spotify.removeItemsPlaylist(
{
params: { playlist_id: playlistId },
body: { items: batch.map((uri) => ({ uri })) },
},
options,
),
'Failed to remove tracks from Spotify playlist.',
)
batches.push({ status: response.status, snapshotId: data?.snapshot_id ?? null })
}
return { removed: uris.length, batches, integration }
}
export async function followPlaylist(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const { playlistId, playlistUri } = playlistIdFromParams(params)
if (isDryRun(params)) {
return dryRunPreview({
action: 'follow-playlist',
playlistId,
uri: playlistUri,
integration,
})
}
const { response } = await callAndParse(
integration,
(options) => spotify.followPlaylist({ params: { playlist_id: playlistId } }, options),
'Failed to follow Spotify playlist.',
{ allowNoContent: true, successStatuses: [200, 204] },
)
return { ok: true, playlistId, uri: playlistUri, status: response.status, integration }
}
export async function getPlaylist(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const { playlistId } = playlistIdFromParams(params)
const { data } = await callAndParse(
integration,
(options) => spotify.getPlaylist({ params: { playlist_id: playlistId } }, options),
'Failed to read Spotify playlist.',
)
return {
id: data?.id ?? playlistId,
name: data?.name ?? 'Unknown playlist',
description: data?.description ?? '',
trackCount: data?.items?.total ?? data?.tracks?.total ?? 0,
owner: {
id: data?.owner?.id ?? null,
name: data?.owner?.display_name ?? data?.owner?.id ?? null,
},
url: data?.external_urls?.spotify ?? null,
tracks: Array.isArray((data?.items ?? data?.tracks)?.items)
? (data.items ?? data.tracks).items
.slice(0, 100)
.map((item: any) => normalizeTrackSummary(item?.item ?? item?.track))
.filter(Boolean)
: [],
integration,
}
}
export async function getAllPlaylistTracks(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const { playlistId } = playlistIdFromParams(params)
const tracks = []
let offset = 0
while (true) {
const { data } = await callAndParse(
integration,
(options) =>
spotify.getPlaylistsItems(
{ params: { playlist_id: playlistId }, query: { limit: 100, offset } },
options,
),
'Failed to read Spotify playlist tracks.',
)
const items = Array.isArray(data?.items) ? data.items : []
tracks.push(...items.map(normalizePlaylistTrackItem).filter(Boolean))
if (!data?.next || items.length === 0) break
offset += items.length
}
return tracks
}
export async function listPlaylists(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const requestedLimit = parseLimit(params.limit, 50, 1e3)
const playlists = []
let offset = 0
while (playlists.length < requestedLimit) {
const batchLimit = Math.min(50, requestedLimit - playlists.length)
const { data } = await callAndParse(
integration,
(options) =>
spotify.getAListOfCurrentUsersPlaylists({ query: { limit: batchLimit, offset } }, options),
'Failed to list Spotify playlists.',
)
const items = Array.isArray(data?.items) ? data.items : []
playlists.push(
...items.map((playlist: any) => ({
id: playlist.id ?? null,
name: playlist.name ?? 'Unknown playlist',
trackCount: playlist.items?.total ?? playlist.tracks?.total ?? 0,
url: playlist.external_urls?.spotify ?? null,
})),
)
if (!data?.next || items.length === 0) break
offset += items.length
}
return playlists
}
export async function saveTracks(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const uris = normalizeIdList(
params.uris ?? params.ids,
'An array of Spotify track URIs or IDs is required.',
).map(normalizeSpotifyTrackUri)
if (isDryRun(params)) {
return dryRunPreview({ action: 'save-tracks', uris, saved: uris.length, integration })
}
for (const batch of chunkArray(uris, 40)) {
await callAndParse(
integration,
(options) => spotify.saveLibraryItems({ query: { uris: batch.join(',') } }, options),
'Failed to save Spotify tracks.',
{ allowNoContent: true, successStatuses: [200, 204] },
)
}
return { saved: uris.length, integration }
}
export async function getSavedTracks(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const { data } = await callAndParse(
integration,
(options) =>
spotify.getUsersSavedTracks({ query: { limit: parseLimit(params.limit, 20, 50) } }, options),
'Failed to read saved Spotify tracks.',
)
return Array.isArray(data?.items)
? data.items
.map((item: any) => {
const track = normalizeTrackDetails(item?.track)
if (!track) return null
return { ...track, addedAt: item.added_at ?? null }
})
.filter(Boolean)
: []
}
export async function getRecentlyPlayed(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const { data } = await callAndParse(
integration,
(options) =>
spotify.getRecentlyPlayed({ query: { limit: parseLimit(params.limit, 20, 50) } }, options),
'Failed to read recently played Spotify tracks.',
)
return Array.isArray(data?.items)
? data.items
.map((item: any) => {
const track = normalizeTrackSummary(item?.track)
if (!track) return null
return {
name: track.name,
uri: track.uri,
playedAt: item.played_at ?? null,
artists: track.artists,
}
})
.filter(Boolean)
: []
}
export async function getRecommendations(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const seedTracks = Array.isArray(params.seedTracks)
? params.seedTracks.map(normalizeSpotifyTrackId)
: []
const seedArtists = Array.isArray(params.seedArtists)
? params.seedArtists.map((artist: unknown) =>
requireNonEmptyString(artist, 'Spotify artist seeds must be strings.'),
)
: []
const seedGenres = Array.isArray(params.seedGenres)
? params.seedGenres.map((genre: unknown) =>
requireNonEmptyString(genre, 'Spotify genre seeds must be strings.'),
)
: []
const seedCount = seedTracks.length + seedArtists.length + seedGenres.length
if (seedCount === 0) {
throw new Error('At least one recommendation seed is required.')
}
if (seedCount > 5) {
throw new Error('Spotify recommendations accept at most five seeds total.')
}
const { data } = await callAndParse(
integration,
(options) =>
spotify.getRecommendations(
{
query: {
seed_tracks: seedTracks.join(','),
seed_artists: seedArtists.join(','),
seed_genres: seedGenres.join(','),
limit: parseLimit(params.limit, 20, 100),
target_energy: params.targetEnergy,
target_valence: params.targetValence,
},
},
options,
),
'Failed to get Spotify recommendations. This endpoint may be unavailable in Development Mode; see the February 2026 Spotify Web API migration guide.',
)
return Array.isArray(data?.tracks) ? data.tracks.map(normalizeTrackDetails).filter(Boolean) : []
}
export async function getTopItems(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const type = params.type
if (type !== 'tracks' && type !== 'artists') {
throw new Error('Top item type must be "tracks" or "artists".')
}
const timeRange = params.timeRange ?? params.time_range ?? 'medium_term'
if (!['short_term', 'medium_term', 'long_term'].includes(timeRange)) {
throw new Error('timeRange must be short_term, medium_term, or long_term.')
}
const invoke = type === 'artists' ? spotify.getUsersTopArtists : spotify.getUsersTopTracks
const { data } = await callAndParse(
integration,
(options) =>
invoke({ query: { time_range: timeRange, limit: parseLimit(params.limit, 20, 50) } }, options),
'Failed to read Spotify top ' + type + '.',
)
const items = Array.isArray(data?.items) ? data.items : []
return type === 'artists'
? items.map(normalizeArtistDetails).filter(Boolean)
: items.map(normalizeTrackDetails).filter(Boolean)
}
export async function getTracks(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const ids = normalizeIdList(params.ids, 'An array of Spotify track IDs is required.', 50)
if (ids.length === 0) return []
if (ids.length === 1) {
const { data } = await callAndParse(
integration,
(options) => spotify.getTrack({ params: { id: ids[0] } }, options),
'Failed to read Spotify track details.',
)
return [normalizeTrackDetails(data)].filter(Boolean)
}
const { data } = await callAndParse(
integration,
(options) => spotify.getSeveralTracks({ query: { ids: ids.join(',') } }, options),
'Failed to read Spotify track details.',
)
const tracks = Array.isArray(data?.tracks) ? data.tracks : []
return tracks.map(normalizeTrackDetails).filter(Boolean)
}
const VALID_SEARCH_TYPES = new Set(['track', 'album', 'artist', 'playlist'])
function resolveSearchTypes(params: { types?: unknown; type?: unknown }) {
if (Array.isArray(params.types) && params.types.length) return params.types
if (typeof params.types === 'string' && params.types) return [params.types]
if (Array.isArray(params.type) && params.type.length) return params.type
if (typeof params.type === 'string' && params.type) return [params.type]
return ['track']
}
function normalizeSearchResult(item: any, type: string) {
if (!item) return null
const result: any = {
id: item.id ?? null,
uri: item.uri ?? null,
name: item.name ?? 'Unknown ' + type,
url: item.external_urls?.spotify ?? null,
}
if (type === 'track' || type === 'album') {
result.artists = normalizeArtistNames(item.artists)
}
return result
}
export async function searchCatalog(params: any = {}) {
const integration = resolveSpotifyIntegration(params)
const query = requireNonEmptyString(
params.query ?? params.q,
'A Spotify search query is required.',
)
const requestedTypes = resolveSearchTypes(params)
const invalidTypes = requestedTypes.filter((type: string) => !VALID_SEARCH_TYPES.has(type))
if (invalidTypes.length) {
throw new Error(
'Unsupported Spotify search type(s): ' +
invalidTypes.map(String).join(', ') +
'. Valid types: ' +
Array.from(VALID_SEARCH_TYPES).join(', ') +
'.',
)
}
const types = requestedTypes
const limit = parseLimit(params.limit, 10, 10)
const { data } = await callAndParse(
integration,
(options) => spotify.search({ query: { q: query, type: types.join(','), limit } }, options),
'Failed to search the Spotify catalog.',
)
const results: Record<string, unknown[]> = {}
for (const type of types) {
const responseKey = type + 's'
results[responseKey] = Array.isArray(data?.[responseKey]?.items)
? data[responseKey].items
.map((item: any) => normalizeSearchResult(item, type))
.filter(Boolean)
: []
}
return results
}