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,127 @@
|
||||
import { and, count, eq, ilike, isNotNull, or } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { requireUser } from '../lib/auth';
|
||||
import { entitlementsFor } from '../lib/entitlements';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { FOLLOWS_CHANGED_CHANNEL, redis } from '../lib/redis';
|
||||
import { watchUsers } from '../services/connections';
|
||||
import { getNowPlaying, toUserSummary } from '../services/nowPlaying';
|
||||
import { getTrackDTOs } from '../services/tracks';
|
||||
|
||||
async function findByUsername(username: string) {
|
||||
const [user] = await db.select().from(schema.users).where(eq(schema.users.username, username.toLowerCase()));
|
||||
if (!user) throw new HttpError(404, 'User not found');
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function userRoutes(app: FastifyInstance) {
|
||||
app.get<{ Querystring: { q?: string } }>('/users/search', async (req) => {
|
||||
const q = (req.query.q ?? '').trim().replace(/[%_\\]/g, '');
|
||||
if (q.length < 2) return [];
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(schema.users)
|
||||
.where(or(ilike(schema.users.username, `${q}%`), ilike(schema.users.displayName, `%${q}%`)))
|
||||
.limit(20);
|
||||
return rows.map(toUserSummary);
|
||||
});
|
||||
|
||||
app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise<ProfileDTO> => {
|
||||
const user = await findByUsername(req.params.username);
|
||||
const viewer = req.user;
|
||||
const [[followers], [following], [follow], [featured], nowPlaying] = await Promise.all([
|
||||
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)),
|
||||
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)),
|
||||
viewer
|
||||
? db
|
||||
.select({ pinnedAt: schema.follows.pinnedAt })
|
||||
.from(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, user.id)))
|
||||
: Promise.resolve([]),
|
||||
db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
|
||||
getNowPlaying([user.id]),
|
||||
]);
|
||||
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
|
||||
await watchUsers([user.id]);
|
||||
|
||||
return {
|
||||
user: { ...toUserSummary(user), bio: user.bio },
|
||||
followerCount: followers?.n ?? 0,
|
||||
followingCount: following?.n ?? 0,
|
||||
isFollowing: Boolean(follow),
|
||||
isPinned: Boolean(follow?.pinnedAt),
|
||||
isSelf: viewer?.id === user.id,
|
||||
featured:
|
||||
featured && featuredTrack
|
||||
? { track: featuredTrack, comment: featured.comment, updatedAt: featured.updatedAt.toISOString() }
|
||||
: null,
|
||||
nowPlaying: nowPlaying[0] ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
app.post<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
|
||||
const viewer = requireUser(req);
|
||||
const target = await findByUsername(req.params.username);
|
||||
if (target.id === viewer.id) throw new HttpError(400, "You can't follow yourself");
|
||||
await db.insert(schema.follows).values({ followerId: viewer.id, followeeId: target.id }).onConflictDoNothing();
|
||||
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { username: string } }>('/users/:username/follow', async (req) => {
|
||||
const viewer = requireUser(req);
|
||||
const target = await findByUsername(req.params.username);
|
||||
await db
|
||||
.delete(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||
await redis.publish(FOLLOWS_CHANGED_CHANNEL, viewer.id);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.put<{ Params: { username: string } }>('/users/:username/pin', async (req) => {
|
||||
const viewer = requireUser(req);
|
||||
const target = await findByUsername(req.params.username);
|
||||
const [follow] = await db
|
||||
.select({ pinnedAt: schema.follows.pinnedAt })
|
||||
.from(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||
if (!follow) throw new HttpError(400, 'Follow someone before pinning them');
|
||||
if (follow.pinnedAt) return { ok: true };
|
||||
const [pinned] = await db
|
||||
.select({ n: count() })
|
||||
.from(schema.follows)
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), isNotNull(schema.follows.pinnedAt)));
|
||||
const max = entitlementsFor(viewer).maxPinnedFriends;
|
||||
if ((pinned?.n ?? 0) >= max) throw new HttpError(402, `You can pin up to ${max} friends on your plan`);
|
||||
await db
|
||||
.update(schema.follows)
|
||||
.set({ pinnedAt: new Date() })
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.delete<{ Params: { username: string } }>('/users/:username/pin', async (req) => {
|
||||
const viewer = requireUser(req);
|
||||
const target = await findByUsername(req.params.username);
|
||||
await db
|
||||
.update(schema.follows)
|
||||
.set({ pinnedAt: null })
|
||||
.where(and(eq(schema.follows.followerId, viewer.id), eq(schema.follows.followeeId, target.id)));
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
/** Now-playing state for everyone the viewer follows, pinned friends first. */
|
||||
app.get('/feed', async (req): Promise<FeedItemDTO[]> => {
|
||||
const viewer = requireUser(req);
|
||||
const followees = await db
|
||||
.select({ id: schema.follows.followeeId, pinnedAt: schema.follows.pinnedAt })
|
||||
.from(schema.follows)
|
||||
.where(eq(schema.follows.followerId, viewer.id));
|
||||
const pins = new Map(followees.map((f) => [f.id, f.pinnedAt?.toISOString() ?? null]));
|
||||
await watchUsers([...pins.keys()]);
|
||||
const items = await getNowPlaying([...pins.keys()]);
|
||||
return sortFeed(items.map((np) => ({ ...np, pinnedAt: pins.get(np.user.id) ?? null })));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user