Skip to content

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

Package listing

@kody/spotify

src/lib/accounts.ts

148 lines · 4.7 KB · TypeScript
export const DEFAULT_SPOTIFY_INTEGRATION = 'spotify'
export const SPOTIFY_CONNECT_ORIGIN = 'https://kody.codes'
export const SPOTIFY_CALLBACK_URL = SPOTIFY_CONNECT_ORIGIN + '/connect/oauth'
export const SPOTIFY_AUTHORIZE_URL = 'https://accounts.spotify.com/authorize'
export const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token'
export const SPOTIFY_API_BASE_URL = 'https://api.spotify.com/v1'
export const SPOTIFY_DASHBOARD_URL = 'https://developer.spotify.com/dashboard'
export const SPOTIFY_ALLOWED_HOSTS = ['api.spotify.com'] as const

/** Exact read-mostly scopes from the official provider_spotify guide. */
export const SPOTIFY_GUIDE_SCOPES = [
	'user-read-recently-played',
	'user-read-playback-state',
	'user-library-read',
] as const

export const SPOTIFY_READ_SCOPES = [
	...SPOTIFY_GUIDE_SCOPES,
	'user-read-currently-playing',
	'playlist-read-private',
	'user-top-read',
] as const

export const SPOTIFY_FULL_SCOPES = [
	...SPOTIFY_READ_SCOPES,
	'user-modify-playback-state',
	'playlist-read-collaborative',
	'playlist-modify-public',
	'playlist-modify-private',
	'user-library-modify',
] as const

const RETIRED_ACCOUNT_ALIASES = ['personal', 'family', 'kent', 'kent-explicit-only'] as const

export type SpotifyIntegrationInput = {
	/** Saved Kody OAuth connection name. Default `spotify`. Extra accounts use `spotify-*`. */
	integration?: string
	/** Alias of `integration`. */
	integrationName?: string
	/**
	 * Retired personal aliases (`personal`, `family`, `kent`) throw.
	 * A `spotify` / `spotify-*` value is accepted as an alias of `integration`.
	 */
	account?: string
}

export type SpotifyAccountInfo = {
	integration: string
	default: boolean
	connectUrl: string
	useWhen: string
}

export function connectOauthUrl(
	integration: string = DEFAULT_SPOTIFY_INTEGRATION,
	scopes: readonly string[] = SPOTIFY_FULL_SCOPES,
): string {
	const url = new URL(SPOTIFY_CALLBACK_URL)
	url.searchParams.set('provider', integration)
	url.searchParams.set('authorizeUrl', SPOTIFY_AUTHORIZE_URL)
	url.searchParams.set('tokenUrl', SPOTIFY_TOKEN_URL)
	url.searchParams.set('scopes', scopes.join(' '))
	url.searchParams.set('allowedHosts', SPOTIFY_ALLOWED_HOSTS.join(','))
	url.searchParams.set('dashboardUrl', SPOTIFY_DASHBOARD_URL)
	return url.toString()
}

/** Guide-documented read-mostly PKCE connect URL (no client secret). */
export const SPOTIFY_READ_CONNECT_URL = connectOauthUrl(
	DEFAULT_SPOTIFY_INTEGRATION,
	SPOTIFY_GUIDE_SCOPES,
)

/** Full helper-set PKCE connect URL, including playback and playlist writes. */
export const SPOTIFY_FULL_CONNECT_URL = connectOauthUrl(
	DEFAULT_SPOTIFY_INTEGRATION,
	SPOTIFY_FULL_SCOPES,
)

export function listSpotifyAccounts(): SpotifyAccountInfo[] {
	return [
		{
			integration: DEFAULT_SPOTIFY_INTEGRATION,
			default: true,
			connectUrl: SPOTIFY_FULL_CONNECT_URL,
			useWhen:
				'Default saved OAuth connection. Connect extra identities as spotify-work, spotify-kids, or another spotify-* name and pass that name as integration.',
		},
	]
}

export function resolveSpotifyIntegration(
	input: SpotifyIntegrationInput | string = {},
): string {
	const raw =
		typeof input === 'string'
			? input
			: (input.integration ?? input.integrationName ?? input.account ?? DEFAULT_SPOTIFY_INTEGRATION)
	if (typeof raw !== 'string' || raw.trim().length === 0) {
		throw new Error('integration must be a non-empty string (Kody OAuth connection name).')
	}
	const integration = raw.trim()
	rejectRetiredAccountAliases(integration)
	if (integration !== DEFAULT_SPOTIFY_INTEGRATION && !integration.startsWith('spotify-')) {
		throw new Error(
			'Spotify integration "' +
				integration +
				'" must be "spotify" or start with "spotify-". ' +
				'Connect an extra account at ' +
				connectOauthUrl('spotify-' + integration.replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '')) +
				'.',
		)
	}
	return integration
}

function rejectRetiredAccountAliases(value: string): void {
	const retired = RETIRED_ACCOUNT_ALIASES.find((alias) => alias === value)
	if (!retired) return
	throw new Error(
		'Spotify account alias "' +
			retired +
			'" was removed from @kody/spotify. ' +
			'Pass integration for a saved OAuth connection (default "spotify", extra accounts "spotify-*"). ' +
			'Connect at ' +
			SPOTIFY_FULL_CONNECT_URL +
			'.',
	)
}

export function accounts() {
	return {
		accounts: listSpotifyAccounts(),
		defaultIntegration: DEFAULT_SPOTIFY_INTEGRATION,
		naming: 'spotify or spotify-*',
		retiredAliases: [...RETIRED_ACCOUNT_ALIASES],
		connectUrl: SPOTIFY_FULL_CONNECT_URL,
		readConnectUrl: SPOTIFY_READ_CONNECT_URL,
		callbackUrl: SPOTIFY_CALLBACK_URL,
		scopes: {
			guide: [...SPOTIFY_GUIDE_SCOPES],
			read: [...SPOTIFY_READ_SCOPES],
			full: [...SPOTIFY_FULL_SCOPES],
		},
	}
}

export default accounts