/** * 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(); }, ); }