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.'
)
}