From 0ddf4480c77567fdea152372633b24c3f1690ae0 Mon Sep 17 00:00:00 2001 From: Michael Peters Date: Wed, 18 Sep 2019 23:05:44 -0400 Subject: [PATCH] Client Created --- .gitignore | 3 + client/actions.js | 211 ++++++ client/diactrics.js | 97 +++ client/index.css | 69 ++ client/index.html | 32 + client/index.js | 133 ++++ client/main.js | 185 ++++++ client/package-lock.json | 1070 +++++++++++++++++++++++++++++++ client/package.json | 16 + command-line/main.js | 31 - extension-firefox/background.js | 59 ++ extension-firefox/content.js | 113 ++++ extension-firefox/manifest.json | 18 + logger/logger.js | 8 +- resources/default-album-art.jpg | Bin 0 -> 35621 bytes 15 files changed, 2011 insertions(+), 34 deletions(-) create mode 100644 client/actions.js create mode 100644 client/diactrics.js create mode 100644 client/index.css create mode 100644 client/index.html create mode 100644 client/index.js create mode 100644 client/main.js create mode 100644 client/package-lock.json create mode 100644 client/package.json delete mode 100644 command-line/main.js create mode 100644 extension-firefox/background.js create mode 100644 extension-firefox/content.js create mode 100644 extension-firefox/manifest.json create mode 100644 resources/default-album-art.jpg diff --git a/.gitignore b/.gitignore index 84a422f..a81c855 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Audio Files audio +# Intermediate Files +intermediate + # Logs logs *.log diff --git a/client/actions.js b/client/actions.js new file mode 100644 index 0000000..3d6dafd --- /dev/null +++ b/client/actions.js @@ -0,0 +1,211 @@ +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', + '../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; + } + 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', `author=\'${song.artists.join(' / ')}\'`, + '-metadata', `album=\'${song.album.name}\'`, + '-b:a', '192k', + '../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; diff --git a/client/diactrics.js b/client/diactrics.js new file mode 100644 index 0000000..1a1d83c --- /dev/null +++ b/client/diactrics.js @@ -0,0 +1,97 @@ +// https://gist.github.com/yeah/1283961 + +var diacriticsMap = [ + {'base':'A', 'letters':/[\u0041\u24B6\uFF21\u00C0\u00C1\u00C2\u1EA6\u1EA4\u1EAA\u1EA8\u00C3\u0100\u0102\u1EB0\u1EAE\u1EB4\u1EB2\u0226\u01E0\u01DE\u1EA2\u00C5\u01FA\u01CD\u0200\u0202\u1EA0\u1EAC\u1EB6\u1E00\u0104\u023A\u2C6F]/g}, + {'base':'AA','letters':/[\uA732]/g}, + {'base':'AE','letters':/[\u00C4\u00C6\u01FC\u01E2]/g}, + {'base':'AO','letters':/[\uA734]/g}, + {'base':'AU','letters':/[\uA736]/g}, + {'base':'AV','letters':/[\uA738\uA73A]/g}, + {'base':'AY','letters':/[\uA73C]/g}, + {'base':'B', 'letters':/[\u0042\u24B7\uFF22\u1E02\u1E04\u1E06\u0243\u0182\u0181]/g}, + {'base':'C', 'letters':/[\u0043\u24B8\uFF23\u0106\u0108\u010A\u010C\u00C7\u1E08\u0187\u023B\uA73E]/g}, + {'base':'D', 'letters':/[\u0044\u24B9\uFF24\u1E0A\u010E\u1E0C\u1E10\u1E12\u1E0E\u0110\u018B\u018A\u0189\uA779]/g}, + {'base':'DZ','letters':/[\u01F1\u01C4]/g}, + {'base':'Dz','letters':/[\u01F2\u01C5]/g}, + {'base':'E', 'letters':/[\u0045\u24BA\uFF25\u00C8\u00C9\u00CA\u1EC0\u1EBE\u1EC4\u1EC2\u1EBC\u0112\u1E14\u1E16\u0114\u0116\u00CB\u1EBA\u011A\u0204\u0206\u1EB8\u1EC6\u0228\u1E1C\u0118\u1E18\u1E1A\u0190\u018E]/g}, + {'base':'F', 'letters':/[\u0046\u24BB\uFF26\u1E1E\u0191\uA77B]/g}, + {'base':'G', 'letters':/[\u0047\u24BC\uFF27\u01F4\u011C\u1E20\u011E\u0120\u01E6\u0122\u01E4\u0193\uA7A0\uA77D\uA77E]/g}, + {'base':'H', 'letters':/[\u0048\u24BD\uFF28\u0124\u1E22\u1E26\u021E\u1E24\u1E28\u1E2A\u0126\u2C67\u2C75\uA78D]/g}, + {'base':'I', 'letters':/[\u0049\u24BE\uFF29\u00CC\u00CD\u00CE\u0128\u012A\u012C\u0130\u00CF\u1E2E\u1EC8\u01CF\u0208\u020A\u1ECA\u012E\u1E2C\u0197]/g}, + {'base':'J', 'letters':/[\u004A\u24BF\uFF2A\u0134\u0248]/g}, + {'base':'K', 'letters':/[\u004B\u24C0\uFF2B\u1E30\u01E8\u1E32\u0136\u1E34\u0198\u2C69\uA740\uA742\uA744\uA7A2]/g}, + {'base':'L', 'letters':/[\u004C\u24C1\uFF2C\u013F\u0139\u013D\u1E36\u1E38\u013B\u1E3C\u1E3A\u0141\u023D\u2C62\u2C60\uA748\uA746\uA780]/g}, + {'base':'LJ','letters':/[\u01C7]/g}, + {'base':'Lj','letters':/[\u01C8]/g}, + {'base':'M', 'letters':/[\u004D\u24C2\uFF2D\u1E3E\u1E40\u1E42\u2C6E\u019C]/g}, + {'base':'N', 'letters':/[\u004E\u24C3\uFF2E\u01F8\u0143\u00D1\u1E44\u0147\u1E46\u0145\u1E4A\u1E48\u0220\u019D\uA790\uA7A4]/g}, + {'base':'NJ','letters':/[\u01CA]/g}, + {'base':'Nj','letters':/[\u01CB]/g}, + {'base':'O', 'letters':/[\u004F\u24C4\uFF2F\u00D2\u00D3\u00D4\u1ED2\u1ED0\u1ED6\u1ED4\u00D5\u1E4C\u022C\u1E4E\u014C\u1E50\u1E52\u014E\u022E\u0230\u022A\u1ECE\u0150\u01D1\u020C\u020E\u01A0\u1EDC\u1EDA\u1EE0\u1EDE\u1EE2\u1ECC\u1ED8\u01EA\u01EC\u00D8\u01FE\u0186\u019F\uA74A\uA74C]/g}, + {'base':'OE','letters':/[\u00D6\u0152]/g}, + {'base':'OI','letters':/[\u01A2]/g}, + {'base':'OO','letters':/[\uA74E]/g}, + {'base':'OU','letters':/[\u0222]/g}, + {'base':'P', 'letters':/[\u0050\u24C5\uFF30\u1E54\u1E56\u01A4\u2C63\uA750\uA752\uA754]/g}, + {'base':'Q', 'letters':/[\u0051\u24C6\uFF31\uA756\uA758\u024A]/g}, + {'base':'R', 'letters':/[\u0052\u24C7\uFF32\u0154\u1E58\u0158\u0210\u0212\u1E5A\u1E5C\u0156\u1E5E\u024C\u2C64\uA75A\uA7A6\uA782]/g}, + {'base':'S', 'letters':/[\u0053\u24C8\uFF33\u1E9E\u015A\u1E64\u015C\u1E60\u0160\u1E66\u1E62\u1E68\u0218\u015E\u2C7E\uA7A8\uA784]/g}, + {'base':'T', 'letters':/[\u0054\u24C9\uFF34\u1E6A\u0164\u1E6C\u021A\u0162\u1E70\u1E6E\u0166\u01AC\u01AE\u023E\uA786]/g}, + {'base':'TZ','letters':/[\uA728]/g}, + {'base':'U', 'letters':/[\u0055\u24CA\uFF35\u00D9\u00DA\u00DB\u0168\u1E78\u016A\u1E7A\u016C\u01DB\u01D7\u01D5\u01D9\u1EE6\u016E\u0170\u01D3\u0214\u0216\u01AF\u1EEA\u1EE8\u1EEE\u1EEC\u1EF0\u1EE4\u1E72\u0172\u1E76\u1E74\u0244]/g}, + {'base':'UE','letters':/[\u00DC]/g}, + {'base':'V', 'letters':/[\u0056\u24CB\uFF36\u1E7C\u1E7E\u01B2\uA75E\u0245]/g}, + {'base':'VY','letters':/[\uA760]/g}, + {'base':'W', 'letters':/[\u0057\u24CC\uFF37\u1E80\u1E82\u0174\u1E86\u1E84\u1E88\u2C72]/g}, + {'base':'X', 'letters':/[\u0058\u24CD\uFF38\u1E8A\u1E8C]/g}, + {'base':'Y', 'letters':/[\u0059\u24CE\uFF39\u1EF2\u00DD\u0176\u1EF8\u0232\u1E8E\u0178\u1EF6\u1EF4\u01B3\u024E\u1EFE]/g}, + {'base':'Z', 'letters':/[\u005A\u24CF\uFF3A\u0179\u1E90\u017B\u017D\u1E92\u1E94\u01B5\u0224\u2C7F\u2C6B\uA762]/g}, + {'base':'a', 'letters':/[\u0061\u24D0\uFF41\u1E9A\u00E0\u00E1\u00E2\u1EA7\u1EA5\u1EAB\u1EA9\u00E3\u0101\u0103\u1EB1\u1EAF\u1EB5\u1EB3\u0227\u01E1\u01DF\u1EA3\u00E5\u01FB\u01CE\u0201\u0203\u1EA1\u1EAD\u1EB7\u1E01\u0105\u2C65\u0250]/g}, + {'base':'aa','letters':/[\uA733]/g}, + {'base':'ae','letters':/[\u00E4\u00E6\u01FD\u01E3]/g}, + {'base':'ao','letters':/[\uA735]/g}, + {'base':'au','letters':/[\uA737]/g}, + {'base':'av','letters':/[\uA739\uA73B]/g}, + {'base':'ay','letters':/[\uA73D]/g}, + {'base':'b', 'letters':/[\u0062\u24D1\uFF42\u1E03\u1E05\u1E07\u0180\u0183\u0253]/g}, + {'base':'c', 'letters':/[\u0063\u24D2\uFF43\u0107\u0109\u010B\u010D\u00E7\u1E09\u0188\u023C\uA73F\u2184]/g}, + {'base':'d', 'letters':/[\u0064\u24D3\uFF44\u1E0B\u010F\u1E0D\u1E11\u1E13\u1E0F\u0111\u018C\u0256\u0257\uA77A]/g}, + {'base':'dz','letters':/[\u01F3\u01C6]/g}, + {'base':'e', 'letters':/[\u0065\u24D4\uFF45\u00E8\u00E9\u00EA\u1EC1\u1EBF\u1EC5\u1EC3\u1EBD\u0113\u1E15\u1E17\u0115\u0117\u00EB\u1EBB\u011B\u0205\u0207\u1EB9\u1EC7\u0229\u1E1D\u0119\u1E19\u1E1B\u0247\u025B\u01DD]/g}, + {'base':'f', 'letters':/[\u0066\u24D5\uFF46\u1E1F\u0192\uA77C]/g}, + {'base':'g', 'letters':/[\u0067\u24D6\uFF47\u01F5\u011D\u1E21\u011F\u0121\u01E7\u0123\u01E5\u0260\uA7A1\u1D79\uA77F]/g}, + {'base':'h', 'letters':/[\u0068\u24D7\uFF48\u0125\u1E23\u1E27\u021F\u1E25\u1E29\u1E2B\u1E96\u0127\u2C68\u2C76\u0265]/g}, + {'base':'hv','letters':/[\u0195]/g}, + {'base':'i', 'letters':/[\u0069\u24D8\uFF49\u00EC\u00ED\u00EE\u0129\u012B\u012D\u00EF\u1E2F\u1EC9\u01D0\u0209\u020B\u1ECB\u012F\u1E2D\u0268\u0131]/g}, + {'base':'j', 'letters':/[\u006A\u24D9\uFF4A\u0135\u01F0\u0249]/g}, + {'base':'k', 'letters':/[\u006B\u24DA\uFF4B\u1E31\u01E9\u1E33\u0137\u1E35\u0199\u2C6A\uA741\uA743\uA745\uA7A3]/g}, + {'base':'l', 'letters':/[\u006C\u24DB\uFF4C\u0140\u013A\u013E\u1E37\u1E39\u013C\u1E3D\u1E3B\u017F\u0142\u019A\u026B\u2C61\uA749\uA781\uA747]/g}, + {'base':'lj','letters':/[\u01C9]/g}, + {'base':'m', 'letters':/[\u006D\u24DC\uFF4D\u1E3F\u1E41\u1E43\u0271\u026F]/g}, + {'base':'n', 'letters':/[\u006E\u24DD\uFF4E\u01F9\u0144\u00F1\u1E45\u0148\u1E47\u0146\u1E4B\u1E49\u019E\u0272\u0149\uA791\uA7A5]/g}, + {'base':'nj','letters':/[\u01CC]/g}, + {'base':'o', 'letters':/[\u006F\u24DE\uFF4F\u00F2\u00F3\u00F4\u1ED3\u1ED1\u1ED7\u1ED5\u00F5\u1E4D\u022D\u1E4F\u014D\u1E51\u1E53\u014F\u022F\u0231\u022B\u1ECF\u0151\u01D2\u020D\u020F\u01A1\u1EDD\u1EDB\u1EE1\u1EDF\u1EE3\u1ECD\u1ED9\u01EB\u01ED\u00F8\u01FF\u0254\uA74B\uA74D\u0275]/g}, + {'base':'oe','letters': /[\u00F6\u0153]/g}, + {'base':'oi','letters':/[\u01A3]/g}, + {'base':'ou','letters':/[\u0223]/g}, + {'base':'oo','letters':/[\uA74F]/g}, + {'base':'p','letters':/[\u0070\u24DF\uFF50\u1E55\u1E57\u01A5\u1D7D\uA751\uA753\uA755]/g}, + {'base':'q','letters':/[\u0071\u24E0\uFF51\u024B\uA757\uA759]/g}, + {'base':'r','letters':/[\u0072\u24E1\uFF52\u0155\u1E59\u0159\u0211\u0213\u1E5B\u1E5D\u0157\u1E5F\u024D\u027D\uA75B\uA7A7\uA783]/g}, + {'base':'s','letters':/[\u0073\u24E2\uFF53\u015B\u1E65\u015D\u1E61\u0161\u1E67\u1E63\u1E69\u0219\u015F\u023F\uA7A9\uA785\u1E9B]/g}, + {'base':'ss','letters':/[\u00DF]/g}, + {'base':'t','letters':/[\u0074\u24E3\uFF54\u1E6B\u1E97\u0165\u1E6D\u021B\u0163\u1E71\u1E6F\u0167\u01AD\u0288\u2C66\uA787]/g}, + {'base':'tz','letters':/[\uA729]/g}, + {'base':'u','letters':/[\u0075\u24E4\uFF55\u00F9\u00FA\u00FB\u0169\u1E79\u016B\u1E7B\u016D\u01DC\u01D8\u01D6\u01DA\u1EE7\u016F\u0171\u01D4\u0215\u0217\u01B0\u1EEB\u1EE9\u1EEF\u1EED\u1EF1\u1EE5\u1E73\u0173\u1E77\u1E75\u0289]/g}, + {'base':'ue','letters':/[\u00FC]/g}, + {'base':'v','letters':/[\u0076\u24E5\uFF56\u1E7D\u1E7F\u028B\uA75F\u028C]/g}, + {'base':'vy','letters':/[\uA761]/g}, + {'base':'w','letters':/[\u0077\u24E6\uFF57\u1E81\u1E83\u0175\u1E87\u1E85\u1E98\u1E89\u2C73]/g}, + {'base':'x','letters':/[\u0078\u24E7\uFF58\u1E8B\u1E8D]/g}, + {'base':'y','letters':/[\u0079\u24E8\uFF59\u1EF3\u00FD\u0177\u1EF9\u0233\u1E8F\u00FF\u1EF7\u1E99\u1EF5\u01B4\u024F\u1EFF]/g}, + {'base':'z','letters':/[\u007A\u24E9\uFF5A\u017A\u1E91\u017C\u017E\u1E93\u1E95\u01B6\u0225\u0240\u2C6C\uA763]/g} +]; + +for(var i=0; i div { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 10px; +} + +#current-song div.info div.description > div:last-child { + margin-bottom: 0; +} + +#current-song div.duration { + display: flex; + align-items: center; + justify-content: center; +} + +#current-song div.duration > * { + padding: 10px; +} + +#current-song div.guid { + margin-top: 10px; + text-align: center; +} \ No newline at end of file diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..7d1e13f --- /dev/null +++ b/client/index.html @@ -0,0 +1,32 @@ + + + + + Spotify Audio Recorder + + + + +
+ Extension Status: Disconnected +
+
+
+
+ +
+
Waiting for song...
+
+
+
+
+
+
+
?
+ +
?
+
+
deadbeef-lead-leaf-ca1f-deadb00b5a1e
+
+ + \ No newline at end of file diff --git a/client/index.js b/client/index.js new file mode 100644 index 0000000..f74088c --- /dev/null +++ b/client/index.js @@ -0,0 +1,133 @@ +const electron = require('electron'); +const electronConsole = electron.remote.getGlobal('console'); + +const LOG = require('../logger/logger.js')('index', electronConsole); + +let UI = { + DEFAULT_ALBUM_ART: '../resources/default-album-art.jpg', + durationTimer: 0, + durationTimerInterval: null, +} + +UI.padLeft = function (s, l, c) { + s = '' + s; + l = l || 0; + c = c || ' '; + while (s.length < l) { + s = c + s; + } + return s; +} + +UI.secondsToTimeString = function(seconds) { + return `${Math.floor(seconds / 60)}:${UI.padLeft(seconds % 60, 2, '0')}`; +} + +UI.getElements = function() { + let qs = (s) => { return document.querySelector(s); }; + return { + extension: { + status: qs('#extension-status'), + }, + guid: qs('#current-song .guid'), + title: qs('#current-song .title'), + artists: qs('#current-song .artists'), + album: { + name: qs('#current-song .album'), + art: qs('#current-song .art'), + }, + duration: { + current: qs('#current-song .duration .current'), + total: qs('#current-song .duration .total'), + progress: qs('#current-song .duration progress'), + }, + recording: { + status: qs('#recording .status'), + }, + conversion: { + output: qs('#conversion .output'), + }, + }; +} + +UI.updateSongData = function(song) { + let elements = UI.getElements(); + + elements.guid.innerText = song.guid; + elements.title.innerText = song.name; + elements.artists.innerText = song.artists.join(', '); + elements.album.name.innerText = song.album.name; + elements.album.art.src = song.album.artFull || song.album.art || UI.DEFAULT_ALBUM_ART; + if (song.duration) { + elements.duration.total.innerText = UI.secondsToTimeString(song.duration); + elements.duration.progress.max = song.duration; + } else { + elements.duration.total.innerText = '?'; + elements.duration.progress.max = 180; + } +} + +UI.onNewSong = function(song) { + UI.updateSongData(song); + + let elements = UI.getElements(); + + elements.duration.current.innerText = UI.secondsToTimeString(0); +} + +UI.onUpdateSong = function(song) { + UI.updateSongData(song); +} + +UI.onDurationTick = function(duration) { + let elements = UI.getElements(); + + elements.duration.current.innerText = UI.secondsToTimeString(duration); + elements.duration.progress.value = duration; +} + +UI.onAdvertisement = function () { + let elements = UI.getElements(); + + elements.guid.innerText = ''; + elements.title.innerText = 'Advertisement'; + elements.artists.innerText = ''; + elements.album.name.innerText = ''; + elements.album.art.src = UI.DEFAULT_ALBUM_ART; + elements.duration.current.innerText = '?'; + elements.duration.progress.value = 0; + elements.duration.progress.max = 1; + elements.duration.total.innerText = '?'; +} + +UI.onExtensionConnect = function() { + let elements = UI.getElements(); + elements.extension.status.innerText = 'Connected'; +} + +UI.onExtensionDisconnect = function() { + let elements = UI.getElements(); + elements.extension.status.innerText = 'Disconnected'; +} + +UI.init = function () { + electron.ipcRenderer.on('new-song', (event, song) => { UI.onNewSong(song); }); + electron.ipcRenderer.on('update-song', (event, song) => { UI.onUpdateSong(song); }); + electron.ipcRenderer.on('advertisement', (event) => { UI.onAdvertisement(); }); + electron.ipcRenderer.on('duration-tick', (event, duration) => { UI.onDurationTick(duration); }); + + electron.ipcRenderer.on('extension-connect', (event) => { UI.onExtensionConnect(); }); + electron.ipcRenderer.on('extension-disconnect', (event) => { UI.onExtensionDisconnect(); }); + + let restartButton = document.getElementById('restart'); + let skipButton = document.getElementById('skip'); + + restartButton.addEventListener('click', (event) => { + electron.ipcRenderer.send('restart-song'); + }); + skipButton.addEventListener('click', (event) => { + electron.ipcRenderer.send('skip-song'); + }); +} + +document.addEventListener('DOMContentLoaded', UI.init); diff --git a/client/main.js b/client/main.js new file mode 100644 index 0000000..f0b7723 --- /dev/null +++ b/client/main.js @@ -0,0 +1,185 @@ +const electron = require('electron'); +const http = require('http'); +const Actions = require('./actions.js'); + +const LOG = require('../logger/logger.js')('main'); + +let window = null; +let port = 50233; + +electron.app.on('ready', (launchInfo) => { + window = new electron.BrowserWindow({ + width: 400, + height: 200, + webPreferences: { + nodeIntegration: true + } + }); + window.setMenuBarVisibility(false); + + LOG.debug('window being created...'); + window.loadFile('index.html'); + + window.on('closed', () => { window = null }); + LOG.debug('window created'); +}); + +electron.app.on('window-all-closed', () => { + electron.app.quit(); +}); + +electron.ipcMain.on('restart-song', () => { + LOG.debug('got restart song request'); +}); + +electron.ipcMain.on('skip-song', () => { + LOG.debug('got skip song request'); +}); + +let wasAd = false; + +let curentSong = null; + +const RECLOG = require('../logger/logger.js')('rec'); +const CVRTLOG = require('../logger/logger.js')('cvrt'); +const METALOG = require('../logger/logger.js')('meta'); + +let recordingProcess = null; + +let durationInterval = null; + +async function stopRecordingStartConversion() { + LOG.debug(`${currentSong.filename} stopping recording...`); + await Actions.stopRecording(recordingProcess); + + let conversionSong = currentSong; + LOG.debug(`${conversionSong.filename} starting conversion...`); + let conversionProcess = Actions.convertToMp3(conversionSong); + Actions.registerLogger(conversionProcess, CVRTLOG); + conversionProcess.on('exit', (code, signal) => { + LOG.info(`${conversionSong.filename} completed conversion`); + + LOG.debug(`${conversionSong.filename} starting adding metadata...`); + let metadataProcess = Actions.addMetadata(conversionSong); + Actions.registerLogger(metadataProcess, METALOG); + metadataProcess.on('exit', (code, signal) => { + LOG.info(`${conversionSong.filename} completed adding metadata`); + Actions.removeIntermediate(conversionSong); + }); + LOG.info(`${conversionSong.filename} started adding metadata`); + }); + LOG.info(`${conversionSong.filename} started conversion`); +} + +async function onNewSong(song) { + wasAd = false; + LOG.debug(`${song.filename} new song`); + + if (recordingProcess != null) { + await stopRecordingStartConversion(); + } + + LOG.debug(`${song.filename} starting recording...`); + recordingProcess = Actions.startRecording(song); + Actions.registerLogger(recordingProcess, RECLOG); + recordingProcess.on('exit', (code, signal) => { + LOG.info(`${song.filename} recording stopped`); + recordingProcess = null; + }); + LOG.info(`${song.filename} started recording`); + window.webContents.send('new-song', song); + + currentSong = song; + + let duration = 0; + clearInterval(durationInterval); + durationInterval = setInterval(() => { + duration += 1; + window.webContents.send('duration-tick', duration); + if (duration >= 60 * 60) { + LOG.debug('song lasted more than an hour, marking as an advertisement'); + onAdvertisement(); + } + }, 1000); + + LOG.debug(`${song.filename} starting album art download`); + await Actions.downloadAlbumArt(song); + LOG.info(`${song.filename} album art downloaded`); +} + +async function onUpdateSong(song) { + LOG.debug(`${song.filename} update song`) + window.webContents.send('update-song', song); + + LOG.debug(`${song.filename} starting full album art download`); + await Actions.downloadAlbumArt(song); + LOG.info(`${song.filename} full album art downloaded`); +} + +async function onAdvertisement() { + if (!wasAd) { + LOG.debug(`advertisement`); + } + wasAd = true; + + clearInterval(durationInterval); + if (recordingProcess != null) { + await stopRecordingStartConversion(); + } + + window.webContents.send('advertisement'); +} + +let heartbeatTimeout = null; +function onHeartbeat() { + if (heartbeatTimeout == null) { + LOG.debug('extension connected'); + window.webContents.send('extension-connect'); + } + clearTimeout(heartbeatTimeout); + heartbeatTimeout = setTimeout(() => { + LOG.debug('extension disconnected'); + window.webContents.send('extension-disconnect'); + heartbeatTimeout = null; + }, 8000); +} + +http.createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + let data = null; + try { + data = JSON.parse(body); + } catch (e) { + LOG.error('unable to parse request json', { body: body, error: e }); + res.statusCode = 400; + res.end(); + return; + } + if (data.song) { + data.song.filename = Actions.getSongFilename(data.song); + } + switch (data.type) { + case 'new-song': + onNewSong(data.song); + break; + case 'update-song': + onUpdateSong(data.song); + break; + case 'advertisement': + onAdvertisement(); + case 'heartbeat': + onHeartbeat(); + break; + default: + res.statusCode = 400; + res.end(); + return; + } + res.statusCode = 200; + res.end(); + }); +}).listen(port); diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..7ca83d8 --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,1070 @@ +{ + "name": "client", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "@types/node": { + "version": "10.14.18", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.14.18.tgz", + "integrity": "sha512-ryO3Q3++yZC/+b8j8BdKd/dn9JlzlHBPdm80656xwYUdmPkpTGTjkAdt6BByiNupGPE8w0FhBgvYy/fX9hRNGQ==" + }, + "ajv": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", + "integrity": "sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw==", + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=" + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=" + }, + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==" + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==" + }, + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=" + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "requires": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + } + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" + }, + "combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + }, + "dependencies": { + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "requires": { + "safe-buffer": "~5.1.0" + } + } + } + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "requires": { + "array-find-index": "^1.0.1" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "debug": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", + "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "requires": { + "ms": "^2.1.1" + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" + }, + "deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==" + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "electron": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/electron/-/electron-6.0.9.tgz", + "integrity": "sha512-lFpSmDNyjpvJFwEnK897Soone3DV7D3ASFUb315H2VTVZSbKib9Kbrsovxf4c+e1q5hTdaONsGIm3Lb4CXIW1g==", + "requires": { + "@types/node": "^10.12.18", + "electron-download": "^4.1.0", + "extract-zip": "^1.0.3" + } + }, + "electron-download": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/electron-download/-/electron-download-4.1.1.tgz", + "integrity": "sha512-FjEWG9Jb/ppK/2zToP+U5dds114fM1ZOJqMAR4aXXL5CvyPE9fiqBK/9YcwC9poIFQTEJk/EM/zyRwziziRZrg==", + "requires": { + "debug": "^3.0.0", + "env-paths": "^1.0.0", + "fs-extra": "^4.0.1", + "minimist": "^1.2.0", + "nugget": "^2.0.1", + "path-exists": "^3.0.0", + "rc": "^1.2.1", + "semver": "^5.4.1", + "sumchecker": "^2.0.2" + } + }, + "env-paths": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-1.0.0.tgz", + "integrity": "sha1-QWgTO0K7BcOKNbGuQ5fIKYqzaeA=" + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "extract-zip": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-1.6.7.tgz", + "integrity": "sha1-qEC0uK9kAyZMjbV/Txp0Mz74H+k=", + "requires": { + "concat-stream": "1.6.2", + "debug": "2.6.9", + "mkdirp": "0.5.1", + "yauzl": "2.4.1" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=" + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=" + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=" + }, + "fd-slicer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", + "integrity": "sha1-i1vL2ewyfFBBv5qwI/1nUPEXfmU=", + "requires": { + "pend": "~1.2.0" + } + }, + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "requires": { + "pinkie-promise": "^2.0.0" + } + } + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=" + }, + "form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, + "fs-extra": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-4.0.3.tgz", + "integrity": "sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg==", + "requires": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + } + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=" + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "requires": { + "assert-plus": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.2.tgz", + "integrity": "sha512-IItsdsea19BoLC7ELy13q1iJFNmd7ofZH5+X/pJr90/nRoPEX0DJo1dHDbgtYWOhJhcCgMDTOw84RZ72q6lB+Q==" + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=" + }, + "har-validator": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.3.tgz", + "integrity": "sha512-sNvOCzEQNr/qrvJgc3UG/kD4QtlHycrzwS+6mfTrrSq97BvaYcPZZI1ZSqGSPR73Cxn4LKTD4PttRwfU7jWq5g==", + "requires": { + "ajv": "^6.5.5", + "har-schema": "^2.0.0" + } + }, + "hosted-git-info": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.4.tgz", + "integrity": "sha512-pzXIvANXEFrc5oFFXRMkbLPQ2rXRoDERwDLyrcUxGhaZhgP54BBSl9Oheh7Vv0T090cszWBxPjkQQ5Sq1PbBRQ==" + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "requires": { + "repeating": "^2.0.0" + } + }, + "inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==" + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=" + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=" + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=" + }, + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=" + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=" + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=" + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=" + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=" + }, + "jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=", + "requires": { + "graceful-fs": "^4.1.6" + } + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + } + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "requires": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + } + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=" + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "requires": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + } + }, + "mime-db": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==" + }, + "mime-types": { + "version": "2.1.24", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", + "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "requires": { + "mime-db": "1.40.0" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" + } + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "normalize-package-data": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", + "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", + "requires": { + "hosted-git-info": "^2.1.4", + "resolve": "^1.10.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "nugget": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/nugget/-/nugget-2.0.1.tgz", + "integrity": "sha1-IBCVpIfhrTYIGzQy+jytpPjQcbA=", + "requires": { + "debug": "^2.1.3", + "minimist": "^1.1.0", + "pretty-bytes": "^1.0.2", + "progress-stream": "^1.1.0", + "request": "^2.45.0", + "single-line-log": "^1.1.2", + "throttleit": "0.0.2" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==" + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" + }, + "object-keys": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-0.4.0.tgz", + "integrity": "sha1-KKaq50KN0sOpLz2V8hM13SBOAzY=" + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "requires": { + "error-ex": "^1.2.0" + } + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=" + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==" + }, + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=" + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=" + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=" + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=" + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "requires": { + "pinkie": "^2.0.0" + } + }, + "pretty-bytes": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-1.0.4.tgz", + "integrity": "sha1-CiLoIQYJrTVUL4yNXSFZr/B1HIQ=", + "requires": { + "get-stdin": "^4.0.1", + "meow": "^3.1.0" + } + }, + "process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "progress-stream": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/progress-stream/-/progress-stream-1.2.0.tgz", + "integrity": "sha1-LNPP6jO6OonJwSHsM0er6asSX3c=", + "requires": { + "speedometer": "~0.1.2", + "through2": "~0.2.3" + } + }, + "psl": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.4.0.tgz", + "integrity": "sha512-HZzqCGPecFLyoRj5HLfuDSKYTJkAfB5thKBIkRHtGjWwY7p1dAyveIbXIq4tO0KYfDF2tHqPUgY9SDnGm00uFw==" + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==" + }, + "rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "requires": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + } + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + } + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "requires": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + } + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "requires": { + "is-finite": "^1.0.0" + } + }, + "request": { + "version": "2.88.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.0.tgz", + "integrity": "sha512-NAqBSrijGLZdM0WZNsInLJpkJokL72XYjUpnB0iwsRgxh7dB6COrHnTBNwN0E+lHDAJzu7kLAkDeY08z2/A0hg==", + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.0", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.4.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + } + }, + "resolve": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz", + "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==", + "requires": { + "path-parse": "^1.0.6" + } + }, + "safe-buffer": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", + "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=" + }, + "single-line-log": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/single-line-log/-/single-line-log-1.1.2.tgz", + "integrity": "sha1-wvg/Jzo+GhbtsJlWYdoO1e8DM2Q=", + "requires": { + "string-width": "^1.0.1" + } + }, + "spdx-correct": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.0.tgz", + "integrity": "sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q==", + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz", + "integrity": "sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA==" + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz", + "integrity": "sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q==" + }, + "speedometer": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/speedometer/-/speedometer-0.1.4.tgz", + "integrity": "sha1-mHbb0qFp0xFUAtSObqYynIgWpQ0=" + }, + "sshpk": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz", + "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==", + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=" + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "requires": { + "is-utf8": "^0.2.0" + } + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "requires": { + "get-stdin": "^4.0.1" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" + }, + "sumchecker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-2.0.2.tgz", + "integrity": "sha1-D0LBDl0F2l1C7qPlbDOZo31sWz4=", + "requires": { + "debug": "^2.2.0" + }, + "dependencies": { + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "requires": { + "ms": "2.0.0" + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=" + } + } + }, + "throttleit": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-0.0.2.tgz", + "integrity": "sha1-z+34jmDADdlpe2H90qg0OptoDq8=" + }, + "through2": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.2.3.tgz", + "integrity": "sha1-6zKE2k6jEbbMis42U3SKUqvyWj8=", + "requires": { + "readable-stream": "~1.1.9", + "xtend": "~2.1.1" + } + }, + "tough-cookie": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.4.3.tgz", + "integrity": "sha512-Q5srk/4vDM54WJsJio3XNn6K2sCG+CQ8G5Wz6bZhRZoAe/+TxjWB/GlFAnYEbkYVlON9FMk/fE3h2RLpPXo4lQ==", + "requires": { + "psl": "^1.1.24", + "punycode": "^1.4.1" + }, + "dependencies": { + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=" + } + } + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=" + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=" + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=" + }, + "universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==" + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "requires": { + "punycode": "^2.1.0" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" + }, + "uuid": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.3.tgz", + "integrity": "sha512-pW0No1RGHgzlpHJO1nsVrHKpOEIxkGg1xB+v0ZmdNH5OAeAwzAVrCnI2/6Mtx+Uys6iaylxa+D3g4j63IKKjSQ==" + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "xtend": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-2.1.2.tgz", + "integrity": "sha1-bv7MKk2tjmlixJAbM3znuoe10os=", + "requires": { + "object-keys": "~0.4.0" + } + }, + "yauzl": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.4.1.tgz", + "integrity": "sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU=", + "requires": { + "fd-slicer": "~1.0.1" + } + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..1c06e8b --- /dev/null +++ b/client/package.json @@ -0,0 +1,16 @@ +{ + "name": "client", + "version": "1.0.0", + "description": "", + "main": "main.js", + "scripts": { + "start": "electron ." + }, + "author": "", + "license": "ISC", + "dependencies": { + "electron": "^6.0.9", + "request": "^2.88.0", + "uuid": "^3.3.3" + } +} diff --git a/command-line/main.js b/command-line/main.js deleted file mode 100644 index ba31c12..0000000 --- a/command-line/main.js +++ /dev/null @@ -1,31 +0,0 @@ -const child_process = require('child_process'); - -// Requirements: -// gstreamer: gst-launch-1.0 -// ffmpeg: ffmpeg - -// gstreamer = audio recording -// see `pacmd list` for audio sources -// -v for verbose output -// $ gst-launch-1.0 -v pulsesrc device = "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" ! wavenc ! filesink location="output.wav" -function start_recording() { - let device = 'alsa_output.pci-0000_00_1f.3.analog-stereo.monitor'; - let output = 'output.wav'; - let recording_process = child_process.spawn( - 'gst-launch-1.0', [ '-v', 'pulsesrc', 'device', '=', device, '!', 'wavenc', '!', 'filesink', 'location', '=', output ] - ); - return recording_process; -} - -async function stop_recording() { - -} - -// ffmpeg = file conversion (wav -> mp3) -// $ ffmpeg -i output.wav -b:a 192k output.mp3 -// metadata can be added as follows -// $ ffmpeg -i output.wav \ -// -metadata title="Song Name" \ -// -metadata author="Song Artist" \ -// -metadata album="Song Album" \ -// -b:a 192k output.mp3 diff --git a/extension-firefox/background.js b/extension-firefox/background.js new file mode 100644 index 0000000..4c5a356 --- /dev/null +++ b/extension-firefox/background.js @@ -0,0 +1,59 @@ +let url = 'http://localhost:50233'; + +function send(data) { + console.log('sending', data); + let xhr = new XMLHttpRequest(); + + xhr.open('POST', 'http://localhost:50233'); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Access-Control-Allow-Origin', '*') + xhr.send(JSON.stringify(data)); + xhr.addEventListener('abort', (event) => { + console.error('XHR Abort', event); + }); + xhr.addEventListener('error', (event) => { + console.error('XHR Error', event); + }); + xhr.addEventListener('timeout', (event) => { + console.error('XHR Timeout', event); + }); + xhr.addEventListener('load', (event) => { + if (xhr.status != 200) { + console.error('bad xhr response status: ' + xhr.status); + } + }); +} + +function sendNewSong(song) { + send({ type: 'new-song', song: song }); +} + +function sendUpdateSong(song) { + send({ type: 'update-song', song: song }); +} + +function sendAdvertisement() { + send({ type: 'advertisement' }); +} + +function sendHeartbeat() { + send({ type: 'heartbeat' }); +} + +browser.runtime.onMessage.addListener((message) => { + message = JSON.parse(message); + switch (message.type) { + case 'new-song': + sendNewSong(message.song); + break; + case 'update-song': + sendUpdateSong(message.song); + break; + case 'advertisement': + sendAdvertisement(); + break; + case 'heartbeat': + sendHeartbeat(); + break; + } +}); \ No newline at end of file diff --git a/extension-firefox/content.js b/extension-firefox/content.js new file mode 100644 index 0000000..c581c6c --- /dev/null +++ b/extension-firefox/content.js @@ -0,0 +1,113 @@ +// See https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript +function uuidv4() { + return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) + ); +} + +function onNewSong(song) { + browser.runtime.sendMessage(JSON.stringify({ type: 'new-song', song: song })); +} + +function onUpdateSong(song) { + browser.runtime.sendMessage(JSON.stringify({ type: 'update-song', song: song })); +} + +function onAdvertisement() { + browser.runtime.sendMessage(JSON.stringify({ type: 'advertisement' })); +} + +function onHeartbeat() { + browser.runtime.sendMessage(JSON.stringify({ type: 'heartbeat' })); +} + +let song = { name: '', artists: [] }; + +// Initial Song Data +function getNameElement() { return document.querySelector('.Root__now-playing-bar .now-playing-bar-container div.track-info__name span a'); } +function getArtistElements() { return document.querySelectorAll('.Root__now-playing-bar .now-playing-bar-container div.track-info__artists span a'); } +function getAlbumArtElement() { return document.querySelector('.Root__now-playing-bar .now-playing-bar-container .cover-art .cover-art-image'); } + +// After album is loaded +function getDurationElement() { return document.querySelector('.Root__now-playing-bar .now-playing-bar .playback-bar .playback-bar__progress-time:last-child'); } +function getAlbumNameElement() { return document.querySelector('.TrackListHeader .mo-info-name'); } +function getAlbumArtFullElement() { return document.querySelector('.TrackListHeader .cover-art .cover-art-image'); } + +function checkSong() { + let nameElement = getNameElement(); + let artistsElement = getArtistElements(); + let albumArtElement = getAlbumArtElement(); + + let name = null; + let artists = []; + let albumArtLink = null; + + name = nameElement ? nameElement.innerText : 'Unknown Name'; + + if ( + name == 'Advertisement' || + document.title.toLowerCase().indexOf('advertisement') != -1 || + document.title.toLowerCase().indexOf('spotify') != -1 + ) { + console.log('advertisement'); + onAdvertisement(); + return; + } + + for (let i = 0; i < artistsElement.length; ++i) { + let artistElement = artistsElement[i]; + artists.push(artistElement.innerText); + } + + let albumArtLinkStyle = albumArtElement.getAttribute('style'); + albumArtLink = albumArtLinkStyle.substring('background-image: url("'.length, albumArtLinkStyle.length - '");'.length); + + if (name != song.name || artists.join(', ') != song.artists.join(', ')) { + // new song + song = { + guid: uuidv4(), + name: name, + artists: artists, + album: { + name: 'Unknown Album', + art: albumArtLink + } + }; + console.log('new song', song); + onNewSong(song); + // Click the album link + nameElement.click(); + setTimeout(() => { + let durationElement = getDurationElement(); + let albumNameElement = getAlbumNameElement(); + let albumArtFullElement = getAlbumArtFullElement(); + + let duration = durationElement ? durationElement.innerText : null; + + if (duration) { + let s = duration.split(':'); + duration = parseInt(s[0]) * 60 + parseInt(s[1]); + } + + let albumName = albumNameElement ? albumNameElement.getAttribute('title') : 'Unknown Album'; + + let albumArtFullLinkStyle = albumArtFullElement.getAttribute('style'); + let albumArtFullLink = albumArtLinkStyle.substring('background-image: url("'.length, albumArtFullLinkStyle.length - 3); + + song.duration = duration; + song.album.name = albumName; + song.album.artFull = albumArtFullLink; + + onUpdateSong(song); + }, 5000); // give 5 seconds to load + } +} + +console.log('Spotify Song Data Sender Extension Running'); + +// Send heartbeats every 3 seconds as soon as the content script is loaded +setInterval(() => { onHeartbeat(); }, 3000); +onHeartbeat(); + +// Check for new songs every 300 ms +setInterval(() => { checkSong(); }, 300); diff --git a/extension-firefox/manifest.json b/extension-firefox/manifest.json new file mode 100644 index 0000000..601357b --- /dev/null +++ b/extension-firefox/manifest.json @@ -0,0 +1,18 @@ +{ + "manifest_version": 2, + "name": "Spotify Song Information Sender", + "version": "1.0", + "description": "Sends spotify song data to a server as you listen.", + "permissions": [ + "*://localhost/*" + ], + "background": { + "scripts": [ "background.js" ] + }, + "content_scripts": [ + { + "matches": [ "*://open.spotify.com/*" ], + "js": [ "content.js" ] + } + ] +} \ No newline at end of file diff --git a/logger/logger.js b/logger/logger.js index 2c150f6..4da1998 100755 --- a/logger/logger.js +++ b/logger/logger.js @@ -17,7 +17,8 @@ function padCenter(x, p, n) { return x; } -function createLogger(name) { +function createLogger(name, processConsole) { + processConsole = processConsole || console; let logger = {}; function colorize(level, text) { let map = { @@ -41,7 +42,8 @@ function createLogger(name) { message = null; } if (message) { - out += `${prefix}: ${message}`; + out += message.split('\n').map(o => `${prefix}: ${o}`).join('\n'); + // out += `${prefix}: ${message}`; } function handleData(data) { if (data) { @@ -68,7 +70,7 @@ function createLogger(name) { } else { handleData(data); } - console.log(out); + processConsole.log(out); } logger.error = (message, data) => { logger.log('error', message, data); }; logger.warn = (message, data) => { logger.log('warn', message, data); }; diff --git a/resources/default-album-art.jpg b/resources/default-album-art.jpg new file mode 100644 index 0000000000000000000000000000000000000000..6df2303e781c4fa3061f19b2d331e127dc11f290 GIT binary patch literal 35621 zcmeFa2|UzW|2Y1!M@2<@mPAt7cVV)J>`EzI%aAN1Oof=Xo6@3CDn*MdNiKy_W4diB zm8?^Vsib1;jKOTb_ZhACxzBx`|M&m;|6kAZ{oT2D&YaKroO3?sy!ZF}ynW)mqP?*LF_u>-++e=Y+o3 z;aTV-Dj@Wwujr5a5Rm)64}rx$?gL=*WxWFGg5TyAUotTrf);|v#4qnhSfVxr%N}eB ze^0)aj+g+i7n9^)VSzi*NL~xJ7n?F^(j@UoQ^dumOrI<%g@BcA4`F$lP6D=oH|WXa+-{!q@)ZgBxNRsO#gQ#;N8KdPr}Y&=Y#|lF~R8q zLem9!t*~tg-hBZatWrdP|A7Ugj}Q=wu!yMGByow!Kn_3O6u<<9zTTXM2?z@a2?`5} zh>1=T5t^a{H>V2;FPxzzV(#iEt+-{Ewy4ap^CcFGlxDj9?ysX8wY$zzOgYuP|J^`9 zwBD=>rF#}zrFjHe-@O>4Kf8X-d;H+Fz4sc3?L#&y={dh%KYqXQ!|S@RQM&@LQ?6H~p!q2$nm;T{Cyu-IJ=SC*6)??oaGpT~!~uDl2O( z6_eGDl+0(v>+m zI|~H&NgP#GRl2lvMtaV^s0;QXtL^Pwmn!AhA5c}cUYVzCm7ZSWvebHIPOh?5!A!x_ zY+hZL;L#<&6zonuwLACcSxzo>OH%Dn)=3{culjmH=WFpJ0i~;a+>WO;nw2H^bl$H} zF|K&s7E;YIP2+eOlw>n%=G?r^!v;>(#QBKb?XDp8mK*V~%a83eqLd%b6E_obJGW73 zr@5Nu+ew-vV%_k@+Zp)@o5t@o?uu|C9xixOXZv|s%GB%O(L9V^Ttehw@860JPuV}+ z=!7&63$Ndum4AGO8|=WNWT~$4xuBrjc&Evj9VXn7CR-h>+S6pwhyW184tU=Ez!&KqP+xO)qd}#CFehJDcP(A zJnTg!PWh4V`%xZtw3vt8Bk-{NOV0EG9#%r*Oujg{@mH=!?U*DFW9##<@njc=h8ZW* z@8>GdT!Kn1RaL9})MJ3%Bwk}1piZ;V_SJxmi~o2XF7lvRxAN4PGSi2uw&|9 zHdBC4_+`9dS96@otHP^qcAQUGJMW6XGqp|E;!nk!QnF~dPnxqE7jdm_@vx~l?u8tg z;nI`G=dQM2x)E&<81Uu`Zs>O#NlE^W^>G zb&Rw{%M{zPfAuStNDVFP)}&{dmOHfcvUFs-@!{NUV>L6}UwJnf2d${l*3s(P-f^UC z*S+_xEyo7>E-LSP8X8L8IhW`hzB7c@pzdQcuX#Tv@ImHs{O9Z5mS-J{#`V`aEWnjN zyK3(AsZoAx3uV*|AlPm88*zoPukE9noQJmMSE8S;_j}obqdU?ze=xh)Ngd{4H!2dn zGVB=lvyB=n#GK<#8(K!nPt7)MOtn*2O8q3Qlva4=Q)2X)R6C_h>hvz9 zBL}x-xwo^QWK3FxrQPv4#)uYteztDQKyO4%YbE_;Y-rJn@)cuisGTL9s*UOOr`-W! z$7i_P`@30um>`KfUUSnIl9(Zw<)Lhnlk0cij!=)CVE@wNNN8O zhb`W5f8%&RzWbqcxgL8KLD5r_(vsm!5asoGYJcV-#Yx_2&5^9;TmnwJkAGp=%bw zrqd(lkjAyOeQwofnXS{w0~N1n&n}mqpE7`YWomiaCLiscVMBTJd8)J;}eo#^4uq}F6o1* z>z_ovpRz~nfh6daK+DM$vvhAU4|+L|)!kf8e*PhCz%u=kl1}<13%B(2=WfTv?cLl} z?LF|iIk|49J@Be_%bvhvE-AqSJV5u@lXx#~s&-hoKUosY|FMT3G;=WUf8Mtlj2xG0 zr^5{C`Sxz9r(lKYmy|VBFX`r{r{B(1*3Cr{=@qX>CFr|_r=zZk8Cuvm3M=!?i)sAw zhXUOb>Ou{36ijg2!^0G|;-jKN2oa`=TI!mL3iyZsLSRTlu&Lr24;wW@MVy(0$wDh< zOON#~)(VkfA<;1kE^91S+gU0os%dDLM@EL>H8iX|tQ1^U+qruvz#JMH)=r8Fiko6$ zB8@dPwr$&{?uUA*2N1$h$7l_gC_*GYDkgR{Or{0{)B|Gz72z=xv;VXlXblVrh%u88 zHwneZn!yxbCX0*$8&=)LFBoq_hzj?Es^cm`R7e~lBE~Pw4Igcyf%?O+FT?+Qd`w6{ zC_Y9#APVmnLx}o1M%*Mi2Br`G;c=S@{u6yoG``%E5dX_5Lt^mZ=2208vEL{4Lzl25 z82EKj|74DTy)85eEIQ20K-)xvUqSQ!<%*(X{9^DnVSd5iRs{V4i1=W9l$oZ9#@8x; zff`>|Xrl3T*(P7x{xv3?qeAe2hZvx$uMhjSlJ@W4= z^uH9^|EST$O}^?z1oE#M6V=hK{$dxX%NH|ZH9jT=50=MwtK#eUKiWE9bmX6@@$Xvb z4|-huo6?3Se$(VXw0>EsM+hO!bgjuYk`=dPoBlyjF2KqlW|G*Rf|Fhk{ z2__JO|H}#Ck30NFO5h_;P0R2nEKWR2F_;U&{`nmZ(i~;}@cAy@?oBgkr#{Wp# zf3>my1`+*z{c9rp{#VNAe`|;T$eRDx3F$wF6SV&Ck<$O(7XOhJ{;w0$k&%J_dqwp> z+uRT0dX2lne}V(^M|JbXRWf(60~Z;^w!d4^U;HdhO?7QBE5CbPf8|cb#76$$fqohD z$7k~OHPYP^zU~imfAhiq>KXsI)~_DoAD8-9v;4TyujBu5pe_ZL;g_>yaBk^Vu z{Ih}{{^7y2FS!{grv>F`2=LDs7W3~(@=Jb!+lft_K1}6bqFj&d{GM~aUCZ%j9pYb$ z^GhfMCPqo{uOR~j^?@uF^pW6agrFM#nL-Ryg;i(-1;L##C?WR*J_27_&YwE{two#P z?gi!7K6?BXweKzb)r`V5hDAbt$)A3NKBL=zdW-k}<{lq1Xle{HdL|%>vV1Upj?4r^ zARz}nf&vqF(Yn!IP>zf+fAl1N3xX8139g*@e!&6C7W#3ndFWEe4vGnApi4{uu!p_? z=QUUa7K4SrHw?pLftVVmg*u>KfB5{+_HF!6U4H2Bt^La)(RBndEDV0oW<~u8#E8IX zXaCIsaP?)JTR;qYG$J3&1Jahl2ob@kjy|jLK`}pGTf`8;C$9P1z}$j2eYwxS7L5)I z3BX6M4O<-zg9UzijtKuAG|zyq82`e~>W8_-f};q;$e)@-;gBgfBmy5n42LmOtliP< za5MmIa6Q$J7(=kdN8qF2tR)Z{Fb|L&Uph-nbVe=cj$L@L0{r}cy?-HMRG5eLIuCf% z7vLh`|J>pj9gP2}Mbs}W#=|f8r#8s|JdDF{kFkqh<>9#6pFjvh6HfZt{8MM~O@yd8 z^RSTMuaJ_O*gTxkO*F$uu|Rx~A2BQjs^VKAQvN61QG}D<{B!ro{=t@nFhbN1z@0uZ zzlH6$8!!ZOB19lqi^mWmory8g_#X*JA`A%WA6lpQ6Jmg%{-J#`pm@_CyYo4433?DV z1-=5fetvy|b5VZY1PP1q`=K6~xD^x#Z09Dv0&g*a6o!?eC1YKgoFG+fxg{Y{I~20ecD=R8p8ZxGV(BA6yp&%mv#7 z^%(d@^J|gNL;7FzP{1rPcT5LOIAOs}cu8XL_Zs|X@ZL$i_>$BX5v}fr{<4Cf)3)6& zQUj&EVUSK2>4y@^6#Vhv5113pv@%1l%)HDr@H6Z~MP3;INph2)4oE=vmoVn``PDS=nf5nVV}`8d>RRX<6A= zT54+B@D~IF?IIxaAtC_(ZJ@5^zZ&=rL&kik5H&K3(AXSwR6^n=);nDbqWI)K9{nEG&QBfEX@q?)%@FILPv{3ow z{`!A$|NHGV5h1`x+F!;wK_=x^yj4grJ{q;^=`YvTGk~ule2tcC>uWF9HqeDHv>ECy z*VEC2uNKsy-B4$_wvqmF9Zjv}I$DPCHCnEx4PPB?XoGnSG~o+<4bZh7+=s7@u8#H! zeQhl(11)ncT|+B93nMFCYYQWD8!dB9T|;dh9c^7bNE8MlHFgQvjt^UB6#^)o$RUOq zf4sH+PjB(j;SRDAzz)FU&-C*qb@UDOb(iaE8tH5DOIW^vmi9!c&2O0~b)a2OYod?t zax}_-U&2;l8_)tJwCEb@Y4S_hCe&e*Py$#CbO30mg#J((q0$g~LW!QG10X~tdWsH! z7L_m-O8!^?tS$gZhhM^Kp+3=Kz^@yk(g@ZGCBKi69@GuAQM&;k0ZKrXu9gwZiAprq z$PkS+GDLlh3{f8=BYqzvEq)!%X2hS($Ot{rh(D(hS_!bU{&GO)av*`_`pXTL8!k6m z4xrJ}TCSzNTuW!UmhN&uFLcr}SPrxRxU2^)2(J-)!*O0e?#DJ2>cC!zaj891pbD=|3wh^ z?w#W!z$M=XzBljvly%?&_`0|`+t{tP<_8j{tXdrsK@fz)%J7JoDC85YTkoYH_8bl? z#o^o(jwSs9q9YyMZP1At2L6HqavV_o%LjrHBh3?MZ4GLxToe?(-uO3vrw2qv!I_wV z3-s3s1Q#3Xd!Zh-EhZARqcbKM|4>vHLs6Ys4gh%>C#A4XQp3x|5#y>b^{N{<`FK_Hm ze_D!HSPN7Je~<>~jtC6lhmJxbesa5inBjjo?1vdpb%L&;BAvs?n2|JB?*yIamR96)VLmu-n-0*h8!a-tN+bwP0_t4vdcVV|c0c`<80W$$>fz<+T0_z2S5eOED5FiTd5ZEJdP#{&{w7_|R0)Y~Nn*zTJJQk=E zcqQ;opi^K_U|dj0aEhRepse6xK@CAeL32R|K@UN1!C=8Hf^mX-1&;`x6ucmKMX+4( zcflILCc(FYbU_BZ_+_%tETM%$YC?uWmO{=#UP3`aQ9_AA$wDWDE(l!}suZdgY7lxW z)Gah7EGjH5tRSo=yh3=D@EYL&;Vr_6!iR)U3+D@$3*Q%R5Pm1zFU%F0DzZRinTWB7 zoyaIT=@1za6&0N&sv>G2x=M7t=w{J)(LPWCc91!nY?rI$;rP>el+>rY7xo zRQELTX^W>>PV<|VI4yJ9&1uwWjOo(TwWd2y51)Q$`sL}5r+=OyHe>M&>luMF_RhFC zB=cORf98ytx--|#+&(jNCVA%D zSwgcG&$62pHY;UT>8$2iytKTTe29FyJVm}sVWxt)LYTryh5HKq3l}V0 zwUD^*{KDr8S&Ax(YZP}Y7Av+cl2~N8C}>goqPvUwmE@Him3Als-9JSu~cxW-qKA= zGndvb6I`ahEM(c)Wp&F%ATScK{K9go+7vZ&wXJG}YVXu%sjpVwtzMzttD&UfrEyH7 zT7#>prx~W1quHW0L(5)kw^pUrp!QPj0PRfeMxCiTHaaAo8#)8J%XIO&S-LOvr1c#2 zlJxHCar6!Jqx7%pcN!=e{9@Y+t!G%Tu|93xW}|2mY*T2{Z>wh;Z+mBz$SQ|b z$5v78WbFd%3hesr4eS%_?>kI!aCbQE@P75u)lsV}9R(fj9n&1&I4L_tI8``f&UVge z&aEygE?Zn~xr(~FxMsS3bklO%;a2S~?e6P-*?q*r!Xw3_WzCW`#5H%kY>@bZpeyxNl>#w~F_6@5es#e8POl zzEZw^zSn*c`Ni{>%fGPwoc%8NG5qcP&-nKTSO#PS&;wTnrU!Q7P4TJtPeGN7zoN zi(D4DFY@gc<1NRx^hY^F}hkpW}sDRBw$SK_;^W?M72j&57K?b`O~+atC= zja?dhAof$7P28n;;rM{~yE_){*tO$bf_cLEM1e&A#Ji+Lq`jn$owhskcS-CD-Sup@ z=I+$p!+SjURPL4CyK`^*ueQGy?vvaXwU3&#A}M>n@cxkf&kyJvICX%V9GLv%pyt67 z2ib=L4m~-nb@=3A-jSdq&r|eMvW|)#jX2tT40kL)b$V)CYI~YvT6wxcdUE>kao^*Q zPw1Y=&XC9;X1qPQ`s9sMN~eyVVxQi0x+&8lv-r${GY8Hv&jy~Y&sv#vHCs0OVD|XA zkaI83+nz7Ku=v7>i=r2|Ui_G|HmCZM;iZDydASF3Ie8IzZ}Z*q?-v*p6kJ|#Ii*mr zkXYDxW#g3>MOH;OuBu%&n;9lu4H*mkX4~ zmiJd|s(5>2{f)Yt_BZcVuBa@#rG6{#_QKnlfhHPWSJ@zqjA@ zz1wnc-Mz;9uJ@lmaCq?Oq0Pg4)#lZdN2ZT%KQ?}R^NHb;iW>cz@~3)F%bw{yD|@d0 zyu8++_QngN7nOA;byfAa`rjL@8Xh*WZ+*>b z+4)A~O>*nB))Q?D+Ah#kX~l2#-;&>1y?grJ{e4S&Ks)_I^apmw-j7p1p78$R3J=1&6^e*YW-nX*vY5%(Zj)ABF-r%93xkGuwy2E!FE{wL3 z@DUa>d35e*{+Qud^|9~8?Y3pt9RFj=mgh_Q7wbkz%>BJiPvxDv{_cU)3sxS{);0#V z0ZZ1b^_*9FxBmTL;GUSYz4(jQ?lrWl8U_s!ml_$bva<(cEqGJN=B?Yd$Hx7-FKPdQ zpV(S@X`0d4N-`}dXYTc&o`%mWIc=Yn~sFB_Jkk|vK3T{4r)j77**xqY%T=MD5 zl}}!GjsN`eGs+T*8BNYx+oUo&sS8I-6eDL35{sSAT;O2|Tv}U7_68n?b2`cqV@Zvs zB+{#EyE~s~j2`Q|$39D_%`>FP)8qT;K4*;{FnE~1DZQa6ODxZ@>LMqF?M!Bzzdzg| z)xcpg%!cD=Cp+skidi$66zAGvNm-+N@GhzV0+U9Mr}{8p&U_xWj&whsR@_2Okv#LB zP5M2FZA&J;yA$(CitWi=L`(4*hHiPIo$>>eIC`tmgIwA>u6+F)(r?UOGBsxz;WJKt z0GFLeZ`FTHs2zrPb#0(kG9A+rNzL+oiA*XvE<9t1hl%pAWERz@vy@N(Pvl|uSrudB z{qGtAX#+x>Zn$ISt?w|#!#1K@G#?Y=u#8Ub9oCEY#>{NTR4(adJZ-F!ndex_v1Rq$ zVHW#bH_VI~BZ*Z_X7rx9z$RTar56Jnp{=N|%{!jWF}ji%!AVYA&f;`5eda+l($atvycldA#Sg9LMwF#FWW4TlKBpSb$C zJTkHOQcn4}{D?3>p#tG4Tn*@{&%lgBtxQ_PA<@>wGkrQM*TPaNm=yKf#u3LU@9mhq zx3|ZN_4(LnR zPD;yt>`aBhQtD)GBA_xmet|Ky*s+vF9UFf&)OppncAPu1o!IAeE{*QXsAbj3F}M+B z9aLT&Wl~B5hd>w~t{ofSvX^7pR}|AZeVkO>=S`sIEpe9bqtFr)SjF<{XD;CS7SSlR zRI+i*eh!Y>&Pkz2-rh031uk9!hb0)@C$*7TMR745($QQ#*shkxMIEThhAE^H>E1?G zw3HF~0p~=PA;x5k!{R$(!v3wjkg2IOXfXnA0HUyVM2Ya@dX7cKt_OB#0+j$ zly)wS(+`O7&G6CYVJR#Evzk`PNc6Ui#nDI)V1-0cTOKCQs0D}$!1(+b6e{NvR{_v8 z)LFHbhi(4=ugQXbF)hmyNVKSgQDSc0spz|!=e!~&>CnZ4!4;psf=0f>NNAB2Jt z+JJB3Fg-#4Nn#ayN9Bvb&Qd_3Gzp+j)_=Oag4M@-gPvf@M<0vH=dd(6=h1kY*I;Kk zfW3{;**qQwlag7l-cge>T*Mj0SYaw9O0#EQp{q6 z5G&e%WuJFw-XvNl7ufSHi?*TjA;^~mjuZ%}&c;z17p`W?F}afD#+R_D={#&2sXdWF zy^(O%em$w0vx|rAhqY8bRR)$OT_Jt&Vfv7%hbZ)HMlCyqR!FV9TaU0I0Cb%|FDA~i zL!y{XXd7qBonHm7Z!8`yU{;YE#pRJOg^38qxs*P6ItZu<<|&6d)$3bbDjva_=F5B| zl{iMc3J4la>uU`gZqXqDDfrTEk5e5>vx_N!yC7i2+m#4i4>_#jxM8a5IDb}m2J>oJ zM?+voY{>HB{|)E)&b$F&?#_)lY9bYh?9CDvnQ`&U~1W z+7OYnOD6zb757ya$GQV-0R|Zua?Iw+EeCiQjg-{hR!MVjCoyS_ z@l3C-9Z?CmHe5B^lvTvgH^NnO^|*2%HBP3Ie&bf~&nlIxVdsy*CgeXVh4fy{xW$N-;Kt5oM=5CC*;1xTrL zk5#A~2BjVJMCG1E01L|dDn>Ja8F-z&W{(H_*8)uw%#i3M#FV>X@26T1?J%s%QwSrX>M4l_L<7 zI~xT_TS!z~pH)09!;8*qtXi_+SdZ;$M3lGVsT(?<%wosCqPH5}Zv(vZ<-kqg-w6UG zz;p8EWOstBW{_DORDdKm8|VT=P0{c>X^ttYqq#DBJ_i8#K8w>les%#!5s=Eo{X_t1 zJ88hRua!`6Km&wD3ECRdr_0TOiyK(P)q|N$gV&R2QneHhMmI6-8KUuDNH9%`6PbFO z)=n4^11<1!nBE!?LZX89C%rcX@$qX1fDGV3>}JeF)e#(sDtfDBEUw)WaF7t1?`DTY zIrm;$C3Dnx7bt-?n3dT)zH1rigDriug#hCPpDvWpw&l0Cxn@hOnZ?%&M}6usonAm&tLzqXq4OCl~XY$d58# z?`kjwmUbRN@QgfAl?5n|I$V|A-sk-VmY@`9>SqE7uxw`pPtYHS0W`q#;~=EEVhCIo zR5k#G0-b`Ca1Ti9tYs6}Qn1`yrEFjmP=t@j^dh3F5`w@YSgPAvU*^5G@z%H}AOfay zhY_#_fp7=2nY2NTxawk`JRj|Bcp)bMhDI?XJKfNwyvMaY01z~4dT1H zt&-|0=gXv}@h}-^bvl1L+n3Jm2aUs!0I@;xO{Dw2>zb14o6Q8fV?&p43WM2O2IR|! z`gj`$q!QWS5eNs^XRCod$OGmZ*xpzSZCSz>!JYu>3tiERV_q#I-DbU_Cm5`LBR>El zVGu+?373^Azm=Xq;x&`kWp?&(Pjx~Wjq(m^DvfVGHb#lkK9BLuUICH^?aXB1lLbfRZlo*A0^->O{ zk7CAYrZ=W%unF8nbnbny9qGnTaI{ELGqa0$^_n=yS_%-vO>ty)l`y**UF`9J42~bS zns$v^I7B~?!6vl;66up2xT74{EpgW+gdz~JLbOVnYnC&UmK{%T>RR-e%R(X}lx=Dm zt3m4IGPy!+lzYwNzbf%rsJ&~s zX?E2ZZ)by|ItDCIypatg2~<0eS^SC$`jt&;!?pWxSjNnaQ$V}*t8i`m65`1I6ShVo zeGLLU4vYbMtDx+?LEHsnu~}nBKKHclYQDZ({>E-u>dlgqZ?0OHu09_*s=8E?V4x!t zYHR0gvUl0Ea`$l0qMgnjG@5+>d8y?2Vtpm8YS#^z2|)#|shJAbdD!O$e~;t!b16q6 zHZOhF+UXx2UgbL6|8|-qao4><5mQsrGUtyWZ+MvLt?bJFTdd99YR5}QoF4uDNk3O8 z#0~%PvE`a$wPnJC!3Ltfs%u|`{Azr3icF*CLE3{v=i`MV#9U1BYA@ok2MH}HYglo6 zl(Kz6lURTz0Sg8UDW;1OsIM15U_49=>ImONIrZYUAs`%-K>K_a6$Fhl0oklO#v>1N=(X z*A7CT?>l;{BOj|s&;SnsM=|^QiR}o#B)|rh0>P3D4wLOU9?vjl%2yPGs8Bpd7`-`H zDD5N$w+eWQ-b_TS0Y+N_l1o7|V0ECb5)}ou{Uv)&bf5*{GrEY^(h*h_q3JMDs{WPOJ;>`J*FBt z^Y9$!$G-?HvT8`#fQ=Cy$Ur_4dR-f(X#Q0zVsg8?jXP}iWp3SSW|QIHlb&5)-UviB|7VGKmYJ#?I(j-zA9PIK754q$A_D% zidEAed1$WLoc!k3m}^ZMvi%viC`eVSAU(&YIf-D zyw6zs*0{y|0|@Rhvp1cTe#UfEN8C>owbKB_{0Wfq2(0RHa6ab)UI6tDD%7@ds!JfL zn2Z!$JP#`Z_4KJ>M+%bB)n6Nb11$zdwY{8;L??JP+Fe{LUF6_FcO|M^*vfXVWiOb*$r9v*E?Hsdln=6%_@a_dd0_y!2q| zb|Ivy>csk-Kc@7Dwts4^C#bVt$M4zVru)L|1>H`*blZ&P--{ooTs;_~Zaa5pk^5Yc zsN)_A^99Wn8OpY6v268%Y%=McZFEIjK%!oZpcQV*rivtD$Dxz};@X5=rJ+sJsSME- zzdjgMWmFv;Cx)Am-Dk&>bVz4>70m`y5}4e92R%<}m7>DhZs(_5)xTEvIBHAN@CqX> zy%qcRYu(tG!*~`c9&jn#;{F(;QlO)5N>GshQwNo%V$ErW~WSH?aWzaW@lM4YTsO-r=c(0J;j}yiwzQgQN)ez@k z5uWX4jSnNvJ~;AvtXpi;+Tik5QG$x?&S%XoAGU6k(Tm-oXS#ZO_i;y=nQ2PSx)F6b zS7mlS+e)h`EY31+VzwGnd|)$F^sqj7P&`l@YIxDvkUiJTV#n?xFZGPhwmaP`gIKYp z8{#9VjurX%sr6gg8{vJ^(INejzf!^dLl>w$Ua6gquI|$gJ+#_evSvVd z3w`CDdxd9s*j{>h)~B(#TvNX?60F2D9Mr6Se`hEeOjNC9vBAxWMOC|+O#oRG@@XmmTN7^_O25Y-7(rXD88gwDH zGpi1aE*}c7q0%s$Q&A?X1zut&xUqnevkOiDOt^A+)hc8n2n;Qyanf!1_+q3pKzai! z77?|S5V~{H#!2PK@4KHtE1*`UmXaW1MD^*isNo{q9=%4YlH*WI#u%sY@>B?sRWiM< zlI|Xvzjk;uk2~MDoWu$wJM3| z{%EAXwMJ`I;i5+CN@mQBjmj~REAQIcug}Z0^Ir1wQPFSWZ;k5|Vi{Y=vOG-uEp0CD z+GBQdpNV8+-e9$|Q+?MVN2kv<&i2h3!kPD?9_)SKM{nFHEvKP631{Qi`&#N$S0zWx zb7mdoN$A^bOU~gSjokbd^P^mhS7jF8xJ;^-w6VMJ;zc7#uy}X2e_fOL(%2bZ8?8-} zKfb%+tuG4*0=6`|T+)*j5z*C+@rW7NS<=wp=B^ZJF1&g9 z=>!}w4wMXrJ?dEO2X2(F`Y zAD=1Nrsl_xE(KnPz!hR@ad|+AOU-ySZV;D9e+ACn&HWH70L9KQHp0z@_#=y9+++-1 zSq}$TM&Ag6V_?%)aOHBVRoDlSgA@n;ZVH$SVEiH{(tU1l1;HZtE3?rWrb zuYw>0n6K-bk&{u$T+fg5Aab}yx_hu??a*i;cYZ)Qju~2FIzbLOzR5J17119Jf;b$t=0?Q=h(esg-PeJpPF2D9RPu?SvVi$02j&FR#>y1T;Hb;Vn zZ>&}~3&%Q-t>2@3H;&=P-BXwhgF6`g)OO12Mn;}R*nXYH!&B-adKzr?*{uUj56)K& zrfeao4xO4}QffCRDt)TMBtm^MCiwfx!LG^!pFhS@syXf8rzV6B(syNWNFyo`$clUT zMaKDSld2GpG~DCL$h@u!BA$Y1;&ZKTa0W}xA?=ACo`GI04eS z?Xqut$^p9b9b#F$L(h|OqSHl_EKs8RLFhZj)4joo{g4Q5^Z_MkO>qfK<$`YgRVfRe zvuC1Q#P=yc*&}H`H?0{5j=e1)!k5={k4|C?=0NCu&ou`o~;_?9|k+`4}G2rl8~jJeJbH3secahBoBL8&>%N_ z#@Q!PHez-M58FKQ>4Wj5IA#W?kVaWZy=?qGh%Xt}4byj>gg8zD;Yv^dP@FGeofltm zHD_n{pIh8Cq*CO}71Dnu&yrzfwm9EUIBR^ycn0a6X;Iz{t|cjDeGiG#g=cf_r5vW|g-FE|rEUCU~~`0ehR{}_P5543FPS^(5<1}yaz$l!0Gs1xVUo*#nQ`_R6mV)+Yk-%)n&j7tD zhFI)2{G%I5w6z1HWwi}qBgK4RgF?*9UP)l9j9q6x>b}Usc0RkE#C>3Pius|vnU=JH zG={s^Qr20?9%rVpO~=V`6tha8vHi@hEb`L_!PT475zFx|CA| zq<;d)*b)pauu<3t0f2696Bf{MfH4pO=r#k8aEPQyEf9&MFUdghQ4}_j2aX9918-q= z_=f8DeWT%58+ll3?!%FmF=EWn=V{!P&+=w_bW)-;6 zsT&MQ-LAd3aGcy3C$aPMxzeLx*snKj^aeUqeI0PzR#i#9)sR>44rhuMMqEOoknn(FY1Q|WU%>e?}@o4s(G;qEl zyyu{S0ze~t^L{?^+~QE+6v!CB2@$?(h~l*puGDCODM~A7u6lk=JPASo6p%AlE&ytZ zYl?|ig4s(<%Lq~j;UB;$zvaxswr0w=%JYdlWl%C4r+G^OXi~2a!U83hd8VC)jEZ;7 zZ@9kT&a|1{WEn?5AQq%cLNz7vIoUbGI~D~D34OF|AI{k=+AL-dot6y&gZ4gJh7VnO zHN>(K&Td}8X3h6}DtQACkZD5dcJ9>($4wuJ>5BYp&0TpoucB%t_+k1jI99{szAXc$ zA)}Jz$srH`+Bx#cks*7Dhxq}tQ(RB`b|R=hLr{MJGxNie#~@mcVm!ER1b0b05(<%^ zNLoDIhh%pCu?nm_yl-P1(%q7rkhv6IU71H3*Sg0UB=+wU!G2_Khahzg zcr!kc+A;*-?CJ7y;J^tSQ5o0=#5d4=D$xC5$~~l{j+XLc*vzVm#$Q3|P`&ad1Pv18 z{L>kq(gR4LGuI1K`gQ>>n@ef^=YpDUakWVMT6*L;uftNpA9pky`1C@B{cEOk4Y%5$ zg54g`FVB)5k8evmz%p*>r-xGvSe_Mep~bP3*Ax_uC$?*G`3{|s#whG5AyOM;mj1Ip?P&W_HtB7Z}(FP8W)N+Rh;|I5x z#%(fIE<(TLNk}ogV zJn56IRa#)Oed86Os?JgRp0%b~)IXfF`}xfx`*WR#pCyr#7_yu4^42ieFRLsW z29K}8KN5Dex|3YT>1>%EWA>_rG4%M*lXrBj_2Z^I?A6VsZ`5t}XxLh>nHsJ(%jdMs z(qd_)Cy%;wxm_P-s5Yg$#DtQLW`^`U=ir#VTdCaP`uOWRoV`fu$lNLW61*Mc7h&hIiH+eL5$&}f^1qPcPtv~u-=2|v1@4`&-l(6<0s-H z5Zr}RlMQsuG{m=M5F8x^yDkA)2gN{Xh15c-77PQ*^D6$-{kme?!$kA)H{I}0U2(3A zcz!X;W^t{G@;Yk$v)N*5Z*U`viBe=0=@H_mcg-)ekO*x-XLGk$#)Od=kHjGMl@{M= z<128l*YEdx@~Pw4R;F)HY!FEA*os_>tlW*^3qBgyUA+I|fupkbj$}(^CCO>0y!67g zFwdf0?yHG81j05k|7{h&1m7~azD;fG=hl|wOSy$t3!@Y+=6Xik?Z5A;%G$A3dj9;; z&H}$R$ue&zr#dK6O-)1$W99-d=xx9?UxOuuAGwy{dF8kI;7p_WH3<9D&fA<+E`!za ztO&Nf4KUUKQK>wyt`!atw#e^MA%jUfw}YF9(BlKlSxLNl;dT~G3Fe0{5(G&~kOh41fnhD>0d+vNP@>G zNTRC2l8?*V&}D(pPOW^7c%B{4h@-A=25Y_*zup-XV?~{74=&4h`nXT&#kmv~6<;5; zq(<3iGU1soc_=+PNsCjIxGeBRuS9qF+L9+8Io2Lsp&Es)cCPmszvYa@cqa`dj~Xy( zA)N6xK`A-Pd(vn6)`TO4zW3T$Qty2n21pfkq@MVzCG_TD0~^w1ZY0~e_jKhBg7>zn z`!#sF@{z10p%YgNUa?XZn&hpHY%lK}%4r#Uy7$VPkbPMe8jqNp_k1+2b2B_r5IV-x zFKQXcgMY&8`7h9`rlZEhHBDUsp|<@P`zFr(3D_)OeTzdT{X#xdtn z)>g8fZ?+d4o0>eYf7GjSG=bRU+T_3%x;oyqe5j7Ln$&i9=y=hNL*3_XGo6Hl4oyFy zcJBHvi>`1-qersFduDbW_ zj-aH0`7Y{a58~pA`TjsF0n8<6yKoyg7_@kl1C%{o8flG2U``-GK;ku(zHQ@l*m z=xpsx?U<8}l!_AKt1E3jRjwklbY>pPO)agey1Mqgp=!wa-~f&oIkMJAUcJm>>@mCc zmca*`V6774Y>m_OtRuOnb9Wo>z)U6Z zA63hnX4w_URqmNoHq^Kv!fe~Q166H`zZI%qa&eIEB{y9I>9@9Fu=clSKm~?9x5T*j zujVkfa~F*eZQnIFW%-U26B64pi^mI_FZn*dDCT=;EJbwfrAaw&IF6;IH(sqAHql+v za!|lk?zdHg4?L5io`n7G?Re#hgZ2(@!z5XcZI#5tZ!uD|SR@C^cXDl8L)Mg6F1yCE zDL?JCw!3G{GUbeq8d zu*LX>z|jdpLC*CO&>(Oe0#2f55pfmmc_BqR)4fPv%Ccwtae-2gL|4z_+aGq@9I@M) z+n9A=%l44{TP~;R-PruAnzPCrFL`Fs;adc?TS{;gXY z{K6rGq^w+KOnZ}j(gxR9uwUsdWIg9=mrroReWjyu3YDbkujY%**Y_-`3>Yoje$Ukk zU;L(J{*v_iUXyT{C055{H=4X04K47WcedUBeV{MD~il4%< zHYx9u?sFfH;a?8N8{l6&v#!__lv_Ys(RKN>`>t1`wrR|u{4-0A6dP=qXZ*rtQS9YQ z!e^)1>ehv+1{}G*N;V>?S$12QF{yZK(z@1k z3cTEAfBMuaQLh8uwQ4>6sz**O&P#CsZU-Ba=7k*EdmIvQI9e5S z%aJ7zU*tzRo8F|oBb%Pz+M#>k#Vz7n%fdTlGe%|yskz&dY>!_^o;LFFNd-GT$jQ|B zjFd-b?^)k(JpD~w(z!M(v@d(N(-M*gN?rX&nj2nul>YY1alQ53mlC&D8t#w$HUC0O z%tMC)1rO!@H*XyqW7rPLhJ|;?_oY;j%ud(V7RTBGlYrw?TYLH3SO`0RJpO}ZfGVYO zXnEYW(x__=8#2=54;@r4Vd`6iOli`46@GI;qu!i1|H-b46`q4z$5M7)iQAzk=1qw6-g<;WASB04I<50zX}$Ta{PJh3BQ9U`x_m=q zr`w$ws^=@K&Nn1Vjjd$Y)vS?-Nj z{?1`*;yFf7D7}u}sqx`h#TH`vP0~@*OX{DG%ucp_Mc(4zy6=I9Nn+kU%C?|ITrE#) zBjfd|`C3E2&R+jJ!Ev{gPLsnpajWSz>I$*^k*pN~#N5Wa-Q!^?{o6yUmh`iB=Vn!G z{;+iYY;V)4`dc;^tbduV^WhiQOPP&t_U!7sZ{{6Z(sT99Po`RdhpygDE z9(%OL{lyCxmxdb84c;3(m%7*2)zvrTq%}5q)GFp|xp3{)Z;*%v3%ltH5_t5T!9%Gd z@r2+{A^v+v5t~mjDSn?f;WvjrIJNuqlE~~W;xWhdKh8UH`tbF~N{;Re>TO~k_b|K! z8kSqi9jOJVlV30(k`+sN$zn+Pu*d07ageB06ma!6_+16m5~{Qo7lL#FeJ|;xjCD`> zqTVz>8%*dv+O?v@1XM-5h!VNrx389uL8cE&Y~4IC0cas~p|2q_l+D41EZ7zt=BFm*Y^Aq(phG*W_rrrzTrAu^FOh7x|><0rI& zY@`mv2KtyOiPb*r%7=)#MsUcfctxL^3I1AN1x(>T;YfCT&9Z7OX+O-!kMo^(47zUDU>d;{v>n19r(w^XS=+I(dJ;vNlVPmixJG*+lwb#4){O-no z*R;L6_xJri&+}%q9*5I+tgNU*+8dfPv$}!ro&DB}PFHpD^lg8?aerx5-Fahb*M-t= z?bnt1InGlVEoJWyRDN-;4yt~`S=9>Fk1WD^&Nq=g$LSKf%K-E%0~~~U`yy$Nyb%6i zK$w#0rEcRS9~c1gx1sj-KX<@MGYRk0>rEVr=i$zj@}r3o@x|?jk!i57*jVK09`MoyJjra{ZgT~ev{?DvWH9*IX=ZS*B)5T1@ZIRAK(B`P@vN4x6o7aAJGGW zGn!Yi6QCNkLFm|A5nF%I98C0TJi6tqSp?{6mrKzp|4IHtzYIa~*WI#fGxXb2MM$^K z%>&N9og^ZBEL179KJ8AN)NJWl5rEX^pEY0$=y5pjBQKkFO*5_gn(|D@f0GrEwJ!Kn(~+go609ppn=|aGqqmB^&I30fd=Ss zbC;GbK>BcDYxtv| zv;NmaP>~)n*-I0A9TNCTfZ*ec&`j11#}`wC5{;bp?ER8T7jk_$?6PdrD_FoX4GX{^ zWBmk03*m?yAQ4)RmteBJxjbP~JwSu^RnQ6MpiOt0Pkr$U8TutinAaT(p~X#nNZw>ri`d(+-^6bfGM8%MFk&6MbPc zqx^Y9M?0W24EW!m)*wLV*e>vnjt5y}`D=a+^@NHM-X&CusE0_hk>(Mj4@JBmono*O z;4xeF39YrdON;RVXBE5v#s#NTDw?SX&~ zl%`9|x_4k*g)3^f%Up!o#YT*j+O;%|FRomC;dJbWvn!`!8|$gxVr7li@AndxK{lr< zD_y4PL%V^{DVmUx&17dDZ}v*?+{|zrK`Z;^?EP7L7&>xk=k%iz-c8) z5Gh}I5t)S^&d9bzC=hF=uJF6TYnwnO_oO`n2z*rp8I$samCoNdM&|IdY(`->tR@{a zEIsg@K)gljVN$9ho;EMS>&j4<*3*0#g@y(cE#3(Io5Or?{NfNUA)Fpjtbx(Wl!`X= zZ=>&k49DGzu%}}cYpAE2EO2BWd^O4pu*AF*g*2PnGl*0$_n{!GL!NiLM;=ZZ$^WwO z)7w%`C^W16Zb#3KBLIjs`77 zn`@Ih%)d^X90X8vfF^5y`?Vx;=Fkx$Fb1f^pMx`M3+U8HTuMrZ(2_9{_X;H?6G5uR zeDSGitdfrdcy7e1V`Xbryk;aZT3^z!eIphT%*P!>gT3QM{K z02w{PEL_OGlM@9f+GA}sPv!>|{&IjNSUGp$p%?f;TWXF=m#?%US5|>&cHgKr5_t63 z(FHy~)3+Z=9jE)2WIxPYYD~@Hes;@c>cBn(91g+6$vB<)i7 zBeWc9S{XaFE0;IIYWWDOB1MBl`pkjM3?te&0Sp8Iy;CykP*x}q(Y(WBPCnNZHf69 zH{sf!b9rR4g4ACK{hD!U#v+bQ;nq~{!e_}U!&#QW4Bill&nrpah4JbEj5Jh*@*gB? z-$mB;`&#aC;Xok1szH4dd*f3|^n%P;@WDPD+G=fT=We={-8{HfT%JV_{g51?!XRgA Smflx@!)VzWNc^LGuJ?Z|N+v1* literal 0 HcmV?d00001