1.1.0 - Translations, downloads, bug fixes

This commit is contained in:
exttex
2020-10-31 16:54:28 +01:00
parent 2f8bca3dfb
commit fbafb4286d
63 changed files with 3820 additions and 636 deletions

View File

@ -2,7 +2,8 @@ const crypto = require('crypto');
const axios = require('axios');
const decryptor = require('nodeezcryptor');
const querystring = require('querystring');
const {Transform} = require('stream');
const https = require('https');
const {Transform, Readable} = require('stream');
const {Track} = require('./definitions');
const logger = require('./winston');
@ -63,6 +64,11 @@ class DeezerAPI {
}
}
//Invalid CSRF
if (data.data.error && data.data.error.VALID_TOKEN_REQUIRED) {
await this.callApi('deezer.getUserData');
return await this.callApi(method, args, gatewayInput);
}
return data.data;
}
@ -118,6 +124,13 @@ class DeezerAPI {
});
data = JSON.parse(data.toString('utf-8'));
//Invalid CSRF
if (data.error && data.error.VALID_TOKEN_REQUIRED) {
await this.callApi('deezer.getUserData');
return await this.callApi(method, args, gatewayInput);
}
return data;
}
@ -131,6 +144,15 @@ class DeezerAPI {
return true;
}
async callPublicApi(path, params) {
let res = await axios({
url: `https://api.deezer.com/${encodeURIComponent(path)}/${encodeURIComponent(params)}`,
responseType: 'json',
method: 'GET'
});
return res.data;
}
//Get track URL
static getUrl(trackId, md5origin, mediaVersion, quality = 3) {
const magic = Buffer.from([0xa4]);
@ -165,46 +187,118 @@ class DeezerAPI {
return `https://e-cdns-proxy-${md5origin.substring(0, 1)}.dzcdn.net/mobile/1/${step3}`;
}
//Quality fallback
async qualityFallback(info, quality = 3) {
if (quality == 1) return {
quality: '128kbps',
format: 'MP3',
source: 'stream',
url: `/stream/${info}?q=1`
};
async fallback(info, quality = 3) {
let qualityInfo = Track.getUrlInfo(info);
//User uploaded MP3s
if (qualityInfo.trackId.startsWith('-')) {
qualityInfo.quality = 3;
return qualityInfo;
}
//Quality fallback
let newQuality = await this.qualityFallback(qualityInfo, quality);
if (newQuality != null) {
return qualityInfo;
}
//ID Fallback
let trackData = await this.callApi('deezer.pageTrack', {sng_id: qualityInfo.trackId});
try {
let tdata = Track.getUrlInfo(info);
let res = await axios.head(DeezerAPI.getUrl(tdata.trackId, tdata.md5origin, tdata.mediaVersion, quality));
if (quality == 3) {
return {
quality: '320kbps',
format: 'MP3',
source: 'stream',
url: `/stream/${info}?q=3`
}
}
//Bitrate will be calculated in client
return {
quality: res.headers['content-length'],
format: 'FLAC',
source: 'stream',
url: `/stream/${info}?q=9`
if (trackData.results.DATA.FALLBACK.SNG_ID.toString() != qualityInfo.trackId) {
let newId = trackData.results.DATA.FALLBACK.SNG_ID.toString();
let newTrackData = await this.callApi('deezer.pageTrack', {sng_id: newId});
let newTrack = new Track(newTrackData.results.DATA);
return this.fallback(newTrack.streamUrl);
}
} catch (e) {
logger.warn('Qualiy fallback: ' + e);
logger.warn('TrackID Fallback failed: ' + e);
}
//ISRC Fallback
try {
let publicTrack = this.callPublicApi('track', 'isrc:' + trackData.results.DATA.ISRC);
let newId = publicTrack.id.toString();
let newTrackData = await this.callApi('deezer.pageTrack', {sng_id: newId});
let newTrack = new Track(newTrackData.results.DATA);
return this.fallback(newTrack.streamUrl);
} catch (e) {
logger.warn('ISRC Fallback failed: ' + e);
}
return null;
}
//Fallback thru available qualities, -1 if none work
async qualityFallback(info, quality = 3) {
try {
let res = await axios.head(DeezerAPI.getUrl(info.trackId, info.md5origin, info.mediaVersion, quality));
if (res.status > 400) throw new Error(`Status code: ${res.status}`);
//Make sure it's an int
info.quality = parseInt(quality.toString(), 10);
info.size = parseInt(res.headers['content-length'], 10);
return info;
} catch (e) {
logger.warn('Quality fallback: ' + e);
//Fallback
//9 - FLAC
//3 - MP3 320
//1 - MP3 128
let q = quality;
if (quality == 9) q = 3;
if (quality == 3) q = 1;
return this.qualityFallback(info, q);
let nq = -1;
if (quality == 3) nq = 1;
if (quality == 9) nq = 3;
if (quality == 1) return null;
return this.qualityFallback(info, nq);
}
}
}
class DeezerStream extends Readable {
constructor(qualityInfo, options) {
super(options);
this.qualityInfo = qualityInfo;
this.ended = false;
}
async open(offset, end) {
//Prepare decryptor
this.decryptor = new DeezerDecryptionStream(this.qualityInfo.trackId, {offset});
this.decryptor.on('end', () => {
this.ended = true;
});
//Calculate headers
let offsetBytes = offset - (offset % 2048);
end = (end == -1) ? '' : end;
let url = DeezerAPI.getUrl(this.qualityInfo.trackId, this.qualityInfo.md5origin, this.qualityInfo.mediaVersion, this.qualityInfo.quality);
//Open request
await new Promise((res) => {
this.request = https.get(url, {headers: {'Range': `bytes=${offsetBytes}-${end}`}}, (r) => {
r.pipe(this.decryptor);
this.size = parseInt(r.headers['content-length'], 10) + offsetBytes;
res();
});
});
}
async _read() {
//Decryptor ended
if (this.ended)
return this.push(null);
this.decryptor.once('readable', () => {
this.push(this.decryptor.read());
});
}
_destroy(err, callback) {
this.request.destroy();
this.decryptor.destroy();
callback();
}
}
class DeezerDecryptionStream extends Transform {
constructor(trackId, options = {offset: 0}) {
@ -258,4 +352,4 @@ class DeezerDecryptionStream extends Transform {
}
module.exports = {DeezerAPI, DeezerDecryptionStream};
module.exports = {DeezerAPI, DeezerDecryptionStream, DeezerStream};

View File

@ -1,3 +1,4 @@
//Datatypes, constructor parameters = gw_light API call.
class Track {
constructor(json) {
@ -11,8 +12,8 @@ class Track {
this.artistString = this.artists.map((a) => a.name).join(', ');
this.album = new Album(json);
this.trackNumber = parseInt((json.TRACK_NUMBER || 0).toString(), 10);
this.diskNumber = parseInt((json.DISK_NUMBER || 0).toString(), 10);
this.trackNumber = parseInt((json.TRACK_NUMBER || 1).toString(), 10);
this.diskNumber = parseInt((json.DISK_NUMBER || 1).toString(), 10);
this.explicit = json['EXPLICIT_LYRICS'] == 1 ? true:false;
this.lyricsId = json.LYRICS_ID;
@ -35,7 +36,7 @@ class Track {
if (info.charAt(32) == '1') md5origin += '.mp3';
let mediaVersion = parseInt(info.substring(33, 34)).toString();
let trackId = info.substring(35);
return {trackId, md5origin, mediaVersion};
return new QualityInfo(md5origin, mediaVersion, trackId);
}
}
@ -76,6 +77,7 @@ class Artist {
this.albumCount = albumsJson.total;
this.albums = albumsJson.data.map((a) => new Album(a));
this.topTracks = topJson.data.map((t) => new Track(t));
this.radio = json.SMARTRADIO;
}
}
@ -277,5 +279,27 @@ class Lyric {
}
}
class QualityInfo {
constructor(md5origin, mediaVersion, trackId, quality = 1, source='stream') {
this.md5origin = md5origin;
this.mediaVersion = mediaVersion;
this.trackId = trackId;
this.quality = quality;
this.source = source;
//For FLAC bitrate calculation
this.size = 1;
this.url = '';
}
//Generate direct stream URL
generateUrl() {
let md5 = this.md5origin.replace('.mp3', '');
let md5mp3bit = this.md5origin.includes('.mp3') ? '1' : '0';
let mv = this.mediaVersion.toString().padStart(2, '0');
this.url = `/stream/${md5}${md5mp3bit}${mv}${this.trackId}?q=${this.quality}`;
}
}
module.exports = {Track, Album, Artist, Playlist, User, SearchResults,
DeezerImage, DeezerProfile, DeezerLibrary, DeezerPage, Lyrics};

View File

@ -1,158 +1,46 @@
const {Settings} = require('./settings');
const {Track} = require('./definitions');
const decryptor = require('nodeezcryptor');
const fs = require('fs');
const path = require('path');
const logger = require('./winston');
const https = require('https');
const {DeezerAPI} = require('./deezer');
const Datastore = require('nedb');
const {Settings} = require('./settings');
const fs = require('fs');
const https = require('https');
const logger = require('./winston');
const path = require('path');
const decryptor = require('nodeezcryptor');
const sanitize = require('sanitize-filename');
const ID3Writer = require('browser-id3-writer');
const Metaflac = require('metaflac-js2');
const sanitize = require("sanitize-filename");
const { DeezerAPI } = require('./deezer');
const { Track, Lyrics } = require('./definitions');
class Downloads {
constructor(settings, qucb) {
this.downloads = [];
this.downloading = false;
this.download;
let deezer;
class DownloadManager {
constructor(settings, callback) {
this.settings = settings;
//Queue update callback
this.qucb = qucb;
}
//Add track to queue
async add(track, quality = null) {
if (this.downloads.filter((e => e.id == track.id)).length > 0) {
//Track already in queue
return;
}
//Sanitize quality
let q = this.settings.downloadsQuality;
if (quality) q = parseInt(quality.toString(), 10);
//Create download
let outpath = this.generateTrackPath(track, q);
let d = new Download(
track,
outpath,
q,
() => {this._downloadDone();}
);
this.downloads.push(d);
//Update callback
if (this.qucb) this.qucb();
//Save to DB
await new Promise((res, rej) => {
this.db.insert(d.toDB(), (e) => {
res();
});
});
}
generateTrackPath(track, quality) {
//Generate filename
let fn = this.settings.downloadFilename;
//Disable feats for single artist
let feats = '';
if (track.artists.length >= 2) feats = track.artists.slice(1).map((a) => a.name).join(', ');
let props = {
'%title%': track.title,
'%artists%': track.artistString,
'%artist%': track.artists[0].name,
'%feats%': feats,
'%trackNumber%': (track.trackNumber ? track.trackNumber : 1).toString(),
'%0trackNumber%': (track.trackNumber ? track.trackNumber : 1).toString().padStart(2, '0'),
'%album%': track.album.title
};
for (let k of Object.keys(props)) {
fn = fn.replace(new RegExp(k, 'g'), sanitize(props[k]));
}
//Generate folders
let p = this.settings.downloadsPath;
if (this.settings.createArtistFolder) p = path.join(p, sanitize(track.artists[0].name));
if (this.settings.createAlbumFolder) p = path.join(p, sanitize(track.album.title));
return path.join(p, fn);
}
async start() {
//Already downloading
if (this.download || this.downloads.length == 0) return;
this.downloading = true;
await this._downloadDone();
}
async stop() {
//Not downloading
if (!this.download || !this.downloading) return;
this.downloading = false;
await this.download.stop();
this.callback = callback;
//Back to queue if undone
if (this.download.state < 3) this.downloads.unshift(this.download);
this.download = null;
this.queue = [];
this.threads = [];
//Update callback
if (this.qucb) this.qucb();
this.updateRequests = 0;
}
//On download finished
async _downloadDone() {
//Save to DB
if (this.download) {
await new Promise((res, rej) => {
this.db.update({_id: this.download.id}, {
state: this.download.state,
fallback: this.download.fallback,
}, (e) => {
res();
});
// this.db.remove({_id: this.download.id}, (e) => {
// res();
// });
});
}
this.download = null;
//All downloads done
if (this.downloads.length == 0 || this.downloading == false) {
this.downloading = false;
if (this.qucb) this.qucb();
return;
}
this.download = this.downloads[0];
this.downloads = this.downloads.slice(1);
this.download.start();
//Update callback
if (this.qucb) this.qucb();
//Update DeezerAPI global
setDeezer(d) {
deezer = d;
}
//Load downloads info
async load() {
this.db = new Datastore({filename: Settings.getDownloadsDB(), autoload: true});
//Load downloads
await new Promise((res, rej) => {
this.db.find({}, (err, docs) => {
if (err) return rej();
if (!docs) return;
for (let d of docs) {
if (d.state < 3 && d.state >= 0) this.downloads.push(Download.fromDB(d, () => {this._downloadDone();}));
//TODO: Ignore for now completed
//Load from DB
await new Promise((resolve) => {
this.db.find({state: 0}, (err, docs) => {
if (!err) {
this.queue = docs.map(d => Download.fromDB(d));
}
res();
resolve();
});
});
@ -162,138 +50,211 @@ class Downloads {
}
}
//Remove download
async delete(index) {
//Clear all
if (index == -1) {
this.downloads = [];
await new Promise((res, rej) => {
this.db.remove({state: 0}, {multi: true}, (e) => {});
res();
});
async start() {
this.downloading = true;
await this.updateQueue();
}
if (this.qucb) this.qucb();
async stop() {
this.downloading = false;
//Stop all threads
let nThreads = this.threads.length;
for (let i=nThreads-1; i>=0; i--) {
await this.threads[i].stop();
}
this.updateQueue();
}
async add(track, quality) {
//Sanitize quality
let q = this.settings.downloadsQuality;
if (quality)
q = parseInt(quality.toString(), 10);
let download = new Download(track, q, 0);
//Check if in queue
if (this.queue.some(d => d.track.id == track.id)) {
return;
}
//Remove single
if (index >= this.downloads.length) return;
await new Promise((res, rej) => {
this.db.remove({_id: this.downloads[index].id}, {}, (e) => {});
res();
});
this.downloads.splice(index, 1);
//Check if in DB
let dbDownload = await new Promise((resolve) => {
this.db.find({_id: download.track.id}, (err, docs) => {
if (err) return resolve(null);
if (docs.length == 0) return resolve(null);
if (this.qucb) this.qucb();
//Update download as not done, will be skipped while downloading
this.db.update({_id: download.track.id}, {state: 0, quality: download.quality}, {}, () => {
resolve(Download.fromDB(docs[0]));
});
});
});
//Insert to DB
if (!dbDownload) {
await new Promise((resolve) => {
this.db.insert(download.toDB(), () => {
resolve();
});
});
}
//Queue
this.queue.push(download);
this.updateQueue();
}
async delete(index) {
//-1 = Delete all
if (index == -1) {
let ids = this.queue.map(q => q.track.id);
this.queue = [];
//Remove from DB
await new Promise((res) => {
this.db.remove({_id: {$in: ids}}, {multi: true}, () => {
res();
})
});
this.updateQueue();
return;
}
//Remove single item
let id = this.queue[index].track.id;
this.queue.splice(index, 1);
await new Promise((res) => {
this.db.remove({_id: id}, {}, () => {
res();
})
})
this.updateQueue();
}
//Thread safe update
async updateQueue() {
this.updateRequests++;
if (this._updatePromise) return;
this._updatePromise = this._updateQueue();
await this._updatePromise;
this._updatePromise = null;
this.updateRequests--;
if (this.updateRequests > 0) {
this.updateRequests--;
this.updateQueue();
}
}
async _updateQueue() {
//Finished downloads
if (this.threads.length > 0) {
for (let i=this.threads.length-1; i>=0; i--) {
if (this.threads[i].download.state == 3 || this.threads[i].download.state == -1) {
//Update DB
await new Promise((resolve) => {
this.db.update({_id: this.threads[i].download.track.id}, {state: this.threads[i].download.state}, {}, () => {
resolve();
});
});
this.threads.splice(i, 1);
} else {
//Remove if stopped
if (this.threads[i].stopped) {
this.queue.unshift(this.threads[i].download);
this.threads.splice(i, 1);
}
}
}
}
//Create new threads
if (this.downloading) {
let nThreads = this.settings.downloadThreads - this.threads.length;
for (let i=0; i<nThreads; i++) {
if (this.queue.length > 0) {
let thread = new DownloadThread(this.queue[0], () => {this.updateQueue();}, this.settings);
thread.start();
this.threads.push(thread);
this.queue.splice(0, 1);
}
}
}
//Stop downloading if queues empty
if (this.queue.length == 0 && this.threads.length == 0 && this.downloading)
this.downloading = false;
//Update UI
if (this.callback)
this.callback();
}
}
class Download {
constructor(track, path, quality, onDone) {
this.track = track;
this.id = track.id;
this.path = path;
this.quality = quality;
this.onDone = onDone;
//States:
//0 - none/stopped
//1 - downloading
//2 - post-processing
//3 - done
//-1 - download error
this.state = 0;
this.fallback = false;
this._request;
//Post Processing Promise
this._ppp;
this.downloaded = 0;
this.size = 0;
class DownloadThread {
constructor (download, callback, settings) {
this.download = download;
this.callback = callback;
this.settings = settings;
this.stopped = true;
this.isUserUploaded = download.track.id.toString().startsWith('-');
}
//Serialize to database json
toDB() {
return {
_id: this.id,
path: this.path,
quality: this.quality,
track: this.track,
state: this.state,
fallback: this.fallback
}
}
//Create download from DB document
static fromDB(doc, onDone) {
let d = new Download(doc.track, doc.path, doc.quality, onDone);
d.fallback = doc.fallback ? true : false; //Null check
d.state = doc.state;
return d;
//Callback wrapper
_cb() {
if (this.callback) this.callback();
}
async start() {
this.state = 1;
this.download.state = 1;
this.stopped = false;
//Fallback
this.qualityInfo = await deezer.fallback(this.download.track.streamUrl, this.download.quality);
if (!this.qualityInfo) {
this.download.state = -1;
return;
}
//Get track info
if (!this.isUserUploaded) {
this.rawTrack = await deezer.callApi('deezer.pageTrack', {'sng_id': this.download.track.id});
this.track = new Track(this.rawTrack.results.DATA);
this.publicTrack = await deezer.callPublicApi('track', this.track.id);
this.publicAlbum = await deezer.callPublicApi('album', this.track.album.id);
}
//Check if exists
let outPath = this.generatePath(this.qualityInfo.quality);
try {
await fs.promises.access(outPath, fs.constants.R_OK);
//File exists
this.download.state = 3;
return this._cb();
} catch (_) {}
//Path to temp file
let tmp = path.join(Settings.getTempDownloads(), `${this.track.id}.ENC`);
let tmp = path.join(Settings.getTempDownloads(), `${this.download.track.id}.ENC`);
//Get start offset
let start = 0;
try {
let stat = await fs.promises.stat(tmp);
if (stat.size) start = stat.size;
// eslint-disable-next-line no-empty
} catch (e) {}
this.downloaded = start;
this.download.downloaded = start;
//Get download info
let streamInfo = Track.getUrlInfo(this.track.streamUrl);
this.url = DeezerAPI.getUrl(streamInfo.trackId, streamInfo.md5origin, streamInfo.mediaVersion, this.quality);
this._request = https.get(this.url, {headers: {'Range': `bytes=${start}-`}}, (r) => {
//Download
let url = DeezerAPI.getUrl(this.qualityInfo.trackId, this.qualityInfo.md5origin, this.qualityInfo.mediaVersion, this.qualityInfo.quality);
if (this.stopped) return;
this._request = https.get(url, {headers: {'Range': `bytes=${start}-`}}, (r) => {
this._response = r;
let outFile = fs.createWriteStream(tmp, {flags: 'a'});
let skip = false;
//Error
if (r.statusCode >= 400) {
//Fallback on error
if (this.quality > 1) {
if (this.quality == 3) this.quality = 1;
if (this.quality == 9) this.quality = 3;
this.url = null;
this.fallback = true;
return this.start();
};
//Error
this.state = -1;
logger.error(`Undownloadable track ID: ${this.track.id}`);
return this.onDone();
} else {
this.path += (this.quality == 9) ? '.flac' : '.mp3';
//Check if file exits
fs.access(this.path, (err) => {
if (err) {
} else {
logger.warn('File already exists! Skipping...');
outFile.close();
skip = true;
this._request.end();
this.state = 3;
return this.onDone();
}
})
}
//On download done
r.on('end', () => {
if (skip) return;
if (this.downloaded != this.size) return;
if (this.download.size != this.download.downloaded) return;
outFile.on('finish', () => {
outFile.close(() => {
this._finished(tmp);
this.postPromise = this._post(tmp);
});
});
outFile.end();
@ -301,7 +262,7 @@ class Download {
//Progress
r.on('data', (c) => {
outFile.write(c);
this.downloaded += c.length;
this.download.downloaded += c.length;
});
r.on('error', (e) => {
@ -311,53 +272,136 @@ class Download {
//Save size
this.size = parseInt(r.headers['content-length'], 10) + start;
this.download.size = this.size;
});
}
//Stop current request
async stop() {
this._request.destroy();
this._request = null;
this.state = 0;
if (this._ppp) await this._ppp;
//If post processing, wait for it
if (this.postPromise) {
await this._postPromise;
return this._cb();
}
//Cancel download
if (this._response)
this._response.destroy();
if (this._request)
this._request.destroy();
// this._response = null;
// this._request = null;
this.stopped = true;
this.download.state = 0;
this._cb();
}
async _finished(tmp) {
this.state = 2;
//Create post processing promise
let resolve;
this._ppp = new Promise((res, rej) => {
resolve = res;
});
//Prepare output directory
try {
await fs.promises.mkdir(path.dirname(this.path), {recursive: true})
} catch (e) {};
async _post(tmp) {
this.download.state = 2;
//Decrypt
//this.path += (this.quality == 9) ? '.flac' : '.mp3';
decryptor.decryptFile(decryptor.getKey(this.track.id), tmp, `${tmp}.DEC`);
await fs.promises.copyFile(`${tmp}.DEC`, this.path);
//Delete encrypted
await fs.promises.unlink(tmp);
decryptor.decryptFile(decryptor.getKey(this.qualityInfo.trackId), tmp, `${tmp}.DEC`);
let outPath = this.generatePath(this.qualityInfo.quality);
await fs.promises.mkdir(path.dirname(outPath), {recursive: true});
await fs.promises.copyFile(`${tmp}.DEC`, outPath);
await fs.promises.unlink(`${tmp}.DEC`);
await fs.promises.unlink(tmp);
//Tags
await this.tagAudio(this.path, this.track);
if (!this.isUserUploaded) {
//Tag
await this.tagTrack(outPath);
//Finish
this.state = 3;
resolve();
this._ppp = null;
this.onDone();
//Lyrics
if (this.settings.downloadLyrics) {
let lrcFile = outPath.split('.').slice(0, -1).join('.') + '.lrc';
let lrc;
try {
lrc = this.generateLRC();
} catch (e) {
logger.warn('Error getting lyrics! ' + e);
}
if (lrc) {
await fs.promises.writeFile(lrcFile, lrc, {encoding: 'utf-8'});
}
}
}
this.download.state = 3;
this._cb();
}
async tagTrack(path) {
let cover;
try {
cover = await this.downloadCover(this.track.albumArt.full);
} catch (e) {}
//Genre tag
let genres = [];
if (this.publicAlbum.genres && this.publicAlbum.genres.data)
genres = this.publicAlbum.genres.data.map(g => g.name);
if (path.toLowerCase().endsWith('.mp3')) {
//Load
const audioData = await fs.promises.readFile(path);
const writer = new ID3Writer(audioData);
writer.setFrame('TIT2', this.track.title);
writer.setFrame('TPE1', this.track.artists.map((a) => a.name));
if (this.publicAlbum.artist) writer.setFrame('TPE2', this.publicAlbum.artist.name);
writer.setFrame('TALB', this.track.album.title);
writer.setFrame('TRCK', this.track.trackNumber);
writer.setFrame('TPOS', this.track.diskNumber);
writer.setFrame('TCON', genres);
let date = new Date(this.publicTrack.release_date);
writer.setFrame('TYER', date.getFullYear());
writer.setFrame('TDAT', `${date.getMonth().toString().padStart(2, '0')}${date.getDay().toString().padStart(2, '0')}`);
if (this.publicTrack.bpm > 2) writer.setFrame('TBPM', this.publicTrack.bpm);
if (this.publicAlbum.label) writer.setFrame('TPUB', this.publicAlbum.label);
writer.setFrame('TSRC', this.publicTrack.isrc);
if (this.rawTrack.results.LYRICS) writer.setFrame('USLT', {
lyrics: this.rawTrack.results.LYRICS.LYRICS_TEXT,
language: 'eng',
description: 'Unsychronised lyrics'
});
if (cover) writer.setFrame('APIC', {type: 3, data: cover, description: 'Cover'});
writer.addTag();
//Write
await fs.promises.writeFile(path, Buffer.from(writer.arrayBuffer));
return;
}
//Tag FLAC
if (path.toLowerCase().endsWith('.flac')) {
const flac = new Metaflac(path);
flac.removeAllTags();
flac.setTag(`TITLE=${this.track.title}`);
flac.setTag(`ALBUM=${this.track.album.title}`);
flac.setTag(`ARTIST=${this.track.artistString}`);
flac.setTag(`TRACKNUMBER=${this.track.trackNumber}`);
flac.setTag(`DISCNUMBER=${this.track.diskNumber}`);
if (this.publicAlbum.artist) flac.setTag(`ALBUMARTIST=${this.publicAlbum.artist.name}`);
flac.setTag(`GENRE=${genres.join(", ")}`);
flac.setTag(`DATE=${this.publicTrack.release_date}`);
if (this.publicTrack.bpm > 2) flac.setTag(`BPM=${this.publicTrack.bpm}`);
if (this.publicAlbum.label) flac.setTag(`LABEL=${this.publicAlbum.label}`);
flac.setTag(`ISRC=${this.publicTrack.isrc}`);
if (this.publicAlbum.upc) flac.setTag(`BARCODE=${this.publicAlbum.upc}`);
if (this.rawTrack.results.LYRICS) flac.setTag(`LYRICS=${this.rawTrack.results.LYRICS.LYRICS_TEXT}`);
if (cover) flac.importPicture(cover);
flac.save();
}
}
//Download cover to buffer
async downloadCover(url) {
return await new Promise((res, rej) => {
return await new Promise((res) => {
let out = Buffer.alloc(0);
https.get(url, (r) => {
r.on('data', (d) => {
@ -370,49 +414,105 @@ class Download {
});
}
//Write tags to audio file
async tagAudio(path, track) {
let cover;
try {
cover = await this.downloadCover(track.albumArt.full);
} catch (e) {}
generateLRC() {
//Check if exists
if (!this.rawTrack.results.LYRICS || !this.rawTrack.results.LYRICS.LYRICS_SYNC_JSON) return;
let lyrics = new Lyrics(this.rawTrack.results.LYRICS);
if (lyrics.lyrics.length == 0) return;
//Metadata
let out = `[ar:${this.track.artistString}]\r\n[al:${this.track.album.title}]\r\n[ti:${this.track.title}]\r\n`;
//Lyrics
for (let l of lyrics.lyrics) {
if (l.lrcTimestamp && l.text)
out += `${l.lrcTimestamp}${l.text}\r\n`;
}
return out;
}
generatePath(quality) {
//User uploaded mp3s
if (this.isUserUploaded) {
//Generate path
let p = this.settings.downloadsPath;
if (this.settings.createArtistFolder && this.download.track.artists[0].name.length > 0)
p = path.join(p, sanitize(this.download.track.artists[0].name));
if (this.settings.createAlbumFolder && this.download.track.album.title.length > 0)
p = path.join(p, sanitize(this.download.track.album.title));
//Filename
let out = path.join(p, sanitize(this.download.track.title));
if (!out.includes('.'))
out += '.mp3';
return out;
}
//Generate filename
let fn = this.settings.downloadFilename;
//Disable feats for single artist
let feats = '';
if (this.track.artists.length >= 2)
feats = this.track.artists.slice(1).map((a) => a.name).join(', ');
if (path.toLowerCase().endsWith('.mp3')) {
//Load
const audioData = await fs.promises.readFile(path);
const writer = new ID3Writer(audioData);
writer.setFrame('TIT2', track.title);
if (track.artists) writer.setFrame('TPE1', track.artists.map((a) => a.name));
if (track.album) writer.setFrame('TALB', track.album.title);
if (track.trackNumber) writer.setFrame('TRCK', track.trackNumber);
if (cover) writer.setFrame('APIC', {
type: 3,
data: cover,
description: 'Cover'
});
writer.addTag();
//Write
await fs.promises.writeFile(path, Buffer.from(writer.arrayBuffer));
//Date
let date = new Date(this.publicTrack.release_date);
let props = {
'%title%': this.track.title,
'%artists%': this.track.artistString,
'%artist%': this.track.artists[0].name,
'%feats%': feats,
'%trackNumber%': (this.track.trackNumber ? this.track.trackNumber : 1).toString(),
'%0trackNumber%': (this.track.trackNumber ? this.track.trackNumber : 1).toString().padStart(2, '0'),
'%album%': this.track.album.title,
'%year%': date.getFullYear().toString(),
};
for (let k of Object.keys(props)) {
fn = fn.replace(new RegExp(k, 'g'), sanitize(props[k]));
}
//Tag FLAC
if (path.toLowerCase().endsWith('.flac')) {
const flac = new Metaflac(path);
flac.removeAllTags();
//Generate folders
let p = this.settings.downloadsPath;
if (this.settings.createArtistFolder) p = path.join(p, sanitize(this.track.artists[0].name));
if (this.settings.createAlbumFolder) p = path.join(p, sanitize(this.track.album.title));
flac.setTag(`TITLE=${track.title}`);
if (track.album)flac.setTag(`ALBUM=${track.album.title}`);
if (track.trackNumber) flac.setTag(`TRACKNUMBER=${track.trackNumber}`);
if (track.artistString) flac.setTag(`ARTIST=${track.artistString}`);
if (cover) flac.importPicture(cover);
flac.save();
//Extension
if (quality.toString() == '9') {
fn += '.flac';
} else {
fn += '.mp3';
}
return path.join(p, fn);
}
}
class Download {
constructor (track, quality, state) {
this.track = track;
this.quality = quality;
// 0 - none
// 1 - downloading
// 2 - postprocess
// 3 - done
// -1 - error
this.state = state;
module.exports = {Downloads, Download};
//Updated from threads
this.downloaded = 0;
this.size = 1;
}
toDB() {
return {
_id: this.track.id,
track: this.track,
quality: this.quality,
state: this.state
}
}
static fromDB(json) {
return new Download(json.track, json.quality, json.state);
}
}
module.exports = {DownloadManager}

View File

@ -4,15 +4,15 @@ const https = require('https');
const fs = require('fs');
const axios = require('axios').default;
const logger = require('./winston');
const {DeezerAPI, DeezerDecryptionStream} = require('./deezer');
const {DeezerAPI, DeezerStream} = require('./deezer');
const {Settings} = require('./settings');
const {Track, Album, Artist, Playlist, DeezerProfile, SearchResults, DeezerLibrary, DeezerPage, Lyrics} = require('./definitions');
const {Downloads} = require('./downloads');
const {DownloadManager} = require('./downloads');
const {Integrations} = require('./integrations');
let settings;
let deezer;
let downloads;
let downloadManager;
let integrations;
let sockets = [];
@ -23,13 +23,16 @@ app.use(express.json({limit: '50mb'}));
app.use(express.static(path.join(__dirname, '../client', 'dist')));
//Server
const server = require('http').createServer(app);
const io = require('socket.io').listen(server);
const io = require('socket.io').listen(server, {
path: '/socket',
});
//Get playback info
app.get('/playback', async (req, res) => {
try {
let data = await fs.promises.readFile(Settings.getPlaybackInfoPath(), 'utf-8');
return res.json(data);
// eslint-disable-next-line no-empty
} catch (e) {}
return res.json({});
@ -53,7 +56,7 @@ app.get('/settings', (req, res) => {
app.post('/settings', async (req, res) => {
if (req.body) {
Object.assign(settings, req.body);
downloads.settings = settings;
downloadManager.settings = settings;
integrations.updateSettings(settings);
await settings.save();
}
@ -70,6 +73,9 @@ app.post('/authorize', async (req, res) => {
settings.arl = req.body.arl;
if (await (deezer.authorize())) {
//Update download manager
downloadManager.setDeezer(deezer);
res.status(200).send('OK');
return;
}
@ -238,16 +244,22 @@ app.put('/library/:type', async (req, res) => {
app.get('/streaminfo/:info', async (req, res) => {
let info = req.params.info;
let quality = req.query.q ? req.query.q : 3;
return res.json(await deezer.qualityFallback(info, quality));
let qualityInfo = await deezer.fallback(info, quality);
if (qualityInfo == null)
return res.sendStatus(404).end();
//Generate stream URL before sending
qualityInfo.generateUrl();
return res.json(qualityInfo);
});
// S T R E A M I N G
app.get('/stream/:info', (req, res) => {
app.get('/stream/:info', async (req, res) => {
//Parse stream info
let quality = req.query.q ? req.query.q : 3;
let streamInfo = Track.getUrlInfo(req.params.info);
let url = DeezerAPI.getUrl(streamInfo.trackId, streamInfo.md5origin, streamInfo.mediaVersion, quality);
let trackId = req.params.info.substring(35);
streamInfo.quality = quality;
//MIME type of audio
let mime = 'audio/mp3';
@ -258,59 +270,38 @@ app.get('/stream/:info', (req, res) => {
if (req.headers.range) range = req.headers.range;
let rangeParts = range.replace(/bytes=/, '').split('-');
let start = parseInt(rangeParts[0], 10);
let end = '';
let end = -1;
if (rangeParts.length >= 2) end = rangeParts[1];
if (end == '' || end == ' ') end = -1;
//Round to 2048 for deezer
let dStart = start - (start % 2048);
//Create Stream
let stream = new DeezerStream(streamInfo, {});
await stream.open(start, end);
//Make request to Deezer CDN
let _request = https.get(url, {headers: {'Range': `bytes=${dStart}-${end}`}}, (r) => {
//Error from Deezer
//TODO: Quality fallback
if (r.statusCode < 200 || r.statusCode > 300) {
res.status(404);
return res.end();
}
//Range header
if (req.headers.range) {
end = (end == -1) ? stream.size - 1 : end;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${stream.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': stream.size - start,
'Content-Type': mime
});
//Normal (non range) request
} else {
res.writeHead(200, {
'Content-Length': stream.size,
'Content-Type': mime
});
}
let decryptor = new DeezerDecryptionStream(trackId, {offset: start});
//Get total size
let chunkSize = parseInt(r.headers["content-length"], 10)
let total = chunkSize;
if (start > 0) total += start;
//Ranged request
if (req.headers.range) {
end = total - 1
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${total}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunkSize,
'Content-Type': mime
});
//Normal (non range) request
} else {
res.writeHead(200, {
'Content-Length': total,
'Content-Type': mime
});
}
//Pipe: Deezer -> Decryptor -> Response
decryptor.pipe(res);
r.pipe(decryptor);
});
//Internet/Request error
_request.on('error', () => {
//console.log('Streaming error: ' + e);
//HTML audio will restart automatically
//Should force HTML5 to retry
stream.on('error', () => {
res.destroy();
});
stream.pipe(res);
});
//Get deezer page
@ -361,6 +352,18 @@ app.get('/smarttracklist/:id', async (req, res) => {
return res.send(tracks);
});
//Artist smart radio
app.get('/smartradio/:id', async (req, res) => {
let data = await deezer.callApi('smart.getSmartRadio', {art_id: req.params.id});
res.send(data.results.data.map(t => new Track(t)));
});
//Track Mix
app.get('/trackmix/:id', async (req, res) => {
let data = await deezer.callApi('song.getContextualTrackMix', {sng_ids: [req.params.id]});
res.send(data.results.data.map(t => new Track(t)));
});
//Load lyrics, ID = SONG ID
app.get('/lyrics/:id', async (req, res) => {
let data = await deezer.callApi('song.getLyrics', {
@ -390,7 +393,7 @@ app.post('/downloads', async (req, res) => {
let tracks = req.body;
let quality = req.query.q;
for (let track of tracks) {
downloads.add(track, quality);
downloadManager.add(track, quality);
}
res.status(200).send('OK');
@ -398,30 +401,29 @@ app.post('/downloads', async (req, res) => {
//PUT to /download to start
app.put('/download', async (req, res) => {
await downloads.start();
await downloadManager.start();
res.status(200).send('OK');
});
//DELETE to /download to stop/pause
app.delete('/download', async (req, res) => {
await downloads.stop();
await downloadManager.stop();
res.status(200).send('OK');
})
//Get all downloads
app.get('/downloads', async (req, res) => {
res.json({
downloading: downloads.downloading,
downloads: downloads.downloads.map((d) => {
return d.toDB();
})
downloading: downloadManager.downloading,
queue: downloadManager.queue,
threads: downloadManager.threads.map(t => t.download)
});
});
//Delete singel download
//Delete single download
app.delete('/downloads/:index', async (req, res) => {
let index = parseInt(req.params.index, 10);
await downloads.delete(index);
await downloadManager.delete(index);
res.status(200).end();
});
@ -499,32 +501,27 @@ async function createServer(electron = false, ecb) {
deezer = new DeezerAPI(settings.arl, electron);
//Prepare downloads
downloads = new Downloads(settings, () => {
downloadManager = new DownloadManager(settings, () => {
//Emit queue change to socket
sockets.forEach((s) => {
s.emit('downloads', {
downloading: downloads.downloading,
downloads: downloads.downloads
downloading: downloadManager.downloading,
queue: downloadManager.queue,
threads: downloadManager.threads.map(t => t.download)
});
});
});
await downloads.load();
await downloadManager.load();
downloadManager.setDeezer(deezer);
//Emit download progress updates
setInterval(() => {
sockets.forEach((s) => {
if (!downloads.download) {
s.emit('download', null);
return;
}
s.emit('download', {
id: downloads.download.id,
size: downloads.download.size,
downloaded: downloads.download.downloaded,
track: downloads.download.track,
path: downloads.download.path
});
if (!downloadManager.downloading && downloadManager.threads.length == 0)
return;
s.emit('currentlyDownloading', downloadManager.threads.map(t => t.download));
});
}, 350);
}, 400);
//Integrations (lastfm, discord)
integrations = new Integrations(settings);

View File

@ -28,6 +28,12 @@ class Settings {
this.lastFM = null;
this.enableDiscord = false;
this.discordJoin = false;
this.showAutocomplete = true;
this.downloadThreads = 4;
this.downloadLyrics = true;
this.primaryColor = '#2196F3';
this.language = 'en';
}
//Based on electorn app.getPath
@ -57,7 +63,12 @@ class Settings {
}
//Get path to downloads database
static getDownloadsDB() {
return path.join(Settings.getDir(), 'downloads.db');
//Delete old DB if exists
let oldPath = path.join(Settings.getDir(), 'downloads.db');
if (fs.existsSync(oldPath))
fs.unlink(oldPath, () => {});
return path.join(Settings.getDir(), 'downloads2.db');
}
//Get path to temporary / unfinished downlaods
static getTempDownloads() {