paxt001/paxt002/index.js

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;