First release

This commit is contained in:
exttex 2020-08-28 23:06:19 +02:00
commit b94234c8e7
50 changed files with 18231 additions and 0 deletions

65
README.md Normal file
View File

@ -0,0 +1,65 @@
# Freezer
Free music streaming client for Deezer based on the Deezloader/Deemix "bug".
## Downloads
Binaries are available in Telegram channel: https://t.me/freezereleases
## Telegram
Join group: https://t.me/freezerandroid
## Building
Requirements: NodeJS 12+
You can build binary using npm script:
```
npm i
npm run build
```
Or manually:
```
npm i
cd app
npm i
cd client
npm i
npm run build
cd ../..
```
Then you can run server-only using, default port: `10069`:
```
cd app
node main.js
```
You can build binaries using:
```
npm run dist
```
## Credits
**Francesco** - design help, tester
**Homam** - tester
**Bas Curtiz** - logo
**RemixDev** - how2deez
## Support me
BTC: `14hcr4PGbgqeXd3SoXY9QyJFNpyurgrL9y`
ETH: `0xb4D1893195404E1F4b45e5BDA77F202Ac4012288`
## Disclaimer
```
Freezer was not developed for piracy, but educational and private usage.
It may be illegal to use this in your country!
I am not responsible in any way for the usage of this app.
```

BIN
app/assets/icon-taskbar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
app/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

185
app/background.js Normal file
View File

@ -0,0 +1,185 @@
const {app, BrowserWindow, ipcMain, Tray, Menu, session, dialog, shell} = require('electron');
const {createServer} = require('./src/server');
const path = require('path');
let win;
let tray;
let settings;
let shouldExit = false;
//Get path to asset
function assetPath(a) {
return path.join(__dirname, 'assets', a);
}
async function createWindow() {
//Start server
settings = await createServer(true, (e) => {
//Server error
shouldExit = true;
if (win) win.close();
dialog.showMessageBoxSync({
type: 'error',
title: 'Server error',
message: 'Server error occured, Freezer is probably already running!',
buttons: ['Close']
});
});
//Create window
win = new BrowserWindow({
width: settings.width,
darkTheme: true,
height: settings.height,
minWidth: 620,
minHeight: 600,
resizable: true,
autoHideMenuBar: true,
icon: assetPath("icon.png"),
title: 'Freezer',
webPreferences: {
enableRemoteModule: true,
nodeIntegration: true,
devTools: true
}
});
win.loadURL(`http://localhost:${settings.port}`);
//Minimize to tray
win.on('minimize', (event) => {
if (settings.minimizeToTray) {
event.preventDefault();
win.hide();
}
});
//On close
win.on('close', async (event) => {
if (shouldExit) {
win = null;
tray = null;
return true;
}
//Normal exit
if (!settings || !settings.arl || settings.arl == '' || settings.closeOnExit) {
win.webContents.send('onExit');
shouldExit = true;
}
event.preventDefault();
win.hide();
return false;
});
}
//Create window
app.on('ready', async () => {
createWindow();
//Create tray
tray = new Tray(assetPath("icon-taskbar.png"));
tray.on('double-click', () => win.show());
tray.on('click', () => win.show());
//Tray menu
const contextMenu = Menu.buildFromTemplate([
{
label: 'Restore',
type: 'normal',
click: () => win.show()
},
{
label: 'Play/Pause',
type: 'normal',
click: () => win.webContents.send('togglePlayback')
},
{
label: 'Next',
type: 'normal',
click: () => win.webContents.send('skipNext')
},
{
label: 'Previous',
type: 'normal',
click: () => win.webContents.send('skipPrev')
},
{
label: 'Exit',
type: 'normal',
click: () => {
shouldExit = true;
win.close();
}
}
]);
tray.setContextMenu(contextMenu);
});
//Update settings from ui
ipcMain.on('updateSettings', (event, args) => {
Object.assign(settings, args);
});
//onExit callback
ipcMain.on('onExit', (event) => {
shouldExit = true;
win.close();
});
//Open downloads directory
ipcMain.on('openDownloadsDir', async (event) => {
if ((await shell.openPath(settings.downloadsPath)) == "") return;
shell.showItemInFolder(settings.downloadsPath);
});
//Download path picker
ipcMain.on('selectDownloadPath', async (event) => {
let res = await dialog.showOpenDialog({
title: 'Downloads folder',
properties: ['openDirectory', 'promptToCreate'],
});
if (!res.canceled && res.filePaths.length > 0) {
event.reply('selectDownloadPath', res.filePaths[0]);
}
});
//Login using browser
ipcMain.on('browserLogin', async (event) => {
//Initial clean
session.defaultSession.clearStorageData();
let lwin = new BrowserWindow({
width: 800,
height: 600,
icon: assetPath('icon.png'),
title: "Deezer Login",
resizable: true,
autoHideMenuBar: true,
webPreferences: {
nodeIntegration: false
}
});
lwin.loadURL('https://deezer.com/login');
let arl = await new Promise((res) => {
lwin.webContents.on('did-navigate', async () => {
let arlCookie = await session.defaultSession.cookies.get({
name: "arl"
});
if (arlCookie.length > 0) {
res(arlCookie[0].value);
}
});
});
lwin.close();
lwin = null;
//Delete deezer junk
session.defaultSession.clearStorageData();
event.reply('browserLogin', arl);
});

