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
+44
View File
@@ -0,0 +1,44 @@
import { hash, verify } from '@node-rs/argon2';
import { eq, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { loginSchema, registerSchema } from '@musicshare/shared';
import { db, schema } from '../db';
import { SESSION_COOKIE, createSession } from '../lib/auth';
import { HttpError } from '../lib/http';
import { toMeDTO } from './me';
const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } };
export async function authRoutes(app: FastifyInstance) {
app.post('/auth/register', authRateLimit, async (req, reply) => {
const input = registerSchema.parse(req.body);
const [taken] = await db
.select({ username: schema.users.username, email: schema.users.email })
.from(schema.users)
.where(or(eq(schema.users.username, input.username), eq(schema.users.email, input.email)));
if (taken) throw new HttpError(409, taken.email === input.email ? 'Email already registered' : 'Username taken');
const [user] = await db
.insert(schema.users)
.values({ username: input.username, email: input.email, passwordHash: await hash(input.password) })
.returning();
const token = await createSession(user!.id, reply);
return { user: toMeDTO(user!), token };
});
app.post('/auth/login', authRateLimit, async (req, reply) => {
const input = loginSchema.parse(req.body);
const [user] = await db.select().from(schema.users).where(eq(schema.users.email, input.email));
if (!user || !(await verify(user.passwordHash, input.password))) {
throw new HttpError(401, 'Invalid email or password');
}
const token = await createSession(user.id, reply);
return { user: toMeDTO(user), token };
});
app.post('/auth/logout', async (req, reply) => {
if (req.sessionId) await db.delete(schema.sessions).where(eq(schema.sessions.id, req.sessionId));
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return { ok: true };
});
}
+213
View File
@@ -0,0 +1,213 @@
import { and, count, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { connectUsernameSchema, jellyfinConnectSchema, type NowPlayingProvider } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { env, spotifyConfigured } from '../env';
import { requireUser } from '../lib/auth';
import { encrypt, randomToken } from '../lib/crypto';
import { entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http';
import { redis } from '../lib/redis';
import { UnsafeUrlError, assertSafeServerUrl } from '../lib/ssrf';
import { jellyfinAuthenticate, jellyfinLogout, jellyfinPublicInfo, type JellyfinMeta } from '../providers/jellyfin';
import { lastfmUserExists } from '../providers/lastfm';
import { listenbrainzUserExists } from '../providers/listenbrainz';
import * as plex from '../providers/plex';
import * as spotify from '../providers/spotify';
import { toConnectionDTO, upsertConnection } from '../services/connections';
import { unschedulePoll } from '../services/scheduler';
const oauthStateKey = (state: string) => `oauth:state:${state}`;
const authorizeBodySchema = z.object({
client: z.enum(['web', 'mobile']).default('web'),
/** Mobile only: deep link to return to (the app scheme, or Expo Go's exp:// URL in development). */
returnTo: z.string().max(500).optional(),
});
/** Where to send the user after OAuth. Only our own web app or app scheme is allowed (no open redirects). */
function resolveReturnTo(body: z.infer<typeof authorizeBodySchema>): string {
if (body.client === 'web') return `${env.WEB_URL}/settings`;
const allowed = body.returnTo?.startsWith('musicshare://') || (env.NODE_ENV !== 'production' && body.returnTo?.startsWith('exp://'));
return allowed ? body.returnTo! : 'musicshare://settings';
}
async function assertCanConnect(user: User, provider: NowPlayingProvider) {
const [existing] = await db
.select({ id: schema.connections.id })
.from(schema.connections)
.where(and(eq(schema.connections.userId, user.id), eq(schema.connections.provider, provider)));
if (existing) return; // reconnecting doesn't count against the limit
const [row] = await db.select({ n: count() }).from(schema.connections).where(eq(schema.connections.userId, user.id));
if ((row?.n ?? 0) >= entitlementsFor(user).maxConnections) {
throw new HttpError(402, 'Connection limit reached for your plan');
}
}
export async function connectionRoutes(app: FastifyInstance) {
app.get('/connections', async (req) => {
const user = requireUser(req);
const rows = await db.select().from(schema.connections).where(eq(schema.connections.userId, user.id));
return rows.map(toConnectionDTO);
});
// ---- Spotify (OAuth) ----
app.post('/connections/spotify/authorize', async (req) => {
const user = requireUser(req);
if (!spotifyConfigured) throw new HttpError(501, 'Spotify is not configured on this server');
await assertCanConnect(user, 'spotify');
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
const state = randomToken(24);
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo }), 'EX', 600);
return { url: spotify.authorizeUrl(state) };
});
// The callback is identified by `state`, not the session cookie: Spotify
// requires a 127.0.0.1 redirect in dev, which won't carry localhost cookies.
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
'/connections/spotify/callback',
async (req, reply) => {
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
if (!raw) throw new HttpError(400, 'Invalid or expired OAuth state');
const { userId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string };
const returnTo = new URL(state.returnTo);
if (req.query.error || !req.query.code) {
returnTo.searchParams.set('error', req.query.error ?? 'spotify_denied');
return reply.redirect(returnTo.toString());
}
const tok = await spotify.exchangeCode(req.query.code);
const profile = await spotify.getProfile(tok.access_token);
await upsertConnection({
userId,
provider: 'spotify',
externalUserId: profile.id,
displayName: profile.display_name,
accessTokenEnc: encrypt(tok.access_token),
refreshTokenEnc: tok.refresh_token ? encrypt(tok.refresh_token) : null,
tokenExpiresAt: new Date(Date.now() + tok.expires_in * 1000),
});
returnTo.searchParams.set('connected', 'spotify');
return reply.redirect(returnTo.toString());
},
);
// ---- Plex (PIN flow) ----
app.post('/connections/plex/authorize', async (req) => {
const user = requireUser(req);
await assertCanConnect(user, 'plex');
const returnTo = resolveReturnTo(authorizeBodySchema.parse(req.body ?? {}));
const pin = await plex.createPin();
const state = randomToken(24);
await redis.set(oauthStateKey(state), JSON.stringify({ userId: user.id, returnTo, pinId: pin.id }), 'EX', 900);
return { url: plex.authAppUrl(pin.code, `${env.API_PUBLIC_URL}/connections/plex/callback?state=${state}`) };
});
app.get<{ Querystring: { state?: string } }>('/connections/plex/callback', async (req, reply) => {
const raw = req.query.state ? await redis.getdel(oauthStateKey(req.query.state)) : null;
if (!raw) throw new HttpError(400, 'Invalid or expired Plex login');
const { userId, pinId, ...state } = JSON.parse(raw) as { userId: string; returnTo: string; pinId: number };
const returnTo = new URL(state.returnTo);
// Plex forwards right after approval; the token can lag by a moment
let token: string | null = null;
for (let i = 0; i < 3 && !token; i++) {
token = await plex.checkPin(pinId);
if (!token) await new Promise((r) => setTimeout(r, 1000));
}
if (!token) {
returnTo.searchParams.set('error', 'Plex sign-in was cancelled');
return reply.redirect(returnTo.toString());
}
const account = await plex.getPlexAccount(token);
await upsertConnection({
userId,
provider: 'plex',
externalUserId: String(account.id),
displayName: account.username || account.title,
accessTokenEnc: encrypt(token),
meta: {},
});
returnTo.searchParams.set('connected', 'plex');
return reply.redirect(returnTo.toString());
});
// ---- Jellyfin (server URL + credentials) ----
app.post('/connections/jellyfin', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (req) => {
const user = requireUser(req);
await assertCanConnect(user, 'jellyfin');
const input = jellyfinConnectSchema.parse(req.body);
let serverUrl: string;
try {
serverUrl = await assertSafeServerUrl(input.serverUrl);
} catch (e) {
if (e instanceof UnsafeUrlError) throw new HttpError(400, e.message);
throw e;
}
const info = await jellyfinPublicInfo(serverUrl).catch(() => {
throw new HttpError(400, `Couldn't reach a Jellyfin server at ${serverUrl}`);
});
const deviceId = `musicshare-${randomToken(12)}`;
const auth = await jellyfinAuthenticate(serverUrl, deviceId, input.username, input.password);
if (!auth) throw new HttpError(401, 'Jellyfin rejected that username or password');
const meta: JellyfinMeta = { serverUrl, serverName: info.ServerName, deviceId };
return toConnectionDTO(
await upsertConnection({
userId: user.id,
provider: 'jellyfin',
externalUserId: auth.User.Id,
displayName: `${auth.User.Name} on ${info.ServerName}`,
accessTokenEnc: encrypt(auth.AccessToken),
meta: { ...meta },
}),
);
});
// ---- username-based scrobblers ----
app.post('/connections/lastfm', async (req) => {
const user = requireUser(req);
if (!env.LASTFM_API_KEY) throw new HttpError(501, 'Last.fm is not configured on this server');
await assertCanConnect(user, 'lastfm');
const { username } = connectUsernameSchema.parse(req.body);
const lfm = await lastfmUserExists(username);
if (!lfm) throw new HttpError(404, 'Last.fm user not found');
return toConnectionDTO(
await upsertConnection({ userId: user.id, provider: 'lastfm', externalUserId: lfm.name, displayName: lfm.name }),
);
});
app.post('/connections/listenbrainz', async (req) => {
const user = requireUser(req);
await assertCanConnect(user, 'listenbrainz');
const { username } = connectUsernameSchema.parse(req.body);
if (!(await listenbrainzUserExists(username))) throw new HttpError(404, 'ListenBrainz user not found');
return toConnectionDTO(
await upsertConnection({ userId: user.id, provider: 'listenbrainz', externalUserId: username, displayName: username }),
);
});
app.delete<{ Params: { provider: string } }>('/connections/:provider', async (req) => {
const user = requireUser(req);
const [deleted] = await db
.delete(schema.connections)
.where(
and(
eq(schema.connections.userId, user.id),
eq(schema.connections.provider, req.params.provider as NowPlayingProvider),
),
)
.returning();
if (deleted) {
await unschedulePoll(deleted.id);
if (deleted.provider === 'jellyfin') await jellyfinLogout(deleted);
}
return { ok: true };
});
}
+85
View File
@@ -0,0 +1,85 @@
import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { setFeaturedSchema, updateMeSchema, type MeDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { SESSION_COOKIE, requireUser } from '../lib/auth';
import { effectivePlan, entitlementsFor } from '../lib/entitlements';
import { HttpError } from '../lib/http';
import { deezerTrack } from '../providers/catalog';
import { enqueueResolve } from '../services/queues';
import { unschedulePoll } from '../services/scheduler';
import { upsertTrack } from '../services/tracks';
export const toMeDTO = (u: User): MeDTO => ({
id: u.id,
username: u.username,
displayName: u.displayName,
avatarUrl: u.avatarUrl,
email: u.email,
bio: u.bio,
preferredService: u.preferredService,
plan: effectivePlan(u),
entitlements: entitlementsFor(u),
});
export async function meRoutes(app: FastifyInstance) {
app.get('/me', async (req) => toMeDTO(requireUser(req)));
app.patch('/me', async (req) => {
const user = requireUser(req);
const input = updateMeSchema.parse(req.body);
const [updated] = await db.update(schema.users).set(input).where(eq(schema.users.id, user.id)).returning();
return toMeDTO(updated!);
});
/** Required by the App Store: users must be able to delete their account in-app. */
app.delete('/me', async (req, reply) => {
const user = requireUser(req);
const conns = await db
.select({ id: schema.connections.id })
.from(schema.connections)
.where(eq(schema.connections.userId, user.id));
await Promise.all(conns.map((c) => unschedulePoll(c.id)));
await db.delete(schema.users).where(eq(schema.users.id, user.id));
reply.clearCookie(SESSION_COOKIE, { path: '/' });
return { ok: true };
});
app.put('/me/featured', async (req) => {
const user = requireUser(req);
const input = setFeaturedSchema.parse(req.body);
let trackId: string;
if ('deezerId' in input) {
const dz = await deezerTrack(input.deezerId);
if (!dz) throw new HttpError(404, 'Track not found');
const t = await upsertTrack({
title: dz.title,
artist: dz.artist.name,
album: dz.album.title,
durationMs: dz.duration * 1000,
artworkUrl: dz.album.cover_xl ?? dz.album.cover_big ?? null,
isrc: dz.isrc ?? null,
links: { deezer: { id: String(dz.id), url: dz.link } },
});
trackId = t.id;
} else {
const [t] = await db.select({ id: schema.tracks.id }).from(schema.tracks).where(eq(schema.tracks.id, input.trackId));
if (!t) throw new HttpError(404, 'Track not found');
trackId = t.id;
}
const values = { trackId, comment: input.comment || null, updatedAt: new Date() };
await db
.insert(schema.featuredSongs)
.values({ userId: user.id, ...values })
.onConflictDoUpdate({ target: schema.featuredSongs.userId, set: values });
await enqueueResolve(trackId);
return { ok: true };
});
app.delete('/me/featured', async (req) => {
const user = requireUser(req);
await db.delete(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id));
return { ok: true };
});
}
+85
View File
@@ -0,0 +1,85 @@
/**
* Live now-playing updates over WebSocket.
*
* Workers publish to Redis channel `np:<userId>`; every API instance
* pattern-subscribes and forwards to its own sockets that follow that user.
* Instances share nothing else, so you can run as many as you like behind a
* load balancer.
*/
import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { db, schema } from '../db';
import { FOLLOWS_CHANGED_CHANNEL, createRedis } from '../lib/redis';
import { watchUsers } from '../services/connections';
const watchers = new Map<string, Set<WebSocket>>(); // watched userId -> sockets
const socketsByOwner = new Map<string, Set<WebSocket>>(); // viewer userId -> their sockets
const watching = new Map<WebSocket, Set<string>>();
function addTo<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
let set = map.get(key);
if (!set) map.set(key, (set = new Set()));
set.add(value);
}
function removeFrom<K, V>(map: Map<K, Set<V>>, key: K, value: V) {
const set = map.get(key);
set?.delete(value);
if (set?.size === 0) map.delete(key);
}
async function subscribeSocket(socket: WebSocket, viewerId: string) {
const rows = await db
.select({ id: schema.follows.followeeId })
.from(schema.follows)
.where(eq(schema.follows.followerId, viewerId));
const ids = new Set([viewerId, ...rows.map((r) => r.id)]);
for (const old of watching.get(socket) ?? []) removeFrom(watchers, old, socket);
for (const id of ids) addTo(watchers, id, socket);
watching.set(socket, ids);
await watchUsers([...ids]);
}
export async function realtimeRoutes(app: FastifyInstance) {
const sub = createRedis();
await sub.psubscribe('np:*');
await sub.subscribe(FOLLOWS_CHANGED_CHANNEL);
sub.on('pmessage', (_pattern, channel, message) => {
const userId = channel.slice(3);
const payload = `{"type":"now_playing","data":${message}}`;
for (const socket of watchers.get(userId) ?? []) socket.send(payload);
});
sub.on('message', (channel, viewerId) => {
if (channel !== FOLLOWS_CHANGED_CHANNEL) return;
for (const socket of socketsByOwner.get(viewerId) ?? []) {
subscribeSocket(socket, viewerId).catch((e) => app.log.error(e));
}
});
// Keep presence alive so workers keep polling watched users at the fast rate.
const heartbeat = setInterval(() => {
watchUsers([...watchers.keys()]).catch((e) => app.log.error(e));
for (const socket of watching.keys()) socket.ping();
}, 30_000);
app.addHook('onClose', async () => {
clearInterval(heartbeat);
sub.disconnect();
});
app.get('/ws', { websocket: true }, (socket, req) => {
const viewer = req.user;
if (!viewer) {
socket.close(4401, 'unauthorized');
return;
}
addTo(socketsByOwner, viewer.id, socket);
subscribeSocket(socket, viewer.id).catch((e) => app.log.error(e));
socket.on('close', () => {
for (const id of watching.get(socket) ?? []) removeFrom(watchers, id, socket);
watching.delete(socket);
removeFrom(socketsByOwner, viewer.id, socket);
});
});
}
+53
View File
@@ -0,0 +1,53 @@
import { eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { STREAMING_SERVICES, openUrl, type StreamingService, type TrackSearchResultDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import { HttpError } from '../lib/http';
import { deezerSearch } from '../providers/catalog';
import { resolveTrackLinks } from '../services/resolver';
import { getTrackDTOs } from '../services/tracks';
export async function trackRoutes(app: FastifyInstance) {
app.get<{ Querystring: { q?: string } }>(
'/tracks/search',
{ config: { rateLimit: { max: 30, timeWindow: '1 minute' } } },
async (req): Promise<TrackSearchResultDTO[]> => {
const q = (req.query.q ?? '').trim();
if (q.length < 2) return [];
const results = await deezerSearch(q, 15);
return results.map((r) => ({
deezerId: String(r.id),
title: r.title,
artist: r.artist.name,
album: r.album.title,
artworkUrl: r.album.cover_big ?? null,
durationMs: r.duration * 1000,
}));
},
);
app.get<{ Params: { id: string } }>('/tracks/:id', async (req) => {
const track = (await getTrackDTOs([req.params.id])).get(req.params.id);
if (!track) throw new HttpError(404, 'Track not found');
return track;
});
/** Shareable redirect: /tracks/:id/open?service=spotify (defaults to the viewer's preferred service). */
app.get<{ Params: { id: string }; Querystring: { service?: string } }>('/tracks/:id/open', async (req, reply) => {
const service = (req.query.service ?? req.user?.preferredService ?? 'spotify') as StreamingService;
if (!STREAMING_SERVICES.includes(service)) throw new HttpError(400, 'Unknown service');
let track = (await getTrackDTOs([req.params.id])).get(req.params.id);
if (!track) throw new HttpError(404, 'Track not found');
if (!track.links[service]) {
const [row] = await db
.select({ resolved: schema.tracks.linksResolvedAt })
.from(schema.tracks)
.where(eq(schema.tracks.id, track.id));
if (!row?.resolved) {
await resolveTrackLinks(track.id).catch((e) => req.log.warn(e, 'inline resolve failed'));
track = (await getTrackDTOs([track.id])).get(track.id)!;
}
}
return reply.redirect(openUrl(track, service).url);
});
}
+127
View File
@@ -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 })));
});
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Inbound webhooks from Plex (Plex Pass) and the Jellyfin Webhook plugin.
*
* We don't trust or parse the payload: a webhook only means "something
* changed", so we poll that connection right away. That keeps one code path
* for state, works with any Jellyfin template, and means a forged request can
* at most cause an extra poll. The secret in the URL identifies the connection.
*/
import { and, eq } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import type { NowPlayingProvider } from '@musicshare/shared';
import { db, schema } from '../db';
import { pollSoon } from '../services/scheduler';
export async function webhookRoutes(app: FastifyInstance) {
// Plex posts multipart/form-data (sometimes with a thumbnail attached). We
// ignore bodies entirely, so accept any content type without parsing.
app.addContentTypeParser('*', { bodyLimit: 10 * 1024 * 1024 }, (_req, payload, done) => {
payload.resume();
payload.on('end', () => done(null, undefined));
payload.on('error', done);
});
app.removeContentTypeParser('application/json');
app.addContentTypeParser('application/json', (_req, payload, done) => {
payload.resume();
payload.on('end', () => done(null, undefined));
});
app.post<{ Params: { provider: string; secret: string } }>(
'/webhooks/:provider/:secret',
{ config: { rateLimit: { max: 120, timeWindow: '1 minute' } } },
async (req, reply) => {
const [conn] = await db
.select({ id: schema.connections.id })
.from(schema.connections)
.where(
and(
eq(schema.connections.webhookSecret, req.params.secret),
eq(schema.connections.provider, req.params.provider as NowPlayingProvider),
eq(schema.connections.status, 'active'),
),
);
if (conn) await pollSoon([conn.id]);
// Same response either way, so secrets can't be probed.
return reply.status(204).send();
},
);
}