Add API and worker: accounts, follows, now-playing, cross-service links
- 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
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
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,
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user