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