10826
app/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
app/client/package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "client",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"watch": "vue-cli-service build --watch",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@mdi/font": "^5.5.55",
"axios": "^0.19.2",
"roboto-fontface": "*",
"vue": "^2.6.11",
"vue-esc": "^3.0.1",
"vue-router": "^3.2.0",
"vue-socket.io": "^3.0.10",
"vuetify": "^2.2.11"
},
"devDependencies": {
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"sass": "^1.19.0",
"sass-loader": "^8.0.0",
"vue-cli-plugin-vuetify": "~2.0.7",
"vue-template-compiler": "^2.6.11",
"vuetify-loader": "^1.3.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"ecmaVersion": 2020
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

View File

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title>Freezer</title>
</head>
<body>
<noscript>
<strong>We're sorry but Freezer doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
<!-- Disable global scrollbars -->
<style>
html {
overflow-y: hidden;
}
</style>
<!-- Disable reloading using CTRL+R -->
<script>
document.onkeydown = (e) => {
let keycode = e.which;
if (window.event) keycode = window.event.keyCode;
if (keycode == 116 || (e.ctrlKey && keycode == 82)) {
e.preventDefault();
e.stopPropagation();
return false;
}
}
</script>

343
app/client/src/App.vue Normal file
View File

@ -0,0 +1,343 @@
<template>
<v-app v-esc='closePlayer'>
<!-- Fullscreen player overlay -->
<v-overlay :value='showPlayer' opacity='0.97' z-index="100">
<FullscreenPlayer @close='closePlayer' @volumeChange='volume = $root.volume'></FullscreenPlayer>
</v-overlay>
<!-- Drawer/Navigation -->
<v-navigation-drawer
permanent
fixed
app
mini-variant
expand-on-hover
><v-list nav dense>
<!-- Profile -->
<v-list-item two-line v-if='$root.profile && $root.profile.picture' class='miniVariant px-0'>
<v-list-item-avatar>
<img :src='$root.profile.picture.thumb'>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>{{$root.profile.name}}</v-list-item-title>
<v-list-item-subtitle>{{$root.profile.id}}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<!-- Home link -->
<v-list-item link to='/home'>
<v-list-item-icon>
<v-icon>mdi-home</v-icon>
</v-list-item-icon>
<v-list-item-title>Home</v-list-item-title>
</v-list-item>
<!-- Browse link -->
<v-list-item link to='/page?target=channels%2Fexplore'>
<v-list-item-icon>
<v-icon>mdi-earth</v-icon>
</v-list-item-icon>
<v-list-item-title>Browse</v-list-item-title>
</v-list-item>
<v-subheader inset>Library</v-subheader>
<v-divider></v-divider>
<!-- Tracks -->
<v-list-item link to='/library/tracks'>
<v-list-item-icon>
<v-icon>mdi-music-note</v-icon>
</v-list-item-icon>
<v-list-item-title>Tracks</v-list-item-title>
</v-list-item>
<!-- Playlists -->
<v-list-item link to='/library/playlists'>
<v-list-item-icon>
<v-icon>mdi-playlist-music</v-icon>
</v-list-item-icon>
<v-list-item-title>Playlists</v-list-item-title>
</v-list-item>
<!-- Albums -->
<v-list-item link to='/library/albums'>
<v-list-item-icon>
<v-icon>mdi-album</v-icon>
</v-list-item-icon>
<v-list-item-title>Albums</v-list-item-title>
</v-list-item>
<!-- Artists -->
<v-list-item link to='/library/artists'>
<v-list-item-icon>
<v-icon>mdi-account-music</v-icon>
</v-list-item-icon>
<v-list-item-title>Artists</v-list-item-title>
</v-list-item>
<v-subheader inset>More</v-subheader>
<v-divider></v-divider>
<!-- Settings -->
<v-list-item link to='/settings'>
<v-list-item-icon>
<v-icon>mdi-cog</v-icon>
</v-list-item-icon>
<v-list-item-title>Settings</v-list-item-title>
</v-list-item>
<!-- Downloads -->
<v-list-item link to='/downloads'>
<!-- Download icon -->
<v-list-item-icon v-if='!$root.download && $root.downloads.length == 0'>
<v-icon>mdi-download</v-icon>
</v-list-item-icon>
<!-- Paused download -->
<v-list-item-icon v-if='!$root.download && $root.downloads.length > 0'>
<v-icon>mdi-pause</v-icon>
</v-list-item-icon>
<!-- Download in progress -->
<v-list-item-icon v-if='$root.download'>
<v-progress-circular :value='downloadPercentage' style='top: -2px' class='text-caption'>
{{$root.downloads.length + 1}}
</v-progress-circular>
</v-list-item-icon>
<v-list-item-title>Downloads</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-app-bar app dense>
<v-btn icon @click='previous'>
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-btn icon @click='next'>
<v-icon>mdi-arrow-right</v-icon>
</v-btn>
<v-text-field
hide-details
flat
prepend-inner-icon="mdi-magnify"
single-line
solo
v-model="searchQuery"
ref='searchBar'
@keyup='search'>
</v-text-field>
</v-app-bar>
<!-- Main -->
<v-main>
<v-container
class='overflow-y-auto'
fluid
style='height: calc(100vh - 118px);'>
<keep-alive include='Search,PlaylistPage,HomeScreen,DeezerPage'>
<router-view></router-view>
</keep-alive>
</v-container>
</v-main>
<!-- Footer -->
<v-footer fixed app height='70' class='pa-0'>
<v-progress-linear
height='5'
:value='position'
style='cursor: pointer;'
class='seekbar'
@change='seek'
background-opacity='0'>
</v-progress-linear>
<v-row no-gutters align='center' ref='footer' class='ma-1'>
<!-- No track loaded -->
<v-col class='col-5 d-none d-sm-flex' v-if='!this.$root.track'>
<h3 class='pl-4'>Freezer</h3>
</v-col>
<!-- Track Info -->
<v-col class='d-none d-sm-flex' cols='5' v-if='this.$root.track'>
<v-img
:src='$root.track.albumArt.thumb'
height="56"
max-width="60"
contain>
</v-img>
<div class='text-truncate flex-column d-flex'>
<span class='text-subtitle-1 pl-2 text-no-wrap'>{{this.$root.track.title}}</span>
<span class='text-subtitle-2 pl-2 text-no-wrap'>{{this.$root.track.artistString}}</span>
</div>
</v-col>
<!-- Controls -->
<v-col class='text-center' cols='12' sm='auto'>
<v-btn icon large @click.stop='$root.skip(-1)'>
<v-icon>mdi-skip-previous</v-icon>
</v-btn>
<v-btn icon x-large @click.stop='$root.toggle'>
<v-icon v-if='!$root.isPlaying()'>mdi-play</v-icon>
<v-icon v-if='$root.isPlaying()'>mdi-pause</v-icon>
</v-btn>
<v-btn icon large @click.stop='$root.skip(1)'>
<v-icon>mdi-skip-next</v-icon>
</v-btn>
</v-col>
<!-- Right side -->
<v-spacer></v-spacer>
<v-col cols='0' md='auto' class='d-none d-sm-none d-md-flex justify-center px-2' v-if='this.$root.track'>
<span class='text-subtitle-2'>
{{$duration($root.position)}} <span class='px-4'>{{qualityText}}</span>
</span>
</v-col>
<v-spacer></v-spacer>
<!-- Volume -->
<v-col cols='auto' class='d-none d-sm-flex px-2' @click.stop>
<div style='width: 180px;' class='d-flex'>
<v-slider
dense
hide-details
min='0.00'
max='1.00'
step='0.01'
v-model='volume'
:prepend-icon='$root.muted ? "mdi-volume-off" : "mdi-volume-high"'
@click:prepend='$root.toggleMute()'
>
<template v-slot:append>
<div style='padding-top: 4px;'>
{{Math.round(volume * 100)}}%
</div>
</template>
</v-slider>
</div>
</v-col>
</v-row>
</v-footer>
</v-app>
</template>
<style lang='scss'>
@import 'styles/scrollbar.scss';
.v-navigation-drawer__content {
overflow-y: hidden !important;
}
</style>
<style lang='scss' scoped>
.seekbar {
transition: none !important;
}
.seekbar .v-progress-linear__determinate {
transition: none !important;
}
</style>
<script>
import FullscreenPlayer from '@/views/FullscreenPlayer.vue';
export default {
name: 'App',
components: {
FullscreenPlayer
},
data () {
return {
volume: this.$root.volume,
showPlayer: false,
position: '0.00',
searchQuery: '',
}
},
methods: {
//Hide fullscreen player overlay
closePlayer() {
if (this.showPlayer) this.showPlayer = false;
this.volume = this.$root.volume;
},
//Navigation
previous() {
if (window.history.length == 3) return;
this.$router.go(-1);
},
next() {
this.$router.go(1);
},
search(event) {
//KeyUp event, enter
if (event.keyCode !== 13) return;
this.$router.push({path: '/search', query: {q: this.searchQuery}});
},
seek(val) {
this.$root.seek(Math.round((val / 100) * this.$root.duration()));
}
},
computed: {
qualityText() {
return `${this.$root.playbackInfo.format} ${this.$root.playbackInfo.quality}`;
},
downloadPercentage() {
if (!this.$root.download) return 0;
let p = (this.$root.download.downloaded / this.$root.download.size) * 100;
if (isNaN(p)) return 0;
return Math.round(p);
}
},
async mounted() {
//onClick for footer
this.$refs.footer.addEventListener('click', () => {
if (this.$root.track) this.showPlayer = true;
});
// /search
document.addEventListener('keypress', (event) => {
if (event.keyCode != 47) return;
this.$refs.searchBar.focus();
setTimeout(() => {
this.searchQuery = this.searchQuery.replace(new RegExp('/', 'g'), '');
}, 40);
});
//Wait for volume to load
if (this.$root.loadingPromise) await this.$root.loadingPromise;
this.volume = this.$root.volume;
},
created() {
//Go to login if unauthorized
if (!this.$root.authorized) {
this.$router.push('/login');
}
},
watch: {
volume() {
if (this.$root.audio) this.$root.audio.volume = this.volume;
this.$root.volume = this.volume;
},
//Update position
'$root.position'() {
this.position = (this.$root.position / this.$root.duration()) * 100;
}
}
};
</script>

View File

@ -0,0 +1,152 @@
<template>
<div>
<v-list-item two-line @click='click' v-if='!card'>
<v-hover v-slot:default='{hover}'>
<v-list-item-avatar>
<v-img :src='album.art.thumb'></v-img>
<v-overlay absolute :value='hover'>
<v-btn icon large @click.stop='play'>
<v-icon>mdi-play</v-icon>
</v-btn>
</v-overlay>
</v-list-item-avatar>
</v-hover>
<v-list-item-content>
<v-list-item-title>{{album.title}}</v-list-item-title>
<v-list-item-subtitle>{{album.artistString}}</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<!-- Context menu -->
<v-menu v-model='menu' offset-y offset-x absolue>
<template v-slot:activator="{on, attrs}">
<v-btn v-on='on' v-bind='attrs' icon>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list dense>
<!-- Play album -->
<v-list-item dense @click='play'>
<v-list-item-icon>
<v-icon>mdi-play</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Play</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Add to library -->
<v-list-item dense @click='addLibrary'>
<v-list-item-icon>
<v-icon>mdi-heart</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Add to library</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Download -->
<v-list-item dense @click='download'>
<v-list-item-icon>
<v-icon>mdi-download</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Download</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-action>
</v-list-item>
<v-card v-if='card' max-width='175px' max-height='210px' @click='click'>
<v-hover v-slot:default='{hover}'>
<div>
<v-img :src='album.art.thumb'>
</v-img>
<v-overlay absolute :value='hover' opacity='0.5'>
<v-btn fab small color='white' @click.stop='play'>
<v-icon color='black'>mdi-play</v-icon>
</v-btn>
</v-overlay>
</div>
</v-hover>
<div class='pa-2 text-subtitle-2 text-center text-truncate'>{{album.title}}</div>
</v-card>
<DownloadDialog :tracks='album.tracks' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
</div>
</template>
<script>
import DownloadDialog from '@/components/DownloadDialog.vue';
export default {
name: 'AlbumTile',
components: {DownloadDialog},
data() {
return {
menu: false,
hover: false,
downloadDialog: false
}
},
props: {
album: Object,
card: {
type: Boolean,
default: false
}
},
methods: {
async play() {
let album = this.album;
//Load album from API if tracks are missing
if (album.tracks.length == 0) {
let data = await this.$axios.get(`/album/${album.id}`)
album = data.data;
}
//Error handling
if (!album) return;
this.$root.queueSource = {
text: album.title,
source: 'album',
data: album.id
};
this.$root.replaceQueue(album.tracks);
this.$root.playIndex(0);
},
//On click navigate to details
click() {
this.$router.push({
path: '/album',
query: {album: JSON.stringify(this.album)}
});
this.$emit('clicked')
},
addLibrary() {
this.$axios.put(`/library/album?id=${this.album.id}`);
},
//Add to downloads
async download() {
//Fetch tracks if missing
let tracks = this.album.tracks;
if (!tracks || tracks.length == 0) {
let data = await this.$axios.get(`/album/${this.album.id}`)
tracks = data.data.tracks;
}
this.album.tracks = tracks;
this.downloadDialog = true;
}
}
};
</script>

View File

@ -0,0 +1,84 @@
<template>
<div>
<v-list-item @click='click' v-if='!card' :class='{dense: tiny}'>
<v-list-item-avatar v-if='!tiny'>
<v-img :src='artist.picture.thumb'></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>{{artist.name}}</v-list-item-title>
<v-list-item-subtitle v-if='!tiny'>{{$abbreviation(artist.fans)}} fans</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<!-- Context menu -->
<v-menu v-model='menu' offset-y offset-x absolue>
<template v-slot:activator="{on, attrs}">
<v-btn v-on='on' v-bind='attrs' icon>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list dense>
<!-- Add library -->
<v-list-item dense @click='addLibrary'>
<v-list-item-icon>
<v-icon>mdi-heart</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Add to library</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-action>
</v-list-item>
<!-- Card version -->
<v-card max-height='200px' max-width='200px' v-if='card' @click='click'>
<div class='d-flex justify-center'>
<v-avatar size='150' class='ma-1'>
<v-img :src='artist.picture.thumb'>
</v-img>
</v-avatar>
</div>
<div class='pa-2 text-subtitle-2 text-center text-truncate'>{{artist.name}}</div>
</v-card>
</div>
</template>
<script>
export default {
name: 'ArtistTile',
data() {
return {
menu: false
}
},
props: {
artist: Object,
card: {
type: Boolean,
default: false,
},
tiny: {
type: Boolean,
default: false
}
},
methods: {
addLibrary() {
this.$axios.put(`/library/artist&id=${this.artist.id}`);
},
click() {
//Navigate to details
this.$router.push({
path: '/artist',
query: {artist: JSON.stringify(this.artist)}
});
this.$emit('clicked');
}
}
}
</script>

View File

@ -0,0 +1,35 @@
<template>
<div>
<v-card
width='225px'
height='100px'
:img='channel.image.thumb'
@click='click'
>
<v-container fill-height class='justify-center'>
<v-card-title class='font-weight-black text-truncate text-h6 pa-1'>{{channel.title}}</v-card-title>
</v-container>
</v-card>
</div>
</template>
<script>
export default {
name: 'DeezerChannel',
props: {
channel: Object
},
methods: {
click() {
console.log(this.channel.target);
this.$router.push({
path: '/page',
query: {target: this.channel.target}
});
}
}
}
</script>

View File

@ -0,0 +1,103 @@
<template>
<div>
<v-dialog v-model='show' max-width='420'>
<v-card>
<v-card-title class='headline'>
Download {{tracks.length}} tracks
</v-card-title>
<v-card-text class='pb-0'>
<v-select
label='Quality'
persistent-hint
:items='qualities'
v-model='qualityString'
:hint='"Estimated size: " + $filesize(estimatedSize)'
></v-select>
<v-checkbox
v-model='autostart'
label='Start downloading'
></v-checkbox>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click='$emit("close")'>Cancel</v-btn>
<v-btn text @click='download'>Download</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
export default {
name: 'DownloadDialog',
props: {
tracks: Array,
show: {
type: Boolean,
default: true
},
},
data() {
return {
shown: true,
qualities: [
'Settings quality',
'MP3 128kbps',
'MP3 320kbps',
'FLAC ~1441kbps'
],
qualityString: 'Settings quality',
autostart: true,
}
},
methods: {
//Get quality int from select
qualityInt() {
let i = this.qualities.indexOf(this.qualityString);
if (i == 1) return 1;
if (i == 2) return 3;
if (i == 3) return 9;
return this.$root.settings.downloadsQuality;
},
//Add files to download queue
async download() {
if (this.qualities.indexOf(this.qualityString) == 0 || !this.qualityString) {
await this.$axios.post(`/downloads`, this.tracks);
} else {
await this.$axios.post(`/downloads?q=${this.qualityInt()}`, this.tracks);
}
if (this.autostart) this.$axios.put('/download');
this.$emit("close");
}
},
computed: {
estimatedSize() {
let qi = this.qualityInt();
let duration = this.tracks.reduce((a, b) => a + (b.duration / 1000), 0);
//Magic numbers = bitrate / 8 * 1024 = bytes per second
switch (qi) {
case 1:
return duration * 16384;
case 3:
return duration * 40960;
case 9:
//FLAC is 1144, because more realistic
return duration * 146432;
}
return duration * this.$root.settings.downloadsQuality;
}
}
}
</script>

View File

@ -0,0 +1,45 @@
<template>
<v-list>
<v-overlay v-if='loading'>
<v-progress-circular indeterminate></v-progress-circular>
</v-overlay>
<v-lazy max-height="100" v-for='album in albums' :key='album.id'>
<AlbumTile :album='album'></AlbumTile>
</v-lazy>
</v-list>
</template>
<script>
import AlbumTile from '@/components/AlbumTile.vue';
export default {
name: 'LibraryAlbums',
data() {
return {
albums: [],
loading: false
}
},
methods: {
//Load data
async load() {
this.loading = true;
let res = await this.$axios.get(`/library/albums`);
if (res.data && res.data.data) {
this.albums = res.data.data;
}
this.loading = false;
}
},
components: {
AlbumTile
},
mounted() {
//Initial load
this.load();
}
}
</script>

View File

@ -0,0 +1,45 @@
<template>
<v-list>
<v-overlay v-if='loading'>
<v-progress-circular indeterminate></v-progress-circular>
</v-overlay>
<v-lazy max-height="100" v-for='artist in artists' :key='artist.id'>
<ArtistTile :artist='artist'></ArtistTile>
</v-lazy>
</v-list>
</template>
<script>
import ArtistTile from '@/components/ArtistTile.vue';
export default {
name: 'LibraryArtists',
components: {
ArtistTile
},
data() {
return {
artists: [],
loading: false
}
},
methods: {
//Load data
async load() {
this.loading = true;
let res = await this.$axios.get(`/library/artists`);
if (res.data && res.data.data) {
this.artists = res.data.data;
}
this.loading = false;
}
},
mounted() {
//Initial load
this.load();
}
}
</script>

View File

@ -0,0 +1,69 @@
<template>
<v-list>
<v-overlay v-if='loading'>
<v-progress-circular indeterminate></v-progress-circular>
</v-overlay>
<!-- Create playlist -->
<v-btn class='ma-2 ml-3' color='primary' @click='popup = true'>
<v-icon left>mdi-playlist-plus</v-icon>
Create new playlist
</v-btn>
<v-dialog max-width="400px" v-model='popup'>
<PlaylistPopup @created='playlistCreated'></PlaylistPopup>
</v-dialog>
<v-lazy max-height="100" v-for='(playlist, index) in playlists' :key='playlist.id'>
<PlaylistTile :playlist='playlist' @remove='removed(index)'></PlaylistTile>
</v-lazy>
</v-list>
</template>
<script>
import PlaylistTile from '@/components/PlaylistTile.vue';
import PlaylistPopup from '@/components/PlaylistPopup.vue';
export default {
name: 'LibraryPlaylists',
components: {
PlaylistTile, PlaylistPopup
},
data() {
return {
playlists: [],
loading: false,
popup: false
}
},
methods: {
//Load data
async load() {
this.loading = true;
let res = await this.$axios.get(`/library/playlists`);
if (res.data && res.data.data) {
this.playlists = res.data.data;
}
this.loading = false;
},
//Playlist created, update list
playlistCreated() {
this.popup = false;
this.playlists = [];
this.load();
},
//On playlist remove
removed(i) {
this.playlists.splice(i, 1);
}
},
mounted() {
//Initial load
this.load();
}
}
</script>

View File

@ -0,0 +1,98 @@
<template>
<v-list :height='height' class='overflow-y-auto' v-scroll.self='scroll'>
<v-lazy
v-for='(track, index) in tracks'
:key='index + "t" + track.id'
max-height="100"
><TrackTile :track='track' @click='play(index)' @remove='removedTrack(index)'>
</TrackTile>
</v-lazy>
<div class='text-center' v-if='loading'>
<v-progress-circular indeterminate></v-progress-circular>
</div>
</v-list>
</template>
<script>
import TrackTile from '@/components/TrackTile.vue';
export default {
name: 'LibraryTracks',
components: {
TrackTile
},
data() {
return {
loading: false,
tracks: [],
count: 0
}
},
props: {
height: String
},
methods: {
scroll(event) {
let loadOffset = event.target.scrollHeight - event.target.offsetHeight - 100;
if (event.target.scrollTop > loadOffset) {
if (!this.loading) this.load();
}
},
//Load initial data
initialLoad() {
this.loading = true;
this.$axios.get(`/library/tracks`).then((res) => {
this.tracks = res.data.data;
this.count = res.data.count;
this.loading = false;
});
},
//Load more tracks
load() {
if (this.tracks.length >= this.count) return;
this.loading = true;
//Library Favorites = playlist
let id = this.$root.profile.favoritesPlaylist;
let offset = this.tracks.length;
this.$axios.get(`/playlist/${id}?start=${offset}`).then((res) => {
this.tracks.push(...res.data.tracks);
this.loading = false;
});
},
//Load all tracks
async loadAll() {
this.loading = true;
let id = this.$root.profile.favoritesPlaylist;
let res = await this.$axios.get(`/playlist/${id}?full=iguess`);
if (res.data && res.data.tracks) {
this.tracks.push(...res.data.tracks.slice(this.tracks.length));
}
this.loading = false;
},
//Play track
async play(index) {
if (this.tracks.length < this.count) {
await this.loadAll();
}
this.$root.queue.source = {
text: 'Loved tracks',
source: 'playlist',
data: this.$root.profile.favoritesPlaylist
};
this.$root.replaceQueue(this.tracks);
this.$root.playIndex(index);
},
removedTrack(index) {
this.tracks.splice(index, 1);
}
},
mounted() {
this.initialLoad();
}
}
</script>

View File

@ -0,0 +1,109 @@
<template>
<div :style='"max-height: " + height' class='overflow-y-auto' ref='content'>
<div class='text-center my-4'>
<v-progress-circular indeterminate v-if='loading'></v-progress-circular>
</div>
<div v-if='!loading && lyrics' class='text-center'>
<div v-for='(lyric, index) in lyrics.lyrics' :key='lyric.offset' class='my-8 mx-4'>
<span
class='my-8'
:class='{"text-h6 font-weight-regular": !playingNow(index), "text-h5 font-weight-bold": playingNow(index)}'
:ref='"l"+index'
>
{{lyric.text}}
</span>
</div>
</div>
<!-- Error -->
<div v-if='!loading && !lyrics' class='pa-4 text-center'>
<span class='red--text text-h5'>
Error loading lyrics or lyrics not found!
</span>
</div>
</div>
</template>
<script>
export default {
name: 'Lyrics',
props: {
songId: String,
height: String
},
data() {
return {
cSongId: this.songId,
loading: true,
lyrics: null,
currentLyricIndex: 0,
}
},
methods: {
//Load data from API
async load() {
this.loading = true;
this.lyrics = null;
try {
let res = await this.$axios.get(`/lyrics/${this.songId}`);
if (res.data) this.lyrics = res.data;
} catch (e) {true;}
this.loading = false;
},
//Wether current lyric is playing rn
playingNow(i) {
if (!this.$root.audio) return false;
//First & last lyric check
if (i == this.lyrics.lyrics.length - 1) {
if (this.lyrics.lyrics[i].offset <= this.$root.position) return true;
return false;
}
if (this.$root.position >= this.lyrics.lyrics[i].offset && this.$root.position < this.lyrics.lyrics[i+1].offset) return true;
return false;
},
//Get index of current lyric
currentLyric() {
if (!this.$root.audio) return 0;
return this.lyrics.lyrics.findIndex((l) => {
return this.playingNow(this.lyrics.lyrics.indexOf(l));
});
},
//Scroll to currently playing lyric
scrollLyric() {
if (!this.lyrics) return;
//Prevent janky scrolling
if (this.currentLyricIndex == this.currentLyric()) return;
this.currentLyricIndex = this.currentLyric();
//Roughly middle
let offset = window.innerHeight / 2 - 500;
this.$refs.content.scrollTo({
top: this.$refs["l"+this.currentLyricIndex][0].offsetTop + offset,
behavior: 'smooth'
});
}
},
mounted() {
this.load();
},
watch: {
songId() {
//Load on song id change
if (this.cSongId != this.songId) {
this.cSongId = this.songId;
this.load();
}
},
'$root.position'() {
this.scrollLyric();
}
}
}
</script>

View File

@ -0,0 +1,106 @@
<template>
<div>
<!-- Create playlist -->
<v-card class='text-center pa-2' v-if='!addToPlaylist'>
<v-card-text>
<p primary-title class='display-1'>Create playlist</p>
<v-text-field label='Title' class='ma-2' v-model='title'></v-text-field>
<v-textarea class='mx-2' v-model='description' label='Description' rows='1' auto-grow></v-textarea>
<v-select class='mx-2' v-model='type' :items='types' label='Type'></v-select>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn class='primary' :loading='createLoading' @click='create'>Create</v-btn>
</v-card-actions>
</v-card>
<!-- Add to playlist -->
<v-card class='text-center pa-2' v-if='addToPlaylist'>
<v-card-text>
<p primary-title class='display-1'>Add to playlist</p>
<v-btn block class='mb-1' @click='addToPlaylist = false'>
<v-icon left>mdi-playlist-plus</v-icon>
Create New
</v-btn>
<v-list>
<div v-for='playlist in playlists' :key='playlist.id'>
<v-list-item
v-if='playlist.user.id == $root.profile.id'
@click='addTrack(playlist)'
dense>
<v-list-item-avatar>
<v-img :src='playlist.image.thumb'></v-img>
</v-list-item-avatar>
<v-list-item-title>{{playlist.title}}</v-list-item-title>
</v-list-item>
</div>
<v-progress-circular indeterminate v-if='loading'></v-progress-circular>
</v-list>
</v-card-text>
</v-card>
</div>
</template>
<script>
export default {
name: 'PlaylistPopup',
data() {
return {
//Make mutable
addToPlaylist: this.track?true:false,
title: '',
description: '',
type: 'Private',
types: ['Private', 'Public'],
createLoading: false,
loading: false,
playlists: []
}
},
props: {
track: {
type: Object,
default: null
}
},
methods: {
//Create playlist
async create() {
this.createLoading = true;
await this.$axios.post('/playlist', {
description: this.description,
title: this.title,
type: this.type.toLowerCase(),
track: this.track ? this.track.id : null
});
this.createLoading = false;
this.$emit('created');
this.$emit('close');
},
//Add track to playlist
async addTrack(playlist) {
await this.$axios.post(`/playlist/${playlist.id}/tracks`, {track: this.track.id});
this.$emit('close');
}
},
async mounted() {
//Load playlists, if adding to playlist
if (this.track) {
this.loading = true;
let res = await this.$axios.get(`/library/playlists`);
this.playlists = res.data.data;
this.loading = false;
}
}
};
</script>

View File

@ -0,0 +1,166 @@
<template>
<div>
<!-- List tile -->
<v-list-item @click='click' v-if='!card'>
<v-hover v-slot:default='{hover}'>
<v-list-item-avatar>
<v-img :src='playlist.image.thumb'></v-img>
<v-overlay absolute :value='hover'>
<v-btn icon large @click.stop='play'>
<v-icon>mdi-play</v-icon>
</v-btn>
</v-overlay>
</v-list-item-avatar>
</v-hover>
<v-list-item-content>
<v-list-item-title>{{playlist.title}}</v-list-item-title>
<v-list-item-subtitle>{{$numberString(playlist.trackCount)}} tracks</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<!-- Context menu -->
<v-menu v-model='menu' offset-y offset-x absolue>
<template v-slot:activator="{on, attrs}">
<v-btn v-on='on' v-bind='attrs' icon>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list dense>
<!-- Play -->
<v-list-item dense @click='play'>
<v-list-item-icon>
<v-icon>mdi-play</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Play</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Remove -->
<v-list-item dense v-if='canRemove' @click='remove'>
<v-list-item-icon>
<v-icon>mdi-playlist-remove</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Remove</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Download -->
<v-list-item dense @click='download'>
<v-list-item-icon>
<v-icon>mdi-download</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Download</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-action>
</v-list-item>
<!-- Card -->
<v-card v-if='card' max-width='175px' max-height='175px' @click='click' rounded>
<v-hover v-slot:default='{hover}'>
<div>
<v-img :src='playlist.image.thumb'>
</v-img>
<v-overlay absolute :value='hover' opacity='0.5'>
<v-btn fab small color='white' @click.stop='play'>
<v-icon color='black'>mdi-play</v-icon>
</v-btn>
</v-overlay>
</div>
</v-hover>
</v-card>
<DownloadDialog :tracks='tracks' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
</div>
</template>
<script>
import DownloadDialog from '@/components/DownloadDialog.vue';
export default {
name: 'PlaylistTile',
components: {DownloadDialog},
data() {
return {
menu: false,
hover: false,
downloadDialog: false,
tracks: null
}
},
props: {
playlist: Object,
card: {
type: Boolean,
default: false
}
},
methods: {
async play() {
let playlist = this.playlist;
//Load playlist tracks
if (playlist.tracks.length != playlist.trackCount) {
let data = await this.$axios.get(`/playlist/${playlist.id}?full=iguess`);
playlist = data.data;
}
//Error handling
if (!playlist) return;
this.$root.queue.source = {
text: playlist.title,
source: 'playlist',
data: playlist.id
};
this.$root.replaceQueue(playlist.tracks);
this.$root.playIndex(0);
},
//On click navigate to details
click() {
this.$router.push({
path: '/playlist',
query: {playlist: JSON.stringify(this.playlist)}
});
},
async remove() {
//Delete own playlist
if (this.playlist.user.id == this.$root.profile.id) {
await this.$axios.delete(`/playlist/${this.playlist.id}`);
} else {
//Remove from library
await this.$axios.get('/library/playlist&id=' + this.playlist.id);
}
this.$emit('remove');
},
async download() {
let tracks = this.playlist.tracks;
if (tracks.length < this.playlist.trackCount) {
let data = await this.$axios.get(`/playlist/${this.playlist.id}?full=iguess`);
tracks = data.data.tracks;
}
this.tracks = tracks;
this.downloadDialog = true;
}
},
computed: {
canRemove() {
//Own playlist
if (this.$root.profile.id == this.playlist.user.id) return true;
return false;
}
}
};
</script>

View File

@ -0,0 +1,50 @@
<template>
<div>
<v-card max-width='175px' max-height='210px' @click='play' :loading='loading'>
<v-img :src='stl.cover.thumb'>
</v-img>
<div class='pa-2 text-subtitle-2 text-center text-truncate'>{{stl.title}}</div>
</v-card>
</div>
</template>
<script>
export default {
name: 'SmartTrackList',
props: {
stl: Object
},
data() {
return {
loading: false
}
},
methods: {
//Load stt as source
async play() {
this.loading = true;
//Load data
let res = await this.$axios.get('/smarttracklist/' + this.stl.id);
if (!res.data) {
this.loading = false;
return;
}
//Send to player
this.$root.queue.source = {
text: this.stl.title,
source: 'smarttracklist',
data: this.stl.id
};
this.$root.replaceQueue(res.data);
this.$root.playIndex(0);
this.loading = false;
}
}
}
</script>

View File

@ -0,0 +1,207 @@
<template>
<v-list-item two-line @click='$emit("click")'>
<v-list-item-avatar>
<v-img :src='track.albumArt.thumb'></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title
:class='{"primary--text": track.id == ($root.track ? $root.track : {id: null}).id}'
>{{track.title}}</v-list-item-title>
<v-list-item-subtitle>{{track.artistString}}</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<!-- Quick add/remoev to library -->
<v-btn @click.stop='addLibrary' icon v-if='!isLibrary'>
<v-icon>mdi-heart</v-icon>
</v-btn>
<v-btn @click.stop='removeLibrary' icon v-if='isLibrary'>
<v-icon>mdi-heart-remove</v-icon>
</v-btn>
</v-list-item-action>
<v-list-item-action>
<!-- Quick add to playlist -->
<v-btn @click.stop='popup = true' icon>
<v-icon>mdi-playlist-plus</v-icon>
</v-btn>
</v-list-item-action>
<v-list-item-action>
<!-- Context menu -->
<v-menu v-model='menu' offset-y offset-x absolue>
<template v-slot:activator="{on, attrs}">
<v-btn v-on='on' v-bind='attrs' icon>
<v-icon>mdi-dots-vertical</v-icon>
</v-btn>
</template>
<v-list dense>
<!-- Play Next -->
<v-list-item dense @click='playNext'>
<v-list-item-icon>
<v-icon>mdi-playlist-plus</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Play next</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Add to end of queue -->
<v-list-item dense @click='addQueue'>
<v-list-item-icon>
<v-icon>mdi-playlist-plus</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Add to queue</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Add to library -->
<v-list-item dense @click='addLibrary' v-if='!isLibrary'>
<v-list-item-icon>
<v-icon>mdi-heart</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Add to library</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Remove from library -->
<v-list-item dense @click='removeLibrary' v-if='isLibrary'>
<v-list-item-icon>
<v-icon>mdi-heart-remove</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Remove from library</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Add to playlist -->
<v-list-item dense @click='popup = true' v-if='!playlistId'>
<v-list-item-icon>
<v-icon>mdi-playlist-plus</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Add to playlist</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Remove from playlist -->
<v-list-item dense @click='removePlaylist' v-if='playlistId'>
<v-list-item-icon>
<v-icon>mdi-playlist-remove</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Remove from playlist</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Go to album -->
<v-list-item dense @click='goAlbum'>
<v-list-item-icon>
<v-icon>mdi-album</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Go to "{{track.album.title}}"</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Go to artists -->
<v-list-item
dense
@click='goArtist(artist)'
v-for="artist in track.artists"
:key='"ART" + artist.id'
>
<v-list-item-icon>
<v-icon>mdi-account-music</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Go to "{{artist.name}}"</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Download -->
<v-list-item dense @click='download'>
<v-list-item-icon>
<v-icon>mdi-download</v-icon>
</v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Download</v-list-item-title>
</v-list-item-content>
</v-list-item>
</v-list>
</v-menu>
</v-list-item-action>
<!-- Add to playlist dialog -->
<v-dialog max-width="400px" v-model='popup'>
<PlaylistPopup :track='this.track' @close='popup = false'></PlaylistPopup>
</v-dialog>
<DownloadDialog :tracks='[track]' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
</v-list-item>
</template>
<script>
import PlaylistPopup from '@/components/PlaylistPopup.vue';
import DownloadDialog from '@/components/DownloadDialog.vue';
export default {
name: 'TrackTile',
components: {
PlaylistPopup, DownloadDialog
},
data() {
return {
menu: false,
popup: false,
downloadDialog: false,
isLibrary: this.$root.libraryTracks.includes(this.track.id)
}
},
props: {
track: Object,
//If specified, track can be removed
playlistId: {
type: String,
default: null
},
},
methods: {
//Add track next to queue
playNext() {
this.$root.addTrackIndex(this.track, this.$root.queueIndex+1);
},
addQueue() {
this.$root.queue.push(this.track);
},
addLibrary() {
this.isLibrary = true;
this.$root.libraryTracks.push(this.track.id);
this.$axios.put(`/library/tracks?id=${this.track.id}`);
},
goAlbum() {
this.$router.push({
path: '/album',
query: {album: JSON.stringify(this.track.album)}
});
},
goArtist(a) {
this.$router.push({
path: '/artist',
query: {artist: JSON.stringify(a)}
});
},
async removeLibrary() {
this.isLibrary = false;
this.$root.libraryTracks.splice(this.$root.libraryTracks.indexOf(this.track.id), 1);
await this.$axios.delete(`/library/tracks?id=${this.track.id}`);
this.$emit('remove');
},
//Remove from playlist
async removePlaylist() {
await this.$axios.delete(`/playlist/${this.playlistId}/tracks`, {
data: {track: this.track.id}
});
this.$emit('remove');
},
//Download track
async download() {
this.downloadDialog = true;
}
}
}
</script>

View File

@ -0,0 +1,86 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import Login from '@/views/Login.vue';
import HomeScreen from '@/views/HomeScreen.vue';
import Search from '@/views/Search.vue';
import Library from '@/views/Library.vue';
import AlbumPage from '@/views/AlbumPage.vue';
import PlaylistPage from '@/views/PlaylistPage.vue';
import ArtistPage from '@/views/ArtistPage.vue';
import Settings from '@/views/Settings.vue';
import DeezerPage from '@/views/DeezerPage.vue';
import DownloadsPage from '@/views/DownloadsPage.vue';
Vue.use(VueRouter);
const routes = [
{
path: '/home',
component: HomeScreen
},
{
path: '/login',
component: Login
},
{
path: '/search',
component: Search,
props: (route) => {
return {query: route.query.q}
}
},
{
path: '/library',
component: Library,
},
//Library short links
{path: '/library/tracks', component: Library, props: () => {return {routeTab: 'tracks'}}},
{path: '/library/albums', component: Library, props: () => {return {routeTab: 'albums'}}},
{path: '/library/artists', component: Library, props: () => {return {routeTab: 'artists'}}},
{path: '/library/playlists', component: Library, props: () => {return {routeTab: 'playlists'}}},
{
path: '/album',
component: AlbumPage,
props: (route) => {
return {albumData: JSON.parse(route.query.album)}
}
},
{
path: '/playlist',
component: PlaylistPage,
props: (route) => {
return {playlistData: JSON.parse(route.query.playlist)}
}
},
{
path: '/artist',
component: ArtistPage,
props: (route) => {
return {artistData: JSON.parse(route.query.artist)}
}
},
{
path: '/settings',
component: Settings
},
{
path: '/page',
component: DeezerPage,
props: (route) => {
return {target: route.query.target}
}
},
{
path: '/downloads',
component: DownloadsPage,
}
];
const router = new VueRouter({
mode: 'hash',
base: process.env.BASE_URL,
routes
});
export default router;

View File

@ -0,0 +1,13 @@
import Vue from 'vue';
import Vuetify from 'vuetify/lib';
import 'roboto-fontface/css/roboto/roboto-fontface.css';
import '@mdi/font/css/materialdesignicons.css';
Vue.use(Vuetify);
export default new Vuetify({
theme: {
dark: true
}
});

427
app/client/src/main.js Normal file
View File

@ -0,0 +1,427 @@
import Vue from 'vue';
import App from './App.vue';
import router from './js/router';
import vuetify from './js/vuetify';
import axios from 'axios';
import VueEsc from 'vue-esc';
import VueSocketIO from 'vue-socket.io';
//Globals
//Axios
let axiosInstance = axios.create({
baseURL: `${window.location.origin}`,
timeout: 16000,
responseType: 'json'
});
Vue.prototype.$axios = axiosInstance;
//Duration formatter
Vue.prototype.$duration = (s) => {
let pad = (n, z = 2) => ('00' + n).slice(-z);
return ((s%3.6e6)/6e4 | 0) + ':' + pad((s%6e4)/1000|0);
};
//Abbrevation
Vue.prototype.$abbreviation = (n) => {
if (!n || n == 0) return '0';
var base = Math.floor(Math.log(Math.abs(n))/Math.log(1000));
var suffix = 'KMB'[base-1];
return suffix ? String(n/Math.pow(1000,base)).substring(0,3)+suffix : ''+n;
}
//Add thousands commas
Vue.prototype.$numberString = (n) => {
if (!n || n == 0) return '0';
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}
//Filesize
Vue.prototype.$filesize = (bytes) => {
if (bytes === 0) return '0 B';
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i];
}
//Sockets
Vue.use(new VueSocketIO({
connection: window.location.origin
}));
Vue.config.productionTip = false;
Vue.use(VueEsc);
new Vue({
data: {
//Globals
settings: {},
profile: {},
authorized: false,
loadingPromise: null,
//Downloads
downloading: false,
downloads: [],
download: null,
//Player
track: null,
audio: null,
volume: 0.00,
//0 = Stopped, 1 = Paused, 2 = Playing, 3 = Loading
state: 0,
loaders: 0,
playbackInfo: {},
position: 0,
muted: false,
//Gapless playback meta
gapless: {
promise: null,
audio: null,
info: null,
track: null
},
//Library cache
libraryTracks: [],
//Queue data
queue: {
data: [],
index: -1,
source: {
text: 'None',
source: 'none',
data: 'none'
}
}
},
methods: {
// PLAYBACK METHODS
isPlaying() {
return this.state == 2;
},
play() {
if (!this.audio || this.state != 1) return;
this.audio.play();
this.state = 2;
},
pause() {
if (!this.audio || this.state != 2) return;
this.audio.pause();
this.state = 1;
},
toggle() {
if (this.isPlaying()) return this.pause();
this.play();
},
seek(t) {
if (!this.audio) return;
//ms -> s
this.audio.currentTime = (t / 1000);
},
//Current track duration
duration() {
//Prevent 0 division
if (!this.audio) return 1;
return this.audio.duration * 1000;
},
//Replace queue, has to make clone of data to not keep references
replaceQueue(newQueue) {
this.queue.data = Object.assign([], newQueue);
},
//Add track to queue at index
addTrackIndex(track, index) {
this.queue.data.splice(index, 0, track);
},
//Play at index in queue
async playIndex(index) {
if (index >= this.queue.data.length || index < 0) return;
this.queue.index = index;
await this.playTrack(this.queue.data[this.queue.index]);
this.play();
this.savePlaybackInfo();
},
//Skip n tracks, can be negative
skip(n) {
let newIndex = this.queue.index + n;
//Out of bounds
if (newIndex < 0 || newIndex >= this.queue.data.length) return;
this.playIndex(newIndex);
},
toggleMute() {
if (this.audio) this.audio.muted = !this.audio.muted;
this.muted = !this.muted;
},
async playTrack(track) {
if (!track || !track.streamUrl) return;
this.resetGapless();
this.track = track;
this.loaders++;
this.state = 3;
//Stop audio
let autoplay = (this.state == 2);
if (this.audio) this.audio.pause();
if (this.audio) this.audio.currentTime = 0;
//Load track meta
this.playbackInfo = await this.loadPlaybackInfo(track.streamUrl, track.duration);
//Stream URL
let url = `${window.location.origin}${this.playbackInfo.url}`;
//Cancel loading
this.loaders--;
if (this.loaders > 0) {
return;
}
//Audio
this.audio = new Audio(url);
this.configureAudio();
this.state = 1;
if (autoplay) this.play();
},
//Configure html audio element
configureAudio() {
//Listen position updates
this.audio.addEventListener('timeupdate', () => {
this.position = this.audio.currentTime * 1000;
//Gapless playback
if (this.position >= (this.duration() - 5000) && this.state == 2) {
this.loadGapless();
}
});
this.audio.muted = this.muted;
this.audio.volume = this.volume;
this.audio.addEventListener('ended', async () => {
//Load gapless
if (this.gapless.promise || this.gapless.audio) {
this.state = 3;
if (this.gapless.promise) await this.gapless.promise;
this.audio = this.gapless.audio;
this.playbackInfo = this.gapless.info;
this.track = this.gapless.track;
this.queue.index++;
this.resetGapless();
this.configureAudio();
//Play
this.state = 2;
this.audio.play();
await this.savePlaybackInfo();
return;
}
//Skip to next track
this.skip(1);
this.savePlaybackInfo();
});
this.updateMediaSession();
},
//Update media session with current track metadata
updateMediaSession() {
if (!this.track || !('mediaSession' in navigator)) return;
// eslint-disable-next-line no-undef
navigator.mediaSession.metadata = new MediaMetadata({
title: this.track.title,
artist: this.track.artistString,
album: this.track.album.title,
artwork: [
{src: this.getImageUrl(this.track.albumArt, 256), sizes: '256x256', type: 'image/jpeg'},
{src: this.getImageUrl(this.track.albumArt, 512), sizes: '512x512', type: 'image/jpeg'}
]
});
//Controls
navigator.mediaSession.setActionHandler('play', this.play);
navigator.mediaSession.setActionHandler('pause', this.pause);
navigator.mediaSession.setActionHandler('nexttrack', () => this.skip(1));
navigator.mediaSession.setActionHandler('previoustrack', () => this.skip(-1));
},
//Get Deezer CDN image url
getImageUrl(img, size = 256) {
return `https://e-cdns-images.dzcdn.net/images/${img.type}/${img.hash}/${size}x${size}-000000-80-0-0.jpg`
},
async loadPlaybackInfo(streamUrl, duration) {
//Get playback info
let quality = this.settings.streamQuality;
let infoUrl = `/streaminfo/${streamUrl}?q=${quality}`;
let res = await this.$axios.get(infoUrl);
let info = res.data;
//Calculate flac bitrate
if (!info.quality.includes('kbps')) {
info.quality = Math.round((parseInt(info.quality, 10)*8) / duration) + 'kbps';
}
return info;
},
//Reset gapless playback meta
resetGapless() {
this.gapless = {promise: null,audio: null,info: null,track: null};
},
//Load next track for gapless
async loadGapless() {
if (this.loaders != 0 || this.gapless.promise || this.gapless.audio) return;
//Last song
if (this.queue.index+1 >= this.queue.data.length) return;
//Save promise
let resolve;
this.gapless.promise = new Promise((res) => {resolve = res});
//Load meta
this.gapless.track = this.queue.data[this.queue.index + 1];
let info = await this.loadPlaybackInfo(this.gapless.track.streamUrl, this.gapless.track.duration);
this.gapless.info = info
this.gapless.audio = new Audio(`${window.location.origin}${info.url}`);
//Might get canceled
if (this.gapless.promise) resolve();
},
//Update & save settings
async saveSettings() {
this.settings.volume = this.volume;
await this.$axios.post('/settings', this.settings);
//Update settings in electron
if (this.settings.electron) {
const {ipcRenderer} = window.require('electron');
ipcRenderer.send('updateSettings', this.settings);
}
},
async savePlaybackInfo() {
let data = {
queue: this.queue,
position: this.position,
track: this.track
}
await this.$axios.post('/playback', data);
},
//Get downloads from server
async getDownloads() {
let res = await this.$axios.get('/downloads');
this.downloading = res.data.downloading;
this.downloads = res.data.downloads;
},
//Start stop downloading
async toggleDownload() {
if (this.downloading) {
await this.$axios.delete('/download');
} else {
await this.$axios.put('/download');
}
},
//Deezer doesn't give information if items are in library, so it has to be cachced
async cacheLibrary() {
let res = await this.$axios.get(`/playlist/${this.profile.favoritesPlaylist}?full=idk`);
this.libraryTracks = res.data.tracks.map((t) => t.id);
}
},
async created() {
//Load settings, create promise so `/login` can await it
let r;
this.loadingPromise = new Promise((resolve) => r = resolve);
let res = await this.$axios.get('/settings');
this.settings = res.data;
this.volume = this.settings.volume;
//Restore playback data
let pd = await this.$axios.get('/playback');
if (pd.data != {}) {
if (pd.data.queue) this.queue = pd.data.queue;
if (pd.data.track) this.track = pd.data.track;
this.playTrack(this.track).then(() => {
this.seek(pd.data.position);
});
}
//Check for electron (src: npm isElectron)
this.settings.electron = ((
typeof window !== 'undefined' &&
typeof window.process === 'object' &&
window.process.type === 'renderer') || (
typeof navigator === 'object' && typeof navigator.userAgent === 'string' &&
navigator.userAgent.indexOf('Electron') >= 0
));
//Setup electron callbacks
if (this.settings.electron) {
const {ipcRenderer} = window.require('electron');
//Save files on exit
ipcRenderer.on('onExit', async () => {
this.pause();
await this.saveSettings();
await this.savePlaybackInfo();
ipcRenderer.send('onExit', '');
});
//Control from electron
ipcRenderer.on('togglePlayback', () => {
this.toggle();
});
ipcRenderer.on('skipNext', () => {
this.skip(1);
});
ipcRenderer.on('skipPrev', () => {
this.skip(-1);
})
}
//Get downloads
this.getDownloads();
//Sockets
//Queue change
this.sockets.subscribe('downloads', (data) => {
this.downloading = data.downloading;
this.downloads = data.downloads;
});
//Current download change
this.sockets.subscribe('download', (data) => {
this.download = data;
});
r();
},
mounted() {
//Save settings on unload
window.addEventListener('beforeunload', () => {
this.savePlaybackInfo();
this.saveSettings();
});
//Save size
window.addEventListener('resize', () => {
this.settings.width = window.innerWidth;
this.settings.height = window.innerHeight;
});
//Keystrokes
document.addEventListener('keyup', (e) => {
//Don't handle keystrokes in text fields
if (e.target.tagName == "INPUT") return;
//K toggle playback
//e.keyCode === 32
if (e.keyCode === 75 || e.keyCode === 107) this.$root.toggle();
//L +10s (from YT)
if (e.keyCode === 108 || e.keyCode === 76) this.$root.seek((this.position + 10000));
//J -10s (from YT)
if (e.keyCode === 106 || e.keyCode === 74) this.$root.seek((this.position - 10000));
});
},
router,
vuetify,
render: function (h) { return h(App) }
}).$mount('#app');

