123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439 |
- "use strict";
- const inquirer = require('inquirer');
- const bent = require('bent');
- const fs = require('fs');
- const util = require('util');
- const stream = require('stream');
- const pipeline = util.promisify(stream.pipeline);
- const path = require('path');
- const async = require('async');
- const chalk = require('chalk');
- const ora = require('ora');
- const spinner = ora({spinner: { interval: 100, frames: ['.', 'o', 'O', '@', '*'] }}); // global spinner
- const MAX_RETRY = 10;
- const RETRY_INTERVAL = 1000;
- const NETWORK_ERRORS = ['ENOTFOUND', 'ECONNRESET', 'ETIMEDOUT', 'ISC503', 'MAXQUERIES'] // ISC503 = Incorrect status code 503
- /*******************************************
- ************* Requests *******************
- *******************************************/
- const retryRequest = async (retryCount, func, params=[]) => {
- if (retryCount==MAX_RETRY && spinner.text.indexOf('Error')==-1)
- spinner.warn('Aaaah! Network probleeems!');
- return await new Promise(resolve => {
- setTimeout(() => resolve(func(...params, retryCount-1)), 2000);
- });
- }
- const getSpotifyToken = async (retry=MAX_RETRY) => {
- // The user-agent header is required. Otherwise, the request returns a 400 status code
- const getJson = bent('GET', 'json', { 'User-Agent': 'Mozilla/5.0', }, 200);
- let json = null;
- try{
- json = await getJson('https://open.spotify.com/get_access_token');
- }
- catch(err){
- if (NETWORK_ERRORS.includes(err.code) && retry) return await retryRequest(retry, getSpotifyToken);
- else throw err;
- }
- return json['accessToken'];
- }
- const getPlaylistFromURL = async (url, retry=MAX_RETRY) => {
- // Retrieve information necessary to make the request
- let token = await getSpotifyToken();
- let [_, type, id] = url.match(/((?:album)|(?:playlist))\/((?:\d|[a-z]|[A-Z])+)(?:\?si=)?/);
- let isAlbum = type==="album";
- // Make request to spotify API
- const requestSpotify = bent('GET', 'json', 200, { 'Authorization': `Bearer ${token}` });
- let response = null;
- try{
- response = await requestSpotify(`https://api.spotify.com/v1/${type}s/${id}`); // to /albums or /playlists
- }
- catch(err){
- if (NETWORK_ERRORS.includes(err.code) && retry) return await retryRequest(retry, getPlaylistFromURL, [plUrl]);
- else throw err;
- }
- let playlist = {};
- // Parse response
- playlist['name'] = response['name'];
- playlist['totalSongs'] = response['tracks']['total'];
- playlist['owner'] = (isAlbum?
- response['artists'][0]['name'] :
- response['owner']['display_name']
- );
- response = response['tracks'];
- playlist['songs'] = response['items'].map(song => ({
- 'artist': (isAlbum?
- song['artists'][0]['name'] :
- song['track']['artists'][0]['name']
- ),
- 'title': (isAlbum?
- song['name'] :
- song['track']['name']
- )
- }));
- // The Spotify API has a limit for 100 tracks per request
- // if the playlist has more than 100 tracks, the response
- // contains a 'next' url for the next 100 tracks
- let counter = 0;
- while (response['next']){
- counter += 100;
- spinner.text = `Loading playlist details. ${counter} songs processed`;
- let nextPage = response['next'];
- try {
- response = await requestSpotify(nextPage)
- }
- catch(err){
- if (NETWORK_ERRORS.includes(err.code) && retry) return await retryRequest(retry, getPlaylistFromURL, [plUrl]);
- else throw err;
- }
- // Parse response
- playlist['songs'] = playlist['songs'].concat(response['items'].map(song => ({
- 'artist': song['track']['artists'][0]['name'],
- 'title': song['track']['name']
- })));
- }
- return playlist;
- }
- const getDeezerSongUrl = async (song, retry=MAX_RETRY) => {
- /*
- @return {String} URL of song in Deezer
- {null} If song couldn't be found
- */
- const deezerSearch = bent('https://api.deezer.com/search?q=', 'GET', 'json', 200);
- let bestTitle = song.title.replace(/(feat\.? )|(ft\.? )|(with )|(con )/, '');
- let encodedSong = encodeURIComponent(`${song.artist} ${bestTitle}`);
- let results = null;
- try {
- results = await deezerSearch(encodedSong);
- if ('error' in results) throw {...results['error'], code:'MAXQUERIES'};
- }
- catch(err){
- if (err.statusCode) err['code'] = `ISC${err.statusCode}`;
- if (NETWORK_ERRORS.includes(err.code) && retry) return await retryRequest(retry, getDeezerSongUrl, [song]);
- else throw err;
- }
- // Parse response
- results = results['data'];
- if (!results.length)
- return null;
- return results[0]['link'];
- }
- const downloadDeezerSong = async (song, directory, quality, retry=MAX_RETRY) => {
- /*
- @return {String} Path to downloaded song
- */
- // Get redirected URL
- // The dzloader API receives the deezer URL and redirects to the file URL
- const requestDzloader = bent('https://dz.loaderapp.info', 'GET', 200, 302);
- let redirectedRes = null;
- try{
- redirectedRes = await requestDzloader(`/deezer/${quality}/${song.deezerUrl}`);
- }
- catch(err){
- if (err.statusCode) err['code'] = `ISC${err.statusCode}`;
- if (NETWORK_ERRORS.includes(err.code) && retry)
- return await retryRequest(retry, downloadDeezerSong, [song, directory, quality]);
- else throw err;
- }
- let downloadEndpoint = redirectedRes.headers['location'];
- // Download file
- let bytes = null;
- try{
- bytes = await requestDzloader(downloadEndpoint);
- }
- catch(err){
- if (err.statusCode) err['code'] = `ISC${err.statusCode}`;
- if (NETWORK_ERRORS.includes(err.code) && retry)
- return await retryRequest(retry, downloadDeezerSong, [song, directory, quality]);
- else throw err;
- }
- // Create file
- let extension = quality==1411? '.flac' : '.mp3';
- let filepath = path.join(directory, song.displayName.replace(/[/\\?%*:|"<>]/, '') + extension);
- const file = fs.createWriteStream(filepath);
- await pipeline(bytes, file).catch(err => { throw err; });
- return file.path;
- }
- const downloadSpotifySong = async (song, directory, quality) => {
- /*
- @return {String} Path to downloaded song
- */
- let deezerUrl = await getDeezerSongUrl(song).catch(async err => { throw err; });
- if (!deezerUrl) throw {'code': 'NFDEEZER'};
- song['deezerUrl'] = deezerUrl;
- return await downloadDeezerSong(song, directory, quality).catch(async err => {throw err;});
- }
- /*******************************************
- ************* Interface *******************
- *******************************************/
- const displaySptDl = () => {
- process.stdout.write(chalk.green(' ██████ ██ ███████ ██████ ████████\n'));
- process.stdout.write(chalk.green(' ██ ██ ██ ██ ██ ██ ██ \n'));
- process.stdout.write(chalk.green(' ██ ██ ██ █████ ███████ ██████ ██ \n'));
- process.stdout.write(chalk.green(' ██ ██ ██ ██ ██ ██ \n'));
- process.stdout.write(chalk.green(' ██████ ███████ ███████ ██ ██ v1.1\n\n'));
-
- }
- const formatTitle = (title) => {
- return chalk.bold(title.toUpperCase());
- }
- const fillStringTemplate = (template, values) => template.replace(/%(.*?)%/g, (x,g)=> values[g]);
- const songToString = (song, firstArtist=true) => {
- if (firstArtist) return (song.artist + ' - ' + song.title);
- return (song.title + ' - ' + song.artist);
- }
- const displayQuestions = async () => {
- let questions = [
- {
- name: 'playlistUrl',
- prefix: '♪',
- message: 'Spotify playlist or album url: ',
- validate: (input) => {
- let regExVal = /^(https:\/\/)?open\.spotify\.com\/((playlist)|(album))\/(\d|[a-z]|[A-Z])+(\?si=.*)?$/;
- if (!regExVal.test(input))
- return 'You sure this is a Spotify URL?';
- return true;
- }
- },
- {
- type: 'list',
- name: 'quality',
- prefix: '♪',
- message: 'Select mp3 quality',
- choices: [
- {'name': '128 kpps (mp3)', 'value': 128},
- {'name': '320 kpps (mp3)', 'value' : 320},
- {'name': '1411 kpps (flac)', 'value' : 1411}
- ]
- },
- {
- name: 'directory',
- prefix: '♪',
- message: 'Where do you want to download your songs?',
- validate: input => {
- if (!fs.existsSync(input))
- return 'Eeeh... this path doesn\'t exist';
- return true;
- }
- },
- {
- type: 'list',
- prefix: '♪',
- name: 'displayNameTemplate',
- message: `How do you prefer to name your songs?
- For example: The Beatles - Hey Jude (%artist% - %title%)
- Hey Jude - The Beatles (%title% - %artist%)\n`,
- choices: [
- '%artist% - %title%',
- '%title% - %artist%'
- ]
- },
- {
- prefix: '⚡',
- name: 'parallelDownloads',
- message: 'Number of parallel downloads (Leave empty if you don\'t know what\'s this): ',
- validate: input => {
- return /^[1-9]|10$/.test(input)? true : 'Noo! Just enter a value between 1 and 10';
- },
- default: 6
- }
- ];
- let answers = await inquirer.prompt(questions);
- // Add 'https:// to spotify url if missing'
- if (!/^https:\/\//.test(answers.playlistUrl))
- answers.playlistUrl = 'https://'+answers.playlistUrl;
- return answers;
- }
- const displayPlaylistInfo = async playlist => {
- /* @param playlist {Object}
- * .name {String} Playlist/Album name
- * .owner {String} Owner name
- * .totalSongs {Number} Number of songs in playlist
- * .songs {Object}
- * .artist {String} Name of song artist
- * .title {String} Song title
- * .displayName {String} Song name to display
- */
- process.stdout.write(formatTitle('Album/Playlist Details\n'));
- process.stdout.write(chalk.bold('Name: ') + playlist.name + '\n');
- process.stdout.write(chalk.bold('Owner: ') + playlist.owner + '\n');
- process.stdout.write(chalk.bold('Total songs: ') + playlist.totalSongs + '\n');
- let answers = await inquirer.prompt([
- {
- type: 'checkbox',
- prefix: '♪',
- name: 'songsToDl',
- message: 'Uncheck any song you DON\'T want to download',
- pageSize: process.stdout.rows - 7, // 5 = 3 process.stdout.write before + inquire logs + 2 extra space
- choices: playlist.songs.map(song => (
- {
- 'name': song.displayName,
- 'value': song,
- 'checked': true
- })
- )
- }
- ]);
- return answers['songsToDl'];
- }
- const displayFinalReport = (failedDownloads, totalSongs, directory) => {
- clearConsole();
- console.log('All done!\n');
- let nfailed = failedDownloads.length;
- let nsuccessful = totalSongs-failedDownloads.length;
- process.stdout.write(formatTitle('Final report\n'));
- process.stdout.write(`${nsuccessful} successful downloads.\nCouldn't download ${nfailed} songs\n`);
- if (!failedDownloads.length) return;
- let buff = '';
- for (let fail of failedDownloads){
- let line = `[${fail.error}] ${fail.song}\n`;
- buff += line;
- process.stdout.write(line);
- }
- // Display error summary
- let errors = failedDownloads.map(fail => fail.error);
- errors = errors.reduce((acum,cur) => Object.assign(acum,{[cur]: (acum[cur] | 0)+1}),{});
- buff += '\n';
- process.stdout.write(chalk.bold('\nError summary\n'));
- if ('NFDEEZER' in errors)
- process.stdout.write(`[NFDEEZER] Songs not found in Deezer: ${errors['NFDEEZER']}\n`);
- for (let error in errors){
- let line = '';
- if (error!=='NFDEEZER')
- line = `[${error}]: ${errors[error]}\n`;
- else
- buff += `[NFDEEZER]: ${errors['NFDEEZER']}\n`;
- buff+=line;
- process.stdout.write(line);
- }
- fs.writeFileSync(path.join(directory, 'failed-songs.txt'), buff);
- process.stdout.write(chalk.bold(`This report has been save in failed-songs.txt in ${directory}\n`))
- }
- const clearConsole = () => { console.clear(); }
- /*******************************************
- *********** Control App Flow **************
- *******************************************/
- (async () => {
- clearConsole();
- displaySptDl();
- /* Get input from user */
- let {playlistUrl, quality, directory, displayNameTemplate, parallelDownloads} = await displayQuestions();
- clearConsole();
- /* Get information about the Spotify playlist with the Spotify API.
- This might take a few seconds */
- spinner.start('Loading playlist details');
- let playlist = await getPlaylistFromURL(playlistUrl);
- spinner.stop();
- /* Add display name attribute to songs */
- playlist.songs.forEach(song => {
- song['displayName'] = fillStringTemplate(displayNameTemplate, song);
- });
- /* Show playlist information and select sorted songs to download */
- playlist.songs.sort( (a,b) => {
- return a.displayName.toLowerCase() < b.displayName.toLowerCase()? -1 : a.displayName > b.displayName? 1 : 0;
- })
- let songsToDownload = await displayPlaylistInfo(playlist);
- clearConsole();
- /* Download songs */
- process.stdout.write(formatTitle('Downloading songs\n'));
- directory = path.join(directory, 'downloaded-songs');
- if (!fs.existsSync(directory)){
- fs.mkdirSync(directory);
- }
-
- // Download songs
- let finished = 0;
- let failedDownloads = [];
- await async.eachOfLimit(songsToDownload, parallelDownloads, async (song, songIndex) => {
- let errorCode = null;
- let filename = await downloadSpotifySong(song, directory, quality)
- .catch(async err => {
- errorCode = err.code? err.code : err.message;
- });
- finished+=1;
- if (!errorCode)
- process.stdout.write(`${chalk.green('√')} Finished ${song.displayName}`);
- else{
- process.stdout.write(chalk.red(`× Error [${errorCode}] Couldn\'t download ${song.displayName}`));
- failedDownloads.push({ 'song': song.displayName, 'error': errorCode });
- }
- process.stdout.write('');
- process.stdout.write(chalk.blue(` [${finished}/${playlist.totalSongs}` +
- ` | ${parseInt(finished/playlist.totalSongs*100)}%` +
- ` | ${failedDownloads.length} errors]\n`));
- })
- .catch(err => { if (err) throw err; });
- displayFinalReport(failedDownloads, playlist.totalSongs, directory);
- process.stdout.write('You can close this now. Press any key to exit.\n');
- process.stdin.setRawMode(true);
- process.stdin.resume();
- process.stdin.on('data', process.exit.bind(process, 0));
- })();
|