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>
132 lines
6.3 KiB
JavaScript
132 lines
6.3 KiB
JavaScript
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 };
|