View File

@ -0,0 +1,125 @@
<template>
<div>
<v-card class='d-flex'>
<v-img
:src='album.art.full'
:lazy-src="album.art.thumb"
max-height="100%"
max-width="35vh"
contain
></v-img>
<div class='pl-4'>
<v-overlay absolute :value="loading" z-index="3" opacity='0.9'>
<v-progress-circular indeterminate></v-progress-circular>
</v-overlay>
<h1>{{album.title}}</h1>
<h3>{{album.artistString}}</h3>
<div class='mt-2' v-if='!loading'>
<span class='text-subtitle-2'>{{album.tracks.length}} tracks</span><br>
<span class='text-subtitle-2'>Duration: {{duration}}</span><br>
<span class='text-subtitle-2'>{{$numberString(album.fans)}} fans</span><br>
<span class='text-subtitle-2'>Released: {{album.releaseDate}}</span><br>
</div>
<div class='my-2'>
<v-btn color='primary' class='mx-1' @click='play'>
<v-icon left>mdi-play</v-icon>
Play
</v-btn>
<v-btn color='red' class='mx-1' @click='library' :loading='libraryLoading'>
<v-icon left>mdi-heart</v-icon>
Library
</v-btn>
<v-btn color='green' class='mx-1' @click='download'>
<v-icon left>mdi-download</v-icon>
Download
</v-btn>
</div>
</div>
</v-card>
<h1 class='mt-2'>Tracks</h1>
<v-list avatar v-if='album.tracks.length > 0'>
<TrackTile
v-for='(track, index) in album.tracks'
:key='track.id'
:track='track'
@click='playTrack(index)'
>
</TrackTile>
</v-list>
<DownloadDialog :tracks='album.tracks' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
</div>
</template>
<script>
import TrackTile from '@/components/TrackTile.vue';
import DownloadDialog from '@/components/DownloadDialog.vue';
export default {
name: 'AlbumPage',
components: {
TrackTile, DownloadDialog
},
props: {
albumData: Object,
},
data() {
return {
//Props cannot be edited
album: this.albumData,
loading: false,
libraryLoading: false,
downloadDialog: false
}
},
methods: {
//Load album and play at index
playTrack(index) {
this.$root.queue.source = {
text: this.album.title,
source: 'album',
data: this.album.id
};
this.$root.replaceQueue(this.album.tracks);
this.$root.playIndex(index);
},
//Play from beggining
play() {
this.playTrack(0);
},
//Add to library
async library() {
this.libraryLoading = true;
await this.$axios.put(`/library/album?id=${this.album.id}`);
this.libraryLoading = false;
},
async download() {
this.downloadDialog = true;
}
},
async mounted() {
//Load album from api if tracks and meta is missing
if (this.album.tracks.length == 0) {
this.loading = true;
let data = await this.$axios.get(`/album/${this.album.id}`);
if (data && data.data && data.data.tracks) {
this.album = data.data;
}
this.loading = false;
}
},
computed: {
duration() {
let durations = this.album.tracks.map((t) => t.duration);
let duration = durations.reduce((a, b) => a + b, 0);
return this.$duration(duration);
}
}
};
</script>

