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
+88
View File
@@ -0,0 +1,88 @@
import { eq, inArray } from 'drizzle-orm';
import type { NowPlayingDTO, NowPlayingProvider, UserSummaryDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { nowPlayingChannel, redis } from '../lib/redis';
import type { PollResult } from '../providers/types';
import { enqueueResolve } from './queues';
import { getTrackDTOs, upsertTrack } from './tracks';
export const toUserSummary = (u: Pick<User, 'id' | 'username' | 'displayName' | 'avatarUrl'>): UserSummaryDTO => ({
id: u.id,
username: u.username,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
});
export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]> {
if (userIds.length === 0) return [];
const rows = await db
.select({ np: schema.nowPlaying, user: schema.users })
.from(schema.nowPlaying)
.innerJoin(schema.users, eq(schema.users.id, schema.nowPlaying.userId))
.where(inArray(schema.nowPlaying.userId, userIds));
const tracks = await getTrackDTOs(rows.map((r) => r.np.trackId));
return rows
.map(({ np, user }) => ({
user: toUserSummary(user),
track: tracks.get(np.trackId)!,
isPlaying: np.isPlaying,
source: np.source,
progressMs: np.progressMs,
updatedAt: np.updatedAt.toISOString(),
}))
.sort((a, b) => Number(b.isPlaying) - Number(a.isPlaying) || b.updatedAt.localeCompare(a.updatedAt));
}
export async function publishNowPlaying(userId: string) {
const [dto] = await getNowPlaying([userId]);
if (dto) await redis.publish(nowPlayingChannel(userId), JSON.stringify(dto));
}
/** Apply one provider's poll result to the user's now-playing state. Returns whether they're playing. */
export async function applyPollResult(
userId: string,
source: NowPlayingProvider,
result: PollResult | null,
): Promise<boolean> {
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
const now = new Date();
if (!result || !result.isPlaying) {
// Only the source that reported playback may mark it stopped; otherwise an
// idle Last.fm connection would clobber an active Spotify session.
if (current?.isPlaying && current.source === source) {
await db
.update(schema.nowPlaying)
.set({ isPlaying: false, updatedAt: now })
.where(eq(schema.nowPlaying.userId, userId));
await publishNowPlaying(userId);
}
return false;
}
const track = await upsertTrack(result.track);
const changed = !current || current.trackId !== track.id || !current.isPlaying;
const values = {
trackId: track.id,
source,
isPlaying: true,
progressMs: result.progressMs,
updatedAt: now,
...(changed ? { startedAt: now } : {}),
};
await db
.insert(schema.nowPlaying)
.values({ userId, ...values })
.onConflictDoUpdate({ target: schema.nowPlaying.userId, set: values });
if (changed) {
await publishNowPlaying(userId);
const [t] = await db
.select({ resolved: schema.tracks.linksResolvedAt })
.from(schema.tracks)
.where(eq(schema.tracks.id, track.id));
if (!t?.resolved) await enqueueResolve(track.id);
}
return true;
}