Add Apple Music, Last.fm sign-in, and a fallback for Spotify's account cap

- Apple Music: sign MusicKit developer tokens (ES256 via node:crypto),
  accept the Music User Token from the iOS app, and poll recently played.
  Apple has no now-playing API or play timestamps, so a new top song is
  shown as playing for its length; connecting records a baseline so an
  old song isn't announced as current
- Last.fm web auth ("Sign in with Last.fm") instead of typing a
  username; session key stored encrypted
- Spotify accounts rejected by the 5-user development cap are sent back
  with error=spotify_limited instead of a 500, so apps can offer
  Spotify via Last.fm

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-04 07:12:19 -04:00
co-authored by Claude Opus 5.5
parent 0b7907146d
commit ca97f1c3ac
11 changed files with 545 additions and 6 deletions
+4 -1
View File
@@ -75,10 +75,13 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt
searchTracks: (query: string) => request<TrackSearchResultDTO[]>(`/tracks/search?q=${q(query)}`),
connections: () => request<ConnectionDTO[]>('/connections'),
authorizeUrl: (provider: 'spotify' | 'plex', body: { client: 'web' | 'mobile'; returnTo?: string }) =>
authorizeUrl: (provider: 'spotify' | 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) =>
request<{ url: string }>(`/connections/${provider}/authorize`, { body }),
connectUsername: (provider: 'lastfm' | 'listenbrainz', username: string) =>
request<ConnectionDTO>(`/connections/${provider}`, { body: { username } }),
appleMusicDeveloperToken: () => request<{ token: string }>('/connections/apple-music/developer-token'),
connectAppleMusic: (musicUserToken: string) =>
request<ConnectionDTO>('/connections/apple-music', { body: { musicUserToken } }),
connectJellyfin: (input: { serverUrl: string; username: string; password: string }) =>
request<ConnectionDTO>('/connections/jellyfin', { body: input }),
disconnect: (provider: string) => request(`/connections/${q(provider)}`, { method: 'DELETE' }),