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;