← Public packages
@kentcdodds/spotify
Spotify playback, playlist, search, library, and device helpers.
src/lib/spotify-api.ts
1061 lines · 33.1 KB · TypeScriptimport { resolveSpotifyAccount, type SpotifyAccountInfo } from './accounts.ts'
import {
buildDeviceMatchError,
hasDeviceSelector,
matchDevice,
readDeviceTarget,
type DeviceTargetParams,
} from './device-target.ts'
import * as spotify from './openapi-client.ts'
import type { ScaffoldOptions } from './openapi-client.ts'
export type AccountParams = { account?: string }
type CallOptions = ScaffoldOptions
function providerOptions(account: SpotifyAccountInfo): CallOptions {
return { provider: account.integrationName }
}
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, account: SpotifyAccountInfo): unknown {
if (!isSpotifyInvalidGrantError(error)) return error
const authError = new Error(
'Spotify authorization expired for the ' +
account.account +
' account. Reconnect at /connect/oauth?provider=' +
account.integrationName +
', then retry this action.',
)
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(
account: SpotifyAccountInfo,
invoke: (options: CallOptions) => Promise<Response>,
message: string,
opts: { allowNoContent?: boolean; successStatuses?: number[] } = {},
): Promise<{ response: Response; data: any; account: SpotifyAccountInfo }> {
let response: Response
try {
response = await invoke(providerOptions(account))
} catch (error) {
throw normalizeSpotifyAuthError(error, account)
}
if (opts.allowNoContent && (response.status === 202 || response.status === 204)) {
return { response, data: null, account }
}
const data = await parseResponseBody(response)
assertSpotifyOk(response, data, message, opts.successStatuses ?? [])
return { response, data, account }
}
export function chunkArray<T>(items: T[], size: number): T[][] {
const chunks: T[][] = []
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size))
}
return chunks
}
export function requireNonEmptyString(value: unknown, message: string): string {
const stringValue = typeof value === 'string' ? value.trim() : ''
if (!stringValue) throw new Error(message)
return stringValue
}
export function normalizeSpotifyPlaylistUri(value: unknown): string {
const stringValue = requireNonEmptyString(
value,
'Spotify playlist IDs or URIs must be non-empty strings.',
)
if (stringValue.startsWith('spotify:playlist:')) return stringValue
const match = stringValue.match(/open\.spotify\.com\/playlist\/([^?/#]+)/)
const playlistId = match ? match[1] : stringValue
return 'spotify:playlist:' + playlistId
}
export function normalizeSpotifyTrackId(value: unknown): string {
const stringValue = requireNonEmptyString(
value,
'Spotify track IDs or URIs must be non-empty strings.',
)
if (stringValue.startsWith('spotify:track:')) {
return stringValue.split(':')[2]
}
const match = stringValue.match(/open\.spotify\.com\/track\/([^?/#]+)/)
return match ? match[1] : stringValue
}
export function normalizeSpotifyTrackUri(value: unknown): string {
return 'spotify:track:' + normalizeSpotifyTrackId(value)
}
export function normalizeTrackUris(uris: unknown): string[] {
if (!Array.isArray(uris)) {
throw new Error('An array of Spotify track URIs is required.')
}
const normalized = uris.map((uri) => (typeof uri === 'string' ? uri.trim() : ''))
const withUris = normalized.map((uri) => (uri ? normalizeSpotifyTrackUri(uri) : ''))
const invalid = withUris
.map((uri, index) =>
uri && uri.startsWith('spotify:track:') ? null : JSON.stringify(uris[index] ?? null),
)
.filter((entry) => entry !== null)
if (invalid.length) {
throw new Error(
'Invalid Spotify track URI(s) (expected non-empty strings like spotify:track:xxx): ' +
invalid.join(', '),
)
}
return withUris
}
export function normalizeIdList(values: unknown, message: string, max?: number): string[] {
if (!Array.isArray(values)) throw new Error(message)
const invalid = values
.filter((value) => typeof value !== 'string' || !value.trim())
.map((value) => JSON.stringify(value ?? null))
if (invalid.length) {
throw new Error(message + ' Invalid entries: ' + invalid.join(', '))
}
const ids = values.map((value) => value.trim()).map(normalizeSpotifyTrackId)
if (max && ids.length > max) {
throw new Error('Spotify accepts at most ' + max + ' track ids.')
}
return ids
}
export function parseLimit(value: unknown, defaultValue: number, max: number): number {
const parsed = Number(value ?? defaultValue)
if (!Number.isFinite(parsed)) return defaultValue
return Math.min(max, Math.max(1, Math.round(parsed)))
}
export function normalizeArtistNames(artists: unknown): string[] {
return Array.isArray(artists) ? artists.map((artist: any) => artist?.name).filter(Boolean) : []
}
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 ?? '',
}
}
// --- Player state and devices ---
export async function getPlaybackState(params: AccountParams = {}) {
const account = resolveSpotifyAccount(params)
let response: Response
try {
response = await spotify.getInformationAboutTheUsersCurrentPlayback({}, providerOptions(account))
} catch (error) {
throw normalizeSpotifyAuthError(error, account)
}
if (response.status === 204 || response.status === 404) {
return { nothingPlaying: true, playback: null, status: response.status }
}
const data = await parseResponseBody(response)
assertSpotifyOk(response, data, 'Failed to read Spotify playback state.')
return { nothingPlaying: false, playback: data, status: response.status }
}
export async function getAvailableDevices(params: AccountParams = {}) {
const account = resolveSpotifyAccount(params)
const { data } = await callAndParse(
account,
(options) => spotify.getAUsersAvailableDevices({}, options),
'Failed to read available Spotify devices.',
)
return Array.isArray(data?.devices) ? data.devices : []
}
/**
* Resolve an optional agent-facing device selector to a Spotify device id.
* Prefer `deviceName` / `deviceType`. Raw `deviceId` remains an escape hatch.
* Returns `undefined` when no selector was provided (active-device happy path).
*/
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) ??
input.fallbackDeviceId ??
null
)
}
export async function requireDeviceId(input: any = {}) {
const deviceId = resolveDeviceId(input)
if (!deviceId) {
throw new Error('No Spotify device is currently available.')
}
return deviceId
}
export { hasDeviceSelector }
// --- Playback control ---
export async function startOrResumePlayback(params: any = {}) {
const account = resolveSpotifyAccount(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,
fallbackDeviceId:
params.fallbackDeviceId === undefined ? account.fallbackDeviceId : params.fallbackDeviceId,
})
const contextUri = params.contextUri ?? account.defaultCollectionUri
const shouldStartContext =
(playbackState.nothingPlaying || params.forceCollection === true) && contextUri != null
const body = shouldStartContext ? { context_uri: contextUri, offset: { position: 0 } } : undefined
const { response } = await callAndParse(
account,
(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,
}
}
export async function pausePlayback(params: any = {}) {
const account = resolveSpotifyAccount(params)
const deviceId = await resolveOptionalDeviceId(params)
const { response } = await callAndParse(
account,
(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 }
}
export async function skipToNext(params: any = {}) {
const account = resolveSpotifyAccount(params)
const deviceId = await resolveOptionalDeviceId(params)
const { response } = await callAndParse(
account,
(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 }
}
export async function skipToPrevious(params: any = {}) {
const account = resolveSpotifyAccount(params)
const deviceId = await resolveOptionalDeviceId(params)
const { response } = await callAndParse(
account,
(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 }
}
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 account = resolveSpotifyAccount(params)
const deviceId = await requireResolvedDeviceId({
...params,
requiredMessage:
'A deviceName (preferred) or deviceId is required to transfer Spotify playback.',
})
const { response } = await callAndParse(
account,
(options) =>
spotify.transferAUsersPlayback(
{ body: { device_ids: [deviceId], play: params.play !== false } },
options,
),
'Failed to transfer Spotify playback.',
{ allowNoContent: true, successStatuses: [204] },
)
return {
ok: true,
action: 'transferred',
deviceId,
play: params.play !== false,
status: response.status,
}
}
export async function setVolume(params: any = {}) {
const account = resolveSpotifyAccount(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,
fallbackDeviceId: null,
})
const { response } = await callAndParse(
account,
(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 }
}
export async function seek(params: any = {}) {
const account = resolveSpotifyAccount(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)
const { response } = await callAndParse(
account,
(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 }
}
const VALID_REPEAT_STATES = new Set(['track', 'context', 'off'])
export async function setRepeat(params: any = {}) {
const account = resolveSpotifyAccount(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)
const { response } = await callAndParse(
account,
(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 }
}
export async function setShuffle(params: any = {}) {
const account = resolveSpotifyAccount(params)
if (typeof params.state !== 'boolean') {
throw new Error('Shuffle state must be a boolean.')
}
const deviceId = await resolveOptionalDeviceId(params)
const { response } = await callAndParse(
account,
(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 }
}
export async function playContext(params: any = {}) {
const account = resolveSpotifyAccount(params)
const contextUri = requireNonEmptyString(
params.contextUri ?? params.context_uri,
'A Spotify contextUri is required.',
)
const deviceId = await resolveOptionalDeviceId(params)
if (typeof params.shuffled === 'boolean') {
await setShuffle({
account: params.account,
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(
account,
(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,
}
}
// --- Queue ---
export async function addToQueue(params: any = {}) {
const account = resolveSpotifyAccount(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)
const { response } = await callAndParse(
account,
(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 }
}
export async function getQueue(params: AccountParams = {}) {
const account = resolveSpotifyAccount(params)
const { data } = await callAndParse(
account,
(options) => spotify.getQueue({}, options),
'Failed to read the Spotify queue.',
)
return data ?? { currently_playing: null, queue: [] }
}
// --- Playlists ---
export async function createPlaylist(params: any = {}) {
const account = resolveSpotifyAccount(params)
const name = requireNonEmptyString(params.name, 'A playlist name is required.')
const { data } = await callAndParse(
account,
(options) =>
spotify.createPlaylist(
{
body: {
name,
description: params.description ?? '',
public: params.public === true,
},
},
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,
}
}
export async function addTracksToPlaylist(params: any = {}) {
const account = resolveSpotifyAccount(params)
const playlistIdOrUri = requireNonEmptyString(
params.playlistId ?? params.playlist_id,
'A playlistId is required.',
)
const playlistUri = normalizeSpotifyPlaylistUri(playlistIdOrUri)
const playlistId = playlistUri.split(':')[2] ?? playlistIdOrUri
const uris = normalizeTrackUris(params.uris)
let snapshotId = null
for (const batch of chunkArray(uris, 100)) {
const { data } = await callAndParse(
account,
(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 }
}
export async function removeTracksFromPlaylist(params: any = {}) {
const account = resolveSpotifyAccount(params)
const playlistIdOrUri = requireNonEmptyString(
params.playlistId ?? params.playlist_id,
'A playlistId is required.',
)
const playlistUri = normalizeSpotifyPlaylistUri(playlistIdOrUri)
const playlistId = playlistUri.split(':')[2] ?? playlistIdOrUri
const uris = normalizeTrackUris(params.uris)
const batches = []
for (const batch of chunkArray(uris, 100)) {
const { response, data } = await callAndParse(
account,
(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 }
}
export async function followPlaylist(params: any = {}) {
const account = resolveSpotifyAccount(params)
const playlistIdOrUri = requireNonEmptyString(
params.playlistId ?? params.playlist_id,
'A playlistId is required.',
)
const playlistUri = normalizeSpotifyPlaylistUri(playlistIdOrUri)
const playlistId = playlistUri.split(':')[2] ?? playlistIdOrUri
const { response } = await callAndParse(
account,
(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,
}
}
export async function getPlaylist(params: any = {}) {
const account = resolveSpotifyAccount(params)
const playlistIdOrUri = requireNonEmptyString(
params.playlistId ?? params.playlist_id,
'A playlistId is required.',
)
const playlistUri = normalizeSpotifyPlaylistUri(playlistIdOrUri)
const playlistId = playlistUri.split(':')[2] ?? playlistIdOrUri
const { data } = await callAndParse(
account,
(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)
: [],
}
}
export async function getAllPlaylistTracks(params: any = {}) {
const account = resolveSpotifyAccount(params)
const playlistIdOrUri = requireNonEmptyString(
params.playlistId ?? params.playlist_id,
'A playlistId is required.',
)
const playlistUri = normalizeSpotifyPlaylistUri(playlistIdOrUri)
const playlistId = playlistUri.split(':')[2] ?? playlistIdOrUri
const tracks = []
let offset = 0
while (true) {
const { data } = await callAndParse(
account,
(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 account = resolveSpotifyAccount(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(
account,
(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
}
// --- Library, history, and catalog ---
export async function saveTracks(params: any = {}) {
const account = resolveSpotifyAccount(params)
const uris = normalizeIdList(
params.uris ?? params.ids,
'An array of Spotify track URIs or IDs is required.',
).map(normalizeSpotifyTrackUri)
for (const batch of chunkArray(uris, 40)) {
await callAndParse(
account,
(options) =>
spotify.saveLibraryItems({ query: { uris: batch.join(',') } }, options),
'Failed to save Spotify tracks.',
{ allowNoContent: true, successStatuses: [200, 204] },
)
}
return { saved: uris.length }
}
export async function getRecentlyPlayed(params: any = {}) {
const account = resolveSpotifyAccount(params)
const { data } = await callAndParse(
account,
(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 account = resolveSpotifyAccount(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(
account,
(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.',
)
return Array.isArray(data?.tracks) ? data.tracks.map(normalizeTrackDetails).filter(Boolean) : []
}
export async function getTopItems(params: any = {}) {
const account = resolveSpotifyAccount(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(
account,
(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 account = resolveSpotifyAccount(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(
account,
(options) => spotify.getTrack({ params: { id: ids[0] } }, options),
'Failed to read Spotify track details.',
)
return [normalizeTrackDetails(data)].filter(Boolean)
}
const { data } = await callAndParse(
account,
(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 account = resolveSpotifyAccount(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(
account,
(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
}