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>
118 lines
5.8 KiB
JavaScript
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;
|