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

15
app/.eslintrc.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
"env": {
"browser": true,
"commonjs": true,
"node": true,
"es2021": true
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 12
},
"rules": {
"allowEmptyCatch": true
}
};

View File

@ -0,0 +1 @@
<svg fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="96px" height="96px"><path d="M 10.699219 16.691406 L 9.894531 14.539063 C 9.894531 14.539063 8.585938 15.972656 6.617188 15.972656 C 4.878906 15.972656 3.644531 14.488281 3.644531 12.117188 C 3.644531 9.074219 5.207031 7.988281 6.746094 7.988281 C 8.960938 7.988281 9.667969 9.394531 10.273438 11.199219 L 11.078125 13.671875 C 11.886719 16.070313 13.398438 18 17.757813 18 C 20.882813 18 23 17.058594 23 14.589844 C 23 12.585938 21.839844 11.546875 19.671875 11.050781 L 18.058594 10.707031 C 16.949219 10.460938 16.621094 10.015625 16.621094 9.273438 C 16.621094 8.433594 17.304688 7.9375 18.410156 7.9375 C 19.621094 7.9375 20.277344 8.382813 20.378906 9.445313 L 22.898438 9.148438 C 22.695313 6.925781 21.132813 6.007813 18.5625 6.007813 C 16.296875 6.007813 14.078125 6.851563 14.078125 9.542969 C 14.078125 11.226563 14.90625 12.289063 17 12.78125 L 18.714844 13.179688 C 20 13.476563 20.429688 13.992188 20.429688 14.710938 C 20.429688 15.625 19.519531 15.996094 17.808594 15.996094 C 15.261719 15.996094 14.203125 14.6875 13.597656 12.882813 L 12.769531 10.410156 C 11.707031 7.195313 10.019531 6.011719 6.667969 6.011719 C 2.964844 6.007813 1 8.308594 1 12.214844 C 1 15.972656 2.964844 18 6.492188 18 C 9.339844 18 10.699219 16.691406 10.699219 16.691406 Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -124,18 +124,24 @@
<v-icon>mdi-arrow-right</v-icon> <v-icon>mdi-arrow-right</v-icon>
</v-btn> </v-btn>
<v-text-field <!-- Search -->
hide-details <v-autocomplete
flat hide-details
prepend-inner-icon="mdi-magnify" prepend-inner-icon="mdi-magnify"
flat
single-line single-line
solo solo
clearable
hide-no-data
placeholder='Search or paste Deezer URL. Use "/" to quickly focus.' placeholder='Search or paste Deezer URL. Use "/" to quickly focus.'
v-model="searchQuery"
ref='searchBar'
:loading='searchLoading' :loading='searchLoading'
@keyup='search'> @keyup='search'
</v-text-field> ref='searchBar'
v-model='searchQuery'
:search-input.sync='searchInput'
:items='suggestions'
></v-autocomplete>
</v-app-bar> </v-app-bar>
<!-- Main --> <!-- Main -->
@ -269,7 +275,10 @@ export default {
showPlayer: false, showPlayer: false,
position: '0.00', position: '0.00',
searchQuery: '', searchQuery: '',
searchLoading: false searchLoading: false,
searchInput: null,
suggestions: [],
preventDoubleEnter: false
} }
}, },
methods: { methods: {
@ -288,19 +297,24 @@ export default {
}, },
async search(event) { async search(event) {
//KeyUp event, enter //KeyUp event, enter
if (event.keyCode !== 13) return; if (event && event.keyCode !== 13) return;
//Prevent double navigation
if (this.preventDoubleEnter) return;
this.preventDoubleEnter = true;
setInterval(() => {this.preventDoubleEnter = false}, 50);
//Check if url //Check if url
if (this.searchQuery.startsWith('http')) { let query = this.searchInput;
if (query.startsWith('http')) {
this.searchLoading = true; this.searchLoading = true;
let url = new URL(this.searchQuery); let url = new URL(query);
//Normal link //Normal link
if (url.hostname == 'www.deezer.com' || url.hostname == 'deezer.com' || url.hostname == 'deezer.page.link') { if (url.hostname == 'www.deezer.com' || url.hostname == 'deezer.com' || url.hostname == 'deezer.page.link') {
//Share link //Share link
if (url.hostname == 'deezer.page.link') { if (url.hostname == 'deezer.page.link') {
let res = await this.$axios.get('/fullurl?url=' + encodeURIComponent(this.searchQuery)); let res = await this.$axios.get('/fullurl?url=' + encodeURIComponent(query));
url = new URL(res.data.url); url = new URL(res.data.url);
} }
@ -332,7 +346,7 @@ export default {
this.searchLoading = false; this.searchLoading = false;
} else { } else {
//Normal search //Normal search
this.$router.push({path: '/search', query: {q: this.searchQuery}}); this.$router.push({path: '/search', query: {q: query}});
} }
}, },
seek(val) { seek(val) {
@ -383,6 +397,33 @@ export default {
//Update position //Update position
'$root.position'() { '$root.position'() {
this.position = (this.$root.position / this.$root.duration()) * 100; this.position = (this.$root.position / this.$root.duration()) * 100;
},
//Autofill
searchInput(query) {
//Filters
if (query && query.startsWith('/')) {
query = query.substring(1);
this.searchInput = query;
}
if (!query || (query && query.startsWith('http'))) {
this.searchLoading = false;
this.suggestions = [];
return;
}
this.searchLoading = true;
//Prevent spam
setTimeout(() => {
if (query != this.searchInput) return;
this.$axios.get('/suggestions/' + encodeURIComponent(query)).then((res) => {
if (query != this.searchInput) return;
this.suggestions = res.data;
this.searchLoading = false;
});
}, 300);
},
searchQuery(q) {
this.searchInput = q;
this.search(null);
} }
} }
}; };

View File

@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<v-list-item @click='click' v-if='!card' :class='{dense: tiny}'> <v-list-item @click='click' v-if='!card'>
<v-list-item-avatar> <v-list-item-avatar>
<v-img :src='artist.picture.thumb'></v-img> <v-img :src='artist.picture.thumb'></v-img>
</v-list-item-avatar> </v-list-item-avatar>

View File

@ -108,6 +108,17 @@ export default {
dShow() { dShow() {
if (!this.dShow) this.$emit('close'); if (!this.dShow) this.$emit('close');
} }
},
mounted() {
//Auto download
if (!this.$root.settings.downloadDialog) {
this.download();
setInterval(() => {
this.$emit('close');
this.dShow = false;
}, 50);
}
} }
} }
</script> </script>

