- Fastify API with cookie (web) and bearer (mobile) sessions, follows, pinned friends, featured songs, account deletion, Redis-backed rate limits - Providers: Spotify (OAuth), Last.fm, ListenBrainz, Plex (PIN flow and server discovery), Jellyfin (token auth, revoked on disconnect) - Worker polls on an adaptive schedule in a Redis sorted set, so polling scales horizontally and API usage tracks whether anyone is watching - Track resolver links songs across services via ISRC (Deezer, Spotify), iTunes search and song.link - Realtime updates over WebSocket fan out through Redis pub/sub - User-supplied server URLs are restricted to public addresses, checked both up front and at connect time to defeat DNS rebinding Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
55 lines
1.9 KiB
TypeScript
55 lines
1.9 KiB
TypeScript
import { env } from '../env';
|
|
import { HttpError, fetchJson } from '../lib/http';
|
|
import { ProviderAuthError, type PollingProvider } from './types';
|
|
|
|
interface LastfmTrack {
|
|
name: string;
|
|
artist: { '#text': string };
|
|
album: { '#text': string };
|
|
image: { size: string; '#text': string }[];
|
|
'@attr'?: { nowplaying?: string };
|
|
}
|
|
|
|
const api = (params: Record<string, string>) =>
|
|
`https://ws.audioscrobbler.com/2.0/?${new URLSearchParams({ ...params, api_key: env.LASTFM_API_KEY!, format: 'json' })}`;
|
|
|
|
export async function lastfmUserExists(username: string): Promise<{ name: string } | null> {
|
|
try {
|
|
const res = await fetchJson<{ user: { name: string } }>(api({ method: 'user.getinfo', user: username }));
|
|
return res ? { name: res.user.name } : null;
|
|
} catch (e) {
|
|
if (e instanceof HttpError && e.status === 404) return null;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
export const lastfmProvider: PollingProvider = {
|
|
id: 'lastfm',
|
|
async poll(conn) {
|
|
let res;
|
|
try {
|
|
res = await fetchJson<{ recenttracks: { track: LastfmTrack[] | LastfmTrack } }>(
|
|
api({ method: 'user.getrecenttracks', user: conn.externalUserId, limit: '1' }),
|
|
);
|
|
} catch (e) {
|
|
if (e instanceof HttpError && e.status === 404) throw new ProviderAuthError('last.fm user not found');
|
|
throw e;
|
|
}
|
|
const raw = res?.recenttracks.track;
|
|
const first = Array.isArray(raw) ? raw[0] : raw;
|
|
if (!first || first['@attr']?.nowplaying !== 'true') return null;
|
|
const art = first.image.find((i) => i.size === 'extralarge')?.['#text'] || null;
|
|
return {
|
|
track: {
|
|
title: first.name,
|
|
artist: first.artist['#text'],
|
|
album: first.album['#text'] || null,
|
|
// Last.fm serves a grey star placeholder when it has no art
|
|
artworkUrl: art && !art.includes('2a96cbd8b46e442fc41c2b86b821562f') ? art : null,
|
|
},
|
|
isPlaying: true,
|
|
progressMs: null,
|
|
};
|
|
},
|
|
};
|