forked from tastytea/userscripts
2396 lines
66 KiB
JavaScript
2396 lines
66 KiB
JavaScript
const ytdl = require('ytdl-core');
|
|
const ffmpeg = require('fluent-ffmpeg');
|
|
const path = require('path');
|
|
const fs = require('fs-extra');
|
|
const EventEmitter = require('events');
|
|
const request = require('request-promise');
|
|
const requestNoPromise = require('request');
|
|
const _ = require('lodash');
|
|
const acoustid = require('acoustid');
|
|
const EyeD3 = require('eyed3');
|
|
let eyed3 = new EyeD3({ eyed3_path: 'eyeD3' });
|
|
eyed3.metaHook = (m) => m;
|
|
const levenshtein = require('fast-levenshtein');
|
|
const randomstring = require('randomstring');
|
|
const cheerio = require('cheerio');
|
|
const Promise = require('bluebird');
|
|
const sharp = require('sharp');
|
|
const smartcrop = require('smartcrop-sharp');
|
|
const ytsr = require('ytsr');
|
|
const ytpl = require('ytpl');
|
|
const lcs = require('longest-common-substring');
|
|
|
|
// API keys
|
|
const API_ACOUSTID = 'lm59lNN597';
|
|
const API_SOUNDCLOUD = 'dba290d84e6ca924414c91ac12fc3c8f';
|
|
const API_SPOTIFY = 'ODNiZjMzMmQ4MDI1NGNlNzhkNjNkOWM2ZWM2N2M5ZTU6Mzg4OTIxY2M0ZjEyNGEwYWFjM2NiMzIzYTNiZGVlYmU=';
|
|
|
|
const at3 = {};
|
|
|
|
// ISO 3166-1 alpha-2 country code of the user (ex: US, FR)
|
|
at3.regionCode;
|
|
|
|
// ISO 639-1 two-letter language code of the user (ex: en, fr)
|
|
at3.relevanceLanguage;
|
|
|
|
// Folder for temporary files
|
|
at3.tempFolder = null;
|
|
|
|
// Fix for renameSync failing on Windows when cropping cover images
|
|
// See https://github.com/lovell/sharp/issues/415
|
|
sharp.cache(false);
|
|
|
|
at3.configEyeD3 = (eyeD3Path, eyeD3PathPythonPath, metaHook) => {
|
|
process.env.PYTHONPATH = eyeD3PathPythonPath;
|
|
eyed3 = new EyeD3({ eyed3_path: eyeD3Path });
|
|
if (!metaHook) {
|
|
metaHook = (m) => m;
|
|
}
|
|
eyed3.metaHook = metaHook;
|
|
};
|
|
|
|
at3.FPCALC_PATH = 'fpcalc';
|
|
at3.setFpcalcPath = (fpcalcPath) => {
|
|
at3.FPCALC_PATH = fpcalcPath;
|
|
};
|
|
|
|
at3.setFfmpegPaths = (ffmpegPath, ffprobePath) => {
|
|
if (ffmpegPath) {
|
|
at3.FFMPEG_PATH = ffmpegPath;
|
|
}
|
|
if (ffprobePath) {
|
|
at3.FFPROBE_PATH = ffprobePath;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Find lyrics for a song
|
|
* @param title string
|
|
* @param artistName string
|
|
* @return Promise
|
|
*/
|
|
at3.findLyrics = (title, artistName) => {
|
|
let promises = [];
|
|
|
|
const textln = (html) => {
|
|
html.find('br').replaceWith('\n');
|
|
html.find('script').replaceWith('');
|
|
html.find('#video-musictory').replaceWith('');
|
|
html.find('strong').replaceWith('');
|
|
html = _.trim(html.text());
|
|
html = html.replace(/\r\n\n/g, '\n');
|
|
html = html.replace(/\t/g, '');
|
|
html = html.replace(/\n\r\n/g, '\n');
|
|
html = html.replace(/ +/g, ' ');
|
|
html = html.replace(/\n /g, '\n');
|
|
return html;
|
|
};
|
|
|
|
const lyricsUrl = (title) => {
|
|
return _.kebabCase(_.trim(_.toLower(_.deburr(title))));
|
|
};
|
|
const lyricsManiaUrl = (title) => {
|
|
return _.snakeCase(_.trim(_.toLower(_.deburr(title))));
|
|
};
|
|
const lyricsManiaUrlAlt = (title) => {
|
|
title = _.trim(_.toLower(title));
|
|
title = title.replace("'", '');
|
|
title = title.replace(' ', '_');
|
|
title = title.replace(/_+/g, '_');
|
|
return title;
|
|
};
|
|
|
|
const reqWikia = request({
|
|
uri: 'http://lyrics.wikia.com/wiki/' + encodeURIComponent(artistName) + ':' + encodeURIComponent(title),
|
|
transform: (body) => {
|
|
return cheerio.load(body);
|
|
},
|
|
}).then(($) => {
|
|
return textln($('.lyricbox'));
|
|
});
|
|
|
|
const reqParolesNet = request({
|
|
uri: 'http://www.paroles.net/' + lyricsUrl(artistName) + '/paroles-' + lyricsUrl(title),
|
|
transform: (body) => {
|
|
return cheerio.load(body);
|
|
},
|
|
}).then(($) => {
|
|
if ($('.song-text').length === 0) {
|
|
return Promise.reject();
|
|
}
|
|
return textln($('.song-text'));
|
|
});
|
|
|
|
const reqLyricsMania1 = request({
|
|
uri: 'http://www.lyricsmania.com/' + lyricsManiaUrl(title) + '_lyrics_' + lyricsManiaUrl(artistName) + '.html',
|
|
transform: (body) => {
|
|
return cheerio.load(body);
|
|
},
|
|
}).then(($) => {
|
|
if ($('.lyrics-body').length === 0) {
|
|
return Promise.reject();
|
|
}
|
|
return textln($('.lyrics-body'));
|
|
});
|
|
|
|
const reqLyricsMania2 = request({
|
|
uri: 'http://www.lyricsmania.com/' + lyricsManiaUrl(title) + '_' + lyricsManiaUrl(artistName) + '.html',
|
|
transform: (body) => {
|
|
return cheerio.load(body);
|
|
},
|
|
}).then(($) => {
|
|
if ($('.lyrics-body').length === 0) {
|
|
return Promise.reject();
|
|
}
|
|
return textln($('.lyrics-body'));
|
|
});
|
|
|
|
const reqLyricsMania3 = request({
|
|
uri:
|
|
'http://www.lyricsmania.com/' +
|
|
lyricsManiaUrlAlt(title) +
|
|
'_lyrics_' +
|
|
encodeURIComponent(lyricsManiaUrlAlt(artistName)) +
|
|
'.html',
|
|
transform: (body) => {
|
|
return cheerio.load(body);
|
|
},
|
|
}).then(($) => {
|
|
if ($('.lyrics-body').length === 0) {
|
|
return Promise.reject();
|
|
}
|
|
return textln($('.lyrics-body'));
|
|
});
|
|
|
|
const reqSweetLyrics = request({
|
|
method: 'POST',
|
|
uri: 'http://www.sweetslyrics.com/search.php',
|
|
form: {
|
|
search: 'title',
|
|
searchtext: title,
|
|
},
|
|
transform: (body) => {
|
|
return cheerio.load(body);
|
|
},
|
|
})
|
|
.then(($) => {
|
|
let closestLink,
|
|
closestScore = -1;
|
|
_.forEach($('.search_results_row_color'), (e) => {
|
|
let artist = $(e)
|
|
.text()
|
|
.replace(/ - .+$/, '');
|
|
let currentScore = levenshtein.get(artistName, artist);
|
|
if (closestScore === -1 || currentScore < closestScore) {
|
|
closestScore = currentScore;
|
|
closestLink = $(e).find('a').last().attr('href');
|
|
}
|
|
});
|
|
if (!closestLink) {
|
|
return Promise.reject();
|
|
}
|
|
return request({
|
|
uri: 'http://www.sweetslyrics.com/' + closestLink,
|
|
transform: (body) => {
|
|
return cheerio.load(body);
|
|
},
|
|
});
|
|
})
|
|
.then(($) => {
|
|
return textln($('.lyric_full_text'));
|
|
});
|
|
|
|
if (/\(.*\)/.test(title) || /\[.*\]/.test(title)) {
|
|
promises.push(at3.findLyrics(title.replace(/\(.*\)/g, '').replace(/\[.*\]/g, ''), artistName));
|
|
}
|
|
|
|
promises.push(reqWikia);
|
|
promises.push(reqParolesNet);
|
|
promises.push(reqLyricsMania1);
|
|
promises.push(reqLyricsMania2);
|
|
promises.push(reqLyricsMania3);
|
|
promises.push(reqSweetLyrics);
|
|
|
|
return Promise.any(promises).then((lyrics) => {
|
|
return lyrics;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Returns true if the query corresponds
|
|
* to an URL, else false
|
|
* @param query string
|
|
* @return boolean
|
|
*/
|
|
at3.isURL = (query) => {
|
|
return /^http(s?):\/\//.test(query);
|
|
};
|
|
|
|
/**
|
|
* Get a fresh access token from Spotify API
|
|
* @return {Promise}
|
|
*/
|
|
at3.spotifyToken = () => {
|
|
return request
|
|
.post({
|
|
uri: 'https://accounts.spotify.com/api/token',
|
|
headers: {
|
|
Authorization: 'Basic ' + API_SPOTIFY,
|
|
},
|
|
form: {
|
|
grant_type: 'client_credentials',
|
|
},
|
|
json: true,
|
|
})
|
|
.then((r) => {
|
|
return r.access_token;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Perform a GET request to the Spotify API `url` endpoint
|
|
* @param {String} url URL of Spotify API endpoint to get
|
|
* @return {Promise} The request
|
|
*/
|
|
at3.requestSpotify = (url) => {
|
|
return at3.spotifyToken().then((token) => {
|
|
return request({
|
|
uri: url,
|
|
json: true,
|
|
headers: {
|
|
Authorization: 'Bearer ' + token,
|
|
},
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Download a single video
|
|
* @param url
|
|
* @param outputFile
|
|
* @return Event
|
|
*/
|
|
at3.downloadWithYoutubeDl = (url, outputFile) => {
|
|
const download = ytdl(url, { quality: 'highestaudio' });
|
|
download.pipe(fs.createWriteStream(outputFile));
|
|
const downloadEmitter = new EventEmitter();
|
|
let aborted = false;
|
|
|
|
const onProgress = (_chunk, nbDownloaded, nbTotal) => {
|
|
const percent = ((nbDownloaded / nbTotal) * 100).toFixed(2);
|
|
downloadEmitter.emit('download-progress', {
|
|
progress: percent,
|
|
});
|
|
};
|
|
|
|
download.on('progress', onProgress);
|
|
|
|
download.once('end', () => {
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
download.removeListener('progress', onProgress);
|
|
downloadEmitter.emit('download-end');
|
|
});
|
|
|
|
download.once('error', (error) => {
|
|
download.removeListener('progress', onProgress);
|
|
downloadEmitter.emit('error', new Error(error));
|
|
});
|
|
|
|
const abort = () => {
|
|
aborted = true;
|
|
download.abort();
|
|
if (fs.existsSync(outputFile)) {
|
|
fs.unlinkSync(outputFile);
|
|
}
|
|
};
|
|
|
|
downloadEmitter.once('abort', abort);
|
|
|
|
return downloadEmitter;
|
|
};
|
|
|
|
/**
|
|
* Convert a outputFile in MP3
|
|
* @param inputFile
|
|
* @param outputFile
|
|
* @param bitrate string
|
|
* @return Event
|
|
*/
|
|
at3.convertInMP3 = (inputFile, outputFile, bitrate) => {
|
|
const convertEmitter = new EventEmitter();
|
|
let aborted = false;
|
|
let started = false;
|
|
|
|
let convert = ffmpeg(inputFile);
|
|
if (at3.FFMPEG_PATH) {
|
|
convert.setFfmpegPath(at3.FFMPEG_PATH);
|
|
}
|
|
if (at3.FFPROBE_PATH) {
|
|
convert.setFfprobePath(at3.FFPROBE_PATH);
|
|
}
|
|
|
|
const onProgress = (progress) => {
|
|
convertEmitter.emit('convert-progress', {
|
|
progress: progress.percent,
|
|
});
|
|
};
|
|
|
|
convert
|
|
.audioBitrate(bitrate)
|
|
.audioCodec('libmp3lame')
|
|
.once('codecData', (_data) => {
|
|
convertEmitter.emit('convert-start');
|
|
})
|
|
.on('progress', onProgress)
|
|
.once('end', () => {
|
|
convert.removeListener('progress', onProgress);
|
|
fs.unlinkSync(inputFile);
|
|
convertEmitter.emit('convert-end');
|
|
})
|
|
.once('error', (e) => {
|
|
convert.removeListener('progress', onProgress);
|
|
if (!aborted) {
|
|
convertEmitter.emit('error', e);
|
|
} else {
|
|
if (fs.existsSync(inputFile)) {
|
|
fs.unlink(inputFile, () => {});
|
|
}
|
|
if (fs.existsSync(outputFile)) {
|
|
fs.unlink(outputFile, () => {});
|
|
}
|
|
}
|
|
})
|
|
.once('start', () => {
|
|
started = true;
|
|
if (aborted) {
|
|
abort();
|
|
}
|
|
})
|
|
.save(outputFile);
|
|
|
|
const abort = () => {
|
|
aborted = true;
|
|
if (started) {
|
|
convert.kill();
|
|
}
|
|
};
|
|
|
|
convertEmitter.once('abort', abort);
|
|
|
|
return convertEmitter;
|
|
};
|
|
|
|
/**
|
|
* Get infos about an online video
|
|
* @param url
|
|
* @return Promise
|
|
*/
|
|
at3.getInfosWithYoutubeDl = (url) => {
|
|
return ytdl.getBasicInfo(url).then((infos) => {
|
|
const thumbnails = infos.player_response.videoDetails.thumbnail.thumbnails;
|
|
|
|
return {
|
|
title: infos.videoDetails.title,
|
|
author: infos.videoDetails.author.name,
|
|
picture: thumbnails[thumbnails.length - 1].url,
|
|
};
|
|
});
|
|
|
|
// youtubedl.getInfo(url, ['--no-check-certificate'], (err, infos) => {
|
|
// if (err || infos === undefined) {
|
|
// reject();
|
|
// } else {
|
|
// resolve({
|
|
// title: infos.title,
|
|
// author: infos.uploader,
|
|
// picture: infos.thumbnail,
|
|
// });
|
|
// }
|
|
// });
|
|
// });
|
|
};
|
|
|
|
/**
|
|
* Download a single URL in MP3
|
|
* @param url
|
|
* @param outputFile
|
|
* @param bitrate
|
|
* @return Event
|
|
*/
|
|
at3.downloadSingleURL = (url, outputFile, bitrate) => {
|
|
const progressEmitter = new EventEmitter();
|
|
let tempFile = outputFile + '.video';
|
|
let downloadEnded = false;
|
|
let convert;
|
|
|
|
const dl = at3.downloadWithYoutubeDl(url, tempFile);
|
|
const onDlProgress = (infos) => {
|
|
progressEmitter.emit('download', {
|
|
progress: infos.progress,
|
|
});
|
|
};
|
|
|
|
dl.once('download-start', () => {
|
|
progressEmitter.emit('start');
|
|
});
|
|
dl.on('download-progress', onDlProgress);
|
|
|
|
dl.once('download-end', () => {
|
|
downloadEnded = true;
|
|
dl.removeListener('download-progress', onDlProgress);
|
|
progressEmitter.emit('download-end');
|
|
|
|
convert = at3.convertInMP3(tempFile, outputFile, bitrate);
|
|
const onConvertProgress = (infos) => {
|
|
progressEmitter.emit('convert', {
|
|
progress: infos.progress,
|
|
});
|
|
};
|
|
convert.on('convert-progress', onConvertProgress);
|
|
convert.once('convert-end', () => {
|
|
convert.removeListener('convert-progress', onConvertProgress);
|
|
progressEmitter.emit('end');
|
|
});
|
|
convert.once('error', (error) => {
|
|
progressEmitter.emit('error', error);
|
|
});
|
|
});
|
|
|
|
dl.once('error', (error) => {
|
|
dl.removeListener('download-progress', onDlProgress);
|
|
progressEmitter.emit('error', new Error(error));
|
|
});
|
|
|
|
progressEmitter.once('abort', () => {
|
|
if (!downloadEnded) {
|
|
dl.emit('abort');
|
|
} else {
|
|
convert.emit('abort');
|
|
}
|
|
});
|
|
|
|
return progressEmitter;
|
|
};
|
|
|
|
/**
|
|
* Try to find to title and artist from a string
|
|
* (example: a YouTube video title)
|
|
* @param query string
|
|
* @param exact boolean Can the query be modified or not
|
|
* @param last boolean Last call
|
|
* @param v boolean Verbose
|
|
* @return Promise
|
|
*/
|
|
at3.guessTrackFromString = (query, exact, last, v) => {
|
|
// [TODO] Replace exact by a level of strictness
|
|
// 0: no change at all
|
|
// 4: remove every thing useless
|
|
if (exact === undefined) {
|
|
exact = false;
|
|
}
|
|
if (last === undefined) {
|
|
last = false;
|
|
}
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
|
|
if (v) {
|
|
console.log('Query: ', query);
|
|
}
|
|
|
|
let searchq = query;
|
|
if (!exact) {
|
|
searchq = searchq.replace(/\(.*\)/g, '');
|
|
searchq = searchq.replace(/\[.*\]/g, '');
|
|
searchq = searchq.replace(/lyric(s?)|parole(s?)/gi, '');
|
|
searchq = searchq.replace(/^'/, '');
|
|
searchq = searchq.replace(/ '/g, ' ');
|
|
searchq = searchq.replace(/' /g, ' ');
|
|
searchq = searchq.replace(/Original Motion Picture Soundtrack/i, '');
|
|
searchq = searchq.replace(/bande originale/i, '');
|
|
}
|
|
|
|
const requests = [];
|
|
const infos = {
|
|
title: null,
|
|
artistName: null,
|
|
};
|
|
|
|
// We search on Deezer and iTunes
|
|
// [TODO] Adding Spotify
|
|
|
|
// Deezer
|
|
const requestDeezer = request({
|
|
url: 'https://api.deezer.com/2.0/search?q=' + encodeURIComponent(searchq),
|
|
json: true,
|
|
}).then((body) => {
|
|
let title, artistName, tempTitle;
|
|
_.forEach(body.data, (s) => {
|
|
if (!title) {
|
|
if (vsimpleName(searchq, exact).replace(new RegExp(vsimpleName(s.artist.name), 'ig'))) {
|
|
if (
|
|
delArtist(s.artist.name, searchq, exact).match(new RegExp(vsimpleName(s.title_short), 'ig')) ||
|
|
vsimpleName(s.title_short).match(new RegExp(delArtist(s.artist.name, searchq, exact), 'ig'))
|
|
) {
|
|
artistName = s.artist.name;
|
|
title = s.title;
|
|
} else if (!artistName) {
|
|
artistName = s.artist.name;
|
|
tempTitle = s.title;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
if (title && artistName) {
|
|
infos.title = title;
|
|
infos.artistName = artistName;
|
|
}
|
|
if (v) {
|
|
console.log('Deezer answer: ', title, '-', artistName);
|
|
}
|
|
});
|
|
|
|
// iTunes
|
|
const requestiTunes = request({
|
|
url: 'https://itunes.apple.com/search?media=music&term=' + encodeURIComponent(searchq),
|
|
json: true,
|
|
}).then((body) => {
|
|
let title, artistName, tempTitle;
|
|
_.forEach(body.results, (s) => {
|
|
if (!title) {
|
|
if (vsimpleName(searchq, exact).match(new RegExp(vsimpleName(s.artistName), 'gi'))) {
|
|
if (delArtist(s.artistName, searchq, exact).match(new RegExp(vsimpleName(s.trackCensoredName), 'gi'))) {
|
|
artistName = s.artistName;
|
|
title = s.trackCensoredName;
|
|
} else if (delArtist(s.artistName, searchq, exact).match(new RegExp(vsimpleName(s.trackName), 'gi'))) {
|
|
artistName = s.artistName;
|
|
title = s.trackName;
|
|
} else if (!artistName) {
|
|
artistName = s.artistName;
|
|
temp_title = s.trackName;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
if (title && artistName) {
|
|
infos.title = title;
|
|
infos.artistName = artistName;
|
|
}
|
|
if (v) {
|
|
console.log('iTunes answer: ', title, '-', artistName);
|
|
}
|
|
});
|
|
|
|
requests.push(requestDeezer);
|
|
requests.push(requestiTunes);
|
|
|
|
return Promise.all(requests).then(() => {
|
|
if (!last && (!infos.title || !infos.artistName)) {
|
|
searchq = searchq.replace(/f(ea)?t(\.)? [^-]+/gi, ' ');
|
|
return at3.guessTrackFromString(searchq, false, true, v);
|
|
}
|
|
return infos;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Try to guess title and artist from mp3 file
|
|
* @param file
|
|
* @return Promise
|
|
*/
|
|
at3.guessTrackFromFile = (file) => {
|
|
return new Promise((resolve, _reject) => {
|
|
acoustid(file, { key: API_ACOUSTID, fpcalc: { command: at3.FPCALC_PATH } }, (err, results) => {
|
|
if (
|
|
err ||
|
|
results.length === 0 ||
|
|
!results[0].recordings ||
|
|
results[0].recordings.length === 0 ||
|
|
!results[0].recordings[0].artists ||
|
|
results[0].recordings[0].artists.length === 0
|
|
) {
|
|
resolve({});
|
|
return;
|
|
}
|
|
resolve({
|
|
title: results[0].recordings[0].title,
|
|
artistName: results[0].recordings[0].artists[0].name,
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Retrieve informations about a track from artist and title
|
|
* @param title
|
|
* @param artistName
|
|
* @param exact boolean Exact search or not
|
|
* @param v boolean Verbose
|
|
* @return Promise
|
|
*/
|
|
at3.retrieveTrackInformations = (title, artistName, exact, v) => {
|
|
if (exact === undefined) {
|
|
exact = false;
|
|
}
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
|
|
if (!exact) {
|
|
title = title.replace(/((\[)|(\())?radio edit((\])|(\)))?/gi, '');
|
|
}
|
|
|
|
const infos = {
|
|
title: title,
|
|
artistName: artistName,
|
|
};
|
|
|
|
const requests = [];
|
|
|
|
const requestDeezer = request({
|
|
url: 'https://api.deezer.com/2.0/search?q=' + encodeURIComponent(artistName + ' ' + title),
|
|
json: true,
|
|
}).then((body) => {
|
|
let deezerInfos;
|
|
_.forEach(body.data, (s) => {
|
|
if (
|
|
!infos.deezerId &&
|
|
imatch(vsimpleName(title), vsimpleName(s.title)) &&
|
|
imatch(vsimpleName(artistName), vsimpleName(s.artist.name))
|
|
) {
|
|
infos.deezerId = s.id;
|
|
deezerInfos = _.clone(s);
|
|
}
|
|
});
|
|
if (infos.deezerId) {
|
|
infos.artistName = deezerInfos.artist.name;
|
|
infos.title = deezerInfos.title;
|
|
|
|
return at3
|
|
.getDeezerTrackInfos(infos.deezerId, v)
|
|
.then((deezerInfos) => {
|
|
infos = deezerInfos;
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
});
|
|
|
|
const requestiTunes = request({
|
|
url: 'https://itunes.apple.com/search?media=music&term=' + encodeURIComponent(artistName + ' ' + title),
|
|
json: true,
|
|
}).then((body) => {
|
|
let itunesInfos;
|
|
_.forEach(body.results, (s) => {
|
|
if (
|
|
!infos.itunesId &&
|
|
(imatch(vsimpleName(title), vsimpleName(s.trackName)) ||
|
|
imatch(vsimpleName(title), vsimpleName(s.trackCensoredName))) &&
|
|
imatch(vsimpleName(artistName), vsimpleName(s.artistName))
|
|
) {
|
|
infos.itunesId = s.trackId;
|
|
itunesInfos = _.clone(s);
|
|
}
|
|
});
|
|
if (!infos.deezerId && itunesInfos) {
|
|
infos.artistName = itunesInfos.artistName;
|
|
if (imatch(vsimpleName(infos.title), vsimpleName(itunesInfos.trackName))) {
|
|
infos.title = itunesInfos.trackName;
|
|
} else {
|
|
infos.title = itunesInfos.trackCensoredName;
|
|
}
|
|
infos.itunesAlbum = itunesInfos.collectionId;
|
|
infos.position = itunesInfos.trackNumber;
|
|
infos.nbTracks = itunesInfos.trackCount;
|
|
infos.album = itunesInfos.collectionName;
|
|
infos.releaseDate = itunesInfos.releaseDate.replace(/T.+/, '');
|
|
infos.cover = itunesInfos.artworkUrl100.replace('100x100', '200x200');
|
|
infos.genre = itunesInfos.primaryGenreName;
|
|
infos.discNumber = itunesInfos.discNumber;
|
|
infos.duration = itunesInfos.trackTimeMillis / 1000;
|
|
}
|
|
|
|
if (v) {
|
|
console.log('iTunes infos: ', itunesInfos);
|
|
}
|
|
});
|
|
|
|
requests.push(requestDeezer);
|
|
requests.push(requestiTunes);
|
|
|
|
return Promise.all(requests).then(() => infos);
|
|
};
|
|
|
|
/**
|
|
* Retrieve detailed infos about a Deezer Track
|
|
* @param trackId
|
|
* @param v boolean Verbosity
|
|
* @return Promise(trackInfos)
|
|
*/
|
|
at3.getDeezerTrackInfos = (trackId, v) => {
|
|
const infos = {
|
|
deezerId: trackId,
|
|
};
|
|
|
|
return request({
|
|
url: 'https://api.deezer.com/2.0/track/' + infos.deezerId,
|
|
json: true,
|
|
})
|
|
.then((trackInfos) => {
|
|
if (trackInfos.error) {
|
|
return Promise.reject();
|
|
}
|
|
|
|
infos.title = trackInfos.title;
|
|
infos.artistName = trackInfos.artist.name;
|
|
infos.position = trackInfos.track_position;
|
|
infos.duration = trackInfos.duration;
|
|
infos.deezerAlbum = trackInfos.album.id;
|
|
infos.discNumber = trackInfos.disk_number;
|
|
|
|
return request({
|
|
url: 'https://api.deezer.com/2.0/album/' + infos.deezerAlbum,
|
|
json: true,
|
|
});
|
|
})
|
|
.then((albumInfos) => {
|
|
infos.album = albumInfos.title;
|
|
infos.releaseDate = albumInfos.release_date;
|
|
infos.nbTracks = albumInfos.tracks.data.length;
|
|
infos.genreId = albumInfos.genre_id;
|
|
infos.cover = albumInfos.cover_big;
|
|
|
|
return request({
|
|
url: 'https://api.deezer.com/2.0/genre/' + infos.genreId,
|
|
json: true,
|
|
});
|
|
})
|
|
.then((genreInfos) => {
|
|
infos.genre = genreInfos.name;
|
|
|
|
if (v) {
|
|
console.log('Deezer infos: ', infos);
|
|
}
|
|
|
|
return infos;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Get complete information (title, artist, release date, genre, album name...)
|
|
* for a Spotify track
|
|
* @param {trackId} string The Spotify track id
|
|
* @param {v} boolean The verbosity
|
|
* @return Promise
|
|
*/
|
|
at3.getSpotifyTrackInfos = (trackId, v) => {
|
|
const infos = {
|
|
spotifyId: trackId,
|
|
};
|
|
|
|
return at3
|
|
.requestSpotify('https://api.spotify.com/v1/tracks/' + trackId)
|
|
.then((trackInfos) => {
|
|
infos.title = trackInfos.name;
|
|
infos.artistName = trackInfos.artists[0].name;
|
|
infos.duration = Math.ceil(trackInfos.duration_ms / 1000);
|
|
infos.position = trackInfos.track_number;
|
|
infos.discNumber = trackInfos.disc_number;
|
|
infos.spotifyAlbum = trackInfos.album.id;
|
|
|
|
return at3.requestSpotify('https://api.spotify.com/v1/albums/' + trackInfos.album.id);
|
|
})
|
|
.then((albumInfos) => {
|
|
infos.album = albumInfos.name;
|
|
infos.cover = albumInfos.images[0].url;
|
|
infos.genre = albumInfos.genres[0] || '';
|
|
infos.nbTracks = albumInfos.tracks.total;
|
|
infos.releaseDate = albumInfos.release_date;
|
|
|
|
return infos;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Add tags to MP3 file
|
|
* @param file
|
|
* @param infos
|
|
* @return Promise
|
|
*/
|
|
at3.tagFile = (file, infos) => {
|
|
const meta = {
|
|
title: infos.title,
|
|
artist: infos.artistName,
|
|
};
|
|
|
|
if (infos.album) {
|
|
meta.album = infos.album;
|
|
}
|
|
if (infos.position) {
|
|
meta.track = infos.position;
|
|
}
|
|
if (infos.nbTracks) {
|
|
meta.trackTotal = infos.nbTracks;
|
|
}
|
|
if (infos.discNumber) {
|
|
meta.disc = infos.discNumber;
|
|
}
|
|
if (infos.lyrics) {
|
|
meta.lyrics = infos.lyrics;
|
|
}
|
|
if (infos.releaseDate) {
|
|
meta.year = /[0-9]{4}/.exec(infos.releaseDate)[0];
|
|
}
|
|
if (infos.genre) {
|
|
meta.genre = infos.genre.replace(/\/.+/g, '');
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
eyed3.updateMeta(file, eyed3.metaHook(meta), (err) => {
|
|
if (err) {
|
|
return reject(err);
|
|
}
|
|
if (infos.cover) {
|
|
let coverPath = file + '.cover.jpg';
|
|
|
|
requestNoPromise(infos.cover, () => {
|
|
// Check that the cover is a square
|
|
const coverFile = sharp(coverPath);
|
|
coverFile
|
|
.metadata()
|
|
.then((metadata) => {
|
|
if (metadata.width != metadata.height) {
|
|
// In that case we will crop the cover to get a square
|
|
const tempCoverPath = file + '.cover.resized.jpg';
|
|
return smartcrop
|
|
.crop(coverPath, { width: 100, height: 100 })
|
|
.then((result) => {
|
|
let crop = result.topCrop;
|
|
return coverFile
|
|
.extract({
|
|
width: crop.width,
|
|
height: crop.height,
|
|
left: crop.x,
|
|
top: crop.y,
|
|
})
|
|
.toFile(tempCoverPath);
|
|
})
|
|
.then(() => {
|
|
fs.renameSync(tempCoverPath, coverPath);
|
|
});
|
|
}
|
|
})
|
|
.then(() => {
|
|
eyed3.updateMeta(file, eyed3.metaHook({ image: coverPath }), (err) => {
|
|
fs.unlinkSync(coverPath);
|
|
|
|
if (err) {
|
|
return reject(err);
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
}).pipe(fs.createWriteStream(coverPath));
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Search and return complete information about a single video url
|
|
* @param url
|
|
* @param v boolean Verbosity
|
|
* @return Promise(object)
|
|
*/
|
|
at3.getCompleteInfosFromURL = (url, v) => {
|
|
let infosFromString;
|
|
// Try to find information based on video title
|
|
return at3
|
|
.getInfosWithYoutubeDl(url)
|
|
.then((videoInfos) => {
|
|
infosFromString = {
|
|
title: videoInfos.title,
|
|
artistName: videoInfos.author,
|
|
cover: videoInfos.picture.replace('hqdefault', 'mqdefault'), // [TODO]: getting a better resolution and removing the black borders
|
|
originalTitle: videoInfos.title,
|
|
};
|
|
|
|
if (v) {
|
|
console.log('Video infos: ', infosFromString);
|
|
}
|
|
|
|
// progressEmitter.emit('infos', _.clone(infosFromString));
|
|
|
|
return at3.guessTrackFromString(videoInfos.title, false, false, v);
|
|
})
|
|
.then((guessStringInfos) => {
|
|
if (guessStringInfos.title && guessStringInfos.artistName) {
|
|
return at3.retrieveTrackInformations(guessStringInfos.title, guessStringInfos.artistName, false, v);
|
|
} else {
|
|
return Promise.resolve();
|
|
}
|
|
})
|
|
.then((guessStringInfos) => {
|
|
if (guessStringInfos) {
|
|
guessStringInfos.originalTitle = infosFromString.originalTitle;
|
|
infosFromString = guessStringInfos;
|
|
// progressEmitter.emit('infos', _.clone(infosFromString));
|
|
if (v) {
|
|
console.log('guessStringInfos: ', guessStringInfos);
|
|
}
|
|
} else {
|
|
if (v) {
|
|
console.log('Cannot retrieve detailed information from video title');
|
|
}
|
|
}
|
|
|
|
return infosFromString;
|
|
})
|
|
.then((guessStringInfos) => {
|
|
if (guessStringInfos.deezerId) {
|
|
return at3.getDeezerTrackInfos(guessStringInfos.deezerId, v);
|
|
} else if (guessStringInfos.spotifyId) {
|
|
return at3.getSpotifyTrackInfos(guessStringInfos.spotifyId, v);
|
|
} else {
|
|
return guessStringInfos;
|
|
}
|
|
})
|
|
.catch((_error) => {
|
|
// The download must have failed to, and emit an error
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Identify the song from a file and then search complete information about it
|
|
* @param file string
|
|
* @param v boolean Verbosity
|
|
* @return Promise(object)
|
|
*/
|
|
at3.getCompleteInfosFromFile = (file, v) => {
|
|
return at3
|
|
.guessTrackFromFile(file)
|
|
.then((guessFileInfos) => {
|
|
if (guessFileInfos.title && guessFileInfos.artistName) {
|
|
return at3.retrieveTrackInformations(guessFileInfos.title, guessFileInfos.artistName, false, v);
|
|
} else {
|
|
return Promise.resolve();
|
|
}
|
|
})
|
|
.then((guessFileInfos) => {
|
|
if (guessFileInfos) {
|
|
if (v) {
|
|
console.log('guessFileInfos: ', guessFileInfos);
|
|
}
|
|
return guessFileInfos;
|
|
} else {
|
|
if (v) {
|
|
console.log('Cannot retrieve detailed information from MP3 file');
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Simplify a string so it works well as a filename
|
|
* @param {String} string
|
|
* @return {String}
|
|
*/
|
|
at3.escapeForFilename = (string) => {
|
|
return _.startCase(_.toLower(_.deburr(string)))
|
|
.replace(/^\.+/, '')
|
|
.replace(/\.+$/, '');
|
|
};
|
|
|
|
/**
|
|
* Return a correctly formatted filename for a song.
|
|
* Example: "02 - On Top Of The World"
|
|
* @param title string Title of the song
|
|
* @param artist string Artist
|
|
* @param position int Position on the disk
|
|
* @return string
|
|
*/
|
|
at3.formatSongFilename = (title, artist, position) => {
|
|
let filename = at3.escapeForFilename(artist) + ' - ';
|
|
if (position) {
|
|
if (position < 10) {
|
|
filename += '0';
|
|
}
|
|
filename += position + ' - ';
|
|
}
|
|
|
|
filename += at3.escapeForFilename(title);
|
|
|
|
return filename;
|
|
};
|
|
|
|
/**
|
|
* Create necessary folders for a subpath
|
|
* @param baseFolder {string} The path of the outputfolder
|
|
* @param subPathFormat {string} The subPath format: {artist}/{title}/
|
|
* @param title {string} Title
|
|
* @param artist {string} Artist
|
|
* @return {String} The complete path
|
|
*/
|
|
at3.createSubPath = (baseFolder, subPathFormat, title, artist) => {
|
|
subPathFormat = subPathFormat.replace(/\{artist\}/g, at3.escapeForFilename(artist));
|
|
subPathFormat = subPathFormat.replace(/\{title\}/g, at3.escapeForFilename(title));
|
|
|
|
let p = path.join(baseFolder, subPathFormat);
|
|
if (p.charAt(p.length - 1) != path.sep) {
|
|
p += path.sep;
|
|
}
|
|
|
|
const folders = subPathFormat.split(path.sep);
|
|
let currentFolder = baseFolder;
|
|
folders.forEach((f) => {
|
|
currentFolder = path.join(currentFolder, f);
|
|
if (!fs.existsSync(currentFolder)) {
|
|
fs.mkdirSync(currentFolder);
|
|
}
|
|
});
|
|
|
|
return p;
|
|
};
|
|
|
|
/**
|
|
* Download and convert a single URL,
|
|
* retrieve and add tags to the MP3 file
|
|
* @param url
|
|
* @param outputFolder
|
|
* @param callback Callback function
|
|
* @param title string Optional requested title
|
|
* @param infos object Basic infos to tag the file
|
|
* @param v boolean Verbosity
|
|
* @param options object { bitrate: '256k' } output audio bitrate
|
|
* @return Event
|
|
*/
|
|
at3.downloadAndTagSingleURL = (url, outputFolder, callback, title, v, infos, options = {}) => {
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
if (callback === undefined) {
|
|
callback = () => {};
|
|
}
|
|
if (outputFolder.charAt(outputFolder.length - 1) !== path.sep) {
|
|
outputFolder += path.sep;
|
|
}
|
|
title = title || '';
|
|
const bitrate = options.bitrate || '256k';
|
|
|
|
const progressEmitter = new EventEmitter();
|
|
|
|
const tempFile = (at3.tempFolder || outputFolder) + randomstring.generate(10) + '.mp3';
|
|
|
|
// Download and convert file
|
|
const dl = at3.downloadSingleURL(url, tempFile, bitrate);
|
|
const onDownload = (infos) => {
|
|
progressEmitter.emit('download', infos);
|
|
};
|
|
const onConvert = (infos) => {
|
|
progressEmitter.emit('convert', infos, infos);
|
|
};
|
|
dl.on('download', onDownload);
|
|
dl.once('download-end', () => {
|
|
dl.removeListener('download', onDownload);
|
|
progressEmitter.emit('download-end');
|
|
});
|
|
dl.on('convert', onConvert);
|
|
dl.once('error', (error) => {
|
|
dl.removeListener('download', onDownload);
|
|
dl.removeListener('convert', onConvert);
|
|
callback(null, 'error');
|
|
progressEmitter.emit('error', new Error(error));
|
|
});
|
|
progressEmitter.once('abort', () => {
|
|
dl.emit('abort');
|
|
});
|
|
|
|
let infosFromString,
|
|
infosFromFile,
|
|
infosRequests = [];
|
|
|
|
if (infos && infos.deezerId) {
|
|
// If deezer track id is provided, with fetch more information
|
|
let getMoreInfos = at3
|
|
.getDeezerTrackInfos(infos.deezerId, v)
|
|
.then((inf) => {
|
|
infosFromString = inf;
|
|
})
|
|
.catch(() => {
|
|
infosFromString = {
|
|
title: infos.title,
|
|
artistName: infos.artistName,
|
|
};
|
|
});
|
|
|
|
infosRequests.push(getMoreInfos);
|
|
} else if (infos && infos.spotifyId) {
|
|
// If spotify track id is provided, with fetch more information
|
|
let getMoreInfos = at3
|
|
.getSpotifyTrackInfos(infos.spotifyId, v)
|
|
.then((inf) => {
|
|
infosFromString = inf;
|
|
})
|
|
.catch(() => {
|
|
infosFromString = {
|
|
title: infos.title,
|
|
artistName: infos.artistName,
|
|
};
|
|
});
|
|
infosRequests.push(getMoreInfos);
|
|
} else {
|
|
// Try to find information based on video title
|
|
let getStringInfos = at3
|
|
.getCompleteInfosFromURL(url, v)
|
|
.then((inf) => {
|
|
if (title === undefined) {
|
|
title = inf.originalTitle;
|
|
}
|
|
infosFromString = inf;
|
|
progressEmitter.emit('infos', _.clone(infosFromString));
|
|
})
|
|
.catch(() => {
|
|
// The download must have failed to, and emit an error
|
|
});
|
|
|
|
infosRequests.push(getStringInfos);
|
|
}
|
|
|
|
// Try to find information based on MP3 file when dl is finished
|
|
dl.once('end', () => {
|
|
dl.removeListener('convert', onConvert);
|
|
progressEmitter.emit('convert-end');
|
|
|
|
if (!infos || (!infos.deezerId && !infos.spotifyId)) {
|
|
let getFileInfos = at3.getCompleteInfosFromFile(tempFile, v).then((inf) => {
|
|
infosFromFile = inf;
|
|
if (infosFromFile && infosFromFile.title && infosFromFile.artistName) {
|
|
progressEmitter.emit('infos', _.clone(infosFromFile));
|
|
}
|
|
});
|
|
|
|
infosRequests.push(getFileInfos);
|
|
}
|
|
|
|
// [TODO] Improve network issue resistance
|
|
Promise.all(infosRequests).then(() => {
|
|
// ça on peut garder
|
|
let infos = infosFromString;
|
|
if (infosFromFile) {
|
|
let scoreFromFile = Math.min(
|
|
levenshtein.get(simpleName(infosFromFile.title + ' ' + infosFromFile.artistName), simpleName(title)),
|
|
levenshtein.get(simpleName(infosFromFile.artistName + ' ' + infosFromFile.title), simpleName(title)),
|
|
);
|
|
let scoreFromString = Math.min(
|
|
levenshtein.get(simpleName(infosFromString.title + ' ' + infosFromString.artistName), simpleName(title)),
|
|
levenshtein.get(simpleName(infosFromString.artistName + ' ' + infosFromString.title), simpleName(title)),
|
|
);
|
|
|
|
if (v) {
|
|
console.log('Infos from file score: ', scoreFromFile);
|
|
console.log('Infos from string score: ', scoreFromString);
|
|
}
|
|
|
|
if (infosFromFile.cover && scoreFromFile < scoreFromString + Math.ceil(simpleName(title).length / 10.0)) {
|
|
infos = infosFromFile;
|
|
}
|
|
}
|
|
|
|
progressEmitter.emit('infos', _.clone(infos));
|
|
|
|
if (v) {
|
|
console.log('Final infos: ', infos);
|
|
}
|
|
|
|
at3
|
|
.findLyrics(infos.title, infos.artistName)
|
|
.then((lyrics) => {
|
|
return new Promise((resolve, reject) => {
|
|
fs.writeFile(tempFile + '.lyrics', lyrics, (error) => {
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
resolve();
|
|
}
|
|
});
|
|
});
|
|
})
|
|
.then(() => {
|
|
infos.lyrics = tempFile + '.lyrics';
|
|
})
|
|
.catch(() => {
|
|
// no lyrics
|
|
})
|
|
.finally(() => {
|
|
return at3.tagFile(tempFile, infos);
|
|
})
|
|
.then(() => {
|
|
let finalFile = outputFolder;
|
|
finalFile += at3.formatSongFilename(infos.title, infos.artistName, infos.position) + '.mp3';
|
|
fs.moveSync(tempFile, finalFile, { overwrite: true });
|
|
if (infos.lyrics) {
|
|
fs.unlinkSync(tempFile + '.lyrics');
|
|
}
|
|
const finalInfos = {
|
|
infos: infos,
|
|
file: finalFile,
|
|
};
|
|
progressEmitter.emit('end', finalInfos);
|
|
callback(finalInfos);
|
|
})
|
|
.catch((err) => {
|
|
progressEmitter.emit('error', err);
|
|
});
|
|
});
|
|
});
|
|
|
|
return progressEmitter;
|
|
};
|
|
|
|
/**
|
|
* Search a query on YouTube and return the detailed results
|
|
* @param query string
|
|
* @param regionCode string ISO 3166-1 alpha-2 country code (ex: FR, US)
|
|
* @param relevanceLanguage string ISO 639-1 two-letter language code (ex: en: fr)
|
|
* @param v boolean Verbosity
|
|
* @return Promise
|
|
*/
|
|
at3.searchOnYoutube = (query, regionCode, relevanceLanguage, v) => {
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
|
|
/**
|
|
* Remove useless information in the title
|
|
* like (audio only), (lyrics)...
|
|
* @param title string
|
|
* @return string
|
|
*/
|
|
const improveTitle = (title) => {
|
|
let useless = [
|
|
'audio only',
|
|
'audio',
|
|
'paroles/lyrics',
|
|
'lyrics/paroles',
|
|
'with lyrics',
|
|
'w/lyrics',
|
|
'w / lyrics',
|
|
'avec paroles',
|
|
'avec les paroles',
|
|
'avec parole',
|
|
'lyrics',
|
|
'paroles',
|
|
'parole',
|
|
'radio edit.',
|
|
'radio edit',
|
|
'radio-edit',
|
|
'shazam version',
|
|
'shazam v...',
|
|
'music video',
|
|
'clip officiel',
|
|
'officiel',
|
|
'new song',
|
|
'official video',
|
|
'official',
|
|
];
|
|
|
|
_.forEach(useless, (u) => {
|
|
title = title.replace(new RegExp('((\\(|\\[)?)( ?)' + u + '( ?)((\\)|\\])?)', 'gi'), '');
|
|
});
|
|
|
|
title = title.replace(new RegExp('(\\(|\\[)( ?)hd( ?)(\\)|\\])', 'gi'), '');
|
|
title = title.replace(new RegExp('hd', 'gi'), '');
|
|
title = _.trim(title);
|
|
|
|
return title;
|
|
};
|
|
|
|
// We simply search on YouTube
|
|
return ytsr(query, { limit: 20 }).then(({ items }) => {
|
|
const videos = items.filter((item) => item.type === 'video');
|
|
|
|
if (videos.length === 0) {
|
|
return Promise.reject();
|
|
}
|
|
|
|
return Promise.all(
|
|
videos.map(async (video) => {
|
|
const infos = await ytdl.getInfo(video.link);
|
|
|
|
let ratio = 1.0;
|
|
if (infos.dislikes > 0) {
|
|
ratio = infos.likes / infos.dislikes;
|
|
}
|
|
if (ratio === 0) {
|
|
ratio = 1;
|
|
}
|
|
const realLike = (infos.likes - infos.dislikes) * ratio;
|
|
|
|
return {
|
|
id: infos.video_id,
|
|
url: video.link,
|
|
title: improveTitle(infos.title),
|
|
hd: infos.formats.some(
|
|
({ qualityLabel }) => qualityLabel && (qualityLabel.startsWith('720p') || qualityLabel.startsWith('1080p')),
|
|
),
|
|
duration: parseInt(infos.length_seconds, 10),
|
|
views: video.views,
|
|
realLike,
|
|
};
|
|
}),
|
|
);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* @param song Object Searched song
|
|
* @param videos Array List of videos
|
|
* @param v boolean Verbosity
|
|
*/
|
|
at3.findBestVideo = (song, videos, v) => {
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
|
|
/**
|
|
* Returns the score of a video, comparing to the request
|
|
* @param song Object Searched song
|
|
* @param video object
|
|
* @param largestRealLike
|
|
* @param largestViews
|
|
* @return Object
|
|
*/
|
|
const score = (song, video, largestRealLike, largestViews) => {
|
|
// weight of each argument
|
|
let weights = {
|
|
title: 30,
|
|
hd: 0.3,
|
|
duration: 20,
|
|
views: 10,
|
|
realLike: 15,
|
|
};
|
|
|
|
let duration = song.duration || video.duration;
|
|
|
|
// Score for title
|
|
let videoTitle = ' ' + _.lowerCase(video.title) + ' ';
|
|
let songTitle = ' ' + _.lowerCase(song.title) + ' '; // we add spaces to help longest-common-substring
|
|
let songArtist = ' ' + _.lowerCase(song.artistName) + ' '; // (example: the artist "M")
|
|
|
|
// for longest-common-substring, which works with arrays
|
|
let videoTitlea = videoTitle.split('');
|
|
let songTitlea = songTitle.split('');
|
|
let songArtista = songArtist.split('');
|
|
|
|
const videoSongTitle = lcs(videoTitlea, songTitlea);
|
|
if (
|
|
videoSongTitle.length > 0 &&
|
|
videoSongTitle.startString2 === 0 &&
|
|
videoTitle[videoSongTitle.startString1 + videoSongTitle.length - 1] === ' '
|
|
) {
|
|
// The substring must start at the beginning of the song title, and the next char in the video title must be a space
|
|
videoTitle =
|
|
videoTitle.substring(0, videoSongTitle.startString1) +
|
|
' ' +
|
|
videoTitle.substring(videoSongTitle.startString1 + videoSongTitle.length);
|
|
videoTitlea = videoTitle.split('');
|
|
}
|
|
const videoSongArtist = lcs(videoTitlea, songArtista);
|
|
if (
|
|
videoSongArtist.length > 0 &&
|
|
videoSongArtist.startString2 === 0 &&
|
|
videoTitle[videoSongArtist.startString1 + videoSongArtist.length - 1] === ' '
|
|
) {
|
|
// The substring must start at the beginning of the song title, and the next char in the video title must be a space
|
|
videoTitle =
|
|
videoTitle.substring(0, videoSongArtist.startString1) +
|
|
videoTitle.substring(videoSongArtist.startString1 + videoSongArtist.length);
|
|
}
|
|
|
|
videoTitle = _.lowerCase(videoTitle);
|
|
const sTitle =
|
|
videoTitle.length + (songTitle.length - videoSongTitle.length) + (songArtist.length - videoSongArtist.length);
|
|
|
|
const videoScore = {
|
|
title: sTitle * weights.title,
|
|
hd: video.hd * weights.hd,
|
|
duration: Math.sqrt(Math.abs(video.duration - duration)) * weights.duration,
|
|
views: (video.views / largestViews) * weights.views,
|
|
realLike: (video.realLike / largestRealLike) * weights.realLike || -50, // video.realLike is NaN when the likes has been deactivated, which is a very bad sign
|
|
};
|
|
video.videoScore = videoScore;
|
|
|
|
let preVideoScore = videoScore.views + videoScore.realLike - videoScore.title - videoScore.duration;
|
|
preVideoScore = preVideoScore + Math.abs(preVideoScore) * videoScore.hd;
|
|
|
|
return preVideoScore;
|
|
};
|
|
|
|
const largestRealLike = _.reduce(
|
|
videos,
|
|
(v, r) => {
|
|
if (r.realLike > v) {
|
|
return r.realLike;
|
|
}
|
|
return v;
|
|
},
|
|
0,
|
|
);
|
|
const largestViews = _.reduce(
|
|
videos,
|
|
(v, r) => {
|
|
if (r.views > v) {
|
|
return r.views;
|
|
}
|
|
return v;
|
|
},
|
|
0,
|
|
);
|
|
|
|
_.forEach(videos, (r) => {
|
|
r.score = score(song, r, largestRealLike, largestViews);
|
|
});
|
|
|
|
return _.reverse(_.sortBy(videos, 'score'));
|
|
};
|
|
|
|
/**
|
|
* Try to find the best video matching a song
|
|
* @param song Object Searched song
|
|
* @param v boolean Verbosity
|
|
* @return Promise
|
|
*/
|
|
at3.findVideoForSong = (song, v) => {
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
|
|
let query = song.title + ' - ' + song.artistName;
|
|
return at3.searchOnYoutube(query, at3.regionCode, at3.relevanceLanguage, v).then((youtubeResults) => {
|
|
return at3.findBestVideo(song, youtubeResults, v);
|
|
});
|
|
};
|
|
|
|
// [TODO] we could also add a method that just take the first youtube video and download it
|
|
/**
|
|
* Try to find the best video matching a song request
|
|
* @param query string
|
|
* @param v boolean Verbosity
|
|
* @return Promise
|
|
*/
|
|
at3.findVideo = (query, v) => {
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
|
|
// We try to find the song
|
|
return at3
|
|
.guessTrackFromString(query, true, false, v)
|
|
.then((guessStringInfos) => {
|
|
if (guessStringInfos.title && guessStringInfos.artistName) {
|
|
return at3.retrieveTrackInformations(guessStringInfos.title, guessStringInfos.artistName, true, v);
|
|
} else {
|
|
return Promise.reject({ error: 'No song corresponds to your query' });
|
|
}
|
|
})
|
|
.then((song) => {
|
|
return at3.findVideoForSong(song, v);
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Find a song from a query, then download the corresponding video,
|
|
* convert and tag it
|
|
* @param query string
|
|
* @param outputFolder
|
|
* @param callback Callback function
|
|
* @param v boolean Verbosity
|
|
* @return Event
|
|
*/
|
|
at3.findAndDownload = (query, outputFolder, callback, v) => {
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
const progressEmitter = new EventEmitter();
|
|
|
|
at3
|
|
.findVideo(query, v)
|
|
.then((results) => {
|
|
if (results.length === 0) {
|
|
progressEmitter.emit('error', new Error('Cannot find any video matching'));
|
|
return callback(null, 'Cannot find any video matching');
|
|
}
|
|
let i = 0;
|
|
progressEmitter.emit('search-end');
|
|
let dl = at3.downloadAndTagSingleURL(results[i].url, outputFolder, callback, query);
|
|
|
|
const onDownload = (infos) => {
|
|
progressEmitter.emit('download', infos);
|
|
};
|
|
const onConvert = (infos) => {
|
|
progressEmitter.emit('convert', infos);
|
|
};
|
|
const onInfos = (infos) => {
|
|
progressEmitter.emit('infos', infos);
|
|
};
|
|
|
|
dl.on('download', onDownload);
|
|
dl.once('download-end', () => {
|
|
dl.removeListener('download', onDownload);
|
|
progressEmitter.emit('download-end');
|
|
});
|
|
dl.on('convert', onConvert);
|
|
dl.once('convert-end', () => {
|
|
dl.removeListener('convert', onConvert);
|
|
progressEmitter.emit('convert-end');
|
|
});
|
|
dl.on('infos', onInfos);
|
|
dl.once('error', (error) => {
|
|
dl.removeListener('download', onDownload);
|
|
dl.removeListener('convert', onConvert);
|
|
dl.removeListener('infos', onInfos);
|
|
// [TODO]: try to download the next video, in case of ytdl error only
|
|
// if (i < results.length) {
|
|
// dl = at3.downloadAndTagSingleURL(results[i++].url, outputFolder, callback, query);
|
|
// } else {
|
|
progressEmitter.emit('error', new Error(error));
|
|
// }
|
|
});
|
|
dl.once('end', () => {
|
|
dl.removeListener('infos', onInfos);
|
|
});
|
|
})
|
|
.catch(() => {
|
|
progressEmitter.emit('error', new Error('Cannot find any video matching'));
|
|
return callback(null, 'Cannot find any video matching');
|
|
});
|
|
|
|
return progressEmitter;
|
|
};
|
|
|
|
/**
|
|
* Find videos for a track, and download it
|
|
* @param track trackInfos
|
|
* @param outputFolder
|
|
* @param callback Callback function
|
|
* @param v boolean Verbosity
|
|
* @return Event
|
|
*/
|
|
at3.downloadTrack = (track, outputFolder, callback, v) => {
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
const progressEmitter = new EventEmitter();
|
|
let aborted = false;
|
|
|
|
at3
|
|
.findVideoForSong(track, v)
|
|
.then((results) => {
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
if (results.length === 0) {
|
|
progressEmitter.emit('error', new Error('Cannot find any video matching'));
|
|
return callback(null, 'Cannot find any video matching');
|
|
}
|
|
let i = 0;
|
|
progressEmitter.emit('search-end');
|
|
const dlNext = () => {
|
|
if (i >= results.length) {
|
|
progressEmitter.emit('error', new Error('Cannot find any video matching'));
|
|
return;
|
|
}
|
|
if (v) {
|
|
console.log('Will be downloaded:', results[i].url);
|
|
}
|
|
let aborted = false;
|
|
let dl = at3.downloadAndTagSingleURL(results[i].url, outputFolder, callback, '', v, track);
|
|
const onDownload = (infos) => {
|
|
progressEmitter.emit('download', infos);
|
|
};
|
|
const onConvert = (infos) => {
|
|
progressEmitter.emit('convert', infos);
|
|
};
|
|
const onInfos = (infos) => {
|
|
progressEmitter.emit('infos', infos);
|
|
};
|
|
dl.on('download', onDownload);
|
|
dl.once('download-end', () => {
|
|
dl.removeListener('download', onDownload);
|
|
progressEmitter.emit('download-end');
|
|
});
|
|
dl.on('convert', onConvert);
|
|
dl.once('convert-end', () => {
|
|
dl.removeListener('convert', onConvert);
|
|
progressEmitter.emit('convert-end');
|
|
});
|
|
dl.on('infos', onInfos);
|
|
dl.once('end', (finalInfos) => {
|
|
dl.removeListener('infos', onInfos);
|
|
progressEmitter.emit('end', finalInfos);
|
|
});
|
|
dl.once('error', (_error) => {
|
|
dl.removeListener('download', onDownload);
|
|
dl.removeListener('convert', onConvert);
|
|
dl.removeListener('infos', onInfos);
|
|
i += 1;
|
|
aborted = true;
|
|
dlNext();
|
|
});
|
|
progressEmitter.once('abort', () => {
|
|
if (!aborted) {
|
|
dl.emit('abort');
|
|
}
|
|
});
|
|
};
|
|
dlNext();
|
|
})
|
|
.catch(() => {
|
|
progressEmitter.emit('error', new Error('Cannot find any video matching'));
|
|
return callback(null, 'Cannot find any video matching');
|
|
});
|
|
|
|
progressEmitter.on('abort', () => {
|
|
aborted = true;
|
|
});
|
|
|
|
return progressEmitter;
|
|
};
|
|
|
|
/**
|
|
* Return URLs contained in a playlist (YouTube or SoundCloud)
|
|
* @param url
|
|
* @return Promise(object)
|
|
*/
|
|
at3.getPlaylistURLsInfos = (url) => {
|
|
let type = at3.guessURLType(url);
|
|
|
|
if (type === 'youtube') {
|
|
let playlistId = url.match(/list=([0-9a-zA-Z_-]+)/);
|
|
playlistId = playlistId[1];
|
|
return ytpl(playlistId).then((playlist) => {
|
|
return {
|
|
title: playlist.title,
|
|
cover: playlist.author.avatar,
|
|
artistName: playlist.author.name,
|
|
items: playlist.items.map((item) => {
|
|
return {
|
|
url: item.url_simple,
|
|
title: item.title,
|
|
cover: item.thumbnail,
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
} else if (type === 'soundcloud') {
|
|
return request({
|
|
url: 'http://api.soundcloud.com/resolve?client_id=' + API_SOUNDCLOUD + '&url=' + url,
|
|
json: true,
|
|
}).then((playlistDetails) => {
|
|
let playlistInfos = {
|
|
title: playlistDetails.title,
|
|
artistName: playlistDetails.user.username,
|
|
cover: playlistDetails.artwork_url,
|
|
};
|
|
let items = [];
|
|
|
|
_.forEach(playlistDetails.tracks, (track) => {
|
|
items.push({
|
|
url: track.permalink_url,
|
|
title: track.title,
|
|
cover: track.artwork_url,
|
|
artistName: track.user.username,
|
|
});
|
|
});
|
|
|
|
playlistInfos.items = items;
|
|
|
|
return playlistInfos;
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Returns info (title, cover, songs) about a playlist (Deezer or Spotify)
|
|
* @param url
|
|
* @return Promise(object)
|
|
*/
|
|
at3.getPlaylistTitlesInfos = (url) => {
|
|
// Deezer Playlist
|
|
// Deezer Album
|
|
// Deezer Loved Tracks [TODO]
|
|
// Spotify playlist
|
|
// Spotify Album
|
|
const type = at3.guessURLType(url);
|
|
|
|
const regDeezerPlaylist = /playlist\/([0-9]+)/;
|
|
const regDeezerAlbum = /album\/([0-9]+)/;
|
|
|
|
const regSpotifyPlaylist = /playlist\/([0-9a-zA-Z]+)/;
|
|
const regSpotifyAlbum = /album\/([0-9a-zA-Z]+)/;
|
|
|
|
if (type === 'deezer') {
|
|
// Deezer Playlist
|
|
if (regDeezerPlaylist.test(url)) {
|
|
const playlistId = url.match(regDeezerPlaylist)[1];
|
|
|
|
return request({
|
|
url: 'https://api.deezer.com/playlist/' + playlistId,
|
|
json: true,
|
|
}).then((playlistDetails) => {
|
|
const playlist = {};
|
|
const items = [];
|
|
|
|
playlist.title = playlistDetails.title;
|
|
playlist.artistName = playlistDetails.creator.name;
|
|
playlist.cover = playlistDetails.picture_big;
|
|
|
|
_.forEach(playlistDetails.tracks.data, (track) => {
|
|
items.push({
|
|
title: track.title,
|
|
artistName: track.artist.name,
|
|
deezerId: track.id,
|
|
album: track.album.title,
|
|
cover: track.album.cover,
|
|
});
|
|
});
|
|
|
|
playlist.items = items;
|
|
|
|
return playlist;
|
|
});
|
|
} else if (regDeezerAlbum.test(url)) {
|
|
// Deezer Album
|
|
let albumId = url.match(regDeezerAlbum)[1];
|
|
let albumInfos = {};
|
|
|
|
return request({
|
|
url: 'https://api.deezer.com/album/' + albumId,
|
|
json: true,
|
|
})
|
|
.then((ralbumInfos) => {
|
|
albumInfos.cover = ralbumInfos.cover_big;
|
|
albumInfos.title = ralbumInfos.title;
|
|
albumInfos.artistName = ralbumInfos.artist.name;
|
|
|
|
return request({
|
|
url: 'https://api.deezer.com/album/' + albumId + '/tracks',
|
|
json: true,
|
|
});
|
|
})
|
|
.then((albumTracks) => {
|
|
let items = [];
|
|
|
|
_.forEach(albumTracks.data, (track) => {
|
|
items.push({
|
|
title: track.title,
|
|
artistName: track.artist.name,
|
|
deezerId: track.id,
|
|
album: albumInfos.title,
|
|
cover: albumInfos.cover,
|
|
duration: track.duration,
|
|
});
|
|
});
|
|
|
|
albumInfos.items = items;
|
|
|
|
return albumInfos;
|
|
});
|
|
}
|
|
} else if (type === 'spotify') {
|
|
// Spotify Playlist
|
|
if (regSpotifyPlaylist.test(url)) {
|
|
const playlistId = url.match(regSpotifyPlaylist)[1];
|
|
|
|
return at3.requestSpotify('https://api.spotify.com/v1/playlists/' + playlistId).then((playlistDetails) => {
|
|
const playlist = {};
|
|
const items = [];
|
|
|
|
playlist.title = playlistDetails.name;
|
|
playlist.artistName = playlistDetails.owner.id;
|
|
playlist.cover = playlistDetails.images[0].url;
|
|
|
|
playlist.items = items;
|
|
|
|
const processSpotifyPage = (page) => {
|
|
page.items.forEach((t) => {
|
|
let track = t.track;
|
|
items.push({
|
|
title: track.name,
|
|
artistName: track.artists[0].name,
|
|
spotifyId: track.id,
|
|
album: track.album.name,
|
|
cover: track.album.images[0] ? track.album.images[0].url : undefined,
|
|
duration: Math.ceil(track.duration_ms / 1000),
|
|
});
|
|
});
|
|
|
|
if (page.next) {
|
|
return at3.requestSpotify(page.next).then(processSpotifyPage);
|
|
} else {
|
|
return playlist;
|
|
}
|
|
};
|
|
|
|
return processSpotifyPage(playlistDetails.tracks);
|
|
});
|
|
} else if (regSpotifyAlbum.test(url)) {
|
|
// Spotify Album
|
|
let albumId = url.match(regSpotifyAlbum)[1];
|
|
let albumInfos = {};
|
|
|
|
return at3.requestSpotify('https://api.spotify.com/v1/albums/' + albumId).then((ralbumInfos) => {
|
|
albumInfos.title = ralbumInfos.name;
|
|
albumInfos.artistName = ralbumInfos.artists[0].name;
|
|
albumInfos.cover = ralbumInfos.images[0].url;
|
|
|
|
let items = [];
|
|
|
|
ralbumInfos.tracks.items.forEach((track) => {
|
|
items.push({
|
|
title: track.name,
|
|
artistName: track.artists[0].name,
|
|
spotifyId: track.id,
|
|
album: albumInfos.title,
|
|
cover: albumInfos.cover,
|
|
duration: Math.ceil(track.duration_ms / 1000),
|
|
});
|
|
});
|
|
|
|
albumInfos.items = items;
|
|
|
|
return albumInfos;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Download a playlist containing URLs
|
|
* @param url {string}
|
|
* @param outputFolder {string}
|
|
* @param callback {Function}
|
|
* @param maxSimultaneous {number} Maximum number of simultaneous track processing
|
|
* @param subPathFormat {string} The format of the subfolder: {artist}/{title}/
|
|
* @return {Event}
|
|
*/
|
|
at3.downloadPlaylistWithURLs = (url, outputFolder, callback, maxSimultaneous, subPathFormat) => {
|
|
if (maxSimultaneous === undefined) {
|
|
maxSimultaneous = 1;
|
|
}
|
|
if (subPathFormat === undefined) {
|
|
subPathFormat = '';
|
|
}
|
|
|
|
const emitter = new EventEmitter();
|
|
let running = 0;
|
|
let lastIndex = 0;
|
|
let aborted = false;
|
|
|
|
at3.getPlaylistURLsInfos(url).then((playlistInfos) => {
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
|
|
outputFolder = at3.createSubPath(outputFolder, subPathFormat, playlistInfos.title, playlistInfos.artistName);
|
|
|
|
emitter.emit('playlist-infos', playlistInfos);
|
|
|
|
for (let i = 0; i < maxSimultaneous; i += 1) {
|
|
downloadNext(playlistInfos.items, i);
|
|
}
|
|
});
|
|
|
|
const downloadNext = (urls, currentIndex) => {
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
if (urls.length === currentIndex) {
|
|
if (running === 0) {
|
|
emitter.emit('end');
|
|
callback(urls);
|
|
}
|
|
return;
|
|
}
|
|
running += 1;
|
|
if (currentIndex > lastIndex) {
|
|
lastIndex = currentIndex;
|
|
}
|
|
|
|
const currentUrl = urls[currentIndex];
|
|
|
|
currentUrl.progress = {};
|
|
|
|
emitter.emit('begin-url', currentIndex);
|
|
|
|
const dl = at3.downloadAndTagSingleURL(currentUrl.url, outputFolder, (infos, _error) => {
|
|
if (infos) {
|
|
currentUrl.file = infos.file;
|
|
currentUrl.infos = infos.infos;
|
|
}
|
|
running -= 1;
|
|
|
|
emitter.emit('end-url', currentIndex);
|
|
|
|
if (running < maxSimultaneous) {
|
|
downloadNext(urls, lastIndex + 1);
|
|
}
|
|
});
|
|
|
|
emitter.once('abort', () => {
|
|
aborted = true;
|
|
dl.emit('abort');
|
|
});
|
|
|
|
const onDownload = (infos) => {
|
|
currentUrl.progress.download = infos;
|
|
emitter.emit('download', currentIndex);
|
|
};
|
|
const onConvert = (infos) => {
|
|
currentUrl.progress.convert = infos;
|
|
emitter.emit('convert', currentIndex);
|
|
};
|
|
const onInfos = (infos) => {
|
|
currentUrl.infos = infos;
|
|
emitter.emit('infos', currentIndex);
|
|
};
|
|
|
|
dl.on('download', onDownload);
|
|
dl.once('download-end', () => {
|
|
dl.removeListener('download', onDownload);
|
|
emitter.emit('download-end', currentIndex);
|
|
if (running < maxSimultaneous) {
|
|
downloadNext(urls, lastIndex + 1);
|
|
}
|
|
});
|
|
dl.on('convert', onConvert);
|
|
dl.once('convert-end', () => {
|
|
dl.removeListener('convert', onConvert);
|
|
emitter.emit('convert-end', currentIndex);
|
|
});
|
|
dl.on('infos', onInfos);
|
|
dl.once('error', () => {
|
|
dl.removeListener('download', onDownload);
|
|
dl.removeListener('convert', onConvert);
|
|
dl.removeListener('infos', onInfos);
|
|
emitter.emit('error', new Error(currentIndex));
|
|
if (running < maxSimultaneous) {
|
|
downloadNext(urls, lastIndex + 1);
|
|
}
|
|
});
|
|
dl.once('end', () => {
|
|
dl.removeListener('infos', onInfos);
|
|
});
|
|
};
|
|
|
|
emitter.once('abort', () => {
|
|
aborted = true;
|
|
});
|
|
|
|
return emitter;
|
|
};
|
|
|
|
/**
|
|
* Download a playlist containing titles
|
|
* @param url {string}
|
|
* @param outputFolder {string}
|
|
* @param callback {Function}
|
|
* @param maxSimultaneous {number} Maximum number of simultaneous track processing
|
|
* @param subPathFormat {string} The format of the subfolder: {artist}/{title}/
|
|
* @return {Event}
|
|
*/
|
|
at3.downloadPlaylistWithTitles = (url, outputFolder, callback, maxSimultaneous, subPathFormat) => {
|
|
if (maxSimultaneous === undefined) {
|
|
maxSimultaneous = 1;
|
|
}
|
|
if (subPathFormat === undefined) {
|
|
subPathFormat = '';
|
|
}
|
|
|
|
const emitter = new EventEmitter();
|
|
let running = 0;
|
|
let lastIndex = 0;
|
|
let aborted = false;
|
|
|
|
at3.getPlaylistTitlesInfos(url).then((playlistInfos) => {
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
|
|
outputFolder = at3.createSubPath(outputFolder, subPathFormat, playlistInfos.title, playlistInfos.artistName);
|
|
|
|
emitter.emit('playlist-infos', playlistInfos);
|
|
|
|
for (let i = 0; i < maxSimultaneous; i += 1) {
|
|
downloadNext(playlistInfos.items, i);
|
|
}
|
|
});
|
|
|
|
const downloadNext = (urls, currentIndex) => {
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
if (urls.length === currentIndex) {
|
|
if (running === 0) {
|
|
emitter.emit('end');
|
|
callback(urls);
|
|
}
|
|
return;
|
|
}
|
|
running += 1;
|
|
if (currentIndex > lastIndex) {
|
|
lastIndex = currentIndex;
|
|
}
|
|
|
|
let currentTrack = urls[currentIndex];
|
|
|
|
currentTrack.progress = {};
|
|
|
|
emitter.emit('begin-url', currentIndex);
|
|
|
|
at3
|
|
.findVideoForSong(currentTrack)
|
|
.then((videos) => {
|
|
if (aborted) {
|
|
return;
|
|
}
|
|
emitter.emit('search-end', currentIndex);
|
|
|
|
const downloadFinished = (infos, error) => {
|
|
if (!infos || error) {
|
|
return;
|
|
}
|
|
currentTrack.file = infos.file;
|
|
currentTrack.infos = infos.infos;
|
|
running -= 1;
|
|
|
|
emitter.emit('end-url', currentIndex);
|
|
|
|
if (running < maxSimultaneous) {
|
|
downloadNext(urls, lastIndex + 1);
|
|
}
|
|
};
|
|
|
|
let i = 0;
|
|
|
|
const handleDl = (dl) => {
|
|
const onDownload = (infos) => {
|
|
currentTrack.progress.download = infos;
|
|
emitter.emit('download', currentIndex);
|
|
};
|
|
const onConvert = (infos) => {
|
|
currentTrack.progress.convert = infos;
|
|
emitter.emit('convert', currentIndex);
|
|
};
|
|
const onInfos = (infos) => {
|
|
currentTrack.infos = infos;
|
|
emitter.emit('infos', currentIndex);
|
|
};
|
|
|
|
dl.on('download', onDownload);
|
|
dl.once('download-end', () => {
|
|
dl.removeListener('download', onDownload);
|
|
emitter.emit('download-end', currentIndex);
|
|
if (running < maxSimultaneous) {
|
|
downloadNext(urls, lastIndex + 1);
|
|
}
|
|
});
|
|
dl.on('convert', onConvert);
|
|
dl.once('convert-end', () => {
|
|
dl.removeListener('convert', onConvert);
|
|
emitter.emit('convert-end', currentIndex);
|
|
});
|
|
dl.on('infos', onInfos);
|
|
dl.once('end', () => {
|
|
dl.removeListener('infos', onInfos);
|
|
});
|
|
dl.once('error', () => {
|
|
dl.removeListener('download', onDownload);
|
|
dl.removeListener('convert', onConvert);
|
|
if (i < videos.length - 1) {
|
|
i += 1;
|
|
handleDl(
|
|
at3.downloadAndTagSingleURL(
|
|
videos[i].url,
|
|
outputFolder,
|
|
downloadFinished,
|
|
undefined,
|
|
false,
|
|
currentTrack,
|
|
),
|
|
);
|
|
} else {
|
|
emitter.emit('error', new Error(currentIndex));
|
|
if (running < maxSimultaneous) {
|
|
downloadNext(urls, lastIndex + 1);
|
|
}
|
|
}
|
|
});
|
|
emitter.once('abort', () => {
|
|
aborted = true;
|
|
dl.emit('abort');
|
|
});
|
|
};
|
|
|
|
handleDl(
|
|
at3.downloadAndTagSingleURL(videos[i].url, outputFolder, downloadFinished, undefined, false, currentTrack),
|
|
);
|
|
})
|
|
.catch(() => {
|
|
emitter.emit('error', new Error(currentIndex));
|
|
if (running < maxSimultaneous) {
|
|
downloadNext(urls, lastIndex + 1);
|
|
}
|
|
});
|
|
};
|
|
|
|
emitter.once('abort', () => {
|
|
aborted = true;
|
|
});
|
|
|
|
return emitter;
|
|
};
|
|
|
|
/**
|
|
* Download a playlist containing urls or titles
|
|
* @param url {string}
|
|
* @param outputFolder {string}
|
|
* @param callback {Function}
|
|
* @param maxSimultaneous {number} Maximum number of simultaneous track processing
|
|
* @return {Event}
|
|
*/
|
|
at3.downloadPlaylist = (url, outputFolder, callback, maxSimultaneous, subPathFormat) => {
|
|
const type = at3.guessURLType(url);
|
|
const sitesTitles = ['deezer', 'spotify'];
|
|
const sitesURLs = ['youtube', 'soundcloud'];
|
|
|
|
if (sitesTitles.indexOf(type) >= 0) {
|
|
return at3.downloadPlaylistWithTitles(url, outputFolder, callback, maxSimultaneous, subPathFormat);
|
|
} else if (sitesURLs.indexOf(type) >= 0) {
|
|
return at3.downloadPlaylistWithURLs(url, outputFolder, callback, maxSimultaneous, subPathFormat);
|
|
} else {
|
|
callback(null, 'Website not supported yet');
|
|
return new EventEmitter().emit('error', new Error('Website not supported yet'));
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Download a track from an URL
|
|
* @param url
|
|
* @param outputFolder
|
|
* @param callback
|
|
* @param v boolean Verbose
|
|
* @return Event
|
|
*/
|
|
at3.downloadTrackURL = (url, outputFolder, callback, v) => {
|
|
if (v === undefined) {
|
|
v = false;
|
|
}
|
|
const type = at3.guessURLType(url);
|
|
const emitter = new EventEmitter();
|
|
|
|
if (type === 'spotify') {
|
|
const trackId = url.match(/\/track\/([0-9a-zA-Z]+)/)[1];
|
|
at3.requestSpotify('https://api.spotify.com/v1/tracks/' + trackId).then((trackInfos) => {
|
|
const track = {
|
|
title: trackInfos.name,
|
|
artistName: trackInfos.artists[0].name,
|
|
duration: Math.ceil(trackInfos.duration_ms / 1000),
|
|
spotifyId: trackId,
|
|
cover: trackInfos.album.images[0].url,
|
|
};
|
|
const e = at3.downloadTrack(track, outputFolder, callback, v);
|
|
|
|
at3.forwardEvents(e, emitter);
|
|
});
|
|
} else if (type === 'deezer') {
|
|
const trackId = url.match(/\/track\/([0-9]+)/)[1];
|
|
at3.getDeezerTrackInfos(trackId, v).then((trackInfos) => {
|
|
const e = at3.downloadTrack(trackInfos, outputFolder, callback, v);
|
|
|
|
at3.forwardEvents(e, emitter);
|
|
});
|
|
}
|
|
|
|
return emitter;
|
|
};
|
|
|
|
/**
|
|
* Forward any classical event from e1 to e2, and abort from e2 to e1
|
|
* @param e1 Event The source
|
|
* @param e2 Event the destination
|
|
* @return e2
|
|
*/
|
|
at3.forwardEvents = (e1, e2) => {
|
|
const events = [
|
|
'download',
|
|
'download-end',
|
|
'convert',
|
|
'convert-end',
|
|
'infos',
|
|
'error',
|
|
'playlist-infos',
|
|
'begin-url',
|
|
'end-url',
|
|
'end',
|
|
'search-end',
|
|
];
|
|
events.forEach((e) => {
|
|
e1.on(e, (data) => {
|
|
e2.emit(e, data);
|
|
});
|
|
});
|
|
e2.once('abort', () => {
|
|
e1.emit('abort');
|
|
});
|
|
return e2;
|
|
};
|
|
|
|
/**
|
|
* Return the suggested songs for the query
|
|
* @param query string
|
|
* @param limit number
|
|
* @return Promise<array<trackInfos>> Array of potential songs
|
|
*/
|
|
at3.suggestedSongs = (query, limit) => {
|
|
if (!limit) {
|
|
limit = 5;
|
|
}
|
|
|
|
return request({
|
|
uri: 'https://api.deezer.com/search?limit=' + limit + '&q=' + encodeURIComponent(query),
|
|
json: true,
|
|
}).then((results) => {
|
|
return _.map(results.data, (r) => {
|
|
return {
|
|
title: r.title,
|
|
artistName: r.artist.name,
|
|
duration: r.duration,
|
|
cover: r.album.cover_medium,
|
|
deezerId: r.id,
|
|
};
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Return the suggested albums for the query
|
|
* @param query string
|
|
* @param limit number
|
|
* @return Promise<array<Object>> Array of potential albums
|
|
*/
|
|
at3.suggestedAlbums = (query, limit) => {
|
|
if (!limit) {
|
|
limit = 5;
|
|
}
|
|
|
|
return request({
|
|
uri: 'https://api.deezer.com/search/album?limit=' + limit + '&q=' + encodeURIComponent(query),
|
|
json: true,
|
|
}).then((results) => {
|
|
return _.map(results.data, (r) => {
|
|
return {
|
|
title: r.title,
|
|
artistName: r.artist.name,
|
|
cover: r.cover_medium,
|
|
deezerId: r.id,
|
|
link: r.link,
|
|
nbTracks: r.nb_tracks,
|
|
};
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Return the type of the query
|
|
* @param query string
|
|
* @return string: text, single-url, playlist-url, track-url, not-supported
|
|
*/
|
|
at3.typeOfQuery = (query) => {
|
|
if (!at3.isURL(query)) {
|
|
return 'text';
|
|
}
|
|
const type = at3.guessURLType(query);
|
|
if (!type) {
|
|
return 'not-supported';
|
|
}
|
|
|
|
if (type === 'youtube' && /list=([0-9a-zA-Z_-]+)/.test(query)) {
|
|
return 'playlist-url';
|
|
} else if (type === 'deezer') {
|
|
if (/\/(playlist|album)\//.test(query)) {
|
|
return 'playlist-url';
|
|
} else if (/\/track\//.test(query)) {
|
|
return 'track-url';
|
|
}
|
|
return 'not-supported';
|
|
} else if (type === 'soundcloud' && /\/sets\//.test(query)) {
|
|
return 'playlist-url';
|
|
} else if (type === 'spotify') {
|
|
if (/\/(playlist|album)\//.test(query)) {
|
|
return 'playlist-url';
|
|
} else if (/\/track\//.test(query)) {
|
|
return 'track-url';
|
|
}
|
|
return 'not-supported';
|
|
}
|
|
|
|
return 'single-url';
|
|
};
|
|
|
|
/**
|
|
* Return URL type
|
|
* @param url
|
|
* @return string
|
|
*/
|
|
at3.guessURLType = (url) => {
|
|
if (/^(https?:\/\/)?((www|m)\.)?((youtube\.([a-z]{2,4}))|(youtu\.be))/.test(url)) {
|
|
return 'youtube';
|
|
} else if (/^(https?:\/\/)?(((www)|(m))\.)?(soundcloud\.([a-z]{2,4}))/.test(url)) {
|
|
return 'soundcloud';
|
|
} else if (/^(https?:\/\/)?(www\.)?(deezer\.([a-z]{2,4}))\//.test(url)) {
|
|
return 'deezer';
|
|
} else if (/^(https?:\/\/)?((open|play)\.)?spotify\.([a-z]{2,4})/.test(url)) {
|
|
return 'spotify';
|
|
}
|
|
};
|
|
|
|
const imatch = (textSearched, text) => {
|
|
// [TODO] Improve this function (use .test and espace special caracters + use it everywhere else)
|
|
return text.match(new RegExp(textSearched, 'gi'));
|
|
};
|
|
const vsimpleName = (text, exact) => {
|
|
if (exact === undefined) {
|
|
exact = false;
|
|
}
|
|
text = text.toLowerCase();
|
|
if (!exact) {
|
|
// text = text.replace('feat', '');
|
|
}
|
|
text = text.replace(/((\[)|(\())?radio edit((\])|(\)))?/gi, '');
|
|
text = text.replace(/[^a-zA-Z0-9]/gi, '');
|
|
return text;
|
|
};
|
|
const delArtist = (artist, text, exact) => {
|
|
if (exact === undefined) {
|
|
exact = false;
|
|
}
|
|
if (vsimpleName(artist).length <= 2) {
|
|
// Artist with a very short name (Mathieu Chedid - M)
|
|
return vsimpleName(text, exact);
|
|
} else {
|
|
// [TODO] Improve, escape regex special caracters in vsimpleName(artist)
|
|
return vsimpleName(text, exact).replace(new RegExp(vsimpleName(artist), 'ig'), '');
|
|
}
|
|
};
|
|
const simpleName = (text) => {
|
|
return text.replace(/\(.+\)/g, '');
|
|
};
|
|
|
|
module.exports = at3;
|