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 };