First release
This commit is contained in:
219
app/src/deezer.js
Normal file
219
app/src/deezer.js
Normal file
@ -0,0 +1,219 @@
|
||||
const crypto = require('crypto');
|
||||
const axios = require('axios');
|
||||
const decryptor = require('nodeezcryptor');
|
||||
const querystring = require('querystring');
|
||||
const {Transform} = require('stream');
|
||||
|
||||
class DeezerAPI {
|
||||
|
||||
constructor(arl, electron = false) {
|
||||
this.arl = arl;
|
||||
this.electron = electron;
|
||||
this.url = 'https://www.deezer.com/ajax/gw-light.php';
|
||||
}
|
||||
|
||||
//Get headers
|
||||
headers() {
|
||||
let cookie = `arl=${this.arl}`;
|
||||
if (this.sid) cookie += `; sid=${this.sid}`;
|
||||
return {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/67.0.3396.99 Safari/537.36",
|
||||
"Content-Language": "en-US",
|
||||
"Cache-Control": "max-age=0",
|
||||
"Accept": "*/*",
|
||||
"Accept-Charset": "utf-8,ISO-8859-1;q=0.7,*;q=0.3",
|
||||
"Accept-Language": "en-US,en;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"Connection": 'keep-alive',
|
||||
"Cookie": cookie
|
||||
}
|
||||
}
|
||||
|
||||
//Wrapper for api calls, because axios doesn't work reliably with electron
|
||||
async callApi(method, args = {}, gatewayInput = null) {
|
||||
if (this.electron) return await this._callApiElectronNet(method, args, gatewayInput);
|
||||
return await this._callApiAxios(method, args, gatewayInput);
|
||||
}
|
||||
|
||||
//gw_light api call using axios, unstable in electron
|
||||
async _callApiAxios(method, args = {}, gatewayInput = null) {
|
||||
let data = await axios({
|
||||
url: this.url,
|
||||
method: 'POST',
|
||||
headers: this.headers(),
|
||||
responseType: 'json',
|
||||
params: Object.assign({
|
||||
api_version: '1.0',
|
||||
api_token: this.token ? this.token : 'null',
|
||||
input: '3',
|
||||
method: method,
|
||||
},
|
||||
gatewayInput ? {gateway_input: JSON.stringify(gatewayInput)} : null
|
||||
),
|
||||
data: args
|
||||
});
|
||||
|
||||
//Save SID cookie to not get token error
|
||||
if (method == 'deezer.getUserData') {
|
||||
let sidCookie = data.headers['set-cookie'].filter((e) => e.startsWith('sid='));
|
||||
if (sidCookie.length > 0) {
|
||||
sidCookie = sidCookie[0].split(';')[0];
|
||||
this.sid = sidCookie.split('=')[1];
|
||||
}
|
||||
}
|
||||
|
||||
return data.data;
|
||||
}
|
||||
|
||||
//gw_light api call using electron net
|
||||
async _callApiElectronNet(method, args = {}, gatewayInput = null) {
|
||||
const net = require('electron').net;
|
||||
let data = await new Promise((resolve, reject) => {
|
||||
//Create request
|
||||
let req = net.request({
|
||||
method: 'POST',
|
||||
url: this.url + '?' + querystring.stringify(Object.assign({
|
||||
api_version: '1.0',
|
||||
api_token: this.token ? this.token : 'null',
|
||||
input: '3',
|
||||
method: method,
|
||||
},
|
||||
gatewayInput ? {gateway_input: JSON.stringify(gatewayInput)} : null
|
||||
)),
|
||||
});
|
||||
|
||||
req.on('response', (res) => {
|
||||
let data = Buffer.alloc(0);
|
||||
|
||||
//Save SID cookie
|
||||
if (method == 'deezer.getUserData') {
|
||||
let sidCookie = res.headers['set-cookie'].filter((e) => e.startsWith('sid='));
|
||||
if (sidCookie.length > 0) {
|
||||
sidCookie = sidCookie[0].split(';')[0];
|
||||
this.sid = sidCookie.split('=')[1];
|
||||
}
|
||||
}
|
||||
|
||||
//Response data
|
||||
res.on('data', (buffer) => {
|
||||
data = Buffer.concat([data, buffer]);
|
||||
});
|
||||
res.on('end', () => {
|
||||
resolve(data);
|
||||
})
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
//Write headers
|
||||
let headers = this.headers();
|
||||
for(let key of Object.keys(headers)) {
|
||||
req.setHeader(key, headers[key]);
|
||||
}
|
||||
req.write(JSON.stringify(args));
|
||||
req.end();
|
||||
});
|
||||
|
||||
data = JSON.parse(data.toString('utf-8'));
|
||||
return data;
|
||||
}
|
||||
|
||||
//true / false if success
|
||||
async authorize() {
|
||||
let data = await this.callApi('deezer.getUserData');
|
||||
this.token = data.results.checkForm;
|
||||
this.userId = data.results.USER.USER_ID;
|
||||
|
||||
if (!this.userId || this.userId == 0 || !this.token) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
//Get track URL
|
||||
static getUrl(trackId, md5origin, mediaVersion, quality = 3) {
|
||||
const magic = Buffer.from([0xa4]);
|
||||
let step1 = Buffer.concat([
|
||||
Buffer.from(md5origin),
|
||||
magic,
|
||||
Buffer.from(quality.toString()),
|
||||
magic,
|
||||
Buffer.from(trackId),
|
||||
magic,
|
||||
Buffer.from(mediaVersion)
|
||||
]);
|
||||
//MD5
|
||||
let md5sum = crypto.createHash('md5');
|
||||
md5sum.update(step1);
|
||||
let step1md5 = md5sum.digest('hex');
|
||||
|
||||
let step2 = Buffer.concat([
|
||||
Buffer.from(step1md5),
|
||||
magic,
|
||||
step1,
|
||||
magic
|
||||
]);
|
||||
//Padding
|
||||
while(step2.length%16 > 0) {
|
||||
step2 = Buffer.concat([step2, Buffer.from('.')]);
|
||||
}
|
||||
//AES
|
||||
let aesCipher = crypto.createCipheriv('aes-128-ecb', Buffer.from('jo6aey6haid2Teih'), Buffer.from(''));
|
||||
let step3 = Buffer.concat([aesCipher.update(step2, 'binary'), aesCipher.final()]).toString('hex').toLowerCase();
|
||||
|
||||
return `https://e-cdns-proxy-${md5origin.substring(0, 1)}.dzcdn.net/mobile/1/${step3}`;
|
||||
}
|
||||
}
|
||||
|
||||
class DeezerDecryptionStream extends Transform {
|
||||
|
||||
constructor(trackId, options = {offset: 0}) {
|
||||
super();
|
||||
//Offset as n chunks
|
||||
this.offset = Math.floor(options.offset / 2048);
|
||||
//How many bytes to drop
|
||||
this.drop = options.offset % 2048;
|
||||
this.buffer = Buffer.alloc(0);
|
||||
|
||||
this.key = decryptor.getKey(trackId);
|
||||
}
|
||||
|
||||
_transform(chunk, encoding, next) {
|
||||
//Restore leftovers
|
||||
chunk = Buffer.concat([this.buffer, chunk]);
|
||||
|
||||
while (chunk.length >= 2048) {
|
||||
//Decrypt
|
||||
let slice = chunk.slice(0, 2048);
|
||||
if ((this.offset % 3) == 0) {
|
||||
slice = decryptor.decryptBuffer(this.key, slice);
|
||||
}
|
||||
this.offset++;
|
||||
|
||||
//Cut bytes
|
||||
if (this.drop > 0) {
|
||||
slice = slice.slice(this.drop);
|
||||
this.drop = 0;
|
||||
}
|
||||
|
||||
this.push(slice);
|
||||
|
||||
//Replace original buffer
|
||||
chunk = chunk.slice(2048);
|
||||
}
|
||||
//Save leftovers
|
||||
this.buffer = chunk;
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
//Last chunk
|
||||
async _flush(cb) {
|
||||
//drop should be 0, so it shouldnt affect
|
||||
this.push(this.buffer.slice(this.drop));
|
||||
this.drop = 0;
|
||||
this.buffer = Buffer.alloc(0);
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {DeezerAPI, DeezerDecryptionStream};
|
265
app/src/definitions.js
Normal file
265
app/src/definitions.js
Normal file
@ -0,0 +1,265 @@
|
||||
const {DeezerAPI} = require('./deezer');
|
||||
|
||||
//Datatypes, constructor parameters = gw_light API call.
|
||||
class Track {
|
||||
constructor(json) {
|
||||
this.id = json.SNG_ID.toString();
|
||||
this.title = `${json.SNG_TITLE}${json.VERSION ? ` ${json.VERSION}` : ''}`;
|
||||
//Duration as ms for easier use in frontend
|
||||
this.duration = parseInt(json.DURATION.toString(), 10) * 1000;
|
||||
this.albumArt = new DeezerImage(json.ALB_PICTURE);
|
||||
this.artists = (json.ARTISTS ? json.ARTISTS : [json]).map((a) => new Artist(a));
|
||||
//Helper
|
||||
this.artistString = this.artists.map((a) => a.name).join(', ');
|
||||
|
||||
this.album = new Album(json);
|
||||
this.trackNumber = json.TRACK_NUMBER;
|
||||
this.diskNumber = json.DISK_NUMBER;
|
||||
this.explicit = json['EXPLICIT_LYRICS'] == 1 ? true:false;
|
||||
this.lyricsId = json.LYRICS_ID;
|
||||
|
||||
this.library = null;
|
||||
|
||||
//Generate URL Part
|
||||
//0 - 32 = MD5 ORIGIN
|
||||
//33 - = 1/0 if md5origin ends with .mp3
|
||||
//34 - 35 = MediaVersion
|
||||
//Rest = Track ID
|
||||
let md5 = json.MD5_ORIGIN.replace('.mp3', '');
|
||||
let md5mp3bit = json.MD5_ORIGIN.includes('.mp3') ? '1' : '0';
|
||||
let mv = json.MEDIA_VERSION.toString().padStart(2, '0');
|
||||
this.streamUrl = `${md5}${md5mp3bit}${mv}${this.id}`;
|
||||
}
|
||||
|
||||
//Get Deezer CDN url by streamUrl
|
||||
static getUrl(info, quality = 3) {
|
||||
let md5origin = info.substring(0, 32);
|
||||
if (info.charAt(32) == '1') md5origin += '.mp3';
|
||||
let mediaVersion = parseInt(info.substring(33, 34)).toString();
|
||||
let trackId = info.substring(35);
|
||||
let url = DeezerAPI.getUrl(trackId, md5origin, mediaVersion, quality);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
class Album {
|
||||
constructor(json, tracksJson = {data: []}) {
|
||||
this.id = json.ALB_ID.toString();
|
||||
this.title = json.ALB_TITLE;
|
||||
this.art = new DeezerImage(json.ALB_PICTURE);
|
||||
this.fans = json.NB_FAN;
|
||||
this.tracks = tracksJson.data.map((t) => new Track(t));
|
||||
this.artists = (json.ARTISTS ? json.ARTISTS : [json]).map((a) => new Artist(a));
|
||||
this.releaseDate = json.DIGITAL_RELEASE_DATE;
|
||||
//Helpers
|
||||
this.artistString = this.artists.map((a) => a.name).join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
class Artist {
|
||||
constructor(json, albumsJson = {data: []}, topJson = {data: []}) {
|
||||
this.id = json.ART_ID.toString();
|
||||
this.name = json.ART_NAME;
|
||||
this.fans = json.NB_FAN;
|
||||
this.picture = new DeezerImage(json.ART_PICTURE, 'artist');
|
||||
this.albumCount = albumsJson.total;
|
||||
this.albums = albumsJson.data.map((a) => new Album(a));
|
||||
this.topTracks = topJson.data.map((t) => new Track(t));
|
||||
}
|
||||
}
|
||||
|
||||
class Playlist {
|
||||
constructor(json, tracksJson = {data: []}) {
|
||||
this.id = json.PLAYLIST_ID.toString(),
|
||||
this.title = json.TITLE,
|
||||
this.trackCount = json.NB_SONG ? json.NB_SONG : tracksJson.total;
|
||||
this.image = new DeezerImage(json.PLAYLIST_PICTURE, 'playlist');
|
||||
this.fans = json.NB_FAN;
|
||||
this.duration = parseInt((json.DURATION ? json.DURATION : 0).toString(), 10) * 1000;
|
||||
this.description = json.DESCRIPTION;
|
||||
this.user = new User(
|
||||
json.PARENT_USER_ID,
|
||||
json.PARENT_USERNAME,
|
||||
new DeezerImage(json.PARENT_USER_PICTURE, 'user')
|
||||
);
|
||||
this.tracks = tracksJson.data.map((t) => new Track(t));
|
||||
}
|
||||
|
||||
//Extend tracks
|
||||
extend(tracksJson = {data: []}) {
|
||||
let tracks = tracksJson.data.map((t) => new Track(t));
|
||||
this.tracks.push(...tracks);
|
||||
}
|
||||
}
|
||||
|
||||
class User {
|
||||
constructor(id, name, picture) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.picture = picture;
|
||||
}
|
||||
}
|
||||
|
||||
class DeezerImage {
|
||||
constructor(hash, type='cover') {
|
||||
this.hash = hash;
|
||||
this.type = type;
|
||||
//Create full and thumb, to standardize size and because functions aren't preserved
|
||||
this.full = this.url(1400);
|
||||
this.thumb = this.url(256);
|
||||
}
|
||||
|
||||
url(size = 256) {
|
||||
return `https://e-cdns-images.dzcdn.net/images/${this.type}/${this.hash}/${size}x${size}-000000-80-0-0.jpg`;
|
||||
}
|
||||
}
|
||||
|
||||
class SearchResults {
|
||||
constructor(json) {
|
||||
this.albums = json.ALBUM.data.map((a) => new Album(a));
|
||||
this.artists = json.ARTIST.data.map((a) => new Artist(a));
|
||||
this.tracks = json.TRACK.data.map((t) => new Track(t));
|
||||
this.playlists = json.PLAYLIST.data.map((p) => new Playlist(p));
|
||||
this.top = json.TOP_RESULT;
|
||||
}
|
||||
}
|
||||
|
||||
class DeezerProfile {
|
||||
constructor(json) {
|
||||
this.token = json.checkForm;
|
||||
this.id = json.USER.USER_ID;
|
||||
this.name = json.USER.BLOG_NAME;
|
||||
this.favoritesPlaylist = json.USER.LOVEDTRACKS_ID;
|
||||
this.picture = new DeezerImage(json.USER.USER_PICTURE, 'user');
|
||||
}
|
||||
}
|
||||
|
||||
class DeezerLibrary {
|
||||
//Pass 'TAB' from API to parse
|
||||
constructor(json, type='tracks') {
|
||||
switch (type) {
|
||||
case 'tracks':
|
||||
this.count = json.loved.total;
|
||||
this.data = json.loved.data.map((t) => new Track(t));
|
||||
break;
|
||||
case 'albums':
|
||||
this.count = json.albums.total;
|
||||
this.data = json.albums.data.map((a) => new Album(a));
|
||||
break;
|
||||
case 'artists':
|
||||
this.count = json.artists.total;
|
||||
this.data = json.artists.data.map((a) => new Artist(a));
|
||||
break;
|
||||
case 'playlists':
|
||||
this.count = json.playlists.total;
|
||||
this.data = json.playlists.data.map((p) => new Playlist(p));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SmartTrackList {
|
||||
constructor(json) {
|
||||
this.title = json.TITLE;
|
||||
this.subtitle = json.SUBTITLE;
|
||||
this.description = json.DESCRIPTION;
|
||||
this.id = json.SMARTTRACKLIST_ID
|
||||
this.cover = new DeezerImage(json.COVER.MD5, json.COVER.TYPE);
|
||||
}
|
||||
}
|
||||
|
||||
class DeezerPage {
|
||||
constructor(json) {
|
||||
this.title = json.title;
|
||||
this.sections = json.sections.map((s) => new ChannelSection(s));
|
||||
}
|
||||
}
|
||||
|
||||
class DeezerChannel {
|
||||
constructor(json, target) {
|
||||
this.title = json.title;
|
||||
this.image = new DeezerImage(json.pictures[0].md5, json.pictures[0].type);
|
||||
this.color = json.background_color;
|
||||
this.id = json.id;
|
||||
this.slug = json.slug; //Hopefully it's used for path
|
||||
this.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelSection {
|
||||
constructor(json) {
|
||||
//Parse layout
|
||||
switch (json.layout) {
|
||||
case 'grid': this.layout = 'grid'; break;
|
||||
case 'horizontal-grid': this.layout = 'row'; break;
|
||||
default: this.layout = 'row'; break;
|
||||
}
|
||||
this.title = json.title;
|
||||
this.hasMore = json.hasMoreItems ? true : false;
|
||||
this.target = json.target;
|
||||
this.items = json.items.map((i) => new ChannelSectionItem(i));
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelSectionItem {
|
||||
constructor(json) {
|
||||
this.id = json.id;
|
||||
this.title = json.title;
|
||||
this.type = json.type;
|
||||
this.subtitle = json.subtitle;
|
||||
//Parse data
|
||||
switch (this.type) {
|
||||
case 'flow':
|
||||
case 'smarttracklist':
|
||||
this.data = new SmartTrackList(json.data);
|
||||
break;
|
||||
case 'playlist':
|
||||
this.data = new Playlist(json.data);
|
||||
break;
|
||||
case 'artist':
|
||||
this.data = new Artist(json.data);
|
||||
break;
|
||||
case 'channel':
|
||||
this.data = new DeezerChannel(json.data, json.target);
|
||||
break;
|
||||
case 'album':
|
||||
this.data = new Album(json.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Lyrics {
|
||||
constructor(json) {
|
||||
this.id = json.LYRICS_ID;
|
||||
this.writer = json.LYRICS_WRITERS;
|
||||
this.text = json.LYRICS_TEXT;
|
||||
|
||||
//Parse invidual lines
|
||||
this.lyrics = [];
|
||||
if (json.LYRICS_SYNC_JSON) {
|
||||
for (let l of json.LYRICS_SYNC_JSON) {
|
||||
let lyric = Lyric.parseJson(l);
|
||||
if (lyric) this.lyrics.push(lyric);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
class Lyric {
|
||||
//NOT for parsing from deezer
|
||||
constructor(offset, text, lrcTimestamp) {
|
||||
this.offset = parseInt(offset.toString(), 10);
|
||||
this.text = text;
|
||||
this.lrcTimestamp = lrcTimestamp;
|
||||
}
|
||||
//Can return null if invalid lyric
|
||||
static parseJson(json) {
|
||||
if (!json.milliseconds || !json.line || json.line == '') return;
|
||||
return new Lyric(json.milliseconds, json.line, json.lrc_timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {Track, Album, Artist, Playlist, User, SearchResults,
|
||||
DeezerImage, DeezerProfile, DeezerLibrary, DeezerPage, Lyrics};
|
340
app/src/downloads.js
Normal file
340
app/src/downloads.js
Normal file
@ -0,0 +1,340 @@
|
||||
const {Settings} = require('./settings');
|
||||
const {Track} = require('./definitions');
|
||||
const decryptor = require('nodeezcryptor');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const Datastore = require('nedb');
|
||||
const ID3Writer = require('browser-id3-writer');
|
||||
const Metaflac = require('metaflac-js2');
|
||||
const sanitize = require("sanitize-filename");
|
||||
|
||||
class Downloads {
|
||||
constructor(settings, qucb) {
|
||||
this.downloads = [];
|
||||
this.downloading = false;
|
||||
this.download;
|
||||
|
||||
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 + (quality == 9 ? '.flac' : '.mp3');
|
||||
|
||||
//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();
|
||||
|
||||
//Back to queue if undone
|
||||
if (this.download.state < 3) this.downloads.unshift(this.download);
|
||||
|
||||
this.download = null;
|
||||
|
||||
//Update callback
|
||||
if (this.qucb) this.qucb();
|
||||
}
|
||||
|
||||
//On download finished
|
||||
async _downloadDone() {
|
||||
//Save to DB
|
||||
if (this.download) {
|
||||
await new Promise((res, rej) => {
|
||||
// this.db.update({_id: this.download.id}, {state: 3}, (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();
|
||||
}
|
||||
|
||||
//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) this.downloads.push(Download.fromDB(d, () => {this._downloadDone();}));
|
||||
//TODO: Ignore for now completed
|
||||
}
|
||||
res();
|
||||
});
|
||||
});
|
||||
|
||||
//Create temp dir
|
||||
if (!fs.existsSync(Settings.getTempDownloads())) {
|
||||
fs.promises.mkdir(Settings.getTempDownloads(), {recursive: true});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
this.state = 0;
|
||||
|
||||
this._request;
|
||||
//Post Processing Promise
|
||||
this._ppp;
|
||||
|
||||
this.downloaded = 0;
|
||||
this.size = 0;
|
||||
}
|
||||
|
||||
//Serialize to database json
|
||||
toDB() {
|
||||
return {
|
||||
_id: this.id,
|
||||
path: this.path,
|
||||
quality: this.quality,
|
||||
track: this.track,
|
||||
state: this.state
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
//Create download from DB document
|
||||
static fromDB(doc, onDone) {
|
||||
let d = new Download(doc.track, doc.path, doc.quality, onDone);
|
||||
d.state = doc.state;
|
||||
return d;
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.state = 1;
|
||||
|
||||
//Path to temp file
|
||||
let tmp = path.join(Settings.getTempDownloads(), `${this.track.id}.ENC`);
|
||||
//Get start offset
|
||||
let start = 0;
|
||||
try {
|
||||
let stat = await fs.promises.stat(tmp);
|
||||
if (stat.size) start = stat.size;
|
||||
} catch (e) {}
|
||||
this.downloaded = start;
|
||||
|
||||
//Get download info
|
||||
if (!this.url) this.url = Track.getUrl(this.track.streamUrl, this.quality);
|
||||
|
||||
this._request = https.get(this.url, {headers: {'Range': `bytes=${start}-`}}, (r) => {
|
||||
//On download done
|
||||
r.on('end', () => {
|
||||
if (this.downloaded != this.size) return;
|
||||
this._finished(tmp);
|
||||
});
|
||||
//Progress
|
||||
r.on('data', (c) => {
|
||||
this.downloaded += c.length;
|
||||
});
|
||||
|
||||
r.on('error', (e) => {
|
||||
console.log(`Download error: ${e}`);
|
||||
//TODO: Download error handling
|
||||
})
|
||||
|
||||
//Save size
|
||||
this.size = parseInt(r.headers['content-length'], 10) + start;
|
||||
|
||||
//Pipe data to file
|
||||
r.pipe(fs.createWriteStream(tmp, {flags: 'a'}));
|
||||
});
|
||||
}
|
||||
|
||||
//Stop current request
|
||||
async stop() {
|
||||
this._request.destroy();
|
||||
this._request = null;
|
||||
this.state = 0;
|
||||
if (this._ppp) await this._ppp;
|
||||
}
|
||||
|
||||
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) {};
|
||||
|
||||
//Decrypt
|
||||
decryptor.decryptFile(decryptor.getKey(this.track.id), tmp, this.path);
|
||||
//Delete encrypted
|
||||
await fs.promises.unlink(tmp);
|
||||
|
||||
//Tags
|
||||
await this.tagAudio(this.path, this.track);
|
||||
|
||||
//Finish
|
||||
this.state = 3;
|
||||
resolve();
|
||||
this._ppp = null;
|
||||
this.onDone();
|
||||
}
|
||||
|
||||
//Download cover to buffer
|
||||
async downloadCover(url) {
|
||||
return await new Promise((res, rej) => {
|
||||
let out = Buffer.alloc(0);
|
||||
https.get(url, (r) => {
|
||||
r.on('data', (d) => {
|
||||
out = Buffer.concat([out, d]);
|
||||
});
|
||||
r.on('end', () => {
|
||||
res(out);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
//Write tags to audio file
|
||||
async tagAudio(path, track) {
|
||||
let cover;
|
||||
try {
|
||||
cover = await this.downloadCover(track.albumArt.full);
|
||||
} catch (e) {}
|
||||
|
||||
|
||||
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));
|
||||
}
|
||||
//Tag FLAC
|
||||
if (path.toLowerCase().endsWith('.flac')) {
|
||||
const flac = new Metaflac(path);
|
||||
flac.removeAllTags();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
module.exports = {Downloads, Download};
|
503
app/src/server.js
Normal file
503
app/src/server.js
Normal file
@ -0,0 +1,503 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const fs = require('fs');
|
||||
const axios = require('axios').default;
|
||||
const {DeezerAPI, DeezerDecryptionStream} = require('./deezer');
|
||||
const {Settings} = require('./settings');
|
||||
const {Track, Album, Artist, Playlist, DeezerProfile, SearchResults, DeezerLibrary, DeezerPage, Lyrics} = require('./definitions');
|
||||
const {Downloads} = require('./downloads');
|
||||
|
||||
let settings;
|
||||
let deezer;
|
||||
let downloads;
|
||||
|
||||
let sockets = [];
|
||||
|
||||
//Express
|
||||
const app = express();
|
||||
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);
|
||||
|
||||
//Get playback info
|
||||
app.get('/playback', async (req, res) => {
|
||||
try {
|
||||
let data = await fs.promises.readFile(Settings.getPlaybackInfoPath(), 'utf-8');
|
||||
return res.json(data);
|
||||
} catch (e) {}
|
||||
|
||||
return res.json({});
|
||||
});
|
||||
|
||||
//Save playback info
|
||||
app.post('/playback', async (req, res) => {
|
||||
if (req.body) {
|
||||
let data = JSON.stringify(req.body);
|
||||
await fs.promises.writeFile(Settings.getPlaybackInfoPath(), data, 'utf-8');
|
||||
}
|
||||
res.status(200).send('');
|
||||
});
|
||||
|
||||
//Get settings
|
||||
app.get('/settings', (req, res) => {
|
||||
res.json(settings);
|
||||
});
|
||||
|
||||
//Save settings
|
||||
app.post('/settings', async (req, res) => {
|
||||
if (req.body) {
|
||||
Object.assign(settings, req.body);
|
||||
downloads.settings = settings;
|
||||
await settings.save();
|
||||
}
|
||||
|
||||
res.status(200).send('');
|
||||
});
|
||||
|
||||
//Post with body {"arl": ARL}
|
||||
app.post('/authorize', async (req, res) => {
|
||||
if (!req.body.arl || req.body.arl.length < 100) return res.status(500).send('Invalid ARL');
|
||||
|
||||
//Check if arl valid
|
||||
deezer.arl = req.body.arl;
|
||||
settings.arl = req.body.arl;
|
||||
|
||||
if (await (deezer.authorize())) {
|
||||
res.status(200).send('OK');
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(500).send('Authorization error / Invalid ARL.');
|
||||
});
|
||||
|
||||
//Get track by id
|
||||
app.get('/track/:id', async (req, res) => {
|
||||
let data = await deezer.callApi('deezer.pageTrack', {sng_id: req.params.id.toString()});
|
||||
res.send(new Track(data.results.DATA));
|
||||
});
|
||||
|
||||
//Get album by id
|
||||
app.get('/album/:id', async (req, res) => {
|
||||
let data = await deezer.callApi('deezer.pageAlbum', {alb_id: req.params.id.toString(), lang: 'us'});
|
||||
res.send(new Album(data.results.DATA, data.results.SONGS));
|
||||
});
|
||||
|
||||
//Get artist by id
|
||||
app.get('/artist/:id', async (req, res) => {
|
||||
let data = await deezer.callApi('deezer.pageArtist', {art_id: req.params.id.toString(), lang: 'us'});
|
||||
res.send(new Artist(data.results.DATA, data.results.ALBUMS, data.results.TOP));
|
||||
});
|
||||
|
||||
//Get playlist by id
|
||||
//start & full query parameters
|
||||
app.get('/playlist/:id', async (req, res) => {
|
||||
//Set anything to `full` query parameter to get entire playlist
|
||||
if (!req.query.full) {
|
||||
let data = await deezer.callApi('deezer.pagePlaylist', {
|
||||
playlist_id: req.params.id.toString(),
|
||||
lang: 'us',
|
||||
nb: 50,
|
||||
start: req.query.start ? parseInt(req.query.start, 10) : 0,
|
||||
tags: true
|
||||
});
|
||||
return res.send(new Playlist(data.results.DATA, data.results.SONGS));
|
||||
}
|
||||
|
||||
//Entire playlist
|
||||
let chunk = 200;
|
||||
let data = await deezer.callApi('deezer.pagePlaylist', {
|
||||
playlist_id: req.params.id.toString(),
|
||||
lang: 'us',
|
||||
nb: chunk,
|
||||
start: 0,
|
||||
tags: true
|
||||
});
|
||||
let playlist = new Playlist(data.results.DATA, data.results.SONGS);
|
||||
let missingChunks = Math.ceil((playlist.trackCount - playlist.tracks.length)/chunk);
|
||||
//Extend playlist
|
||||
for(let i=0; i<missingChunks; i++) {
|
||||
let d = await deezer.callApi('deezer.pagePlaylist', {
|
||||
playlist_id: id.toString(),
|
||||
lang: 'us',
|
||||
nb: chunk,
|
||||
start: (i+1)*chunk,
|
||||
tags: true
|
||||
});
|
||||
playlist.extend(d.results.SONGS);
|
||||
}
|
||||
res.send(playlist);
|
||||
});
|
||||
|
||||
//DELETE playlist
|
||||
app.delete('/playlist/:id', async (req, res) => {
|
||||
await deezer.callApi('playlist.delete', {playlist_id: req.params.id.toString()});
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
//POST create playlist
|
||||
// {
|
||||
// desciption,
|
||||
// title,
|
||||
// type: 'public' || 'private',
|
||||
// track: trackID
|
||||
// }
|
||||
app.post('/playlist', async (req, res) => {
|
||||
await deezer.callApi('playlist.create', {
|
||||
description: req.body.description,
|
||||
title: req.body.title,
|
||||
status: req.body.type == 'public' ? 2 : 1,
|
||||
songs: req.body.track ? [[req.body.track, 0]] : []
|
||||
});
|
||||
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
//POST track to playlist
|
||||
//Body {"track": "trackId"}
|
||||
app.post('/playlist/:id/tracks', async (req, res) => {
|
||||
await deezer.callApi('playlist.addSongs', {
|
||||
offset: -1,
|
||||
playlist_id: req.params.id,
|
||||
songs: [[req.body.track, 0]]
|
||||
});
|
||||
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
//DELETE track from playlist
|
||||
//Body {"track": "trackId"}
|
||||
app.delete('/playlist/:id/tracks', async (req, res) => {
|
||||
await deezer.callApi('playlist.deleteSongs', {
|
||||
playlist_id: req.params.id,
|
||||
songs: [[req.body.track, 0]]
|
||||
});
|
||||
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
//Get more albums
|
||||
//ID = artist id, QP start = offset
|
||||
app.get('/albums/:id', async (req, res) => {
|
||||
let data = await deezer.callApi('album.getDiscography', {
|
||||
art_id: parseInt(req.params.id.toString(), 10),
|
||||
discography_mode: "all",
|
||||
nb: 25,
|
||||
nb_songs: 200,
|
||||
start: req.query.start ? parseInt(req.query.start, 10) : 0
|
||||
});
|
||||
|
||||
let albums = data.results.data.map((a) => new Album(a));
|
||||
res.send(albums);
|
||||
})
|
||||
|
||||
//Search, q as query parameter
|
||||
app.get('/search', async (req, res) => {
|
||||
let data = await deezer.callApi('deezer.pageSearch', {query: req.query.q, nb: 100});
|
||||
res.send(new SearchResults(data.results));
|
||||
});
|
||||
|
||||
//Get user profile data
|
||||
app.get('/profile', async (req, res) => {
|
||||
let data = await deezer.callApi('deezer.getUserData');
|
||||
let profile = new DeezerProfile(data.results);
|
||||
res.send(profile);
|
||||
});
|
||||
|
||||
//Get list of `type` from library
|
||||
app.get('/library/:type', async (req, res) => {
|
||||
let type = req.params.type;
|
||||
let data = await deezer.callApi('deezer.pageProfile', {
|
||||
nb: 50,
|
||||
tab: (type == 'tracks') ? 'loved' : type,
|
||||
user_id: deezer.userId
|
||||
});
|
||||
res.send(new DeezerLibrary(data.results.TAB, type));
|
||||
});
|
||||
|
||||
//DELETE from library
|
||||
app.delete('/library/:type', async (req, res) => {
|
||||
let type = req.params.type;
|
||||
let id = req.query.id;
|
||||
|
||||
if (type == 'track') await deezer.callApi('favorite_song.remove', {SNG_ID: id});
|
||||
if (type == 'album') await deezer.callApi('album.deleteFavorite', {ALB_ID: id});
|
||||
if (type == 'playlist') await deezer.callApi('playlist.deleteFavorite', {playlist_id: parseInt(id, 10)});
|
||||
if (type == 'artist') await deezer.callApi('artist.deleteFavorite', {ART_ID: id});
|
||||
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
//PUT (add) to library
|
||||
app.put('/library/:type', async (req, res) => {
|
||||
let type = req.params.type;
|
||||
let id = req.query.id;
|
||||
|
||||
if (type == 'track') await deezer.callApi('favorite_song.add', {SNG_ID: id});
|
||||
if (type == 'album') await deezer.callApi('album.addFavorite', {ALB_ID: id});
|
||||
if (type == 'artist') await deezer.callApi('artist.addFavorite', {ART_ID: id});
|
||||
if (type == 'playlist') await deezer.callApi('playlist.addFavorite', {parent_playlist_id: parseInt(id)});
|
||||
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
|
||||
//Get streaming metadata, quality fallback
|
||||
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 qualityFallback(info, quality));
|
||||
});
|
||||
|
||||
// S T R E A M I N G
|
||||
app.get('/stream/:info', (req, res) => {
|
||||
//Parse stream info
|
||||
let quality = req.query.q ? req.query.q : 3;
|
||||
let url = Track.getUrl(req.params.info, quality);
|
||||
let trackId = req.params.info.substring(35);
|
||||
|
||||
//MIME type of audio
|
||||
let mime = 'audio/mp3';
|
||||
if (quality == 9) mime = 'audio/flac';
|
||||
|
||||
//Parse range header
|
||||
let range = 'bytes=0-';
|
||||
if (req.headers.range) range = req.headers.range;
|
||||
let rangeParts = range.replace(/bytes=/, '').split('-');
|
||||
let start = parseInt(rangeParts[0], 10);
|
||||
let end = '';
|
||||
if (rangeParts.length >= 2) end = rangeParts[1];
|
||||
|
||||
//Round to 2048 for deezer
|
||||
let dStart = start - (start % 2048);
|
||||
|
||||
//Make request to Deezer CDN
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
//Get deezer page
|
||||
app.get('/page', async (req, res) => {
|
||||
let target = req.query.target.replace(/"/g, '');
|
||||
|
||||
let st = ['album', 'artist', 'channel', 'flow', 'playlist', 'smarttracklist', 'track', 'user'];
|
||||
let data = await deezer.callApi('page.get', {}, {
|
||||
'PAGE': target,
|
||||
'VERSION': '2.3',
|
||||
'SUPPORT': {
|
||||
'grid': st,
|
||||
'horizontal-grid': st,
|
||||
'item-highlight': ['radio'],
|
||||
'large-card': ['album', 'playlist', 'show', 'video-link'],
|
||||
'ads': [] //None
|
||||
},
|
||||
'LANG': 'us',
|
||||
'OPTIONS': []
|
||||
});
|
||||
res.send(new DeezerPage(data.results));
|
||||
});
|
||||
|
||||
//Get smart track list or flow tracks
|
||||
app.get('/smarttracklist/:id', async (req, res) => {
|
||||
let id = req.params.id;
|
||||
|
||||
//Flow not normal STL
|
||||
if (id == 'flow') {
|
||||
let data = await deezer.callApi('radio.getUserRadio', {
|
||||
user_id: deezer.userId
|
||||
});
|
||||
let tracks = data.results.data.map((t) => new Track(t));
|
||||
return res.send(tracks);
|
||||
}
|
||||
|
||||
//Normal STL
|
||||
let data = await deezer.callApi('smartTracklist.getSongs', {
|
||||
smartTracklist_id: id
|
||||
});
|
||||
let tracks = data.results.data.map((t) => new Track(t));
|
||||
return res.send(tracks);
|
||||
});
|
||||
|
||||
//Load lyrics, ID = SONG ID
|
||||
app.get('/lyrics/:id', async (req, res) => {
|
||||
let data = await deezer.callApi('song.getLyrics', {
|
||||
sng_id: parseInt(req.params.id, 10)
|
||||
});
|
||||
if (!data.results || data.error.length > 0) return res.status(502).send('Lyrics not found!');
|
||||
|
||||
res.send(new Lyrics(data.results));
|
||||
});
|
||||
|
||||
//Post list of tracks to download
|
||||
app.post('/downloads', async (req, res) => {
|
||||
let tracks = req.body;
|
||||
let quality = req.query.q;
|
||||
for (let track of tracks) {
|
||||
downloads.add(track, quality);
|
||||
}
|
||||
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
//PUT to /download to start
|
||||
app.put('/download', async (req, res) => {
|
||||
await downloads.start();
|
||||
res.status(200).send('OK');
|
||||
});
|
||||
|
||||
//DELETE to /download to stop/pause
|
||||
app.delete('/download', async (req, res) => {
|
||||
await downloads.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();
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
//Redirect to index on unknown path
|
||||
app.all('*', (req, res) => {
|
||||
res.redirect('/');
|
||||
});
|
||||
|
||||
// S O C K E T S
|
||||
io.on('connection', (socket) => {
|
||||
sockets.push(socket);
|
||||
//Remove on disconnect
|
||||
socket.on('disconnect', () => {
|
||||
sockets.splice(sockets.indexOf(socket), 1);
|
||||
});
|
||||
});
|
||||
|
||||
//Quality fallback
|
||||
async function qualityFallback(info, quality = 3) {
|
||||
if (quality == 1) return {
|
||||
quality: '128kbps',
|
||||
format: 'MP3',
|
||||
source: 'stream',
|
||||
url: `/stream/${info}?q=1`
|
||||
};
|
||||
try {
|
||||
let res = await axios.head(Track.getUrl(info, 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`
|
||||
}
|
||||
} catch (e) {
|
||||
//Fallback
|
||||
//9 - FLAC
|
||||
//3 - MP3 320
|
||||
//1 - MP3 128
|
||||
let q = quality;
|
||||
if (quality == 9) q = 3;
|
||||
if (quality == 3) q = 1;
|
||||
return qualityFallback(info, q);
|
||||
}
|
||||
}
|
||||
|
||||
//ecb = Error callback
|
||||
async function createServer(electron = false, ecb) {
|
||||
//Prepare globals
|
||||
settings = new Settings(electron);
|
||||
settings.load();
|
||||
|
||||
deezer = new DeezerAPI(settings.arl, electron);
|
||||
|
||||
//Prepare downloads
|
||||
downloads = new Downloads(settings, () => {
|
||||
//Emit queue change to socket
|
||||
sockets.forEach((s) => {
|
||||
s.emit('downloads', {
|
||||
downloading: downloads.downloading,
|
||||
downloads: downloads.downloads
|
||||
});
|
||||
});
|
||||
|
||||
//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
|
||||
});
|
||||
});
|
||||
}, 500);
|
||||
|
||||
});
|
||||
await downloads.load();
|
||||
|
||||
//Start server
|
||||
server.on('error', (e) => {
|
||||
ecb(e);
|
||||
});
|
||||
server.listen(settings.port, settings.serverIp);
|
||||
console.log(`Running on: http://${settings.serverIp}:${settings.port}`);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
module.exports = {createServer};
|
101
app/src/settings.js
Normal file
101
app/src/settings.js
Normal file
@ -0,0 +1,101 @@
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
class Settings {
|
||||
|
||||
constructor(electron = false) {
|
||||
//Defaults
|
||||
this.port = 10069;
|
||||
this.serverIp = '127.0.0.1';
|
||||
this.arl;
|
||||
this.streamQuality = 3;
|
||||
this.volume = 0.69;
|
||||
this.electron = electron;
|
||||
this.minimizeToTray = true;
|
||||
this.closeOnExit = false;
|
||||
this.width = 1280;
|
||||
this.height = 720;
|
||||
|
||||
this.downloadsPath = this.getDefaultDownloadPath();
|
||||
this.downloadsQuality = 3;
|
||||
this.createAlbumFolder = true;
|
||||
this.createArtistFolder = true;
|
||||
this.downloadFilename = '%0trackNumber%. %artists% - %title%';
|
||||
}
|
||||
|
||||
//Based on electorn app.getPath
|
||||
static getDir() {
|
||||
let home = os.homedir();
|
||||
if (os.platform() === 'win32') {
|
||||
return path.join(process.env.APPDATA, 'freezer');
|
||||
}
|
||||
if (os.platform() === 'linux') {
|
||||
return path.join(home, '.config', 'freezer');
|
||||
}
|
||||
|
||||
//UNTESTED
|
||||
if (os.platform() == 'darwin') {
|
||||
return path.join(home, 'Library', 'Application Support', 'freezer');
|
||||
}
|
||||
throw Error('Unsupported platform!');
|
||||
}
|
||||
|
||||
//Get settings.json path
|
||||
static getPath() {
|
||||
return path.join(Settings.getDir(), 'settings.json');
|
||||
}
|
||||
//Get path to playback.json
|
||||
static getPlaybackInfoPath() {
|
||||
return path.join(Settings.getDir(), 'playback.json');
|
||||
}
|
||||
//Get path to downloads database
|
||||
static getDownloadsDB() {
|
||||
return path.join(Settings.getDir(), 'downloads.db');
|
||||
}
|
||||
//Get path to temporary / unfinished downlaods
|
||||
static getTempDownloads() {
|
||||
return path.join(Settings.getDir(), 'downloadsTemp');
|
||||
}
|
||||
|
||||
getDefaultDownloadPath() {
|
||||
return path.join(os.homedir(), 'FreezerMusic');
|
||||
}
|
||||
|
||||
//Blocking load settings
|
||||
load() {
|
||||
//Preserve electorn option
|
||||
let e = this.electron;
|
||||
//Create dir if doesn't exist
|
||||
try {
|
||||
fs.mkdirSync(Settings.getDir(), {recursive: true});
|
||||
} catch (_) {}
|
||||
|
||||
//Load settings from file
|
||||
try {
|
||||
if (fs.existsSync(Settings.getPath())) {
|
||||
let data = fs.readFileSync(Settings.getPath(), 'utf-8');
|
||||
Object.assign(this, JSON.parse(data));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error loading settings: ${e}. Using defaults.`)
|
||||
}
|
||||
this.electron = e;
|
||||
|
||||
//Defaults for backwards compatibility
|
||||
if (!this.downloadsPath) this.downloadsPath = this.getDefaultDownloadPath();
|
||||
}
|
||||
|
||||
//ASYNC save settings
|
||||
async save() {
|
||||
//Create dir if doesn't exist
|
||||
try {
|
||||
await fs.promises.mkdir(Settings.getDir(), {recursive: true});
|
||||
} catch (_) {}
|
||||
|
||||
await fs.promises.writeFile(Settings.getPath(), JSON.stringify(this), 'utf-8');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = {Settings};
|
Reference in New Issue
Block a user