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