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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user