View File

@ -0,0 +1,155 @@
<template>
<div>
<v-card class='d-flex'>
<v-img
:src='artist.picture.full'
:lazy-src="artist.picture.thumb"
max-height="100%"
max-width="35vh"
contain
></v-img>
<div class='pl-4'>
<v-overlay absolute :value="loading" z-index="3" opacity='0.9'>
<v-progress-circular indeterminate></v-progress-circular>
</v-overlay>
<h1>{{artist.name}}</h1>
<div class='mt-2' v-if='!loading'>
<span class='text-subtitle-2'>{{artist.albumCount}} albums</span><br>
<span class='text-subtitle-2'>{{$numberString(artist.fans)}} fans</span><br>
</div>
<div class='my-2'>
<v-btn color='primary' class='mx-1' @click='play'>
<v-icon left>mdi-play</v-icon>
Play top
</v-btn>
<v-btn color='red' class='mx-1' @click='library' :loading='libraryLoading'>
<v-icon left>mdi-heart</v-icon>
Library
</v-btn>
</div>
</div>
</v-card>
<h1 class='my-2'>Top tracks</h1>
<v-list class='overflow-y-auto' height="300px">
<div
v-for='(track, index) in artist.topTracks'
:key='"top-" + track.id'
>
<TrackTile
v-if='index < 3 || (index >= 3 && allTopTracks)'
:track='track'
@click='playIndex(index)'
></TrackTile>
<v-list-item v-if='!allTopTracks && index == 3' @click='allTopTracks = true'>
<v-list-item-title>Show all top tracks</v-list-item-title>
</v-list-item>
</div>
</v-list>
<h1 class='my-2'>Albums</h1>
<v-list class='overflow-y-auto' style='max-height: 500px' @scroll.native="scroll">
<AlbumTile
v-for='album in artist.albums'
:key='album.id'
:album='album'
></AlbumTile>
<div class='text-center my-2' v-if='loadingMore'>
<v-progress-circular indeterminate></v-progress-circular>
</div>
</v-list>
</div>
</template>
<script>
import TrackTile from '@/components/TrackTile.vue';
import AlbumTile from '@/components/AlbumTile.vue';
export default {
name: 'ArtistPage',
components: {
TrackTile, AlbumTile
},
data() {
return {
//Because props are const
artist: this.artistData,
loading: false,
libraryLoading: false,
allTopTracks: false,
loadingMore: false
}
},
props: {
artistData: Object
},
methods: {
playIndex(index) {
this.$root.queue.source = {
text: this.artist.name,
source: 'top',
data: this.artist.id
};
this.$root.replaceQueue(this.artist.topTracks);
this.$root.playIndex(index);
},
play() {
this.playIndex(0);
},
//Add to library
async library() {
this.libraryLoading = true;
await this.$axios.put(`/library/artist?id=${this.artist.id}`);
this.libraryLoading = false;
},
async load() {
//Load meta and tracks
if (this.artist.topTracks.length == 0) {
this.loading = true;
let data = await this.$axios.get(`/artist/${this.artist.id}`);
if (data && data.data && data.data.topTracks) {
this.artist = data.data;
}
this.loading = false;
}
},
async loadMoreAlbums() {
if (this.artist.albumCount <= this.artist.albums.length) return;
this.loadingMore = true;
//Load more albums from API
let res = await this.$axios.get(`/albums/${this.artist.id}?start=${this.artist.albums.length}`);
if (res.data) {
this.artist.albums.push(...res.data);
}
this.loadingMore = false;
},
//On scroll load more albums
scroll(event) {
let loadOffset = event.target.scrollHeight - event.target.offsetHeight - 150;
if (event.target.scrollTop > loadOffset) {
if (!this.loadingMore && !this.loading) this.loadMoreAlbums();
}
}
},
async mounted() {
this.load();
},
watch: {
artistData(v) {
if (v.id == this.artist.id) return;
this.artist = v;
this.load();
}
}
}
</script>

