Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions server/services/airplay/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ const AirplayHandler = require('./lib');
const airplayController = require('./api/airplay.controller');

module.exports = function AirplayService(gladys, serviceId) {
const airtunes = require('node-airtunes2');
const { start: AirplaySender } = require('@lox-audioserver/node-airplay-sender');
const bonjourLib = require('bonjour')();
const childProcess = require('child_process');
const airplayHandler = new AirplayHandler(gladys, airtunes, bonjourLib, childProcess, serviceId);
const airplayHandler = new AirplayHandler(gladys, AirplaySender, bonjourLib, childProcess, serviceId);

/**
* @public
Expand Down
108 changes: 67 additions & 41 deletions server/services/airplay/lib/airplay.setValue.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,53 +18,79 @@ async function setValue(device, deviceFeature, value, options) {
}

if (deviceFeature.type === DEVICE_FEATURE_TYPES.MUSIC.PLAY_NOTIFICATION) {
const client = new this.Airtunes();
const airplayDevice = client.add(ipAddress, {
volume: options?.volume || 70,
});
const MAX_NOTIFICATION_DURATION_MS = 5 * 60 * 1000; // 5 minutes max
let decodeProcess;
let killTimer;

client.on('buffer', (event) => {
if (event === 'end') {
logger.debug('Playback ended, waiting for AirTunes devices');
setTimeout(() => {
client.stopAll(() => {
if (decodeProcess) {
decodeProcess.kill();
}
});
}, 5000);
const cleanup = () => {
clearTimeout(killTimer);
if (decodeProcess) {
decodeProcess.kill();
decodeProcess = null;
}
});
};

const sender = this.airplaySender(
{
host: ipAddress,
airplay2: true,
volume: options?.volume ?? 70,
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
async (event) => {
if (event.event === 'device' && event.message === 'ready') {
decodeProcess = this.childProcess.spawn('ffmpeg', [
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any risk that this process could run indefinitely?
Why not use execFile with a proper timeout, like we do in getImage.js for the RTSP camera integration?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it can happen if there is a deconnection during url reading. I added a global timeout after 5 minutes (we can adjust, I don't know user's notification duration). In this case execFile is not possible, file is send in realtime using chunks to homepods, execFile needs to buffer all file in memory

'-re',
'-i',
value,
'-acodec',
'pcm_s16le',
'-f',
's16le', // PCM 16bits, little-endian
'-ar',
'44100', // Sampling rate
'-ac',
2, // Stereo
'pipe:1', // Output on stdout
]);

airplayDevice.on('status', async (status) => {
if (status === 'ready') {
decodeProcess = this.childProcess.spawn('ffmpeg', [
'-i',
value,
'-acodec',
'pcm_s16le',
'-f',
's16le', // PCM 16bits, little-endian
'-ar',
'44100', // Sampling rate
'-ac',
2, // Stereo
'pipe:1', // Output on stdout
]);
decodeProcess.stdout.pipe(client);
killTimer = setTimeout(() => {
logger.warn('ffmpeg exceeded max notification duration, killing process');
cleanup();
sender.stop();
}, MAX_NOTIFICATION_DURATION_MS);

// detect if ffmpeg was not spawned correctly
decodeProcess.stderr.setEncoding('utf8');
decodeProcess.stderr.on('data', (data) => {
if (/^execvp\(\)/.test(data)) {
decodeProcess.on('error', (err) => {
logger.error('Failed to start ffmpeg');
logger.error(`stderr: ${data}`);
client.stopAll(() => {});
}
});
}
});
logger.error(err);
cleanup();
sender.stop();
});

decodeProcess.stdout.on('data', (chunk) => sender.sendPcm(chunk));
decodeProcess.stdout.on('end', () => {
clearTimeout(killTimer);
setTimeout(() => {
sender.stop();
}, 7000);
});

// detect if ffmpeg was not spawned correctly
decodeProcess.stderr.setEncoding('utf8');
decodeProcess.stderr.on('data', (data) => {
if (/^execvp\(\)/.test(data)) {
logger.error('Failed to start ffmpeg');
logger.error(`stderr: ${data}`);
cleanup();
sender.stop();
}
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

if (event.event === 'buffer' && event.message === 'end') {
cleanup();
}
},
);
}
}

Expand Down
4 changes: 2 additions & 2 deletions server/services/airplay/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ const { init } = require('./airplay.init');
const { scan } = require('./airplay.scan');
const { setValue } = require('./airplay.setValue');

const AirplayHandler = function AirplayHandler(gladys, airtunes, bonjourLib, childProcess, serviceId) {
const AirplayHandler = function AirplayHandler(gladys, airplaySender, bonjourLib, childProcess, serviceId) {
this.gladys = gladys;
this.Airtunes = airtunes;
this.airplaySender = airplaySender;
this.bonjourLib = bonjourLib;
this.childProcess = childProcess;
this.serviceId = serviceId;
Expand Down
Loading
Loading