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