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:
@ -182,6 +182,7 @@ app.use('/api/settings', require('./routes/settings'));
|
||||
app.use('/api/history', require('./routes/history'));
|
||||
app.use('/api/laya', require('./routes/laya'));
|
||||
app.use('/api/live', require('./routes/live'));
|
||||
app.use('/api/cast', require('./routes/cast'));
|
||||
|
||||
// Version endpoint
|
||||
app.get('/api/version', (req, res) => {
|
||||
|
||||
117
server/routes/cast.js
Normal file
117
server/routes/cast.js
Normal file
@ -0,0 +1,117 @@
|
||||
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;
|
||||
76
server/services/castMediaUrlService.js
Normal file
76
server/services/castMediaUrlService.js
Normal file
@ -0,0 +1,76 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
function encode(value) {
|
||||
return Buffer.from(value).toString('base64url');
|
||||
}
|
||||
|
||||
function timingSafeEqual(left, right) {
|
||||
const a = Buffer.from(left);
|
||||
const b = Buffer.from(right);
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function createCastMediaUrlService(options = {}) {
|
||||
const secret = String(options.secret || process.env.CAST_SIGNING_SECRET || '');
|
||||
const publicBaseUrl = String(options.publicBaseUrl || process.env.CAST_PUBLIC_BASE_URL || '').replace(/\/$/, '');
|
||||
const ttlMs = Number(options.ttlMs || 5 * 60 * 1000);
|
||||
const now = options.now || Date.now;
|
||||
const paths = new Map();
|
||||
|
||||
function requireSecret() {
|
||||
if (Buffer.byteLength(secret, 'utf8') < 32) {
|
||||
throw Object.assign(new Error('CAST_SIGNING_SECRET must contain at least 32 bytes'), { code: 'CAST_CONFIG_INVALID' });
|
||||
}
|
||||
}
|
||||
|
||||
function signature(payload) {
|
||||
requireSecret();
|
||||
return crypto.createHmac('sha256', secret).update(payload).digest('base64url');
|
||||
}
|
||||
|
||||
function sign(path) {
|
||||
if (!path || !String(path).startsWith('/')) throw new Error('Cast media path must be absolute');
|
||||
if (!publicBaseUrl) throw new Error('CAST_PUBLIC_BASE_URL is required for cast media URLs');
|
||||
const id = crypto.randomBytes(18).toString('base64url');
|
||||
const exp = now() + ttlMs;
|
||||
const pathHash = crypto.createHash('sha256').update(String(path)).digest('base64url');
|
||||
const payload = encode(JSON.stringify({ id, exp, pathHash }));
|
||||
paths.set(id, { path: String(path), exp });
|
||||
const token = `${payload}.${signature(payload)}`;
|
||||
return `${publicBaseUrl}/api/cast/media/${id}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function validate(id, token) {
|
||||
requireSecret();
|
||||
if (!id || typeof id !== 'string') throw new Error('Invalid cast media token');
|
||||
if (!token || typeof token !== 'string') throw new Error('Invalid cast media token');
|
||||
const [payload, providedSignature, extra] = token.split('.');
|
||||
if (!payload || !providedSignature || extra || !timingSafeEqual(signature(payload), providedSignature)) {
|
||||
throw new Error('Invalid cast media token');
|
||||
}
|
||||
let claims;
|
||||
try { claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); }
|
||||
catch { throw new Error('Invalid cast media token'); }
|
||||
if (claims.id !== id) throw new Error('Invalid cast media token');
|
||||
if (!Number.isFinite(claims.exp) || now() > claims.exp) {
|
||||
paths.delete(claims.id);
|
||||
throw new Error('Expired cast media token');
|
||||
}
|
||||
const entry = paths.get(claims.id);
|
||||
if (!entry || entry.exp !== claims.exp) throw new Error('Invalid cast media token');
|
||||
const pathHash = crypto.createHash('sha256').update(entry.path).digest('base64url');
|
||||
if (!claims.pathHash || !timingSafeEqual(pathHash, claims.pathHash)) throw new Error('Invalid cast media token');
|
||||
return { path: entry.path };
|
||||
}
|
||||
|
||||
function revoke(idOrUrl) {
|
||||
const value = String(idOrUrl || '');
|
||||
let id = value;
|
||||
try { id = new URL(value, publicBaseUrl || 'http://invalid').pathname.split('/').filter(Boolean).at(-1); } catch { /* use raw id */ }
|
||||
return paths.delete(id);
|
||||
}
|
||||
|
||||
return { revoke, sign, validate };
|
||||
}
|
||||
|
||||
module.exports = { createCastMediaUrlService };
|
||||
126
server/services/castSessionService.js
Normal file
126
server/services/castSessionService.js
Normal 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 };
|
||||
131
server/services/googleCastAdapter.js
Normal file
131
server/services/googleCastAdapter.js
Normal file
@ -0,0 +1,131 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
function defaultDependencies() {
|
||||
return { Bonjour: require('bonjour-service'), castClient: require('chromecast-client') };
|
||||
}
|
||||
|
||||
function firstAddress(service) {
|
||||
return (service.addresses || []).find(address => /^\d{1,3}(?:\.\d{1,3}){3}$/.test(address)) ||
|
||||
service.addresses?.[0] || service.referer?.address || service.host || null;
|
||||
}
|
||||
|
||||
function normalizeDevice(service) {
|
||||
const txt = service.txt || {};
|
||||
const model = String(txt.md || txt.model || '').trim();
|
||||
const descriptor = `${model} ${service.name || ''}`.toLowerCase();
|
||||
const host = firstAddress(service);
|
||||
return {
|
||||
id: String(txt.id || service.fqdn || service.name || `${host}:${service.port || 8009}`),
|
||||
name: String(txt.fn || service.name || model || 'Google Cast device'),
|
||||
type: /android tv|google tv|chromecast built-in|bravia|shield/.test(descriptor) ? 'android_tv_cast' : 'google_cast',
|
||||
ready: Boolean(host),
|
||||
host,
|
||||
port: Number(service.port || 8009),
|
||||
model: model || null
|
||||
};
|
||||
}
|
||||
|
||||
function unwrap(result, castClient) {
|
||||
if (result && typeof result.unwrapAndThrow === 'function') return result.unwrapAndThrow();
|
||||
if (castClient.Result?.unwrapAndThrow) return castClient.Result.unwrapAndThrow(result);
|
||||
if (result && result.isOk === false) throw result.value;
|
||||
if (result && result.isOk === true) return result.value;
|
||||
return result;
|
||||
}
|
||||
|
||||
function createGoogleCastAdapter(options = {}) {
|
||||
let dependencies;
|
||||
try { dependencies = options.dependencies || defaultDependencies(); }
|
||||
catch (error) {
|
||||
return {
|
||||
async discover() { return []; },
|
||||
async startSession() {
|
||||
throw Object.assign(new Error(`Google Cast support is unavailable: ${error.message}`), { code: 'CAST_ADAPTER_UNAVAILABLE' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const { Bonjour, castClient } = dependencies;
|
||||
const discoveryTimeoutMs = Number(options.discoveryTimeoutMs || 1500);
|
||||
const connectTimeoutMs = Number(options.connectTimeoutMs || 8000);
|
||||
const sessions = new Map();
|
||||
|
||||
async function discover() {
|
||||
const bonjour = new Bonjour(undefined, () => {});
|
||||
const found = new Map();
|
||||
let browser;
|
||||
let timer;
|
||||
try {
|
||||
await new Promise(resolve => {
|
||||
timer = setTimeout(resolve, discoveryTimeoutMs);
|
||||
browser = bonjour.find({ type: 'googlecast', protocol: 'tcp' }, service => {
|
||||
const device = normalizeDevice(service);
|
||||
if (device.host) found.set(device.id, device);
|
||||
});
|
||||
browser?.on?.('error', resolve);
|
||||
});
|
||||
return [...found.values()].sort((left, right) => left.name.localeCompare(right.name));
|
||||
} catch (error) {
|
||||
throw Object.assign(new Error(`Google Cast discovery failed: ${error.message}`), { code: 'CAST_DISCOVERY_FAILED' });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
try { browser?.stop?.(); } catch { /* best effort */ }
|
||||
try { bonjour.destroy(); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function startSession({ device, mediaUrl, media = {} }) {
|
||||
if (!device?.host) throw Object.assign(new Error('Google Cast device has no reachable host'), { code: 'CAST_DEVICE_UNREACHABLE' });
|
||||
let client;
|
||||
let controller;
|
||||
try {
|
||||
client = await castClient.connect({ host: device.host, port: device.port || 8009, timeout: connectTimeoutMs });
|
||||
controller = unwrap(await castClient.DefaultMediaApp.launchAndJoin({ client }), castClient);
|
||||
const status = unwrap(await controller.load({
|
||||
media: {
|
||||
contentId: mediaUrl,
|
||||
contentType: media.contentType || 'application/vnd.apple.mpegurl',
|
||||
streamType: media.streamType || 'LIVE',
|
||||
metadata: media.metadata || { metadataType: 0, title: media.title || 'Nodecast TV' }
|
||||
},
|
||||
autoplay: media.autoplay !== false,
|
||||
currentTime: Number(media.currentTime || 0)
|
||||
}), castClient);
|
||||
const sessionId = crypto.randomUUID();
|
||||
sessions.set(sessionId, { client, controller, device });
|
||||
return { sessionId, state: String(status?.playerState || 'PLAYING').toLowerCase(), currentTime: Number(status?.currentTime || 0) };
|
||||
} catch (error) {
|
||||
try { controller?.dispose?.(); } catch { /* best effort */ }
|
||||
try { client?.close?.(); } catch { /* best effort */ }
|
||||
throw Object.assign(new Error(`Unable to start Google Cast playback: ${error.message}`), { code: 'CAST_START_FAILED' });
|
||||
}
|
||||
}
|
||||
|
||||
async function command({ session, action, ...payload }) {
|
||||
const active = sessions.get(session.sessionId || session.id);
|
||||
if (!active) throw Object.assign(new Error('Google Cast connection is no longer active'), { code: 'CAST_SESSION_DISCONNECTED' });
|
||||
if (action === 'volume') throw Object.assign(new Error('Volume control is unavailable for this Cast adapter'), { code: 'CAST_CONTROL_UNAVAILABLE' });
|
||||
const method = active.controller[action];
|
||||
if (typeof method !== 'function') throw Object.assign(new Error(`Unsupported Cast command: ${action}`), { code: 'CAST_CONTROL_UNAVAILABLE' });
|
||||
const argument = action === 'seek'
|
||||
? (payload.currentTime !== undefined ? { currentTime: Number(payload.currentTime) } : { relativeTime: Number(payload.relativeTime || 0) })
|
||||
: undefined;
|
||||
const status = unwrap(await (argument ? method(argument) : method()), castClient);
|
||||
return { state: String(status?.playerState || action).toLowerCase(), currentTime: Number(status?.currentTime || 0) };
|
||||
}
|
||||
|
||||
async function stopSession(session) {
|
||||
const id = session.sessionId || session.id;
|
||||
const active = sessions.get(id);
|
||||
if (!active) return;
|
||||
try { unwrap(await active.controller.stop(), castClient); } finally {
|
||||
try { active.controller.dispose?.(); } catch { /* best effort */ }
|
||||
try { active.client.close?.(); } catch { /* best effort */ }
|
||||
sessions.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { command, discover, startSession, stopSession };
|
||||
}
|
||||
|
||||
module.exports = { createGoogleCastAdapter, normalizeDevice };
|
||||
Reference in New Issue
Block a user