View File

@ -0,0 +1,88 @@
<template>
<div style='overflow-x: hidden;'>
<!-- Loading & Error -->
<v-overlay opacity='0.95' z-index='0' v-if='loading || error'>
<v-progress-circular indeterminate v-if='loading'></v-progress-circular>
<v-icon class='red--text' v-if='error'>
mdi-alert-circle
</v-icon>
</v-overlay>
<div v-if='data'>
<div v-for='(section, sectionIndex) in data.sections' :key='"section"+sectionIndex' class='mb-8'>
<h1 class='py-2'>{{section.title}}</h1>
<div class='d-flex' style='overflow-x: auto; overflow-y: hidden;'>
<div v-for='(item, index) in section.items' :key='"item"+index' class='mr-4 my-2'>
<PlaylistTile v-if='item.type == "playlist"' :playlist='item.data' card></PlaylistTile>
<ArtistTile v-if='item.type == "artist"' :artist='item.data' card></ArtistTile>
<DeezerChannel v-if='item.type == "channel"' :channel='item.data'></DeezerChannel>
<AlbumTile v-if='item.type == "album"' :album='item.data' card></AlbumTile>
<SmartTrackList v-if='item.type == "smarttracklist" || item.type == "flow"' :stl='item.data'></SmartTrackList>
</div>
<div v-if='section.hasMore' class='mx-2 align-center justify-center d-flex'>
<v-btn @click='showMore(section)' color='primary'>
Show more
</v-btn>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import PlaylistTile from '@/components/PlaylistTile.vue';
import ArtistTile from '@/components/ArtistTile.vue';
import DeezerChannel from '@/components/DeezerChannel.vue';
import AlbumTile from '@/components/AlbumTile.vue';
import SmartTrackList from '@/components/SmartTrackList.vue';
export default {
name: 'DeezerPage',
components: {PlaylistTile, ArtistTile, DeezerChannel, AlbumTile, SmartTrackList},
props: {
target: String
},
data() {
return {
data: null,
loading: true,
error: false,
Ctarget: this.target
}
},
methods: {
//Load data
async load() {
this.loading = true;
this.data = null;
let data = await this.$axios.get(`/page?target=${this.target}`);
this.data = data.data;
this.loading = false;
},
//Show more items
showMore(section) {
this.$router.push({
path: '/page',
query: {target: section.target}
});
}
},
//Load data on load
created() {
this.load();
},
watch: {
//Check if target changed to not use cached version
target() {
if (this.target == this.Ctarget) return;
this.Ctarget = this.target;
this.load();
}
}
}
</script>

