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>
This commit is contained in:
blomios
2026-10-06 22:40:17 +02:00
parent e6e481bf0e
commit 5ee07570e0
15 changed files with 1394 additions and 9 deletions

View File

@ -0,0 +1,126 @@
const crypto = require('crypto');
const { createGoogleCastAdapter } = require('./googleCastAdapter');
const { createCastMediaUrlService } = require('./castMediaUrlService');
function validatePublicBaseUrl(value) {
let url;
try { url = new URL(value); } catch { throw new Error('A castable LAN public base URL is required'); }
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Cast public base URL must use HTTP or HTTPS');
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
if (!host || host === 'localhost' || host === '127.0.0.1' || host === '::1') {
throw new Error('Cast public base URL must use a LAN address, not localhost');
}
return url;
}
function assertExactKeys(value, allowed, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`);
const unknown = Object.keys(value).find(key => !allowed.includes(key));
if (unknown) throw new Error(`Unknown ${label} field: ${unknown}`);
}
function validateMediaPath(value) {
if (typeof value !== 'string' || !value || value.length > 512) throw new Error('mediaPath must be a string of at most 512 characters');
if (/url\s*=\s*(?:https?%3a|https?:)/i.test(value) || value.startsWith('/api/proxy/stream')) {
throw new Error('Raw upstream media URLs cannot be cast');
}
const path = value.split('?')[0];
const safe = /^\/api\/transcode\/[A-Za-z0-9_-]{1,128}\/stream\.m3u8$/.test(path) ||
/^\/api\/proxy\/xtream\/\d+\/stream\/[A-Za-z0-9._-]{1,128}\/(?:live|movie|series)$/.test(path);
if (!safe || value.includes('?')) throw new Error('mediaPath is not an allowed cast media endpoint');
return value;
}
function validateStartRequest(request) {
assertExactKeys(request, ['deviceId', 'mediaPath', 'media'], 'session');
if (typeof request.deviceId !== 'string' || !request.deviceId.trim() || request.deviceId.length > 128) {
throw new Error('deviceId must be a non-empty string of at most 128 characters');
}
validateMediaPath(request.mediaPath);
if (request.media !== undefined) {
assertExactKeys(request.media, ['contentType', 'streamType', 'autoplay', 'currentTime', 'title'], 'media');
if (request.media.contentType !== undefined && (typeof request.media.contentType !== 'string' || request.media.contentType.length > 128)) throw new Error('Invalid media contentType');
if (request.media.streamType !== undefined && !['LIVE', 'BUFFERED', 'OTHER'].includes(request.media.streamType)) throw new Error('Invalid media streamType');
if (request.media.autoplay !== undefined && typeof request.media.autoplay !== 'boolean') throw new Error('Invalid media autoplay');
if (request.media.currentTime !== undefined && (!Number.isFinite(request.media.currentTime) || request.media.currentTime < 0)) throw new Error('Invalid media currentTime');
if (request.media.title !== undefined && (typeof request.media.title !== 'string' || request.media.title.length > 200)) throw new Error('Invalid media title');
}
}
function createCastSessionService(options = {}) {
const adapter = options.adapter || createGoogleCastAdapter();
const publicBaseUrl = options.publicBaseUrl || process.env.CAST_PUBLIC_BASE_URL;
const mediaUrlSigner = options.mediaUrlSigner || createCastMediaUrlService({
publicBaseUrl: publicBaseUrl || 'http://127.0.0.1',
secret: options.secret
});
const sessions = new Map();
const discoveryTtlMs = Number(options.discoveryTtlMs || 15_000);
const now = options.now || Date.now;
let devices = [];
let devicesRefreshedAt = 0;
async function refreshDevices() {
devices = (await adapter.discover()).map(device => ({ ...device }));
devicesRefreshedAt = now();
return devices;
}
async function getDevices() {
if (!devices.length || now() - devicesRefreshedAt >= discoveryTtlMs) await refreshDevices();
return devices;
}
async function startSession(request = {}) {
const base = validatePublicBaseUrl(publicBaseUrl);
validateStartRequest(request);
const available = await getDevices();
const device = available.find(item => String(item.id) === String(request.deviceId));
if (!device) throw Object.assign(new Error('Cast device not found'), { code: 'CAST_DEVICE_NOT_FOUND' });
const type = device.type || device.kind;
if (type === 'fire_tv' && device.status === 'receiver_required') {
throw Object.assign(new Error('Fire TV requires a compatible receiver application'), { code: 'CAST_RECEIVER_REQUIRED' });
}
if (device.ready === false) throw Object.assign(new Error('Cast device is not ready'), { code: 'CAST_DEVICE_NOT_READY' });
const signed = mediaUrlSigner.sign(String(request.mediaPath));
const mediaUrl = new URL(signed, base).toString();
const result = await adapter.startSession({ ...request, mediaUrl, device });
const session = {
id: result.sessionId || crypto.randomUUID(),
sessionId: result.sessionId || null,
deviceId: request.deviceId,
mediaUrl,
state: result.state || 'playing',
...result
};
session.sessionId ||= session.id;
sessions.set(session.sessionId, session);
return session;
}
function getSession(id) { return sessions.get(String(id)) || null; }
function listSessions() { return [...sessions.values()]; }
async function command(id, action, payload = {}) {
const session = getSession(id);
if (!session) throw Object.assign(new Error('Cast session not found'), { code: 'CAST_SESSION_NOT_FOUND' });
if (typeof adapter.command !== 'function') throw Object.assign(new Error('Cast controls are unavailable'), { code: 'CAST_CONTROL_UNAVAILABLE' });
const result = await adapter.command({ session, action, ...payload });
Object.assign(session, result || {}, { state: result?.state || action });
return session;
}
async function stopSession(id) {
const session = getSession(id);
if (!session) return false;
if (typeof adapter.stopSession === 'function') await adapter.stopSession(session);
else if (typeof adapter.command === 'function') await adapter.command({ session, action: 'stop' });
mediaUrlSigner.revoke?.(session.mediaUrl);
sessions.delete(String(id));
return true;
}
return { command, getDevices, getSession, listSessions, refreshDevices, startSession, stopSession };
}
module.exports = { createCastSessionService, validateMediaPath, validatePublicBaseUrl, validateStartRequest };