Files
muzick/backend/src/routes/playback.routes.ts
T
kami 60085c1d72 fix(playback): stop a moment without signal from pausing the phone
A phone changing cell or locking its screen drops the push stream for a
few seconds while its audio keeps playing. The server released session
ownership the instant that stream closed, and the phone read its own
reconnect snapshot as another device taking over: it paused, and rewound
to whatever position it had last reported. Ownership now survives a
closed stream. A device that is really gone still loses the session, via
pagehide and via the sweep that frees an owner whose heartbeat stopped.

The client no longer treats an unowned session as an instruction to
stop. With audio loaded it claims the session back instead.

Two ways a phone could go quiet until the page was reloaded are also
gone: registration is retried rather than attempted once, and the push
stream reopens after an error status, which EventSource treats as final.
It also checks itself when the network or the tab comes back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KENqSChfyqWnor6ud2WWH6
2026-08-10 13:59:29 +04:00

213 lines
8.5 KiB
TypeScript

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<string, unknown>;
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();
// Ownership deliberately survives a closed stream. A phone changing cell,
// locking its screen or dozing drops this connection for a few seconds
// while its audio keeps playing; releasing here published an unowned
// session, which the phone then read as "something else took over" and
// paused itself. A device that is really gone loses the session two other
// ways: `pagehide` releases it outright, and the stale-device sweep frees
// an owner whose heartbeat has been silent past DEVICE_STALE_MS.
});
});
}