import { FastifyInstance, FastifyRequest } from 'fastify'; import { NotSessionOwnerError, PlaybackCommand, PlaybackCommandType, PlaybackStatePatch, PlaybackSyncService, } from '../services/playback-sync.service.js'; const DEFAULT_USER_ID = '00000000-0000-0000-0000-000000000000'; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const COMMAND_TYPES: PlaybackCommandType[] = ['play', 'pause', 'next', 'prev', 'seek', 'play_track']; /** Well inside the 90s staleness window, and enough to keep proxies from closing an idle stream. */ const HEARTBEAT_MS = 25_000; type Body = Record; function isObject(value: unknown): value is Body { return typeof value === 'object' && value !== null && !Array.isArray(value); } function userIdFrom(request: FastifyRequest): string { const header = request.headers['x-user-id']; return typeof header === 'string' && UUID_RE.test(header) ? header : DEFAULT_USER_ID; } function parsePatch(body: unknown): PlaybackStatePatch | { error: string } { const input = isObject(body) ? body : {}; const patch: PlaybackStatePatch = {}; if (input.trackId !== undefined) { if (input.trackId !== null && !(typeof input.trackId === 'string' && UUID_RE.test(input.trackId))) { return { error: 'trackId must be a UUID or null' }; } patch.trackId = input.trackId as string | null; } if (input.queue !== undefined) { if (!Array.isArray(input.queue)) return { error: 'queue must be an array' }; patch.queue = input.queue; } if (input.queueIndex !== undefined) { if (typeof input.queueIndex !== 'number' || !Number.isInteger(input.queueIndex)) { return { error: 'queueIndex must be an integer' }; } patch.queueIndex = input.queueIndex; } if (input.position !== undefined) { if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) { return { error: 'position must be a non-negative number of seconds' }; } patch.position = input.position; } if (input.isPlaying !== undefined) { if (typeof input.isPlaying !== 'boolean') return { error: 'isPlaying must be a boolean' }; patch.isPlaying = input.isPlaying; } return patch; } function parseCommand(body: unknown): PlaybackCommand | { error: string } { const input = isObject(body) ? body : {}; const type = input.type; if (typeof type !== 'string' || !COMMAND_TYPES.includes(type as PlaybackCommandType)) { return { error: `type must be one of ${COMMAND_TYPES.join(', ')}` }; } const command: PlaybackCommand = { type: type as PlaybackCommandType }; if (type === 'seek') { if (typeof input.position !== 'number' || !Number.isFinite(input.position) || input.position < 0) { return { error: 'seek requires a non-negative position in seconds' }; } command.position = input.position; } if (type === 'play_track') { if (typeof input.trackId !== 'string' || !UUID_RE.test(input.trackId)) { return { error: 'play_track requires a trackId' }; } command.trackId = input.trackId; } return command; } export default async function playbackRoutes( fastify: FastifyInstance, options: { playbackSync: PlaybackSyncService } ) { const { playbackSync } = options; fastify.post('/playback/devices', async (request, reply) => { const input = isObject(request.body) ? request.body : {}; const name = typeof input.name === 'string' ? input.name : ''; const deviceId = typeof input.deviceId === 'string' && UUID_RE.test(input.deviceId) ? input.deviceId : null; const device = await playbackSync.registerDevice(userIdFrom(request), name, deviceId); return reply.code(200).send(device); }); fastify.get('/playback/devices', async (request, reply) => { return reply.code(200).send({ devices: await playbackSync.listDevices(userIdFrom(request)) }); }); fastify.get('/playback/state', async (request, reply) => { const userId = userIdFrom(request); const [state, devices] = await Promise.all([ playbackSync.getState(userId), playbackSync.listDevices(userId), ]); return reply.code(200).send({ state, devices }); }); fastify.post('/playback/state', async (request, reply) => { const input = isObject(request.body) ? request.body : {}; const deviceId = input.deviceId; if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) { return reply.code(400).send({ error: 'deviceId must be a UUID' }); } const patch = parsePatch(input); if ('error' in patch) return reply.code(400).send({ error: patch.error }); try { const state = await playbackSync.reportState(userIdFrom(request), deviceId, patch); return reply.code(200).send({ state }); } catch (err) { if (err instanceof NotSessionOwnerError) { // 409, not 403: the device is allowed here, it is simply no longer the // one holding the audio, and its own state report is the stale thing. return reply.code(409).send({ error: err.message }); } throw err; } }); fastify.post('/playback/command', async (request, reply) => { const command = parseCommand(request.body); if ('error' in command) return reply.code(400).send({ error: command.error }); const result = await playbackSync.sendCommand(userIdFrom(request), command); if (!result.deliveredTo) return reply.code(409).send({ error: 'no device is holding playback' }); return reply.code(202).send(result); }); fastify.post('/playback/transfer', async (request, reply) => { const input = isObject(request.body) ? request.body : {}; const deviceId = input.deviceId; if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) { return reply.code(400).send({ error: 'deviceId must be a UUID' }); } try { return reply.code(200).send({ state: await playbackSync.transfer(userIdFrom(request), deviceId) }); } catch { return reply.code(404).send({ error: 'unknown device' }); } }); fastify.post('/playback/release', async (request, reply) => { const input = isObject(request.body) ? request.body : {}; const deviceId = input.deviceId; if (typeof deviceId !== 'string' || !UUID_RE.test(deviceId)) { return reply.code(400).send({ error: 'deviceId must be a UUID' }); } await playbackSync.releaseIfOwner(userIdFrom(request), deviceId); return reply.code(204).send(); }); /** * The push channel. Every device holds one of these open: it receives the * session snapshot on connect, every later change, and the commands aimed at * it. The periodic comment line doubles as the device's liveness heartbeat, * so an open stream is what "this device is online" means. */ fastify.get('/playback/stream', async (request, reply) => { const userId = userIdFrom(request); const query = request.query as { deviceId?: string }; const deviceId = typeof query.deviceId === 'string' && UUID_RE.test(query.deviceId) ? query.deviceId : null; if (!deviceId) return reply.code(400).send({ error: 'deviceId must be a UUID' }); reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', // nginx buffers event streams into uselessness without this. 'X-Accel-Buffering': 'no', }); const write = (payload: unknown) => { reply.raw.write(`data: ${JSON.stringify(payload)}\n\n`); }; const unsubscribe = playbackSync.subscribe(userId, (event) => { if (event.type === 'command' && event.deviceId !== deviceId) return; write(event); }); const [state, devices] = await Promise.all([ playbackSync.getState(userId), playbackSync.listDevices(userId), ]); write({ type: 'state', state, devices }); const heartbeat = setInterval(() => { reply.raw.write(': ping\n\n'); playbackSync.touchDevice(userId, deviceId).catch(() => {}); }, HEARTBEAT_MS); request.raw.on('close', () => { clearInterval(heartbeat); unsubscribe(); // A closed tab must not keep the session hostage: hand ownership back so // any other device can pick the same track up where this one left it. playbackSync.releaseIfOwner(userId, deviceId).catch(() => {}); }); }); }