Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/spotify

src/lib/spotify-api.ts

1065 lines · 33.5 KB · TypeScript
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
}