UNPKG

@petro-kushchak/homebridge-homepod-radio

Version:

Homebridge accessory for streaming radio to Homepod Mini and Apple TV

227 lines 9.78 kB
import { delay } from './promises.js'; import * as child from 'child_process'; import * as path from 'path'; import { promisify } from 'util'; import { fileURLToPath } from 'url'; const execAsync = promisify(child.exec); const __filename = fileURLToPath(import.meta.url); /** * AirPlay device */ export class AirPlayDevice { homepodId; logger; verboseMode; streamerName; streamMetadataUrl; streamArtworkUrl; STREAMING_RESTART_TIMEOUT = 500; HEARTBEAT_TIMEOUT = 5000; LAST_SEEN_THRESHOLD_MS = 10000; DEFAULT_ARTWORK_URL = 'https://www.apple.com/v/apple-music/q/images/shared/og__ckjrh2mu8b2a_image.png'; streaming; lastSeen; heartbeat; streamingRetries = 0; // eslint-disable-next-line @typescript-eslint/no-explicit-any debug; pluginPath; constructor(homepodId, logger, verboseMode, streamerName, streamMetadataUrl, streamArtworkUrl) { this.homepodId = homepodId; this.logger = logger; this.verboseMode = verboseMode; this.streamerName = streamerName; this.streamMetadataUrl = streamMetadataUrl; this.streamArtworkUrl = streamArtworkUrl; this.debug = this.verboseMode ? this.logger.info.bind(this.logger) : this.logger.debug.bind(this.logger); this.pluginPath = path.resolve(path.dirname(__filename), '..', '..'); } async killProcess(procId) { const cmd = `kill -9 ${procId}`; const result = await execAsync(cmd); this.debug(`[${this.streamerName}] Executing "${result}" result: ${JSON.stringify(result)}`); } async getPlaybackTitle() { const currentTitleCmd = `atvremote --id ${this.homepodId} title`; const result = await execAsync(currentTitleCmd); return result.stdout.replace(/\r?\n|\r/g, ' '); } async setVolume(volume) { const scriptPath = path.resolve(path.dirname(__filename), '..', 'stream.py'); const setVolumeCmd = `python3 ${scriptPath} --id ${this.homepodId} --volume ${volume}`; await execAsync(setVolumeCmd); } async playFile(filePath, volume) { // create pipe for the command: const scriptPath = path.resolve(path.dirname(__filename), '..', 'stream.py'); const streamParams = [ scriptPath, '--id', this.homepodId, '--title', this.streamerName, '--album', this.streamerName, '--file', filePath, '--verbose', '--volume', '' + volume, ]; this.logger.debug(`[${this.streamerName}] Child: python3 ${streamParams.join(' ')}`); this.streaming = child.spawn('python3', streamParams, { cwd: this.pluginPath, env: { ...process.env } }); this.streaming.stdout.on('data', (data) => { this.debug(`[${this.streamerName}] Streaming data: ${data}`); }); this.streaming.on('exit', async (code, signal) => { this.debug(`[${this.streamerName}] Streaming exit: code ${code} signal ${signal}`); const streamExited = true; await this.endStreaming(streamExited); }); this.streaming.stderr.on('data', (data) => { this.debug(`[${this.streamerName}] Streaming data: ${data}`); }); this.debug(`[${this.streamerName}] Started hearbeat ${this.heartbeat}`); this.debug(`[${this.streamerName}] Spawn streaming: ${this.streaming.pid}`); this.logger.info(`[${this.streamerName}] Started file streaming ${filePath}`); return true; } async playStream(streamUrl, streamName, volume) { this.streamingRetries = 0; const heartbeat = this.handleHearbeat.bind(this); const heartbeatFailed = async () => { //identify reason and restart streaming... const title = await this.getPlaybackTitle(); this.debug(`[${this.streamerName}] Received from device: ${this.homepodId}; Title: ${title}`); const restartStreaming = false; if (restartStreaming) { //need to restart streaming, after some delay await delay(this.STREAMING_RESTART_TIMEOUT * this.streamingRetries, 0); await this.startStreaming(streamUrl, streamName, volume, heartbeat, heartbeatFailed); } else { //device is used to play something else, need to change state to "STOPPED" await this.endStreaming(); this.logger.info(`[${this.streamerName}] Streaming finished - restart canceled`); } }; if (this.isPlaying()) { await this.endStreaming(); this.logger.info(`[${this.streamerName}] Previous streaming finished`); return await this.startStreaming(streamUrl, streamName, volume, heartbeat, heartbeatFailed); } else { return await this.startStreaming(streamUrl, streamName, volume, heartbeat, heartbeatFailed); } } /** * Streaming crash monitoring/prevention */ async handleHearbeat(heatbeatType, heartbeatFailed) { this.debug(`[${this.streamerName}] Playback heartbeat, source: ${heatbeatType}`); if (!this.isPlaying()) { this.logger.info(`[${this.streamerName}] Playback heartbeat ignored (${heatbeatType}) - streaming stopped`); this.logger.info(`[${this.streamerName}] Cleared hearbeat ${this.heartbeat} - streaming stopped`); clearInterval(this.heartbeat); this.heartbeat = undefined; return; } if (heatbeatType !== 'heartbeat') { this.lastSeen = Date.now(); this.streamingRetries = 0; } else { const diffMs = Date.now() - this.lastSeen; this.debug(`[${this.streamerName}] Playback heartbeat, diff: ${diffMs}ms`); if (diffMs > this.LAST_SEEN_THRESHOLD_MS) { this.debug(`[${this.streamerName}] Playback heartbeat failed`); await heartbeatFailed(); } } } async startStreaming(streamUrl, streamName, volume, heartbeat, heartbeatFailed) { // create pipe for the command: const scriptPath = path.resolve(path.dirname(__filename), '..', 'stream.py'); const streamParams = [ scriptPath, '--id', this.homepodId, '--title', streamName, '--album', this.streamerName, '--stream_url', streamUrl, '--stream_timeout', '30', '--stream_metadata', this.streamMetadataUrl ? this.streamMetadataUrl : '-1', '--stream_artwork', this.streamArtworkUrl ? this.streamArtworkUrl : this.DEFAULT_ARTWORK_URL, '--volume', '' + volume, '--verbose', ]; this.logger.debug(`[${this.streamerName}] Child: python3 ${streamParams.join(' ')}`); this.streaming = child.spawn('python3', streamParams, { cwd: this.pluginPath, env: { ...process.env } }); this.streaming.stdout.on('data', (data) => { this.debug(`[${this.streamerName}] Streaming data: ${data}`); heartbeat('streaming', heartbeatFailed); }); this.streaming.on('exit', async (code, signal) => { this.debug(`[${this.streamerName}] Streaming exit: code ${code} signal ${signal}`); const streamExited = true; await this.endStreaming(streamExited); }); this.streaming.stderr.on('data', (data) => { this.debug(`[${this.streamerName}] Streaming data: ${data}`); heartbeat('streaming', heartbeatFailed); }); if (this.heartbeat !== undefined) { this.debug(`[${this.streamerName}] Cleared hearbeat ${this.heartbeat} - previous timer`); clearInterval(this.heartbeat); this.heartbeat = undefined; } this.heartbeat = setInterval(() => { heartbeat('heartbeat', heartbeatFailed); }, this.HEARTBEAT_TIMEOUT); this.debug(`[${this.streamerName}] Started hearbeat ${this.heartbeat}`); this.debug(`[${this.streamerName}] Spawn streaming: ${this.streaming.pid}`); this.logger.info(`[${this.streamerName}] Started streaming ${streamUrl}`); return true; } async endStreaming(streamExited = false) { try { if (this.streaming === undefined) { this.debug(`[${this.streamerName}] End streaming: streaming: ${this.streaming}`); return Promise.resolve(true); } this.debug(`[${this.streamerName}] Killing process: streaming: ${this.streaming.pid}`); this.debug(`[${this.streamerName}] Cleared hearbeat ${this.heartbeat} - stop requested`); clearInterval(this.heartbeat); this.heartbeat = undefined; if (!streamExited) { await this.killProcess(this.streaming.pid); } this.streaming = undefined; } catch (err) { this.logger.error(`[${this.streamerName}] Error while trying to stop: ${err}`); this.streaming = undefined; } return Promise.resolve(true); } async stop() { if (!this.isPlaying()) { this.debug(`[${this.streamerName}] Trying to stop stopped process!`); return true; } await this.endStreaming(); this.logger.info(`[${this.streamerName}] Streaming finished - stop requested`); return true; } isPlaying() { return !!this.streaming; } } //# sourceMappingURL=airplayDevice.js.map