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:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user