1.0.6 - Discord integration, logging, bugfixes

This commit is contained in:
exttex
2020-09-28 12:04:19 +02:00
parent 863c1aff40
commit 83860ff052
23 changed files with 1648 additions and 129 deletions

View File

@ -3,6 +3,8 @@ const axios = require('axios');
const decryptor = require('nodeezcryptor');
const querystring = require('querystring');
const {Transform} = require('stream');
const {Track} = require('./definitions');
const logger = require('./winston');
class DeezerAPI {
@ -162,6 +164,45 @@ 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`
};
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`
}
} catch (e) {
logger.warning('Qualiy 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);
}
}
}
class DeezerDecryptionStream extends Transform {

View File

@ -1,5 +1,3 @@
const {DeezerAPI} = require('./deezer');
//Datatypes, constructor parameters = gw_light API call.
class Track {
constructor(json) {
@ -32,13 +30,12 @@ class Track {
}
//Get Deezer CDN url by streamUrl
static getUrl(info, quality = 3) {
static getUrlInfo(info) {
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;
return {trackId, md5origin, mediaVersion};
}
}
@ -124,6 +121,8 @@ class DeezerImage {
}
url(size = 256) {
if (!this.hash)
return `https://e-cdns-images.dzcdn.net/images/${this.type}/${size}x${size}-000000-80-0-0.jpg`;
return `https://e-cdns-images.dzcdn.net/images/${this.type}/${this.hash}/${size}x${size}-000000-80-0-0.jpg`;
}
}

View File

@ -3,11 +3,13 @@ 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 Datastore = require('nedb');
const ID3Writer = require('browser-id3-writer');
const Metaflac = require('metaflac-js2');
const sanitize = require("sanitize-filename");
const { DeezerAPI } = require('./deezer');
class Downloads {
constructor(settings, qucb) {
@ -246,9 +248,12 @@ class Download {
this.downloaded = start;
//Get download info
if (!this.url) this.url = Track.getUrl(this.track.streamUrl, this.quality);
if (!this.url) {
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) => {
let skip = false;
//Error
if (r.statusCode >= 400) {
//Fallback on error
@ -261,12 +266,31 @@ class Download {
};
//Error
this.state = -1;
console.log(`Undownloadable track ID: ${this.track.id}`);
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) {
//Pipe data to file
r.pipe(fs.createWriteStream(tmp, {flags: 'a'}));
} else {
logger.warn('File already exists! Skipping...');
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;
this._finished(tmp);
});
@ -276,15 +300,13 @@ class Download {
});
r.on('error', (e) => {
console.log(`Download error: ${e}`);
logger.error(`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'}));
});
}
@ -311,7 +333,7 @@ class Download {
} catch (e) {};
//Decrypt
this.path += (this.quality == 9) ? '.flac' : '.mp3';
//this.path += (this.quality == 9) ? '.flac' : '.mp3';
decryptor.decryptFile(decryptor.getKey(this.track.id), tmp, `${tmp}.DEC`);
fs.promises.copyFile(`${tmp}.DEC`, this.path);
//Delete encrypted

142
app/src/integrations.js Normal file
View File

@ -0,0 +1,142 @@
const LastfmAPI = require('lastfmapi');
const DiscordRPC = require('discord-rpc');
const {EventEmitter} = require('events');
const logger = require('./winston');
class Integrations extends EventEmitter {
//LastFM, Discord etc
constructor(settings) {
super();
this.settings = settings;
this.discordReady = false;
this.discordRPC = null;
//LastFM
//plz don't steal creds, it's just lastfm
this.lastfm = new LastfmAPI({
api_key: 'b6ab5ae967bcd8b10b23f68f42493829',
secret: '861b0dff9a8a574bec747f9dab8b82bf'
});
this.authorizeLastFM();
//Discord
if (settings.enableDiscord)
this.connectDiscord();
}
updateSettings(settings) {
this.settings = settings;
}
//Autorize lastfm with saved credentials
authorizeLastFM() {
if (!this.settings.lastFM) return;
this.lastfm.setSessionCredentials(this.settings.lastFM.name, this.settings.lastFM.key);
}
//Login to lastfm by token
async loginLastFM(token) {
let response = await new Promise((res) => {
this.lastfm.authenticate(token, (err, sess) => {
if (err) res();
res({
name: sess.username,
key: sess.key
});
});
});
this.settings.lastFM = response;
this.authorizeLastFM();
return response;
}
//LastFM Scrobble
async scrobbleLastFM(title, artist) {
if (this.settings.lastFM)
this.lastfm.track.scrobble({
artist: artist,
track: title,
timestamp: Math.floor((new Date()).getTime() / 1000)
});
}
//Connect to discord client
connectDiscord() {
//Don't steal, k ty
const CLIENTID = '759835951450292324';
this.discordReady = false;
DiscordRPC.register(CLIENTID);
this.discordRPC = new DiscordRPC.Client({transport: 'ipc'});
this.discordRPC.on('connected', () => {
this.discordReady = true;
//Allow discord "join" button
if (this.settings.discordJoin) {
//Always accept join requests
this.discordRPC.subscribe('ACTIVITY_JOIN_REQUEST', (user) => {
this.discordRPC.sendJoinInvite(user.user).catch((e) => {
logger.warning('Unable to accept Discord invite: ' + e);
});
});
//Joined
this.discordRPC.subscribe('ACTIVITY_JOIN', async (data) => {
let params = JSON.parse(data.secret);
this.emit('discordJoin', params);
});
}
});
//Connect to discord
this.discordRPC.login({clientId: CLIENTID}).catch(() => {
logger.info('Error connecting to Discord!');
//Wait 5s to retry
setTimeout(() => {
if (!this.discordReady)
this.connectDiscord();
}, 5000);
});
}
//Called when playback state changed
async updateState(data) {
if (this.discordReady) {
let richPresence = {
state: data.track.artistString,
details: data.track.title,
largeImageKey: 'icon',
instance: true,
}
//Show timestamp only if playing
if (data.state == 2) {
Object.assign(richPresence, {
startTimestamp: Date.now() - data.position,
endTimestamp: (Date.now() - data.position) + data.duration,
});
}
//Enabled discord join
if (this.settings.discordJoin) {
Object.assign(richPresence, {
partySize: 1,
partyMax: 10,
matchSecret: 'match_secret_' + data.track.id,
joinSecret: JSON.stringify({
pos: Math.floor(data.position),
ts: Date.now(),
id: data.track.id
}),
partyId: 'party_id_' + data.track.id
});
}
//Set
this.discordRPC.setActivity(richPresence);
}
}
}
module.exports = {Integrations};

View File

@ -3,15 +3,17 @@ const path = require('path');
const https = require('https');
const fs = require('fs');
const axios = require('axios').default;
const LastfmAPI = require('lastfmapi');
const logger = require('./winston');
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');
const {Integrations} = require('./integrations');
let settings;
let deezer;
let downloads;
let integrations;
let sockets = [];
@ -22,12 +24,6 @@ app.use(express.static(path.join(__dirname, '../client', 'dist')));
//Server
const server = require('http').createServer(app);
const io = require('socket.io').listen(server);
//LastFM
//plz don't steal creds, it's just lastfm
let lastfm = new LastfmAPI({
api_key: 'b6ab5ae967bcd8b10b23f68f42493829',
secret: '861b0dff9a8a574bec747f9dab8b82bf'
});
//Get playback info
app.get('/playback', async (req, res) => {
@ -58,6 +54,7 @@ app.post('/settings', async (req, res) => {
if (req.body) {
Object.assign(settings, req.body);
downloads.settings = settings;
integrations.updateSettings(settings);
await settings.save();
}
@ -241,14 +238,15 @@ 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 qualityFallback(info, quality));
return res.json(await deezer.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 streamInfo = Track.getUrlInfo(req.params.info);
let url = DeezerAPI.getUrl(streamInfo.trackId, streamInfo.md5origin, streamInfo.mediaVersion, quality);
let trackId = req.params.info.substring(35);
//MIME type of audio
@ -307,7 +305,7 @@ app.get('/stream/:info', (req, res) => {
});
//Internet/Request error
_request.on('error', (e) => {
_request.on('error', () => {
//console.log('Streaming error: ' + e);
//HTML audio will restart automatically
res.destroy();
@ -367,6 +365,20 @@ app.get('/lyrics/:id', async (req, res) => {
res.send(new Lyrics(data.results));
});
//Search Suggestions
app.get('/suggestions/:query', async (req, res) => {
let query = req.params.query;
try {
let data = await deezer.callApi('search_getSuggestedQueries', {
QUERY: query
});
let out = data.results.SUGGESTION.map((s) => s.QUERY);
res.json(out);
} catch (e) {
res.json([]);
}
});
//Post list of tracks to download
app.post('/downloads', async (req, res) => {
let tracks = req.body;
@ -410,12 +422,7 @@ app.delete('/downloads/:index', async (req, res) => {
//Log listen to deezer & lastfm
app.post('/log', async (req, res) => {
//LastFM
if (settings.lastFM)
lastfm.track.scrobble({
artist: req.body.artists[0].name,
track: req.body.title,
timestamp: Math.floor((new Date()).getTime() / 1000)
});
integrations.scrobbleLastFM(req.body.title, req.body.artists[0].name);
//Deezer
if (settings.logListen)
@ -436,26 +443,19 @@ app.get('/lastfm', async (req, res) => {
//Got token
if (req.query.token) {
let token = req.query.token;
await new Promise((res, rej) => {
lastfm.authenticate(token, (err, sess) => {
if (err) res();
//Save to settings
settings.lastFM = {
name: sess.username,
key: sess.key
};
settings.save();
res();
});
});
authorizeLastFM();
//Authorize
let authinfo = await integrations.loginLastFM(token);
if (authinfo) {
settings.lastFM = authinfo;
settings.save();
}
//Redirect to homepage
return res.redirect('/');
}
//Get auth url
res.json({
url: lastfm.getAuthenticationUrl({cb: `http://${req.socket.remoteAddress}:${settings.port}/lastfm`})
url: integrations.lastfm.getAuthenticationUrl({cb: `http://${req.socket.remoteAddress}:${settings.port}/lastfm`})
}).end();
});
@ -478,51 +478,12 @@ io.on('connection', (socket) => {
socket.on('disconnect', () => {
sockets.splice(sockets.indexOf(socket), 1);
});
//Send to integrations
socket.on('stateChange', (data) => {
integrations.updateState(data);
});
});
//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);
}
}
//Autorize lastfm with saved credentials
function authorizeLastFM() {
if (!settings.lastFM) return;
lastfm.setSessionCredentials(settings.lastFM.name, settings.lastFM.key);
}
//ecb = Error callback
async function createServer(electron = false, ecb) {
//Prepare globals
@ -559,8 +520,21 @@ async function createServer(electron = false, ecb) {
});
}, 350);
//LastFM
authorizeLastFM();
//Integrations (lastfm, discord)
integrations = new Integrations(settings);
//Discord Join = Sync tracks
integrations.on('discordJoin', async (data) => {
let trackData = await deezer.callApi('deezer.pageTrack', {sng_id: data.id});
let track = new Track(trackData.results.DATA);
let out = {
track: track,
position: (Date.now() - data.ts) + data.pos
}
//Emit to sockets
sockets.forEach((s) => {
s.emit('playOffset', out);
});
});
//Start server
server.on('error', (e) => {

View File

@ -22,9 +22,12 @@ class Settings {
this.createAlbumFolder = true;
this.createArtistFolder = true;
this.downloadFilename = '%0trackNumber%. %artists% - %title%';
this.downloadDialog = true;
this.logListen = false;
this.lastFM = null;
this.enableDiscord = false;
this.discordJoin = false;
}
//Based on electorn app.getPath

23
app/src/winston.js Normal file
View File

@ -0,0 +1,23 @@
const winston = require('winston');
const path = require('path');
const {Settings} = require('./settings');
const { Transform } = require('stream');
const logger = winston.createLogger({
level: 'info',
format: winston.format.simple(),
transports: [
new winston.transports.Console(),
new winston.transports.File({filename: path.join(Settings.getDir(), "freezer-server.log")}),
]
});
//Node errors
process.on('uncaughtException', (err) => {
logger.error('Unhandled Exception: ' + err + "\nStack: " + err.stack);
});
process.on('unhandledRejection', (err) => {
logger.error('Unhandled Rejection: ' + err + "\nStack: " + err.stack);
})
module.exports = logger;