View File

@ -74,10 +74,6 @@ export default {
}, },
//Play track //Play track
async play(index) { async play(index) {
if (this.tracks.length < this.count) {
await this.loadAll();
}
this.$root.queue.source = { this.$root.queue.source = {
text: 'Loved tracks', text: 'Loved tracks',
source: 'playlist', source: 'playlist',
@ -85,6 +81,15 @@ export default {
}; };
this.$root.replaceQueue(this.tracks); this.$root.replaceQueue(this.tracks);
this.$root.playIndex(index); this.$root.playIndex(index);
//Load all tracks
if (this.tracks.length < this.count) {
this.loadAll().then(() => {
this.$root.replaceQueue(this.tracks);
});
}
}, },
removedTrack(index) { removedTrack(index) {
this.tracks.splice(index, 1); this.tracks.splice(index, 1);

View File

@ -85,7 +85,7 @@ export default {
}, },
//Scroll to currently playing lyric //Scroll to currently playing lyric
scrollLyric() { scrollLyric() {
if (!this.lyrics) return; if (!this.lyrics || !this.lyrics.lyrics || this.lyrics.lyrics.length == 0) return;
//Prevent janky scrolling //Prevent janky scrolling
if (this.currentLyricIndex == this.currentLyric()) return; if (this.currentLyricIndex == this.currentLyric()) return;

View File

@ -109,14 +109,12 @@ export default {
methods: { methods: {
async play() { async play() {
let playlist = this.playlist; let playlist = this.playlist;
//Load playlist tracks //Load if no tracks
if (playlist.tracks.length != playlist.trackCount) { if (!playlist || playlist.tracks.length == 0)
let data = await this.$axios.get(`/playlist/${playlist.id}?full=iguess`); playlist = (await this.$axios.get(`/playlist/${playlist.id}?full=iguess`)).data;
playlist = data.data;
}
//Error handling
if (!playlist) return; if (!playlist) return;
//Play
this.$root.queue.source = { this.$root.queue.source = {
text: playlist.title, text: playlist.title,
source: 'playlist', source: 'playlist',
@ -124,6 +122,12 @@ export default {
}; };
this.$root.replaceQueue(playlist.tracks); this.$root.replaceQueue(playlist.tracks);
this.$root.playIndex(0); this.$root.playIndex(0);
//Load all tracks
if (playlist.tracks.length != playlist.trackCount) {
let data = await this.$axios.get(`/playlist/${playlist.id}?full=iguess`);
playlist = data.data;
}
}, },
//On click navigate to details //On click navigate to details
click() { click() {

View File

@ -180,12 +180,14 @@ export default {
this.$axios.put(`/library/tracks?id=${this.track.id}`); this.$axios.put(`/library/tracks?id=${this.track.id}`);
}, },
goAlbum() { goAlbum() {
this.$emit('redirect')
this.$router.push({ this.$router.push({
path: '/album', path: '/album',
query: {album: JSON.stringify(this.track.album)} query: {album: JSON.stringify(this.track.album)}
}); });
}, },
goArtist(a) { goArtist(a) {
this.$emit('redirect');
this.$router.push({ this.$router.push({
path: '/artist', path: '/artist',
query: {artist: JSON.stringify(a)} query: {artist: JSON.stringify(a)}

View File

@ -131,9 +131,11 @@ new Vue({
this.play(); this.play();
}, },
seek(t) { seek(t) {
if (!this.audio) return; if (!this.audio || isNaN(t) || !t) return;
//ms -> s //ms -> s
this.audio.currentTime = (t / 1000); this.audio.currentTime = (t / 1000);
this.updateState();
}, },
//Current track duration //Current track duration
@ -161,11 +163,11 @@ new Vue({
this.savePlaybackInfo(); this.savePlaybackInfo();
}, },
//Skip n tracks, can be negative //Skip n tracks, can be negative
skip(n) { async skip(n) {
let newIndex = this.queue.index + n; let newIndex = this.queue.index + n;
//Out of bounds //Out of bounds
if (newIndex < 0 || newIndex >= this.queue.data.length) return; if (newIndex < 0 || newIndex >= this.queue.data.length) return;
this.playIndex(newIndex); await this.playIndex(newIndex);
}, },
//Skip wrapper with shuffle //Skip wrapper with shuffle
skipNext() { skipNext() {
@ -253,7 +255,7 @@ new Vue({
} }
//Repeat list //Repeat list
if (this.queue.index == this.queue.data.length - 1) { if (this.repeat == 1 && this.queue.index == this.queue.data.length - 1) {
this.skip(-(this.queue.data.length - 1)); this.skip(-(this.queue.data.length - 1));
return; return;
} }
@ -403,6 +405,18 @@ new Vue({
this.logListenId = this.track.id; this.logListenId = this.track.id;
await this.$axios.post(`/log`, this.track); await this.$axios.post(`/log`, this.track);
},
//Send state update to integrations
async updateState() {
//Wait for duration
if (this.state == 2 && (this.duration() == null || isNaN(this.duration())))
await new Promise((res) => setTimeout(res, 1000));
this.$socket.emit('stateChange', {
position: this.position,
duration: this.duration(),
state: this.state,
track: this.track
});
} }
}, },
async created() { async created() {
@ -471,6 +485,12 @@ new Vue({
this.sockets.subscribe('download', (data) => { this.sockets.subscribe('download', (data) => {
this.download = data; this.download = data;
}); });
//Play at offset (for integrations)
this.sockets.subscribe('playOffset', async (data) => {
this.queue.data.splice(this.queue.index + 1, 0, data.track);
await this.skip(1);
this.seek(data.position);
});
r(); r();
}, },
@ -499,6 +519,12 @@ new Vue({
if (e.keyCode === 106 || e.keyCode === 74) this.$root.seek((this.position - 10000)); if (e.keyCode === 106 || e.keyCode === 74) this.$root.seek((this.position - 10000));
}); });
}, },
watch: {
//Watch state for integrations
state() {
this.updateState();
}
},
router, router,
vuetify, vuetify,

View File

@ -144,6 +144,7 @@
><TrackTile ><TrackTile
:track='track' :track='track'
@click='$root.playIndex(index)' @click='$root.playIndex(index)'
@redirect='close'
></TrackTile> ></TrackTile>
</v-lazy> </v-lazy>
@ -161,14 +162,13 @@
></AlbumTile> ></AlbumTile>
<!-- Artists --> <!-- Artists -->
<h3>Artists:</h3> <h3>Artists:</h3>
<v-list dense> <v-list>
<ArtistTile <ArtistTile
v-for='(artist, index) in $root.track.artists' v-for='(artist, index) in $root.track.artists'
:artist='artist' :artist='artist'
:key="index + 'a' + artist.id" :key="index + 'a' + artist.id"
@clicked='$emit("close")' @clicked='$emit("close")'
tiny tiny
class='text-left'
></ArtistTile> ></ArtistTile>
</v-list> </v-list>
<!-- Meta --> <!-- Meta -->
@ -179,6 +179,7 @@
<h3>Source: {{$root.playbackInfo.source}}</h3> <h3>Source: {{$root.playbackInfo.source}}</h3>
<h3>Format: {{$root.playbackInfo.format}}</h3> <h3>Format: {{$root.playbackInfo.format}}</h3>
<h3>Quality: {{$root.playbackInfo.quality}}</h3> <h3>Quality: {{$root.playbackInfo.quality}}</h3>
<h3>ID: {{$root.track.id}}</h3>
</v-list> </v-list>
</v-tab-item> </v-tab-item>
<!-- Lyrics --> <!-- Lyrics -->

View File

@ -87,9 +87,8 @@ export default {
methods: { methods: {
async playIndex(index) { async playIndex(index) {
//Load tracks //Load tracks
if (this.playlist.tracks.length < this.playlist.trackCount) { if (this.playlist.tracks.length == 0)
await this.loadAllTracks(); await this.loadAllTracks();
}
this.$root.queue.source = { this.$root.queue.source = {
text: this.playlist.title, text: this.playlist.title,
@ -98,6 +97,13 @@ export default {
}; };
this.$root.replaceQueue(this.playlist.tracks); this.$root.replaceQueue(this.playlist.tracks);
this.$root.playIndex(index); this.$root.playIndex(index);
//Load rest of tracks on background
if (this.playlist.tracks.length < this.playlist.trackCount) {
this.loadAllTracks().then(() => {
this.$root.replaceQueue(this.playlist.tracks);
});
}
}, },
play() { play() {
this.playIndex(0); this.playIndex(0);

View File

@ -28,6 +28,17 @@
append-icon='mdi-open-in-app' append-icon='mdi-open-in-app'
@click:append='selectDownloadPath' @click:append='selectDownloadPath'
></v-text-field> ></v-text-field>
<!-- Download dialog -->
<v-list-item>
<v-list-item-action>
<v-checkbox v-model='$root.settings.downloadDialog'></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Show download dialog</v-list-item-title>
<v-list-item-subtitle>Always show download confirm dialog before downloading.</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<!-- Create artist folder --> <!-- Create artist folder -->
<v-list-item> <v-list-item>
@ -57,6 +68,10 @@
hint='Variables: %title%, %artists%, %artist%, %feats%, %trackNumber%, %0trackNumber%, %album%' hint='Variables: %title%, %artists%, %artist%, %feats%, %trackNumber%, %0trackNumber%, %album%'
></v-text-field> ></v-text-field>
<!-- Accounts -->
<v-subheader>Integrations</v-subheader>
<v-divider></v-divider>
<!-- Log listening --> <!-- Log listening -->
<v-list-item> <v-list-item>
<v-list-item-action> <v-list-item-action>
@ -69,11 +84,46 @@
</v-list-item> </v-list-item>
<!-- LastFM --> <!-- LastFM -->
<v-list-item @click='connectLastFM' v-if='!$root.settings.lastFM'> <v-list-item @click='connectLastFM' v-if='!$root.settings.lastFM'>
<v-list-item-avatar>
<v-img src='lastfm.svg'></v-img>
</v-list-item-avatar>
<v-list-item-content> <v-list-item-content>
<v-list-item-title>Login with LastFM</v-list-item-title> <v-list-item-title>Login with LastFM</v-list-item-title>
<v-list-item-subtitle>Connect your LastFM account to allow scrobbling.</v-list-item-subtitle> <v-list-item-subtitle>Connect your LastFM account to allow scrobbling.</v-list-item-subtitle>
</v-list-item-content> </v-list-item-content>
</v-list-item> </v-list-item>
<v-list-item v-if='$root.settings.lastFM' @click='disconnectLastFM'>
<v-list-item-avatar>
<v-icon>mdi-logout</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title class='red--text'>Disconnect LastFM</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Discord -->
<v-list-item>
<v-list-item-action>
<v-checkbox v-model='$root.settings.enableDiscord' @click='snackbarText = "Requires restart to apply!"; snackbar = true'></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Discord Rich Presence</v-list-item-title>
<v-list-item-subtitle>Enable Discord Rich Presence, requires restart to toggle!</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<!-- Discord Join Button -->
<v-list-item>
<v-list-item-action>
<v-checkbox v-model='$root.settings.discordJoin' @click='snackbarText = "Requires restart to apply!"; snackbar = true'></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Discord Join Button</v-list-item-title>
<v-list-item-subtitle>Enable Discord join button for syncing tracks, requires restart to toggle!</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<!-- Misc -->
<v-subheader>Other</v-subheader>
<v-divider></v-divider>
<!-- Minimize to tray --> <!-- Minimize to tray -->
<v-list-item v-if='$root.settings.electron'> <v-list-item v-if='$root.settings.electron'>
@ -108,6 +158,22 @@
Save Save
</v-btn> </v-btn>
<!-- Info snackbar -->
<v-snackbar v-model="snackbar">
{{ snackbarText }}
<template v-slot:action="{ attrs }">
<v-btn
color="primary"
text
v-bind="attrs"
@click="snackbar = false"
>
Dismiss
</v-btn>
</template>
</v-snackbar>
</div> </div>
</template> </template>
@ -124,7 +190,9 @@ export default {
], ],
streamingQuality: null, streamingQuality: null,
downloadQuality: null, downloadQuality: null,
devToolsCounter: 0 devToolsCounter: 0,
snackbarText: null,
snackbar: false
} }
}, },
methods: { methods: {
@ -178,6 +246,12 @@ export default {
async connectLastFM() { async connectLastFM() {
let res = await this.$axios.get('/lastfm'); let res = await this.$axios.get('/lastfm');
window.location.replace(res.data.url); window.location.replace(res.data.url);
},
//Disconnect LastFM
async disconnectLastFM() {
this.$root.settings.lastFM = null;
await this.$root.saveSettings();
window.location.reload();
} }
}, },
mounted() { mounted() {

1127
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "freezer", "name": "freezer",
"private": true, "private": true,
"version": "1.0.5", "version": "1.0.6",
"description": "", "description": "",
"main": "background.js", "main": "background.js",
"scripts": { "scripts": {
@ -12,13 +12,17 @@
"dependencies": { "dependencies": {
"axios": "^0.19.2", "axios": "^0.19.2",
"browser-id3-writer": "^4.4.0", "browser-id3-writer": "^4.4.0",
"discord-rpc": "^3.1.4",
"express": "^4.17.1", "express": "^4.17.1",
"lastfmapi": "^0.1.1", "lastfmapi": "^0.1.1",
"metaflac-js2": "^1.0.7", "metaflac-js2": "^1.0.7",
"nedb": "^1.8.0", "nedb": "^1.8.0",
"nodeezcryptor": "git+https://notabug.org/xefglm/nodeezcryptor", "nodeezcryptor": "git+https://notabug.org/xefglm/nodeezcryptor",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"socket.io": "^2.3.0" "socket.io": "^2.3.0",
"winston": "^3.3.3"
}, },
"devDependencies": {} "devDependencies": {
"eslint": "^7.10.0"
}
} }

View File

@ -3,6 +3,8 @@ const axios = require('axios');
const decryptor = require('nodeezcryptor'); const decryptor = require('nodeezcryptor');
const querystring = require('querystring'); const querystring = require('querystring');
const {Transform} = require('stream'); const {Transform} = require('stream');
const {Track} = require('./definitions');
const logger = require('./winston');
class DeezerAPI { class DeezerAPI {
@ -162,6 +164,45 @@ class DeezerAPI {
return `https://e-cdns-proxy-${md5origin.substring(0, 1)}.dzcdn.net/mobile/1/${step3}`; 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 { class DeezerDecryptionStream extends Transform {

View File

@ -1,5 +1,3 @@
const {DeezerAPI} = require('./deezer');
//Datatypes, constructor parameters = gw_light API call. //Datatypes, constructor parameters = gw_light API call.
class Track { class Track {
constructor(json) { constructor(json) {
@ -32,13 +30,12 @@ class Track {
} }
//Get Deezer CDN url by streamUrl //Get Deezer CDN url by streamUrl
static getUrl(info, quality = 3) { static getUrlInfo(info) {
let md5origin = info.substring(0, 32); let md5origin = info.substring(0, 32);
if (info.charAt(32) == '1') md5origin += '.mp3'; if (info.charAt(32) == '1') md5origin += '.mp3';
let mediaVersion = parseInt(info.substring(33, 34)).toString(); let mediaVersion = parseInt(info.substring(33, 34)).toString();
let trackId = info.substring(35); let trackId = info.substring(35);
let url = DeezerAPI.getUrl(trackId, md5origin, mediaVersion, quality); return {trackId, md5origin, mediaVersion};
return url;
} }
} }
@ -124,6 +121,8 @@ class DeezerImage {
} }
url(size = 256) { 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`; 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 decryptor = require('nodeezcryptor');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const logger = require('./winston');
const https = require('https'); const https = require('https');
const Datastore = require('nedb'); const Datastore = require('nedb');
const ID3Writer = require('browser-id3-writer'); const ID3Writer = require('browser-id3-writer');
const Metaflac = require('metaflac-js2'); const Metaflac = require('metaflac-js2');
const sanitize = require("sanitize-filename"); const sanitize = require("sanitize-filename");
const { DeezerAPI } = require('./deezer');
class Downloads { class Downloads {
constructor(settings, qucb) { constructor(settings, qucb) {
@ -246,9 +248,12 @@ class Download {
this.downloaded = start; this.downloaded = start;
//Get download info //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) => { this._request = https.get(this.url, {headers: {'Range': `bytes=${start}-`}}, (r) => {
let skip = false;
//Error //Error
if (r.statusCode >= 400) { if (r.statusCode >= 400) {
//Fallback on error //Fallback on error
@ -261,12 +266,31 @@ class Download {
}; };
//Error //Error
this.state = -1; this.state = -1;
console.log(`Undownloadable track ID: ${this.track.id}`); logger.error(`Undownloadable track ID: ${this.track.id}`);
return this.onDone(); 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 //On download done
r.on('end', () => { r.on('end', () => {
if (skip) return;
if (this.downloaded != this.size) return; if (this.downloaded != this.size) return;
this._finished(tmp); this._finished(tmp);
}); });
@ -276,15 +300,13 @@ class Download {
}); });
r.on('error', (e) => { r.on('error', (e) => {
console.log(`Download error: ${e}`); logger.error(`Download error: ${e}`);
//TODO: Download error handling //TODO: Download error handling
}) })
//Save size //Save size
this.size = parseInt(r.headers['content-length'], 10) + start; 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) {}; } catch (e) {};
//Decrypt //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`); decryptor.decryptFile(decryptor.getKey(this.track.id), tmp, `${tmp}.DEC`);
fs.promises.copyFile(`${tmp}.DEC`, this.path); fs.promises.copyFile(`${tmp}.DEC`, this.path);
//Delete encrypted //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 https = require('https');
const fs = require('fs'); const fs = require('fs');
const axios = require('axios').default; const axios = require('axios').default;
const LastfmAPI = require('lastfmapi'); const logger = require('./winston');
const {DeezerAPI, DeezerDecryptionStream} = require('./deezer'); const {DeezerAPI, DeezerDecryptionStream} = require('./deezer');
const {Settings} = require('./settings'); const {Settings} = require('./settings');
const {Track, Album, Artist, Playlist, DeezerProfile, SearchResults, DeezerLibrary, DeezerPage, Lyrics} = require('./definitions'); const {Track, Album, Artist, Playlist, DeezerProfile, SearchResults, DeezerLibrary, DeezerPage, Lyrics} = require('./definitions');
const {Downloads} = require('./downloads'); const {Downloads} = require('./downloads');
const {Integrations} = require('./integrations');
let settings; let settings;
let deezer; let deezer;
let downloads; let downloads;
let integrations;
let sockets = []; let sockets = [];
@ -22,12 +24,6 @@ app.use(express.static(path.join(__dirname, '../client', 'dist')));
//Server //Server
const server = require('http').createServer(app); const server = require('http').createServer(app);
const io = require('socket.io').listen(server); 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 //Get playback info
app.get('/playback', async (req, res) => { app.get('/playback', async (req, res) => {
@ -58,6 +54,7 @@ app.post('/settings', async (req, res) => {
if (req.body) { if (req.body) {
Object.assign(settings, req.body); Object.assign(settings, req.body);
downloads.settings = settings; downloads.settings = settings;
integrations.updateSettings(settings);
await settings.save(); await settings.save();
} }
@ -241,14 +238,15 @@ app.put('/library/:type', async (req, res) => {
app.get('/streaminfo/:info', async (req, res) => { app.get('/streaminfo/:info', async (req, res) => {
let info = req.params.info; let info = req.params.info;
let quality = req.query.q ? req.query.q : 3; 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 // S T R E A M I N G
app.get('/stream/:info', (req, res) => { app.get('/stream/:info', (req, res) => {
//Parse stream info //Parse stream info
let quality = req.query.q ? req.query.q : 3; 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); let trackId = req.params.info.substring(35);
//MIME type of audio //MIME type of audio
@ -307,7 +305,7 @@ app.get('/stream/:info', (req, res) => {
}); });
//Internet/Request error //Internet/Request error
_request.on('error', (e) => { _request.on('error', () => {
//console.log('Streaming error: ' + e); //console.log('Streaming error: ' + e);
//HTML audio will restart automatically //HTML audio will restart automatically
res.destroy(); res.destroy();
@ -367,6 +365,20 @@ app.get('/lyrics/:id', async (req, res) => {
res.send(new Lyrics(data.results)); 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 //Post list of tracks to download
app.post('/downloads', async (req, res) => { app.post('/downloads', async (req, res) => {
let tracks = req.body; let tracks = req.body;
@ -410,12 +422,7 @@ app.delete('/downloads/:index', async (req, res) => {
//Log listen to deezer & lastfm //Log listen to deezer & lastfm
app.post('/log', async (req, res) => { app.post('/log', async (req, res) => {
//LastFM //LastFM
if (settings.lastFM) integrations.scrobbleLastFM(req.body.title, req.body.artists[0].name);
lastfm.track.scrobble({
artist: req.body.artists[0].name,
track: req.body.title,
timestamp: Math.floor((new Date()).getTime() / 1000)
});
//Deezer //Deezer
if (settings.logListen) if (settings.logListen)
@ -436,26 +443,19 @@ app.get('/lastfm', async (req, res) => {
//Got token //Got token
if (req.query.token) { if (req.query.token) {
let token = req.query.token; let token = req.query.token;
await new Promise((res, rej) => { //Authorize
lastfm.authenticate(token, (err, sess) => { let authinfo = await integrations.loginLastFM(token);
if (err) res(); if (authinfo) {
//Save to settings settings.lastFM = authinfo;
settings.lastFM = { settings.save();
name: sess.username, }
key: sess.key
};
settings.save();
res();
});
});
authorizeLastFM();
//Redirect to homepage //Redirect to homepage
return res.redirect('/'); return res.redirect('/');
} }
//Get auth url //Get auth url
res.json({ 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(); }).end();
}); });
@ -478,51 +478,12 @@ io.on('connection', (socket) => {
socket.on('disconnect', () => { socket.on('disconnect', () => {
sockets.splice(sockets.indexOf(socket), 1); 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 //ecb = Error callback
async function createServer(electron = false, ecb) { async function createServer(electron = false, ecb) {
//Prepare globals //Prepare globals
@ -559,8 +520,21 @@ async function createServer(electron = false, ecb) {
}); });
}, 350); }, 350);
//LastFM //Integrations (lastfm, discord)
authorizeLastFM(); 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 //Start server
server.on('error', (e) => { server.on('error', (e) => {

View File

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

View File

@ -1,7 +1,7 @@
{ {
"name": "freezer", "name": "freezer",
"private": true, "private": true,
"version": "1.0.5", "version": "1.0.6",
"description": "", "description": "",
"scripts": { "scripts": {
"pack": "electron-builder --dir", "pack": "electron-builder --dir",