Skip to content

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

Package listing

@kody/spotify

src/lib/device-target.ts

105 lines · 3.4 KB · TypeScript
export type DeviceTargetParams = {
	integration?: string
	integrationName?: string
	account?: string
	deviceId?: string
	device_id?: string
	deviceName?: string
	device_name?: string
	deviceType?: string
	device_type?: string
}

export type SpotifyDeviceLike = {
	id?: string | null
	name?: string | null
	type?: string | null
	is_active?: boolean | null
}

function trimOrNull(value: unknown): string | null {
	if (typeof value !== 'string') return null
	const trimmed = value.trim()
	return trimmed ? trimmed : null
}

export function readDeviceTarget(params: DeviceTargetParams = {}) {
	return {
		deviceId: trimOrNull(params.deviceId ?? params.device_id),
		deviceName: trimOrNull(params.deviceName ?? params.device_name),
		deviceType: trimOrNull(params.deviceType ?? params.device_type),
	}
}

/** True when the caller supplied any explicit device selector. */
export function hasDeviceSelector(params: DeviceTargetParams = {}) {
	const target = readDeviceTarget(params)
	return Boolean(target.deviceId || target.deviceName || target.deviceType)
}

function normalizeMatchText(value: string) {
	return value.trim().toLowerCase()
}

/**
 * Pick a Connect device by human-friendly name/type (and optional id).
 * Name match is case-insensitive substring; type is case-insensitive equality.
 * When several devices match, prefer the active one, then the first match.
 */
export function matchDevice(
	devices: Array<SpotifyDeviceLike>,
	target: { deviceId?: string | null; deviceName?: string | null; deviceType?: string | null },
): SpotifyDeviceLike | null {
	const deviceId = trimOrNull(target.deviceId)
	const deviceName = trimOrNull(target.deviceName)
	const deviceType = trimOrNull(target.deviceType)
	if (!deviceId && !deviceName && !deviceType) return null

	const nameNeedle = deviceName ? normalizeMatchText(deviceName) : null
	const typeNeedle = deviceType ? normalizeMatchText(deviceType) : null

	const matches = devices.filter((device) => {
		if (!device?.id) return false
		if (deviceId && device.id !== deviceId) return false
		if (typeNeedle) {
			const type = typeof device.type === 'string' ? normalizeMatchText(device.type) : ''
			if (type !== typeNeedle) return false
		}
		if (nameNeedle) {
			const name = typeof device.name === 'string' ? normalizeMatchText(device.name) : ''
			if (!name.includes(nameNeedle)) return false
		}
		return true
	})

	if (matches.length === 0) return null
	return matches.find((device) => device.is_active) ?? matches[0] ?? null
}

export function formatAvailableDevices(devices: Array<SpotifyDeviceLike>) {
	if (devices.length === 0) return '(none)'
	return devices
		.map((device) => {
			const name = device.name ?? 'Unknown device'
			const type = device.type ?? 'Unknown'
			const active = device.is_active ? ', active' : ''
			return `"${name}" (${type}${active})`
		})
		.join(', ')
}

export function buildDeviceMatchError(
	devices: Array<SpotifyDeviceLike>,
	target: { deviceName?: string | null; deviceType?: string | null; deviceId?: string | null },
) {
	const parts: Array<string> = []
	if (target.deviceName) parts.push(`deviceName "${target.deviceName}"`)
	if (target.deviceType) parts.push(`deviceType "${target.deviceType}"`)
	if (target.deviceId) parts.push('deviceId')
	const wanted = parts.length > 0 ? parts.join(' + ') : 'the requested device'
	return (
		`No Spotify device matched ${wanted}. ` +
		`Available: ${formatAvailableDevices(devices)}. ` +
		'Prefer deviceName (and optional deviceType); omit selectors to use the active device.'
	)
}