View File

@ -0,0 +1,71 @@
<template>
<div>
<h1 class='pb-2'>Downloads</h1>
<v-card v-if='$root.download' max-width='100%'>
<v-list-item three-line>
<v-list-item-avatar>
<v-img :src='$root.download.track.albumArt.thumb'></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>{{$root.download.track.title}}</v-list-item-title>
<v-list-item-subtitle>
Downloaded: {{$filesize($root.download.downloaded)}} / {{$filesize($root.download.size)}}<br>
Path: {{$root.download.path}}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-card>
<h1 class='pb-2'>Queue:</h1>
<div class='text-h6 pb-2 d-flex'>Total: {{$root.downloads.length}}
<v-btn @click='$root.toggleDownload' class='ml-2' color='primary'>
<div v-if='$root.downloading'>
<v-icon>mdi-stop</v-icon>
Stop
</div>
<div v-if='!$root.downloading'>
<v-icon>mdi-download</v-icon>
Start
</div>
</v-btn>
<!-- Open dir -->
<v-btn @click='openDir' class='ml-2' v-if='$root.settings.electron'>
<v-icon>mdi-folder</v-icon>
Show folder
</v-btn>
</div>
<!-- Downloads -->
<v-list dense>
<div v-for='download in $root.downloads' :key='download.id'>
<v-list-item dense>
<v-list-item-avatar>
<v-img :src='download.track.albumArt.thumb'></v-img>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title>{{download.track.title}}</v-list-item-title>
<v-list-item-subtitle>{{download.track.artistString}}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</div>
</v-list>
</div>
</template>
<script>
export default {
name: 'DownloadsPage',
methods: {
//Open downloads directory using electron
openDir() {
const {ipcRenderer} = window.require('electron');
ipcRenderer.send('openDownloadsDir');
}
}
}
</script>

View File

@ -0,0 +1,291 @@
<template>
<div class='main pa-0'>
<v-app-bar dense>
<v-btn icon @click='close'>
<v-icon>mdi-close</v-icon>
</v-btn>
<v-toolbar-title>Playing from: {{$root.queue.source.text}}</v-toolbar-title>
</v-app-bar>
<!-- Split to half -->
<v-row class='pa-2' no-gutters justify="center">
<!-- Left side (track info...) -->
<v-col class='col-6 text-center' align-self="center">
<v-img
:src='$root.track.albumArt.full'
:lazy-src="$root.track.albumArt.thumb"
aspect-ratio="1"
max-height="calc(90vh - 285px)"
class='ma-4'
contain>
</v-img>
<h1 class='text-no-wrap text-truncate'>{{$root.track.title}}</h1>
<h2 class='primary--text text-no-wrap text-truncate'>{{$root.track.artistString}}</h2>
<!-- Slider, timestamps -->
<v-row no-gutters class='py-2'>
<v-col class='text-center' align-self="center">
<span>{{$duration(position * 1000)}}</span>
</v-col>
<v-col class='col-8'>
<v-slider
min='0'
step='1'
:max='this.$root.duration() / 1000'
@click='seekEvent'
@start='seeking = true'
@end='seek'
:value='position'
ref='seeker'
class='seekbar'
hide-details>
</v-slider>
</v-col>
<v-col class='text-center' align-self="center">
<span>{{$duration($root.duration())}}</span>
</v-col>
</v-row>
<!-- Controls -->
<v-row no-gutters class='ma-4'>
<v-col>
<v-btn icon x-large @click='$root.skip(-1)'>
<v-icon size='42px'>mdi-skip-previous</v-icon>
</v-btn>
</v-col>
<v-col>
<v-btn icon x-large @click='$root.toggle()'>
<v-icon size='56px' v-if='!$root.isPlaying()'>mdi-play</v-icon>
<v-icon size='56px' v-if='$root.isPlaying()'>mdi-pause</v-icon>
</v-btn>
</v-col>
<v-col>
<v-btn icon x-large @click='$root.skip(1)'>
<v-icon size='42px'>mdi-skip-next</v-icon>
</v-btn>
</v-col>
</v-row>
<!-- Bottom actions -->
<div class='d-flex my-1 mx-8 '>
<v-btn icon @click='addLibrary'>
<v-icon v-if='!inLibrary'>mdi-heart</v-icon>
<v-icon v-if='inLibrary'>mdi-heart-remove</v-icon>
</v-btn>
<v-btn icon @click='playlistPopup = true'>
<v-icon>mdi-playlist-plus</v-icon>
</v-btn>
<v-btn icon @click='download'>
<v-icon>mdi-download</v-icon>
</v-btn>
<!-- Volume -->
<v-slider
min='0.00'
:prepend-icon='$root.muted ? "mdi-volume-off" : "mdi-volume-high"'
max='1.00'
step='0.01'
v-model='$root.audio.volume'
class='px-8'
style='padding-top: 2px;'
@change='updateVolume'
@click:prepend='$root.toggleMute()'
>
<template v-slot:append>
<div style='position: absolute; padding-top: 4px;'>
{{Math.round($root.audio.volume * 100)}}%
</div>
</template>
</v-slider>
</div>
</v-col>
<!-- Right side -->
<v-col class='col-6 pt-4'>
<v-tabs v-model='tab'>
<v-tab key='queue'>
Queue
</v-tab>
<v-tab key='info'>
Info
</v-tab>
<v-tab key='lyrics'>
Lyrics
</v-tab>
</v-tabs>
<v-tabs-items v-model='tab'>
<!-- Queue tab -->
<v-tab-item key='queue'>
<v-list two-line avatar class='overflow-y-auto' style='max-height: calc(100vh - 140px)'>
<v-lazy
min-height="1"
transition="fade-transition"
v-for="(track, index) in $root.queue.data"
:key='index + "q" + track.id'
><TrackTile
:track='track'
@click='$root.playIndex(index)'
></TrackTile>
</v-lazy>
</v-list>
</v-tab-item>
<!-- Info tab -->
<v-tab-item key='info'>
<v-list two-line avatar class='overflow-y-auto text-center' style='max-height: calc(100vh - 140px)'>
<h1>{{$root.track.title}}</h1>
<!-- Album -->
<h3>Album:</h3>
<AlbumTile
:album='$root.track.album'
@clicked='$emit("close")'
></AlbumTile>
<!-- Artists -->
<h3>Artists:</h3>
<v-list dense>
<ArtistTile
v-for='(artist, index) in $root.track.artists'
:artist='artist'
:key="index + 'a' + artist.id"
@clicked='$emit("close")'
tiny
class='text-left'
></ArtistTile>
</v-list>
<!-- Meta -->
<h3>Duration: <span>{{$duration($root.track.duration)}}</span></h3>
<h3>Track number: {{$root.track.trackNumber}}</h3>
<h3>Disk number: {{$root.track.diskNumber}}</h3>
<h3>Explicit: {{$root.track.explicit?"Yes":"No"}}</h3>
<h3>Source: {{$root.playbackInfo.source}}</h3>
<h3>Format: {{$root.playbackInfo.format}}</h3>
<h3>Quality: {{$root.playbackInfo.quality}}</h3>
</v-list>
</v-tab-item>
<!-- Lyrics -->
<v-tab-item key='lyrics'>
<Lyrics :songId='$root.track.id' height='calc(100vh - 140px)'></Lyrics>
</v-tab-item>
</v-tabs-items>
</v-col>
</v-row>
<!-- Add to playlist dialog -->
<v-dialog max-width="400px" v-model='playlistPopup'>
<PlaylistPopup :track='$root.track' @close='playlistPopup = false'></PlaylistPopup>
</v-dialog>
<DownloadDialog :tracks='[$root.track]' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
</div>
</template>
<style scoped>
.main {
width: 100vw;
height: 100vh;
}
@media screen and (max-height: 864px) {
.imagescale {
max-height: 50vh;
}
}
@media screen and (max-height: 600px) {
.imagescale {
max-height: 45vh;
}
}
</style>
<script>
import TrackTile from '@/components/TrackTile.vue';
import ArtistTile from '@/components/ArtistTile.vue';
import AlbumTile from '@/components/AlbumTile.vue';
import PlaylistPopup from '@/components/PlaylistPopup.vue';
import Lyrics from '@/components/Lyrics.vue';
import DownloadDialog from '@/components/DownloadDialog.vue';
export default {
name: 'FullscreenPlayer',
components: {
TrackTile, ArtistTile, AlbumTile, PlaylistPopup, Lyrics, DownloadDialog
},
data() {
return {
//Position used in seconds, because of CPU usage
position: this.$root.position / 1000,
seeking: false,
tab: null,
inLibrary: this.$root.track.library ? true:false,
playlistPopup: false,
downloadDialog: false
}
},
methods: {
//Emit close event
close() {
this.$emit('close');
},
seek(v) {
this.$root.seek(v * 1000);
this.seeking = false;
},
//Mouse event seek
seekEvent(v) {
let seeker = this.$refs.seeker;
let offsetp = (v.pageX - seeker.$el.offsetLeft) / seeker.$el.clientWidth;
let pos = offsetp * this.$root.duration();
this.$root.seek(pos);
this.position = pos;
this.seeking = false;
},
//Add/Remove track from library
async addLibrary() {
if (this.inLibrary) {
await this.$axios.delete(`/library/track?id=` + this.$root.track.id);
this.inLibrary = false;
//Remove from cache
this.$root.libraryTracks.splice(this.$root.libraryTracks.indexOf(this.$root.track.id), 1);
return;
}
await this.$axios.put('/library/track?id=' + this.$root.track.id);
this.$root.libraryTracks.push(this.$root.track.id);
this.inLibrary = true;
},
//Download current track
async download() {
this.downloadDialog = true;
},
//Save volume
updateVolume(v) {
this.$root.volume = v;
}
},
mounted() {
},
computed: {
},
watch: {
//Update add to library button on track change
'$root.track'() {
this.inLibrary = this.$root.libraryTracks.includes(this.$root.track.id);
},
'$root.position'() {
if (!this.seeking) this.position = this.$root.position / 1000;
}
}
};
</script>

