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:
2026-10-03 09:59:02 -04:00
co-authored by Claude Opus 5.5
parent 57c1733bab
commit 6791b62d87
45 changed files with 4990 additions and 0 deletions
+54
View File
@@ -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,
};
},
};