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
+7
View File
@@ -23,7 +23,13 @@ const schema = z.object({
SPOTIFY_CLIENT_ID: optional,
SPOTIFY_CLIENT_SECRET: optional,
LASTFM_API_KEY: optional,
/** Enables "Sign in with Last.fm" (web auth). Without it, users type their username. */
LASTFM_SHARED_SECRET: optional,
ODESLI_API_KEY: optional,
/** Apple Music (MusicKit): Team ID, MusicKit key ID, and the key's .p8 contents (newlines may be written as \n). */
APPLE_TEAM_ID: optional,
APPLE_MUSICKIT_KEY_ID: optional,
APPLE_MUSICKIT_PRIVATE_KEY: optional,
/** Identifies this app to Plex. Keep it stable: changing it invalidates users' Plex authorizations. */
PLEX_CLIENT_ID: z.string().default('musicshare-dev'),
/**
@@ -40,3 +46,4 @@ const schema = z.object({
export const env = schema.parse(process.env);
export const spotifyConfigured = Boolean(env.SPOTIFY_CLIENT_ID && env.SPOTIFY_CLIENT_SECRET);
export const appleMusicConfigured = Boolean(env.APPLE_TEAM_ID && env.APPLE_MUSICKIT_KEY_ID && env.APPLE_MUSICKIT_PRIVATE_KEY);
+71
View File
@@ -0,0 +1,71 @@
/**
* Apple Music API access. Requests need two tokens: a developer token (a JWT
* we sign with the MusicKit key) and the user's Music User Token, which the
* iOS app obtains from the system "Allow access to Apple Music" prompt.
*/
import { createPrivateKey, sign } from 'node:crypto';
import { env } from '../env';
import { HttpError } from './http';
const API = 'https://api.music.apple.com/v1';
/** Apple allows up to ~6 months; shorter limits the damage if a token leaks. */
const TOKEN_TTL_SECONDS = 30 * 86_400;
let cached: { token: string; expiresAt: number } | null = null;
const b64url = (input: Buffer | string) => Buffer.from(input).toString('base64url');
/** Signed ES256 developer token, cached until a day before it expires. */
export function appleDeveloperToken(now = Date.now()): string {
if (cached && cached.expiresAt - now > 86_400_000) return cached.token;
if (!env.APPLE_TEAM_ID || !env.APPLE_MUSICKIT_KEY_ID || !env.APPLE_MUSICKIT_PRIVATE_KEY) {
throw new HttpError(501, 'Apple Music is not configured on this server');
}
const iat = Math.floor(now / 1000);
const header = b64url(JSON.stringify({ alg: 'ES256', kid: env.APPLE_MUSICKIT_KEY_ID }));
const payload = b64url(JSON.stringify({ iss: env.APPLE_TEAM_ID, iat, exp: iat + TOKEN_TTL_SECONDS }));
const key = createPrivateKey(env.APPLE_MUSICKIT_PRIVATE_KEY.replace(/\\n/g, '\n'));
// JWS wants the raw r||s signature, not DER.
const signature = sign('sha256', Buffer.from(`${header}.${payload}`), { key, dsaEncoding: 'ieee-p1363' });
const token = `${header}.${payload}.${b64url(signature)}`;
cached = { token, expiresAt: (iat + TOKEN_TTL_SECONDS) * 1000 };
return token;
}
/** Thrown when Apple rejects the Music User Token (expired after ~6 months, or access revoked). */
export class AppleUserTokenError extends Error {}
export async function appleMusicGet<T>(path: string, musicUserToken: string): Promise<T> {
const res = await fetch(`${API}${path}`, {
headers: { Authorization: `Bearer ${appleDeveloperToken()}`, 'Music-User-Token': musicUserToken },
signal: AbortSignal.timeout(8000),
});
if (res.status === 401 || res.status === 403) throw new AppleUserTokenError(`Apple Music ${res.status}`);
if (!res.ok) throw new HttpError(res.status, `Apple Music ${res.status}`);
return (await res.json()) as T;
}
export interface AppleSong {
id: string;
type: string; // songs | library-songs | music-videos ...
attributes?: {
name: string;
artistName: string;
albumName?: string;
durationInMillis?: number;
isrc?: string;
url?: string;
artwork?: { url: string };
};
}
export const recentlyPlayed = (musicUserToken: string, limit = 1) =>
appleMusicGet<{ data: AppleSong[] }>(`/me/recent/played/tracks?limit=${limit}`, musicUserToken);
export const storefront = (musicUserToken: string) =>
appleMusicGet<{ data: { id: string; attributes?: { name: string } }[] }>('/me/storefront', musicUserToken);
/** Test hook: forget the cached developer token. */
export const resetAppleTokenCache = () => {
cached = null;
};
+70
View File
@@ -0,0 +1,70 @@
/**
* Apple Music has no "now playing" endpoint, only recently played (no
* timestamps). We remember the newest item; when it changes, that song is
* shown as "listening now" for its duration. The connection starts from a
* baseline so an old last-played song isn't announced as current.
* The iOS app adds exact live updates while it's open (device reports).
*/
import { eq } from 'drizzle-orm';
import { db, schema } from '../db';
import type { Connection } from '../db/schema';
import { AppleUserTokenError, recentlyPlayed, type AppleSong } from '../lib/appleMusic';
import { decrypt } from '../lib/crypto';
import { ProviderAuthError, type IncomingTrack, type PollingProvider } from './types';
export interface AppleMusicMeta {
storefront?: string;
lastSeenId?: string | null;
lastSeenAt?: number;
}
/** Without a duration, assume a typical song length. */
const DEFAULT_DURATION_MS = 4 * 60_000;
const GRACE_MS = 30_000;
export function toIncomingTrack(song: AppleSong): IncomingTrack | null {
const a = song.attributes;
if (!a?.name) return null;
const isCatalog = song.type === 'songs';
return {
title: a.name,
artist: a.artistName || 'Unknown artist',
album: a.albumName ?? null,
durationMs: a.durationInMillis ?? null,
isrc: a.isrc ?? null,
artworkUrl: a.artwork?.url.replace('{w}', '600').replace('{h}', '600') ?? null,
// Library uploads have no catalog page to link to.
links: isCatalog && a.url ? { apple_music: { id: song.id, url: a.url } } : undefined,
};
}
export const appleMusicProvider: PollingProvider = {
id: 'apple_music',
async poll(conn: Connection) {
const meta = conn.meta as AppleMusicMeta;
let latest: AppleSong | undefined;
try {
latest = (await recentlyPlayed(decrypt(conn.accessTokenEnc!))).data[0];
} catch (err) {
if (err instanceof AppleUserTokenError) throw new ProviderAuthError('Apple Music access expired or was revoked');
throw err;
}
if (!latest) return null;
const now = Date.now();
if (latest.id !== meta.lastSeenId) {
await db
.update(schema.connections)
.set({ meta: { ...meta, lastSeenId: latest.id, lastSeenAt: now } })
.where(eq(schema.connections.id, conn.id));
const track = toIncomingTrack(latest);
return track ? { track, isPlaying: true, progressMs: null } : null;
}
// Same song as last time: keep it "playing" until it would have finished.
const track = toIncomingTrack(latest);
const duration = latest.attributes?.durationInMillis ?? DEFAULT_DURATION_MS;
if (!track || !meta.lastSeenAt || now - meta.lastSeenAt > duration + GRACE_MS) return null;
return { track, isPlaying: true, progressMs: now - meta.lastSeenAt };
},
};
+2
View File
@@ -1,4 +1,5 @@
import type { NowPlayingProvider } from '@musicshare/shared';
import { appleMusicProvider } from './appleMusic';
import { jellyfinProvider } from './jellyfin';
import { lastfmProvider } from './lastfm';
import { listenbrainzProvider } from './listenbrainz';
@@ -13,6 +14,7 @@ export const pollingProviders: Partial<Record<NowPlayingProvider, PollingProvide
listenbrainz: listenbrainzProvider,
plex: plexProvider,
jellyfin: jellyfinProvider,
apple_music: appleMusicProvider,
};
export const POLLED_PROVIDERS = Object.keys(pollingProviders) as NowPlayingProvider[];
+23
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { env } from '../env';
import { HttpError, fetchJson } from '../lib/http';
import { ProviderAuthError, type PollingProvider } from './types';
@@ -13,6 +14,28 @@ interface LastfmTrack {
const api = (params: Record<string, string>) =>
`https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({ ...params, api_key: env.LASTFM_API_KEY!, format: 'json' })}`;
/** Last.fm web auth: send the user here; Last.fm redirects back to `callbackUrl` with ?token=…/&token=… */
export const lastfmAuthUrl = (callbackUrl: string) =>
`https://www.last.fm/api/auth/?${new URLSearchParams({ api_key: env.LASTFM_API_KEY!, cb: callbackUrl })}`;
/** Signature rules: params sorted by name, concatenated as name+value, shared secret appended, md5. */
export function lastfmSignature(params: Record<string, string>, secret = env.LASTFM_SHARED_SECRET!): string {
const base = Object.keys(params)
.sort()
.map((k) => k + params[k])
.join('');
return createHash('md5').update(base + secret, 'utf8').digest('hex');
}
/** Exchange the callback token for a session (username + session key). */
export async function lastfmSession(token: string) {
const params = { api_key: env.LASTFM_API_KEY!, method: 'auth.getSession', token };
const url = `https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({ ...params, api_sig: lastfmSignature(params), format: 'json' })}`;
const res = await fetchJson<{ session?: { name: string; key: string } }>(url);
if (!res?.session) throw new ProviderAuthError('Last.fm sign-in failed');
return res.session;
}
export async function lastfmUserExists(username: string): Promise<{ name: string } | null> {
try {
const res = await fetchJson<{ user: { name: string } }>(api({ method: 'user.getinfo', user: username }));
+94 -3
View File
@@ -4,15 +4,17 @@ import { z } from 'zod';
import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { env, spotifyConfigured } from '../env';
import { appleMusicConfigured, env, spotifyConfigured } from '../env';
import { requireUser } from '../lib/auth';
import { encrypt, randomToken } from '../lib/crypto';
import { entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http';
import { redis } from '../lib/redis';
import { AppleUserTokenError, appleDeveloperToken, recentlyPlayed, storefront } from '../lib/appleMusic';
import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf';
import { jellyfinAuthenticate, jellyfinLogout, jellyfinPublicInfo, type JellyfinMeta } from '../providers/jellyfin';
import { lastfmUserExists } from '../providers/lastfm';
import { lastfmAuthUrl, lastfmSession, lastfmUserExists } from '../providers/lastfm';
import { ProviderAuthError } from '../providers/types';
import { listenbrainzUserExists } from '../providers/listenbrainz';
import * as plex from '../providers/plex';
import * as spotify from '../providers/spotify';
@@ -80,7 +82,17 @@ export async function connectionRoutes(app: FastifyInstance) {
return reply.redirect(returnTo.toString());
}
const tok = await spotify.exchangeCode(req.query.code);
const profile = await spotify.getProfile(tok.access_token);
let profile;
try {
profile = await spotify.getProfile(tok.access_token);
} catch (err) {
// Development-mode apps reject accounts not on the allowlist with 403.
if (err instanceof HttpError && err.status === 403) {
returnTo.searchParams.set('error', 'spotify_limited');
return reply.redirect(returnTo.toString());
}
throw err;
}
await upsertConnection({
userId,
provider: 'spotify',
@@ -169,6 +181,85 @@ export async function connectionRoutes(app: FastifyInstance) {
);
});
// ---- Apple Music (MusicKit on iOS) ----
/** The iOS app needs a developer token to ask the system for the user's Music User Token. */
app.get('/connections/apple-music/developer-token', { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, async (req) => {
requireUser(req);
if (!appleMusicConfigured) throw new HttpError(501, 'Apple Music is not configured on this server');
return { token: appleDeveloperToken() };
});
app.post('/connections/apple-music', async (req) => {
const user = requireUser(req);
if (!appleMusicConfigured) throw new HttpError(501, 'Apple Music is not configured on this server');
await assertCanConnect(user, 'apple_music');
const { musicUserToken } = z.object({ musicUserToken: z.string().min(20).max(4096) }).parse(req.body);
let region: { id: string; attributes?: { name: string } } | undefined;
let latest: string | null = null;
try {
region = (await storefront(musicUserToken)).data[0];
// Baseline: whatever was played last is history, not "listening now".
latest = (await recentlyPlayed(musicUserToken)).data[0]?.id ?? null;
} catch (err) {
if (err instanceof AppleUserTokenError) throw new HttpError(401, 'Apple Music rejected the authorization. Try again.');
throw err;
}
return toConnectionDTO(
await upsertConnection({
userId: user.id,
provider: 'apple_music',
externalUserId: 'me',
displayName: region?.attributes?.name ? `Apple Music (${region.attributes.name})` : 'Apple Music',
accessTokenEnc: encrypt(musicUserToken),
// lastSeenAt 0: the baseline song is history, so it never counts as "listening now".
meta: { storefront: region?.id, lastSeenId: latest, lastSeenAt: 0 },
}),
);
});
// ---- Last.fm (web auth): also the scalable route for Spotify, via Spotify's Last.fm link ----
app.post('/connections/lastfm/authorize', async (req) => {
const user = requireUser(req);
if (!env.LASTFM_API_KEY || !env.LASTFM_SHARED_SECRET) throw new HttpError(501, 'Last.fm sign-in is not configured on this server');
await assertCanConnect(user, 'lastfm');
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
const state = randomToken(24);
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo }), 'EX', 900);
return { url: lastfmAuthUrl(`${env.API_PUBLIC_URL}/connections/lastfm/callback?state=${state}`) };
});
app.get<{ Querystring: { state?: string; token?: string } }>('/connections/lastfm/callback', async (req, reply) => {
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
if (!raw) throw new HttpError(400, 'Invalid or expired Last.fm sign-in');
const { userId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string };
const returnTo = new URL(state.returnTo);
if (!req.query.token) {
returnTo.searchParams.set('error', 'Last.fm sign-in was cancelled');
return reply.redirect(returnTo.toString());
}
let session;
try {
session = await lastfmSession(req.query.token);
} catch (err) {
if (!(err instanceof ProviderAuthError) && !(err instanceof HttpError)) throw err;
returnTo.searchParams.set('error', 'Last.fm sign-in failed');
return reply.redirect(returnTo.toString());
}
await upsertConnection({
userId,
provider: 'lastfm',
externalUserId: session.name,
displayName: session.name,
// Not needed for public listening history; kept for private profiles and future features.
accessTokenEnc: encrypt(session.key),
});
returnTo.searchParams.set('connected', 'lastfm');
return reply.redirect(returnTo.toString());
});
// ---- username-based scrobblers ----
app.post('/connections/lastfm', async (req) => {