View File

@ -0,0 +1,25 @@
<template>
<div>
<DeezerPage target='home'></DeezerPage>
</div>
</template>
<script>
import DeezerPage from '@/views/DeezerPage.vue';
export default {
name: 'HomeScreen',
components: {DeezerPage},
data() {
return {
}
},
methods: {
},
created() {
}
}
</script>

View File

@ -0,0 +1,83 @@
<template>
<div>
<h1>Library</h1>
<v-tabs v-model='tab'>
<v-tab key='tracks'>
Tracks
</v-tab>
<v-tab key='albums'>
Albums
</v-tab>
<v-tab key='artists'>
Artists
</v-tab>
<v-tab key='playlists'>
Playlists
</v-tab>
</v-tabs>
<v-tabs-items v-model='tab'>
<!-- Tracks -->
<v-tab-item key='tracks'>
<LibraryTracks height='calc(100vh - 240px)'></LibraryTracks>
</v-tab-item>
<!-- Albums -->
<v-tab-item key='albums'>
<LibraryAlbums></LibraryAlbums>
</v-tab-item>
<!-- Artists -->
<v-tab-item key='artists'>
<LibraryArtists></LibraryArtists>
</v-tab-item>
<!-- Playlists -->
<v-tab-item key='playlists'>
<LibraryPlaylists></LibraryPlaylists>
</v-tab-item>
</v-tabs-items>
</div>
</template>
<script>
import LibraryTracks from '@/components/LibraryTracks.vue';
import LibraryAlbums from '@/components/LibraryAlbums.vue';
import LibraryArtists from '@/components/LibraryArtists.vue';
import LibraryPlaylists from '@/components/LibraryPlaylists.vue';
export default {
name: 'Library',
components: {
LibraryTracks, LibraryAlbums, LibraryArtists, LibraryPlaylists
},
props: {
routeTab: {
default: 'tracks',
type: String
}
},
data() {
return {
tab: null,
tabs: ['tracks', 'albums', 'artists', 'playlists'],
}
},
methods: {
},
mounted() {
//Make mutable
this.tab = this.tabs.indexOf(this.routeTab);
},
watch: {
//Update when navigating from drawer
routeTab() {
this.tab = this.tabs.indexOf(this.routeTab);
}
}
}
</script>

View File

@ -0,0 +1,126 @@
<template>
<v-overlay opacity='1.0' z-index='666'>
<!-- Fullscreen loader -->
<div v-if='authorizing && !error'>
<v-progress-circular indeterminate>
</v-progress-circular>
</div>
<!-- Error -->
<v-card class='text-center pa-4' v-if='error'>
<h1 class='text--red'>Error logging in!</h1>
<h3>Please try again later, or try another account.</h3>
<v-btn large class='my-4' @click='logout'>
<v-icon left>mdi-logout-variant</v-icon>
Logout
</v-btn>
</v-card>
<!-- Login form -->
<div v-if='showForm' class='text-center'>
<v-img src='banner.png' contain max-width='400px' class='py-8'></v-img>
<h3>Please login using your Deezer account:</h3>
<v-btn large class='my-2 mb-4 primary' @click='browserLogin'>
<v-icon left>mdi-open-in-app</v-icon>
Login using browser
</v-btn>
<h3 class='mt-4'>...or paste your ARL/Token below:</h3>
<v-text-field label='ARL/Token' v-model='arl'>
</v-text-field>
<v-btn large class='my-4 primary' :loading='authorizing' @click='login'>
<v-icon left>mdi-login-variant</v-icon>Login
</v-btn>
<br>
<span class='mt-8 text-caption'>
By using this program, you disagree with Deezer's ToS.
</span>
</div>
</v-overlay>
</template>
<script>
export default {
name: 'Login',
data() {
return {
error: false,
arl: '',
showForm: false,
authorizing: false
}
},
methods: {
async login() {
this.showForm = false;
this.authorizing = true;
if (this.arl && this.arl != '') {
//Save arl
this.$root.settings.arl = this.arl;
}
//Authorize
try {
await this.$axios.post('/authorize', {arl: this.$root.settings.arl});
this.$root.authorized = true;
} catch (e) {
this.error = true;
}
//Get profile on sucess
if (this.$root.authorized) {
//Save
await this.$root.saveSettings();
//Load profile
let res = await this.$axios.get('/profile');
this.$root.profile = res.data;
this.$router.push('/home');
//Cache library
this.$root.cacheLibrary();
}
this.authorizing = false;
},
//Log out, show login form
logout() {
this.error = false;
this.arl = '';
this.$root.settings.arl = '';
this.showForm = true;
},
//Login using browser
browserLogin() {
if (!this.$root.settings.electron) return alert('Only in Electron version!');
const {ipcRenderer} = window.require('electron');
ipcRenderer.on('browserLogin', (event, newArl) => {
this.arl = newArl;
this.login();
});
ipcRenderer.send('browserLogin');
}
},
async mounted() {
//Wait for settings to load
if (this.$root.loadingPromise) {
this.authorizing = true;
await this.$root.loadingPromise;
this.authorizing = false;
}
this.showForm = true;
if (this.$root.settings.arl) {
this.login();
}
}
};
</script>

View File

@ -0,0 +1,176 @@
<template>
<v-list height='calc(100vh - 145px)' class='overflow-y-auto' v-scroll.self='scroll'>
<v-card class='d-flex'>
<v-img
:src='playlist.image.full'
:lazy-src="playlist.image.thumb"
max-height="100%"
max-width="35vh"
contain
></v-img>
<div class='pl-4'>
<v-overlay absolute :value="loading" z-index="3" opacity='0.9'>
<v-progress-circular indeterminate></v-progress-circular>
</v-overlay>
<h1>{{playlist.title}}</h1>
<h3>{{playlist.user.name}}</h3>
<h5>{{playlist.description}}</h5>
<div class='mt-2' v-if='!loading'>
<span class='text-subtitle-2'>{{playlist.trackCount}} tracks</span><br>
<span class='text-subtitle-2'>Duration: {{$duration(playlist.duration)}}</span><br>
<span class='text-subtitle-2'>{{$numberString(playlist.fans)}} fans</span><br>
</div>
<div class='my-1'>
<v-btn color='primary' class='mr-1' @click='play'>
<v-icon left>mdi-play</v-icon>
Play
</v-btn>
<v-btn color='red' class='mx-1' @click='library' :loading='libraryLoading'>
<v-icon left>mdi-heart</v-icon>
Library
</v-btn>
<v-btn color='green' class='mx-1' @click='download'>
<v-icon left>mdi-download</v-icon>
Download
</v-btn>
</div>
</div>
</v-card>
<h1 class='my-2 px-2'>Tracks</h1>
<v-lazy
v-for='(track, index) in playlist.tracks'
:key='index.toString() + "-" + track.id'
><TrackTile
:track='track'
@click='playIndex(index)'
:playlistId='playlist.id'
@remove='trackRemoved(index)'
></TrackTile>
</v-lazy>
<div class='text-center' v-if='loadingTracks'>
<v-progress-circular indeterminate></v-progress-circular>
</div>
<DownloadDialog :tracks='playlist.tracks' v-if='downloadDialog' @close='downloadDialog = false'></DownloadDialog>
</v-list>
</template>
<script>
import TrackTile from '@/components/TrackTile.vue';
import DownloadDialog from '@/components/DownloadDialog.vue';
export default {
name: 'PlaylistTile',
components: {
TrackTile, DownloadDialog
},
props: {
playlistData: Object
},
data() {
return {
//Props cannot be modified
playlist: this.playlistData,
//Initial loading
loading: false,
loadingTracks: false,
//Add to library button
libraryLoading: false,
downloadDialog: false
}
},
methods: {
async playIndex(index) {
//Load tracks
if (this.playlist.tracks.length < this.playlist.trackCount) {
await this.loadAllTracks();
}
this.$root.queue.source = {
text: this.playlist.title,
source: 'playlist',
data: this.playlist.id
};
this.$root.replaceQueue(this.playlist.tracks);
this.$root.playIndex(index);
},
play() {
this.playIndex(0);
},
scroll(event) {
let loadOffset = event.target.scrollHeight - event.target.offsetHeight - 100;
if (event.target.scrollTop > loadOffset) {
if (!this.loadingTracks && !this.loading) this.loadTracks();
}
},
//Lazy loading
async loadTracks() {
if (this.playlist.tracks.length >= this.playlist.trackCount) return;
this.loadingTracks = true;
let offset = this.playlist.tracks.length;
let res = await this.$axios.get(`/playlist/${this.playlist.id}?start=${offset}`);
if (res.data && res.data.tracks) {
this.playlist.tracks.push(...res.data.tracks);
}
this.loadingTracks = false;
},
//Load all the tracks
async loadAllTracks() {
this.loadingTracks = true;
let data = await this.$axios.get(`/playlist/${this.playlist.id}?full=iguess`);
if (data && data.data && data.data.tracks) {
this.playlist.tracks.push(...data.data.tracks.slice(this.playlist.tracks.length));
}
this.loadingTracks = false;
},
async library() {
this.libraryLoading = true;
await this.$axios.put(`/library/playlist?id=${this.playlist.id}`);
this.libraryLoading = false;
},
async initialLoad() {
//Load meta and intial tracks
if (this.playlist.tracks.length < this.playlist.trackCount) {
this.loading = true;
let data = await this.$axios.get(`/playlist/${this.playlist.id}?start=0`);
if (data && data.data && data.data.tracks) {
this.playlist = data.data;
}
this.loading = false;
}
},
//On track removed
trackRemoved(index) {
this.playlist.tracks.splice(index, 1);
},
async download() {
//Load all tracks
if (this.playlist.tracks.length < this.playlist.trackCount) {
await this.loadAllTracks();
}
this.downloadDialog = true;
}
},
mounted() {
this.initialLoad();
},
watch: {
//Reload on playlist change from drawer
playlistData(n, o) {
if (n.id == o.id) return;
this.playlist = this.playlistData;
this.loading = false;
this.initialLoad();
}
}
}
</script>

