Files
Nodecast/server/routes/cast.js
blomios 5ee07570e0 feat(cast): add server-managed Google Cast playback (#34)
Add server-side Google Cast discovery and sessions, signed cast media URLs, browser device picker integration, and deployment documentation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-06 22:40:17 +02:00

118 lines
5.8 KiB
JavaScript

const express = require('express');
const { Readable } = require('stream');
const { requireAuth } = require('../auth');
const { createCastMediaUrlService } = require('../services/castMediaUrlService');
const { createCastSessionService } = require('../services/castSessionService');
const router = express.Router();
const publicBaseUrl = process.env.CAST_PUBLIC_BASE_URL;
const mediaUrls = createCastMediaUrlService({
publicBaseUrl: publicBaseUrl || 'http://127.0.0.1',
secret: process.env.CAST_SIGNING_SECRET
});
const cast = createCastSessionService({ publicBaseUrl, mediaUrlSigner: mediaUrls });
function statusFor(error) {
if (error.code === 'CAST_DEVICE_NOT_FOUND' || error.code === 'CAST_SESSION_NOT_FOUND') return 404;
if (error.code === 'CAST_ADAPTER_UNAVAILABLE' || error.code === 'CAST_CONTROL_UNAVAILABLE' || error.code === 'CAST_CONFIG_INVALID') return 503;
if (error.code?.startsWith('CAST_')) return 409;
return 400;
}
function validateCommandBody(action, body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Command body must be an object');
const keys = Object.keys(body);
if (action === 'seek') {
if (keys.length !== 1 || keys[0] !== 'currentTime' || !Number.isFinite(body.currentTime) || body.currentTime < 0) {
throw new Error('seek requires currentTime as a number greater than or equal to 0');
}
return { currentTime: body.currentTime };
}
if (action === 'volume') {
if (keys.some(key => !['level', 'muted'].includes(key)) || !keys.length) throw new Error('volume accepts only level and muted');
if (body.level !== undefined && (!Number.isFinite(body.level) || body.level < 0 || body.level > 1)) throw new Error('volume level must be between 0 and 1');
if (body.muted !== undefined && typeof body.muted !== 'boolean') throw new Error('volume muted must be boolean');
return { ...(body.level !== undefined ? { level: body.level } : {}), ...(body.muted !== undefined ? { muted: body.muted } : {}) };
}
if (keys.length) throw new Error(`${action} does not accept a request body`);
return {};
}
function validId(value) {
return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value);
}
// Signed media delivery intentionally precedes authentication: a Cast receiver has
// no browser cookie. The short-lived opaque HMAC token is the authorization.
router.get('/media/:id', async (req, res) => {
try {
const { path } = mediaUrls.validate(req.params.id, req.query.token);
if (path.startsWith('/api/cast/')) return res.status(400).json({ error: 'Invalid cast media target' });
const target = new URL(path, publicBaseUrl || `${req.protocol}://${req.get('host')}`);
const headers = {};
if (req.get('range')) headers.range = req.get('range');
const response = await fetch(target, { headers, redirect: 'follow' });
res.status(response.status);
for (const header of ['content-type', 'content-length', 'content-range', 'accept-ranges', 'cache-control']) {
const value = response.headers.get(header);
if (value) res.setHeader(header, value);
}
if (!response.body) return res.end();
Readable.fromWeb(response.body).pipe(res);
} catch (error) {
console.warn('[Cast] Public media request failed:', error.message);
return res.status(/token/i.test(error.message) ? 401 : 502).json({ error: 'Cast media is unavailable' });
}
});
router.use(requireAuth);
router.get('/devices', async (req, res) => {
try { res.json({ devices: await cast.getDevices() }); }
catch (error) { res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
});
router.post('/devices/refresh', async (req, res) => {
try { res.json({ devices: await cast.refreshDevices() }); }
catch (error) { res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
});
router.get('/sessions', (req, res) => res.json({ sessions: cast.listSessions() }));
router.post('/sessions', async (req, res) => {
try { res.status(201).json(await cast.startSession(req.body)); }
catch (error) { res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
});
router.get('/sessions/:id', (req, res) => {
if (!validId(req.params.id)) return res.status(400).json({ error: 'Invalid session ID' });
const session = cast.getSession(req.params.id);
return session ? res.json(session) : res.status(404).json({ error: 'Cast session not found' });
});
router.delete('/sessions/:id', async (req, res) => {
try {
if (!validId(req.params.id)) return res.status(400).json({ error: 'Invalid session ID' });
const stopped = await cast.stopSession(req.params.id);
return stopped ? res.status(204).end() : res.status(404).json({ error: 'Cast session not found' });
} catch (error) { return res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
});
for (const action of ['play', 'pause', 'stop', 'seek', 'volume']) {
router.post(`/sessions/:id/${action}`, async (req, res) => {
try {
if (!validId(req.params.id)) return res.status(400).json({ error: 'Invalid session ID' });
const body = validateCommandBody(action, req.body || {});
if (action === 'stop') {
const stopped = await cast.stopSession(req.params.id);
return stopped ? res.json({ success: true }) : res.status(404).json({ error: 'Cast session not found' });
}
res.json(await cast.command(req.params.id, action, body));
}
catch (error) { res.status(statusFor(error)).json({ error: error.message, code: error.code }); }
});
}
module.exports = router;
module.exports.validateCommandBody = validateCommandBody;