const child_process = require('child_process'); const request = require('request'); const fs = require('fs'); const LOG = require('../logger/logger.js')('action'); let Actions = {}; // Notes: // While it may be nice to be able to use the webview tag // in electron, it probably does not play DRM controlled // content (i.e. Spotify) so this needs to connect to a // firefox extension. This can be done by running a web // server and sending messages to the client from the // firefox client over said server. // // An alternative to this may be to use this: // https://github.com/castlabs/electron-releases/ // Recording A Song Outline // Song starts playing on firefox browser // firefox extension sends web request to the recording client (song started (name, artists, album name, album art (small))) // recording client starts recording (using gstreamer, song.guid + .wav) // recording client downloads basic album art // firefox extension looks up the better album art image // firefox extension sends web request to the recording client (new song data with same guid) // recording client downloads song full album art // Song finishes playing on firefox browser // firefox extension sends web request to the recording client (song stopped) // recording client stops recording // ffmpeg conversion begins to convert song.guid + .wav -> song.filename + .mp3 with album art and mp3 metadata // Key Objects // Requirements: // gstreamer: gst-launch-1.0 // ffmpeg: ffmpeg /* Song * { * guid: string ("11bf5b37-e0b8-42e0-8dcf-dc8c4aefc000"), * filename: string ("Low-11bf5b37"), * name: string ("Low") * artists: list ([ "Michael", "Eric" ]) * duration: string ("3:15") * album: { * name: string ("Best Yoinks 2019") * art: string ("https://spotify.com/art/872364") * artFull: string ("https://spotify.com/art/2398423428374") [optional] * } * } */ // Removes Windows special characters from the song name for the file name Actions.getSongFilename = function(song) { return song.name.replace(/[\\\/:*?\"<>|]+/g, '') + ' ' + song.guid; } // uses a logger to log the output of a process Actions.registerLogger = function(process, log) { process.stdout.on('data', (chunk) => { log.debug(chunk.toString('utf-8')); }); process.stderr.on('data', (chunk) => { log.error(chunk.toString('utf-8')); }); } // Starts a process and handles general errors Actions.spawnProcess = function(name, command, args) { let process = child_process.spawn(command, args); LOG.info(`spawned ${name} ${process.pid}`); process.on('close', (code, signal) => { LOG.silly(`${name} ${process.pid} closed, code: ${code}, signal: ${signal}`); }); process.on('exit', (code, signal) => { LOG.debug(`${name} ${process.pid} exited, code: ${code}, signal: ${signal}`); }); process.on('disconnect', () => { LOG.silly(`${name} ${process.pid} disconnected from parent`); }); process.on('error', (err) => { LOG.silly(`${name} ${process.pid} error`, err); }); return process; } /** * Starts recording audio output * @param {Song} song The song to start recording (used for file name) * @returns the child process that was spawned */ Actions.startRecording = function(song) { let device = 'alsa_output.pci-0000_00_1f.3.analog-stereo.monitor'; let output = '../intermediate/' + song.guid + '.wav'; // -v for verbose output let recording_process = Actions.spawnProcess( 'recording process', 'gst-launch-1.0', [ '-v', 'pulsesrc', 'device', '=', device, '!', 'wavenc', '!', 'filesink', 'location', '=', output ] ); return recording_process; } /** * Stops the recording process * @param {*} recordingProcess The recording child process */ Actions.stopRecording = async function(recordingProcess) { return new Promise((resolve, reject) => { LOG.debug(`killing process ${recordingProcess.pid}`); recordingProcess.on('exit', (code, signal) => { resolve(); }); recordingProcess.kill('SIGINT'); }); } /** * Downloads the album art for a song. If song.album.art_full exists, uses that, else song.album.art * @param {Song} song The song to download album art for */ Actions.downloadAlbumArt = async function(song) { return new Promise((resolve, reject) => { try { let url = song.album.artFull || song.album.art || null; if (url) { let writeStream = fs.createWriteStream('../intermediate/' + song.guid + '.jpg'); // Assume .jpg image writeStream.on('finish', () => { resolve(); }); request.get(url).pipe(writeStream); } else { fs.copyFile('../resources/default-album-art.jpg', '../intermediate/' + song.guid + '.jpg', (err) => { if (err) { reject(err); } else { resolve(); } }); } } catch (e) { reject(e); } }); } Actions.convertToMp3 = function(song) { if ( !fs.existsSync('../intermediate/' + song.guid + '.wav') || !fs.existsSync('../intermediate/' + song.guid + '.jpg') ) { LOG.warn(`Cannot convert ${song.name} to mp3: [${song.guid}.wav/${song.guid}.jpg] does not exist`); return; } // Album Art: https://stackoverflow.com/questions/18710992/how-to-add-album-art-with-ffmpeg let conversionProcess = Actions.spawnProcess( 'conversion process', 'ffmpeg', [ '-hide_banner', '-i', '../intermediate/' + song.guid + '.wav', '-f', 'mp3', '-b:a', '192k', '../intermediate/' + song.guid + '.mp3' ] ); return conversionProcess; } Actions.addMetadata = function(song) { if (!fs.existsSync('../intermediate/' + song.guid + '.mp3')) { LOG.warn(`Cannot add metadata to ${song.name}: [${song.guid}.mp3] does not exist`); return; } LOG.debug(`adding metadata: title: ${song.name}, artist: ${song.artists.join(' / ')}, album: ${song.album.name}`); let metadataProcess = Actions.spawnProcess( 'metadata process', 'ffmpeg', [ '-hide_banner', '-i', '../intermediate/' + song.guid + '.mp3', '-i', '../intermediate/' + song.guid + '.jpg', '-map', '0:0', '-map', '1:0', '-c', 'copy', '-id3v2_version', '3', '-metadata:s:v', 'title=Album Cover', '-metadata:s:v', 'comment=Cover (front)', '-metadata', `title=${song.name}`, '-metadata', `artist=${song.artists.join(' / ')}`, '-metadata', `album=${song.album.name}`, '../audio/' + song.filename + '.mp3' ] ); return metadataProcess; } Actions.removeIntermediate = function(song) { fs.unlink('../intermediate/' + song.guid + '.wav', (err) => { if (err) { LOG.error(`error removing ${song.guid}.wav`, err); } }); fs.unlink('../intermediate/' + song.guid + '.jpg', (err) => { if (err) { LOG.error(`error removing ${song.guid}.jpg`, err); } }); fs.unlink('../intermediate/' + song.guid + '.mp3', (err) => { if (err) { LOG.error(`error removing ${song.guid}.mp3`, err); } }); } module.exports = Actions;