View File

@ -0,0 +1,151 @@
<template>
<div>
<h1 class='pb-2'>Search results for: "{{query}}"</h1>
<!-- Loading overlay -->
<v-overlay opacity='0.9' :value='loading' z-index='3'>
<v-progress-circular indeterminate>
</v-progress-circular>
</v-overlay>
<!-- Error overlay -->
<v-overlay opacity='0.9' :value='error' z-index="3">
<h1 class='red--text'>Error loading data!</h1><br>
<h3>Try again later!</h3>
</v-overlay>
<!-- Tabs -->
<v-tabs v-model="tab">
<v-tabs-slider></v-tabs-slider>
<v-tab key="tracks">
<v-icon left>mdi-music-note</v-icon>Tracks
</v-tab>
<v-tab>
<v-icon left>mdi-album</v-icon>Albums
</v-tab>
<v-tab>
<v-icon left>mdi-account-music</v-icon>Artists
</v-tab>
<v-tab>
<v-icon left>mdi-playlist-music</v-icon>Playlists
</v-tab>
</v-tabs>
<v-tabs-items v-model="tab">
<!-- Tracks -->
<v-tab-item key="tracks">
<div v-if="data && data.tracks">
<v-list avatar>
<TrackTile
v-for="(track, i) in data.tracks"
:track="track"
:key="track.id"
@click="playTrack(i)"
></TrackTile>
</v-list>
</div>
</v-tab-item>
<!-- Albums -->
<v-tab-item key="albums">
<div v-if="data && data.albums">
<v-list avatar>
<AlbumTile
v-for="(album) in data.albums"
:album="album"
:key="album.id"
></AlbumTile>
</v-list>
</div>
</v-tab-item>
<!-- Artists -->
<v-tab-item key="artists">
<div v-if="data && data.artists">
<v-list avatar>
<ArtistTile
v-for="(artist) in data.artists"
:artist="artist"
:key="artist.id"
></ArtistTile>
</v-list>
</div>
</v-tab-item>
<!-- Playlists -->
<v-tab-item key="playlists">
<div v-if="data && data.playlists">
<v-list avatar>
<PlaylistTile
v-for="(playlist) in data.playlists"
:playlist="playlist"
:key="playlist.id"
></PlaylistTile>
</v-list>
</div>
</v-tab-item>
</v-tabs-items>
</div>
</template>
<script>
import TrackTile from "@/components/TrackTile.vue";
import AlbumTile from "@/components/AlbumTile.vue";
import ArtistTile from '@/components/ArtistTile.vue';
import PlaylistTile from '@/components/PlaylistTile.vue';
export default {
name: "Search",
components: {
TrackTile, AlbumTile, ArtistTile, PlaylistTile
},
data() {
return {
data: null,
loading: true,
error: false,
tab: null
};
},
props: {
query: String
},
methods: {
load() {
this.data = null;
this.loading = true;
//Call API
this.$axios
.get("/search", {
params: { q: this.query }
})
.then(data => {
this.data = data.data;
this.loading = false;
})
.catch(() => {
this.loading = false;
this.error = true;
});
},
//On click for track tile
playTrack(i) {
this.$root.queue.source = {
text: "Search",
source: "search",
data: this.query
};
this.$root.replaceQueue(this.data.tracks);
this.$root.playIndex(i);
}
},
watch: {
//Reload on new search query
query() {
this.load();
}
},
mounted() {
this.load();
}
};
</script>

View File

@ -0,0 +1,181 @@
<template>
<div>
<h1 class='pb-2'>Settings</h1>
<v-list>
<v-select
class='px-4'
label='Streaming Quality'
persistent-hint
:items='qualities'
@change='updateStreamingQuality'
v-model='streamingQuality'
></v-select>
<v-select
class='px-4'
label='Download Quality'
persistent-hint
:items='qualities'
@change='updateDownloadQuality'
v-model='downloadQuality'
></v-select>
<!-- Download path -->
<v-text-field
class='px-4'
label='Downloads Directory'
v-model='$root.settings.downloadsPath'
append-icon='mdi-open-in-app'
@click:append='selectDownloadPath'
></v-text-field>
<!-- Create artist folder -->
<v-list-item>
<v-list-item-action>
<v-checkbox v-model='$root.settings.createArtistFolder'></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Create folders for artists</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Create album folder -->
<v-list-item>
<v-list-item-action>
<v-checkbox v-model='$root.settings.createAlbumFolder'></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Create folders for albums</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Download naming -->
<v-text-field
class='px-4 mb-2'
label='Download Filename'
persistent-hint
v-model='$root.settings.downloadFilename'
hint='Variables: %title%, %artists%, %artist%, %feats%, %trackNumber%, %0trackNumber%, %album%'
></v-text-field>
<!-- Minimize to tray -->
<v-list-item v-if='$root.settings.electron'>
<v-list-item-action>
<v-checkbox v-model='$root.settings.minimizeToTray'></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Minimize to tray</v-list-item-title>
</v-list-item-content>
</v-list-item>
<!-- Close on exit -->
<v-list-item v-if='$root.settings.electron'>
<v-list-item-action>
<v-checkbox v-model='$root.settings.closeOnExit'></v-checkbox>
</v-list-item-action>
<v-list-item-content>
<v-list-item-title>Close on exit</v-list-item-title>
<v-list-item-subtitle>Don't minimize to tray</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<!-- Logout -->
<v-btn block color='red' class='mt-4' @click='logout'>
<v-icon>mdi-logout</v-icon>
Logout
</v-btn>
</v-list>
<v-btn class='my-4' large color='primary' :loading='saving' block @click='save'>
<v-icon>mdi-content-save</v-icon>
Save
</v-btn>
</div>
</template>
<script>
export default {
name: 'Settings',
data() {
return {
saving: false,
qualities: [
'MP3 128kbps',
'MP3 320kbps',
'FLAC ~1441kbps'
],
streamingQuality: null,
downloadQuality: null,
devToolsCounter: 0
}
},
methods: {
//Save settings
save() {
this.saving = true;
this.$root.saveSettings();
//Artificial wait to make it seem like something happened.
setTimeout(() => {this.saving = false;}, 500);
},
getQuality(v) {
let i = this.qualities.indexOf(v);
if (i == 0) return 1;
if (i == 1) return 3;
if (i == 2) return 9;
return 3;
},
//Update streaming quality
updateStreamingQuality(v) {
this.$root.settings.streamQuality = this.getQuality(v);
},
updateDownloadQuality(v) {
this.$root.settings.downloadsQuality = this.getQuality(v);
},
//Quality to show currently selected quality
getPresetQuality(q) {
if (q == 9) return this.qualities[2];
if (q == 3) return this.qualities[1];
if (q == 1) return this.qualities[0];
return this.qualities[1];
},
//Select download path, electron only
selectDownloadPath() {
//Electron check
if (!this.$root.settings.electron) {
alert('Available only in Electron version!');
return;
}
const {ipcRenderer} = window.require('electron');
ipcRenderer.on('selectDownloadPath', (event, newPath) => {
if (newPath) this.$root.settings.downloadsPath = newPath;
});
ipcRenderer.send('selectDownloadPath');
},
async logout() {
this.$root.settings.arl = null;
await this.$root.saveSettings();
location.reload();
}
},
mounted() {
this.streamingQuality = this.getPresetQuality(this.$root.settings.streamQuality);
this.downloadQuality = this.getPresetQuality(this.$root.settings.downloadsQuality);
//Press 'f' 10 times, to open dev tools
document.addEventListener('keyup', (event) => {
if (event.keyCode === 70) {
this.devToolsCounter += 1;
} else {
this.devToolsCounter = 0;
}
if (this.devToolsCounter == 10) {
this.devToolsCounter = 0;
if (this.$root.settings.electron) {
const {remote} = window.require('electron');
remote.getCurrentWindow().toggleDevTools();
}
}
});
}
}
</script>

View File

@ -0,0 +1,21 @@
::-webkit-scrollbar {
width: 10px;
}
::-webkit-scrollbar:horizontal {
height: 10px;
}
::-webkit-scrollbar-track {
background-color: #10101044;
border-radius: 5px;
}
::-webkit-scrollbar-track:hover {
background-color: #080808;
}
::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: #363636;
}

5
app/client/vue.config.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
"transpileDependencies": [
"vuetify"
]
}

7
app/main.js Normal file
View File

@ -0,0 +1,7 @@
const {createServer} = require('./src/server');
createServer(false);
/*
This script is used to start standalone server without electron
*/

1920
app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
app/package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "freezer",
"private": true,
"version": "1.0.0",
"description": "",
"main": "background.js",
"scripts": {
"start": "electron ."
},
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^0.19.2",
"browser-id3-writer": "^4.4.0",
"express": "^4.17.1",
"metaflac-js2": "^1.0.7",
"nedb": "^1.8.0",
"nodeezcryptor": "git+https://notabug.org/xefglm/nodeezcryptor",
"sanitize-filename": "^1.6.3",
"socket.io": "^2.3.0"
},
"devDependencies": {
"electron": "^9.2.1"
}
}

219
app/src/deezer.js Normal file
View 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
View 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
View 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
View 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
View 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};

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
build/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

48
package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "freezer",
"private": true,
"version": "1.0.0",
"description": "",
"scripts": {
"pack": "electron-builder --dir",
"dist": "electron-builder",
"postinstall": "electron-builder install-app-deps",
"build": "cd app && npm i && cd client && npm i && npm run build && cd .. && cd .. && npm run dist"
},
"author": "",
"license": "ISC",
"devDependencies": {
"electron": "^9.2.1",
"electron-builder": "^22.8.0"
},
"build": {
"appId": "com.exttex.freezer",
"extraResources": [
{
"from": "app/assets/**",
"to": "assets/"
}
],
"files": [
"**/*",
"!app/client/",
"app/client/dist/**"
],
"win": {
"target": [
"portable"
],
"icon": "build/icon.ico",
"asarUnpack": [
"app/node_modules/nodeezcryptor/**"
]
},
"linux": {
"target": [
"AppImage"
],
"category": "audio",
"icon": "build/icon.png"
}
}
}