Compare commits

...

4 Commits

Author SHA1 Message Date
Jill 67e306625c split everything into files 2021-10-23 20:33:00 +03:00
Jill c423c65273 update readme 2021-10-23 13:59:51 +03:00
Jill 24c097e2b1 ts migration wee 2021-10-23 13:57:36 +03:00
Jill e5b48778f1 pnpm 2021-10-23 09:22:47 +03:00
21 changed files with 2016 additions and 3192 deletions

View File

@ -18,11 +18,13 @@ it's intended use is for small groups of people to self-host, and as such there'
2. (optionally) copy the config.example.json to config.json in the same folder, and modify it 2. (optionally) copy the config.example.json to config.json in the same folder, and modify it
3. `npm install` 3. `npm install` / `pnpm install`
4. install the `zip` linux tool into your path (there are currently no plans for windows support, however feel free to contribute) 4. install the `zip` linux tool into your path (there are currently no plans for windows support, however feel free to contribute)
5. build the project with `npm run build` / `pnpm build`
5. (optionally) put the service on pm2 like such: `pm2 start src/index.js --name deemix-web-frontend` (or just run it with `node src/index.js`) 6. (optionally) put the service on pm2 like such: `pm2 start src/index.js --name deemix-web-frontend` (or just run it with `node src/index.js`)
### nginx addenum ### nginx addenum

2863
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,9 +2,11 @@
"name": "deemix-web-frontend", "name": "deemix-web-frontend",
"version": "1.0.0", "version": "1.0.0",
"description": "a dumb frontend for just getting some got damned songs", "description": "a dumb frontend for just getting some got damned songs",
"main": "src/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1",
"quickrun": "tsc && node dist/index.js",
"build": "tsc"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -30,5 +32,11 @@
"optionalDependencies": { "optionalDependencies": {
"bufferutil": "^4.0.5", "bufferutil": "^4.0.5",
"utf-8-validate": "^5.0.7" "utf-8-validate": "^5.0.7"
},
"devDependencies": {
"typescript": "^4.4.4",
"@types/express": "^4.17.13",
"@types/express-ws": "^3.0.1",
"@types/ws": "^8.2.0"
} }
} }

1183
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

17
src/config.ts Normal file
View File

@ -0,0 +1,17 @@
import fs from 'fs';
import toml from 'toml';
import { logger } from './logger';
if (!fs.existsSync('./config.toml')) {
if (!fs.existsSync('./config.example.toml')) {
logger.error('no config.toml OR config.example.toml found!!! what the hell are you up to!!!');
process.exit(1);
}
logger.warn('copying config.example.toml to config.toml as it was not found. the default config may not be preferable!');
fs.copyFileSync('./config.example.toml', './config.toml');
}
export const config = toml.parse(fs.readFileSync('./config.toml', {encoding: 'utf8'}));
logger.info('loaded config');

25
src/deemix.ts Normal file
View File

@ -0,0 +1,25 @@
import deezer from 'deezer-js';
import deemix from 'deemix';
import path from 'path';
import { config } from './config';
export const deezerInstance = new deezer.Deezer();
export const format = deezer.TrackFormats[config.deemix.trackFormat || 'FLAC'];
export const deemixSettings = deemix.settings.DEFAULTS;
deemixSettings.downloadLocation = path.join(process.cwd(), 'data/');
deemixSettings.overwriteFile = deemix.settings.OverwriteOption.ONLY_TAGS;
deemixSettings.maxBitrate = String(format);
deemixSettings.tracknameTemplate = config.deemix.trackNameTemplate || deemixSettings.tracknameTemplate;
deemixSettings.albumTracknameTemplate = config.deemix.albumTrackNameTemplate || deemixSettings.albumTracknameTemplate;
deemixSettings.albumNameTemplate = config.deemix.albumNameTemplate || deemixSettings.createM3U8File;
deemixSettings.createM3U8File = config.deemix.createM3U8File !== undefined ? config.deemix.createM3U8File : deemixSettings.createM3U8File;
deemixSettings.embeddedArtworkPNG = config.deemix.embeddedArtworkPNG !== undefined ? config.deemix.embeddedArtworkPNG : deemixSettings.embeddedArtworkPNG;
deemixSettings.embeddedArtworkSize = config.deemix.embeddedArtworkSize || deemixSettings.embeddedArtworkSize;
deemixSettings.saveArtwork = config.deemix.saveArtwork !== undefined ? config.deemix.saveArtwork : deemixSettings.saveArtwork;
deemixSettings.localArtworkSize = deemixSettings.localArtworkSize || deemixSettings.localArtworkSize;
deemixSettings.localArtworkFormat = deemixSettings.localArtworkFormat || deemixSettings.localArtworkFormat;
deemixSettings.jpegImageQuality = deemixSettings.jpegImageQuality || deemixSettings.jpegImageQuality;
deemixSettings.removeDuplicateArtists = config.deemix.removeDuplicateArtists !== undefined ? config.deemix.removeDuplicateArtists : deemixSettings.removeDuplicateArtists;

71
src/deletionQueue.ts Normal file
View File

@ -0,0 +1,71 @@
import fs from 'fs';
import * as timeago from 'timeago.js';
import { logger } from './logger';
import { config } from './config';
interface QueuedFile {
file: string,
date: number
}
export const deleteTimer = config.timer.deleteTimer || 1000 * 60 * 25;
const toDeleteLocation = './data/toDelete.json';
if (!fs.existsSync(toDeleteLocation)) fs.writeFileSync(toDeleteLocation, '[]', {encoding: 'utf8'});
let toDelete: QueuedFile[] = JSON.parse(fs.readFileSync(toDeleteLocation, {encoding: 'utf8'}));
export function updateQueueFile() {
try {
fs.writeFileSync(toDeleteLocation, JSON.stringify(toDelete), {encoding: 'utf8'});
} catch(err) {
logger.error('failed to write to deletion queue json file! wrong permissions or ran out of space?', err);
}
}
export function deleteQueuedFile(file: string) {
toDelete = toDelete.filter((c: QueuedFile) => c.file !== file);
updateQueueFile();
fs.unlink(file, (err) => {
if (err) {
logger.warn(`failed to delete ${file}!`);
logger.warn(err.toString());
logger.warn('if this file still exists, you will have to manually delete it');
}
});
}
export function queueDeletion(file: string) {
logger.info(`queued deletion of ${file} ${timeago.format(Date.now() + deleteTimer)}`);
toDelete.push({
date: Date.now() + deleteTimer,
file
});
setTimeout(() => {
logger.info(`deleting queued file ${file}`);
deleteQueuedFile(file);
}, deleteTimer);
updateQueueFile();
}
logger.info(`loaded ${toDelete.length} items in deletion queue`);
let updateQueue = false;
for (let del of toDelete) {
if (Date.now() - del.date >= 0) {
logger.warn(`deleting ${del.file} - was meant to be deleted ${timeago.format(del.date)}`);
deleteQueuedFile(del.file);
} else {
logger.info(`queueing deletion of ${del.file} ${timeago.format(del.date)}`);
setTimeout(() => {
logger.info(`deleting queued file ${del.file}`);
deleteQueuedFile(del.file);
}, del.date - Date.now());
}
};
if (updateQueue) {
updateQueueFile();
logger.info('updated deletion queue json');
}

64
src/download.ts Normal file
View File

@ -0,0 +1,64 @@
import * as deemix from 'deemix';
import { queueDeletion, deleteQueuedFile } from './deletionQueue';
import { deemixSettings, deezerInstance } from './deemix';
import { logger } from './logger';
import { promisify } from 'util';
import { exec } from 'child_process';
import { config } from './config';
export interface Metadata {
id: number,
title: string,
artist: string,
}
export async function deemixDownloadWrapper(dlObj: deemix.types.downloadObjects.IDownloadObject, ws: WebSocket, coverArt: string, metadata: Metadata) {
let trackpaths: string[] = [];
const listener = {
send(key: any, data: any) {
if (data.downloaded) {
trackpaths.push(data.downloadPath);
queueDeletion(data.downloadPath);
}
if (data.state !== 'tagging' && data.state !== 'getAlbumArt' && data.state !== 'getTags' && !dlObj.isCanceled) ws.send(JSON.stringify({key, data}));
}
};
listener.send('coverArt', coverArt);
listener.send('metadata', metadata);
let deemixDownloader = new deemix.downloader.Downloader(deezerInstance, dlObj, deemixSettings, listener);
try {
await deemixDownloader.start();
} catch(err) {
logger.warn((err as Error).toString());
logger.warn('(this may be deemix just being whiny)');
}
if (dlObj.isCanceled) {
logger.debug('download gracefully cancelled, cleaning up');
trackpaths.forEach((q) => {
logger.info(`removing ${q}`);
deleteQueuedFile(q);
});
} else if (trackpaths.length > 1) {
await ws.send(JSON.stringify({key: 'zipping'}));
const folderName = trackpaths[0].split('/').slice(-2)[0];
logger.debug(`zipping ${folderName}`);
try {
await promisify(exec)(`${config.server.zipBinaryLocation} ${config.server.zipArguments} "data/${folderName}.zip" "data/${folderName}"`);
} catch(err) {
logger.error((err as Error).toString());
return ws.close(1011, 'Zipping album failed');
}
await ws.send(JSON.stringify({key: 'download', data: `data/${folderName}.zip`}));
queueDeletion('./data/' + folderName + '.zip');
} else if (trackpaths.length === 1) {
await ws.send(JSON.stringify({key: 'download', data: trackpaths[0].replace(process.cwd(), '')}));
}
}

39
src/get/album.ts Normal file
View File

@ -0,0 +1,39 @@
import express from 'express';
import { albumcache } from '..';
import { logger } from '../logger';
import { deezerInstance } from '../deemix';
const router = express.Router();
router.get('/api/album', async (req, res) => {
if (!req.query.id) return res.sendStatus(400);
if (Array.isArray(req.query.id)) req.query.id = req.query.id.join('');
req.query.id = req.query.id as string;
let album: Album;
try {
album = albumcache[req.query.id] || (await deezerInstance.api.get_album(req.query.id));
if (!albumcache[req.query.id]) albumcache[req.query.id] = album;
} catch (err) {
logger.error((err as Error).toString());
return res.status(404).send('Album not found!');
}
res.send({
id: album.id,
title: album.title,
link: album.link,
tracks: album.tracks.data.map(t => {
return {
id: t.id,
title: t.title,
duration: t.duration,
link: t.link,
artist: t.artist.name,
};
})
});
});
export default router;

7
src/get/index.ts Normal file
View File

@ -0,0 +1,7 @@
import album from './album';
import search from './search';
export default [
album,
search
];

41
src/get/search.ts Normal file
View File

@ -0,0 +1,41 @@
import express from 'express';
import { searchcache } from '..';
import { config } from '../config';
import { logger } from '../logger';
import { deezerInstance } from '../deemix';
const router = express.Router();
router.get('/api/search', async (req, res) => {
if (!req.query.search) return res.sendStatus(400);
if (Array.isArray(req.query.search)) req.query.search = req.query.search.join('');
req.query.search = req.query.search as string;
let s: [Album];
try {
s = searchcache[req.query.search] || (await deezerInstance.api.search_album(req.query.search, {
limit: config.limits.searchLimit || 15,
})).data;
} catch(err) {
logger.error((err as Error).toString());
return res.sendStatus(500);
}
if (!searchcache[req.query.search]) searchcache[req.query.search] = s;
let format = s.map(s => {
return {
id: s.id,
title: s.title,
cover: s.md5_image,
artist: {
id: s.artist.id,
name: s.artist.name
},
};
});
res.send(format);
});
export default router;

View File

@ -1,325 +0,0 @@
const express = require('express');
const deezer = require('deezer-js');
const deemix = require('deemix');
const path = require('path');
const { promisify } = require('util');
const fs = require('fs');
const { exec } = require('child_process');
const timeago = require('timeago.js');
const toml = require('toml');
const winston = require('winston');
const logFormatter = winston.format.printf(({ level, message, timestamp }) => {
return `${new Date(timestamp).toLocaleDateString('en-GB', {timeZone: 'UTC'})} ${new Date(timestamp).toLocaleTimeString('en-GB', {timeZone: 'UTC'})} ${level.replace('info', 'I').replace('warn', '!').replace('error', '!!')}: ${message}`;
});
winston.addColors({
error: 'red',
debug: 'blue',
warn: 'yellow',
http: 'gray',
info: 'blue',
verbose: 'cyan',
silly: 'magenta'
});
const logger = winston.createLogger({
level: 'debug',
format: winston.format.combine(
winston.format.simple()
),
transports: [
new winston.transports.File({filename: 'deemix-web-fe-error.log', level: 'warn'}),
new winston.transports.File({filename: 'deemix-web-fe.log'}),
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
logFormatter
)
})
]
});
if (!fs.existsSync('./config.toml')) {
if (!fs.existsSync('./config.example.toml')) {
logger.error('no config.toml OR config.example.toml found!!! what the hell are you up to!!!');
process.exit(1);
}
logger.warn('copying config.example.toml to config.toml as it was not found. the default config may not be preferable!');
fs.copyFileSync('./config.example.toml', './config.toml');
}
const config = toml.parse(fs.readFileSync('./config.toml'));
logger.info('loaded config');
let searchcache = {};
let albumcache = {};
let trackcache = {};
const port = config.server.port || 4500;
const deleteTimer = config.timer.deleteTimer || 1000 * 60 * 25;
require('dotenv').config();
const app = new express();
const expressWs = require('express-ws')(app);
const deezerInstance = new deezer.Deezer();
let deemixDownloader;
let deemixSettings = deemix.settings.DEFAULTS
deemixSettings.downloadLocation = path.join(process.cwd(), 'data/');
deemixSettings.overwriteFile = deemix.settings.OverwriteOption.ONLY_TAGS;
const format = deezer.TrackFormats[config.deemix.trackFormat || 'FLAC'];
deemixSettings.maxBitrate = String(format);
deemixSettings.tracknameTemplate = config.deemix.trackNameTemplate || deemixSettings.tracknameTemplate;
deemixSettings.albumTracknameTemplate = config.deemix.albumTrackNameTemplate || deemixSettings.albumTracknameTemplate;
deemixSettings.albumNameTemplate = config.deemix.albumNameTemplate || deemixSettings.createM3U8File;
deemixSettings.createM3U8File = config.deemix.createM3U8File !== undefined ? config.deemix.createM3U8File : deemixSettings.createM3U8File;
deemixSettings.embeddedArtworkPNG = config.deemix.embeddedArtworkPNG !== undefined ? config.deemix.embeddedArtworkPNG : deemixSettings.embeddedArtworkPNG;
deemixSettings.embeddedArtworkSize = config.deemix.embeddedArtworkSize || deemixSettings.embeddedArtworkSize;
deemixSettings.saveArtwork = config.deemix.saveArtwork !== undefined ? config.deemix.saveArtwork : deemixSettings.saveArtwork;
deemixSettings.localArtworkSize = deemixSettings.localArtworkSize || deemixSettings.localArtworkSize;
deemixSettings.localArtworkFormat = deemixSettings.localArtworkFormat || deemixSettings.localArtworkFormat;
deemixSettings.jpegImageQuality = deemixSettings.jpegImageQuality || deemixSettings.jpegImageQuality;
deemixSettings.removeDuplicateArtists = config.deemix.removeDuplicateArtists !== undefined ? config.deemix.removeDuplicateArtists : deemixSettings.removeDuplicateArtists;
const toDeleteLocation = './data/toDelete.json';
if (!fs.existsSync(toDeleteLocation)) fs.writeFileSync(toDeleteLocation, '[]', {encoding: 'utf8'});
let toDelete = JSON.parse(fs.readFileSync(toDeleteLocation, {encoding: 'utf8'}));
function updateQueueFile() {
try {
fs.writeFileSync(toDeleteLocation, JSON.stringify(toDelete), {encoding: 'utf8'});
} catch(err) {
logger.error('failed to write to deletion queue json file! wrong permissions or ran out of space?', err);
}
}
function deleteQueuedFile(file) {
toDelete = toDelete.filter(c => c.file !== file);
updateQueueFile();
fs.unlink(file, (err) => {
if (err) {
logger.warn(`failed to delete ${file}!`);
logger.warn(err.toString());
logger.warn('if this file still exists, you will have to manually delete it');
}
});
}
function queueDeletion(file) {
logger.info(`queued deletion of ${file} ${timeago.format(Date.now() + deleteTimer)}`);
toDelete.push({
date: Date.now() + deleteTimer,
file
});
setTimeout(() => {
logger.info(`deleting queued file ${file}`);
deleteQueuedFile(file);
}, deleteTimer);
updateQueueFile();
}
logger.info(`loaded ${toDelete.length} items in deletion queue`);
let updateQueue = false;
for (let del of toDelete) {
if (Date.now() - del.date >= 0) {
logger.warn(`deleting ${del.file} - was meant to be deleted ${timeago.format(del.date)}`);
deleteQueuedFile(del.file);
} else {
logger.info(`queueing deletion of ${del.file} ${timeago.format(del.date)}`);
setTimeout(() => {
logger.info(`deleting queued file ${del.file}`);
deleteQueuedFile(del.file);
}, del.date - Date.now());
}
};
if (updateQueue) {
updateQueueFile();
logger.info('updated deletion queue json');
}
if (config.server.proxy) {
app.enable('trust proxy');
logger.info('enabled express.js reverse proxy settings');
}
app.use((req, res, next) => {
logger.http(`${(config.server.proxy && req.headers['x-forwarded-for']) || req.connection.remoteAddress} ${req.method} ${req.originalUrl} `);
next();
});
app.use(express.static('public'));
app.use('/data', express.static('data', {extensions: ['flac', 'mp3']}));
app.get('/api/search', async (req, res) => {
if (!req.query.search) return res.sendStatus(400);
let s;
try {
s = searchcache[req.query.search] || (await deezerInstance.api.search_album(req.query.search, {
limit: config.limits.searchLimit || 15,
}));
} catch(err) {
logger.error(err.toString());
res.sendStatus(500);
}
if (!searchcache[req.query.search]) searchcache[req.query.search] = s;
let format = s.data.map(s => {
return {
id: s.id,
title: s.title,
cover: s.md5_image,
artist: {
id: s.artist.id,
name: s.artist.name
},
};
});
res.send(format);
});
app.get('/api/album', async (req, res) => {
if (!req.query.id) return req.sendStatus(400);
let album;
try {
album = albumcache[req.query.id] || (await deezerInstance.api.get_album(req.query.id));
if (!albumcache[req.query.id]) albumcache[req.query.id] = album;
} catch (err) {
logger.error(err.toString());
return req.status(404).send('Album not found!');
}
res.send({
id: album.id,
title: album.title,
link: album.link,
tracks: album.tracks.data.map(t => {
return {
id: t.id,
title: t.title,
duration: t.duration,
link: t.link,
artist: t.artist.name,
};
})
});
});
async function deemixDownloadWrapper(dlObj, ws, coverArt, metadata) {
let trackpaths = [];
const listener = {
send(key, data) {
if (data.downloaded) {
trackpaths.push(data.downloadPath);
queueDeletion(data.downloadPath);
}
if (data.state !== 'tagging' && data.state !== 'getAlbumArt' && data.state !== 'getTags' && !dlObj.isCanceled) ws.send(JSON.stringify({key, data}));
}
};
listener.send('coverArt', coverArt);
listener.send('metadata', metadata);
deemixDownloader = new deemix.downloader.Downloader(deezerInstance, dlObj, deemixSettings, listener);
try {
await deemixDownloader.start();
} catch(err) {
logger.warn(err.toString());
logger.warn('(this may be deemix just being whiny)');
}
if (dlObj.isCanceled) {
logger.debug('download gracefully cancelled, cleaning up');
trackpaths.forEach((q) => {
logger.info(`removing ${q}`);
deleteQueuedFile(q);
});
} else if (trackpaths.length > 1) {
await ws.send(JSON.stringify({key: 'zipping'}));
const folderName = trackpaths[0].split('/').slice(-2)[0];
logger.debug(`zipping ${folderName}`);
try {
await promisify(exec)(`${config.server.zipBinaryLocation} ${config.server.zipArguments} "data/${folderName}.zip" "data/${folderName}"`);
} catch(err) {
logger.error(err.toString());
return ws.close(1011, 'Zipping album failed');
}
await ws.send(JSON.stringify({key: 'download', data: `data/${folderName}.zip`}));
queueDeletion('./data/' + folderName + '.zip');
} else if (trackpaths.length === 1) {
await ws.send(JSON.stringify({key: 'download', data: trackpaths[0].replace(process.cwd(), '')}));
}
}
app.ws('/api/album', async (ws, req) => {
if (!req.query.id) return ws.close(1008, 'Supply a track ID in the query!');
const dlObj = await deemix.generateDownloadObject(deezerInstance, 'https://www.deezer.com/album/' + req.query.id, format);
let isDone = false;
ws.on('close', (code) => {
if (isDone) return;
dlObj.isCanceled = true;
logger.debug(`client left unexpectedly with code ${code}; cancelling download`);
});
let album;
try {
album = albumcache[req.query.id] || (await deezerInstance.api.get_album(req.query.id));
if (!albumcache[req.query.id]) albumcache[req.query.id] = album;
} catch(err) {
logger.error(err.toString());
return ws.close(1012, 'Album not found');
}
await deemixDownloadWrapper(dlObj, ws, album.cover_medium, {id: album.id, title: album.title, artist: album.artist.name});
isDone = true;
logger.debug('download done');
ws.close(1000);
});
app.ws('/api/track', async (ws, req) => {
if (!req.query.id) return ws.close(1008, 'Supply a track ID in the query!');
const dlObj = await deemix.generateDownloadObject(deezerInstance, 'https://www.deezer.com/track/' + req.query.id, format);
let isDone = false;
ws.on('close', (code) => {
if (isDone) return;
dlObj.isCanceled = true;
logger.debug(`client left unexpectedly with code ${code}; cancelling download`);
});
let track;
try {
track = trackcache[req.query.id] || (await deezerInstance.api.get_track(req.query.id));
if (!trackcache[req.query.id]) trackcache[req.query.id] = track;
} catch(err) {
logger.error(err.toString());
return ws.close(1012, 'Track not found');
}
await deemixDownloadWrapper(dlObj, ws, track.album.cover_medium, {id: track.id, title: track.title, artist: track.artist.name});
isDone = true;
logger.debug('download done');
ws.close(1000);
});
deezerInstance.login_via_arl(process.env.DEEZER_ARL).then(() => {
logger.info('logged into deezer');
app.listen(port, () => {
logger.info(`hosting on http://localhost:${port} and wss://localhost:${port}`);
});
});

44
src/index.ts Normal file
View File

@ -0,0 +1,44 @@
import express from 'express';
import * as dotenv from 'dotenv';
import expressws from 'express-ws';
dotenv.config();
import { logger } from './logger';
import { config } from './config';
import { deezerInstance } from './deemix';
export const port = config.server.port || 4500;
export let searchcache: Record<string, [Album]> = {};
export let albumcache: Record<string, Album> = {};
export let trackcache: Record<string, Track> = {};
export const app = express();
expressws(app);
if (config.server.proxy) {
app.enable('trust proxy');
logger.info('enabled express.js reverse proxy settings');
}
app.use((req, res, next) => {
logger.http(`${(config.server.proxy && req.headers['x-forwarded-for']) || req.connection.remoteAddress} ${req.method} ${req.originalUrl} `);
next();
});
app.use(express.static('public'));
app.use('/data', express.static('data', {extensions: ['flac', 'mp3']}));
import get from './get';
get.forEach((q) => {app.use(q)});
import ws from './ws';
ws.forEach((q) => {app.use(q)});
deezerInstance.login_via_arl(process.env.DEEZER_ARL || '').then(() => {
logger.info('logged into deezer');
app.listen(port, () => {
logger.info(`hosting on http://localhost:${port} and wss://localhost:${port}`);
});
});

33
src/logger.ts Normal file
View File

@ -0,0 +1,33 @@
import winston from 'winston';
const logFormatter = winston.format.printf(({ level, message, timestamp }) => {
return `${new Date(timestamp).toLocaleDateString('en-GB', {timeZone: 'UTC'})} ${new Date(timestamp).toLocaleTimeString('en-GB', {timeZone: 'UTC'})} ${level.replace('info', 'I').replace('warn', '!').replace('error', '!!')}: ${message}`;
});
winston.addColors({
error: 'red',
debug: 'blue',
warn: 'yellow',
http: 'gray',
info: 'blue',
verbose: 'cyan',
silly: 'magenta'
});
export const logger = winston.createLogger({
level: 'debug',
format: winston.format.combine(
winston.format.simple()
),
transports: [
new winston.transports.File({filename: 'deemix-web-fe-error.log', level: 'warn'}),
new winston.transports.File({filename: 'deemix-web-fe.log'}),
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.colorize(),
logFormatter
)
})
]
});

129
src/types/deemix.d.ts vendored Normal file
View File

@ -0,0 +1,129 @@
interface Listener {
send(key: any, data: any): void
}
interface TagOptions {
title: boolean,
artist: boolean,
album: boolean,
cover: boolean,
trackNumber: boolean,
trackTotal: boolean,
discNumber: boolean,
discTotal: boolean,
albumArtist: boolean,
genre: boolean,
year: boolean,
date: boolean,
explicit: boolean,
isrc: boolean,
length: boolean,
barcode: boolean,
bpm: boolean,
replayGain: boolean,
label: boolean,
lyrics: boolean,
syncedLyrics: boolean,
copyright: boolean,
composer: boolean,
involvedPeople: boolean,
source: boolean,
rating: boolean,
savePlaylistAsCompilation: boolean,
useNullSeparator: boolean,
saveID3v1: boolean,
multiArtistSeparator: string,
singleAlbumArtist: boolean,
coverDescriptionUTF8: boolean
}
interface DeemixSettings {
downloadLocation: string,
tracknameTemplate: string,
albumTracknameTemplate: string,
playlistTracknameTemplate: string,
createPlaylistFolder: boolean,
playlistNameTemplate: string,
createArtistFolder: boolean,
artistNameTemplate: string,
createAlbumFolder: boolean,
albumNameTemplate: string,
createCDFolder: boolean,
createStructurePlaylist: boolean,
createSingleFolder: boolean,
padTracks: boolean,
paddingSize: string,
illegalCharacterReplacer: string,
queueConcurrency: number,
maxBitrate: keyof TrackFormats,
fallbackBitrate: boolean,
fallbackSearch: boolean,
logErrors: boolean,
logSearched: boolean,
overwriteFile: OverwriteOption,
createM3U8File: boolean,
playlistFilenameTemplate: string,
syncedLyrics: boolean,
embeddedArtworkSize: number,
embeddedArtworkPNG: boolean,
localArtworkSize: number,
localArtworkFormat: string,
saveArtwork: boolean,
coverImageTemplate: string,
saveArtworkArtist: boolean,
artistImageTemplate: string,
jpegImageQuality: number,
dateFormat: string,
albumVariousArtists: boolean,
removeAlbumVersion: boolean,
removeDuplicateArtists: boolean,
featuredToTitle: FeaturesOption,
titleCasing: string,
artistCasing: string,
executeCommand: string,
tags: TagOptions
}
declare module 'deemix' {
function generateDownloadObject(dz: Deezer, link: string, bitrate: TrackFormats, plugins?: any, listener?: Listener): Promise<IDownloadObject>
module downloader {
class Downloader {
dz: Deezer;
constructor(dz: Deezer, downloadObject: IDownloadObject, settings: DeemixSettings, listener: Listener): void
start(): Promise<void>
}
}
module types {
module downloadObjects {
class IDownloadObject {
isCanceled: boolean;
constructor(obj: any): void;
}
}
}
module settings {
enum OverwriteOption {
OVERWRITE = 'y', // Yes, overwrite the file
DONT_OVERWRITE = 'n', // No, don't overwrite the file
DONT_CHECK_EXT = 'e', // No, and don't check for extensions
KEEP_BOTH = 'b', // No, and keep both files
ONLY_TAGS = 't', // Overwrite only the tags
}
// What should I do with featured artists?
enum FeaturesOption {
NO_CHANGE = "0", // Do nothing
REMOVE_TITLE = "1", // Remove from track title
REMOVE_TITLE_ALBUM = "3", // Remove from track title and album title
MOVE_TITLE = "2" // Move to track title
}
const DEFAULTS: DeemixSettings
}
}

217
src/types/deezer.d.ts vendored Normal file
View File

@ -0,0 +1,217 @@
type DeezerResponse<Data> = {
data: Data,
total: number,
next: string,
}
enum ExplicitContent {
NOT_EXPLICIT,
EXPLICIT,
UNKNOWN,
EDITED,
PARTIALLY_EXPLICIT,
PARTIALLY_UNKNOWN,
NO_ADVICE_AVAILABLE,
PARTIALLY_NO_ADVICE_AVAILABLE,
}
enum SearchOrder {
RANKING = "RANKING",
TRACK_ASC = "TRACK_ASC",
TRACK_DESC = "TRACK_DESC",
ARTIST_ASC = "ARTIST_ASC",
ARTIST_DESC = "ARTIST_DESC",
ALBUM_ASC = "ALBUM_ASC",
ALBUM_DESC = "ALBUM_DESC",
RATING_ASC = "RATING_ASC",
RATING_DESC = "RATING_DESC",
DURATION_ASC = "DURATION_ASC",
DURATION_DESC = "DURATION_DESC"
}
interface Track {
id: number,
readable: boolean,
title: string,
title_short: string,
title_version: string,
isrc: string,
link: string,
share: string,
duration: number,
track_position: number,
disk_number: number,
rank: number,
release_date: string,
explicit_lyrics: boolean,
explicit_content_lyrics: ExplicitContent,
explicit_content_cover: ExplicitContent,
preview: string,
bpm: number,
gain: number,
available_countries: string[],
contributors: Contributor[],
md5_image: string,
artist: Pick<Artist, "id" | "name" | "link" | "share" | "picture" | "picture_small" | "picture_medium" | "picture_big" | "picture_xl" | "radio" | "tracklist">,
album: Pick<Album, "id" | "title" | "link" | "cover" | "cover_small" | "cover_medium" | "cover_big" | "cover_xl" | "md5_image" | "release_date" | "tracklist">,
}
interface Artist {
id: number,
name: string,
link: string,
share: string,
picture: string,
picture_small: string,
picture_medium: string,
picture_big: string,
picture_xl: string,
nb_album: number,
nb_fan: number,
radio: boolean,
tracklist: string,
}
interface Contributor {
id: number,
name: string,
link: string,
share: string,
picture: string,
picture_small: string,
picture_medium: string,
picture_big: string,
picture_xl: string,
radio: boolean,
tracklist: string,
role: string,
}
interface Album {
id: number,
title: string,
upc: string,
link: string,
share: string,
cover: string,
cover_small: string,
cover_medium: string,
cover_big: string,
cover_xl: string,
md5_image: string,
genre_id: number, // use an enum?
genres: any,
label: string,
nb_tracks: number,
duration: number,
fans: number,
release_date: string,
record_type: string,
available: boolean,
tracklist: string,
explicit_lyrics: boolean,
explicit_content_lyrics: ExplicitContent,
explicit_content_cover: ExplicitContent,
contributors: Contributor[],
artist: Pick<Artist, "id" | "name" | "picture" | "picture_small" | "picture_medium" | "picture_big" | "picture_xl", "tracklist">,
tracks: Pick<DeezerResponse<Pick<Track, "id" | "readable" | "title" | "title_short" | "title_version" | "link" | "duration" | "rank" | "explicit_lyrics" | "explicit_content_lyrics" | "explicit_content_cover" | "preview" | "md5_image" | "artist">[]>, "data">,
}
interface SearchOptions {
index?: number,
limit?: number,
}
interface QueryOptions extends SearchOptions {
strict?: boolean,
order?: SearchOrder,
}
declare module 'deezer-js' {
enum TrackFormats {
FLAC = 9,
MP3_320 = 3,
MP3_128 = 1,
MP4_RA3 = 15,
MP4_RA2 = 14,
MP4_RA1 = 13,
DEFAULT = 8,
LOCAL = 0,
}
class API {
http_headers: any;
cookie_jar: CookieJar;
access_token: string | null;
constructor(cookie_jar: CookieJar, headers: any): void;
api_call(method: string, args?: any): Promise<any>;
get_album(album_id: string | number): Promise<Album>;
get_album_by_UPC(upc: string): Promise<Album>;
get_album_comments(album_id: string | number, options?: SearchOptions): Promise<any>;
get_album_fans(album_id: string | number, options?: SearchOptions): Promise<any>;
get_album_tracks(album_id: string | number, options?: SearchOptions): Promise<DeezerResponse<[Track]>>;
get_artist(artist_id: string | number): Promise<Artist>;
get_artist_top(artist_id: string | number, options?: SearchOptions): Promise<any>;
get_artist_albums(artist_id: string | number, options?: SearchOptions): Promise<DeezerResponse<[Album]>>;
get_artist_comments(artist_id: string | number, options?: SearchOptions): Promise<any>;
get_artist_fans(artist_id: string | number, options?: SearchOptions): Promise<any>;
get_artist_related(artist_id: string | number, options?: SearchOptions): Promise<any>;
get_artist_radio(artist_id: string | number, options?: SearchOptions): Promise<any>;
get_artist_playlists(artist_id: string | number, options?: SearchOptions): Promise<any>;
get_chart(genre_id?: string | number, options?: SearchOptions): Promise<any>;
get_chart_tracks(genre_id?: string | number, options?: SearchOptions): Promise<any>;
get_chart_albums(genre_id?: string | number, options?: SearchOptions): Promise<any>;
get_chart_artists(genre_id?: string | number, options?: SearchOptions): Promise<any>;
get_chart_playlists(genre_id?: string | number, options?: SearchOptions): Promise<any>;
get_chart_podcasts(genre_id?: string | number, options?: SearchOptions): Promise<any>;
get_comment(comment_id: string | number): Promise<Comment>;
get_editorials(options?: SearchOptions): Promise<any>;
get_editorial(genre_id?: number): Promise<any>;
// for now who cares
search(query: string, options?: QueryOptions): Promise<DeezerResponse<[Track | Album | Artist | Playlist | Radio | User]>>;
search_album(query: string, options?: QueryOptions): Promise<DeezerResponse<[Album]>>;
search_artist(query: string, options?: QueryOptions): Promise<DeezerResponse<[Artist]>>;
search_playlist(query: string, options?: QueryOptions): Promise<DeezerResponse<[Playlist]>>;
search_radio(query: string, options?: QueryOptions): Promise<DeezerResponse<[Radio]>>;
search_track(query: string, options?: QueryOptions): Promise<DeezerResponse<[Track]>>;
get_track(song_id: string | number): Promise<Track>;
get_track_by_ISRC(isrc: string): Promise<Track>;
get_user(user_id: string | number): Promise<User>;
}
class GW {
}
class Deezer {
http_headers: any;
cookie_jar: CookieJar;
logged_in: boolean;
current_user: any;
childs: any[];
selected_account: number;
api: API;
gw: GW;
constructor(): void;
login(email: string, password: string, re_captcha_token: string, child: number): Promise<boolean>;
login_via_arl(arl: string): Promise<boolean>;
_post_login(user_data: any): void;
change_account(child_n: number): any[2];
get_track_url(track_token: string, format: TrackFormats): Promise<Track>;
get_tracks_url(track_tokens: string[] | string, format: TrackFormats): Promise<Track[]>;
}
}

13
src/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
import * as WS from 'ws';
/* eslint-disable no-unused-vars */
export {};
declare global {
namespace Express {
// Inject additional properties on express.Request
interface Application {
ws(route: string, callback: (ws: WS.WebSocket, req: Express.Request) => void): Express
}
}
}

48
src/ws/album.ts Normal file
View File

@ -0,0 +1,48 @@
import express from 'express';
import * as deemix from 'deemix';
import { deezerInstance, format } from '../deemix';
import { logger } from '../logger';
import { albumcache } from '..';
import { deemixDownloadWrapper } from '../download';
const router = express.Router();
router.ws('/api/album', async (ws, req: any) => {
if (!req.query.id) return ws.close(1008, 'Supply a track ID in the query!');
let dlObj: deemix.types.downloadObjects.IDownloadObject;
try {
dlObj = await deemix.generateDownloadObject(deezerInstance, 'https://www.deezer.com/album/' + req.query.id, format);
} catch(err) {
logger.error((err as Error).toString());
return ws.close(1012, 'Album not found');
}
let isDone = false;
ws.on('close', (code) => {
if (isDone) return;
dlObj.isCanceled = true;
logger.debug(`client left unexpectedly with code ${code}; cancelling download`);
});
let album;
try {
album = albumcache[req.query.id] || (await deezerInstance.api.get_album(req.query.id));
if (!albumcache[req.query.id]) albumcache[req.query.id] = album;
} catch(err) {
logger.error((err as Error).toString());
return ws.close(1012, 'Album not found');
}
// @ts-expect-error
await deemixDownloadWrapper(dlObj, ws, album.cover_medium, {id: album.id, title: album.title, artist: album.artist.name});
isDone = true;
logger.debug('download done');
ws.close(1000);
});
export default router;

7
src/ws/index.ts Normal file
View File

@ -0,0 +1,7 @@
import album from './album';
import track from './track';
export default [
album,
track
];

46
src/ws/track.ts Normal file
View File

@ -0,0 +1,46 @@
import express from 'express';
import * as deemix from 'deemix';
import { deezerInstance, format } from '../deemix';
import { logger } from '../logger';
import { trackcache } from '..';
import { deemixDownloadWrapper } from '../download';
const router = express.Router();
router.ws('/api/track', async (ws, req: any) => {
if (!req.query.id) return ws.close(1008, 'Supply a track ID in the query!');
let dlObj: deemix.types.downloadObjects.IDownloadObject;
try {
dlObj = await deemix.generateDownloadObject(deezerInstance, 'https://www.deezer.com/track/' + req.query.id, format);
} catch(err) {
logger.error((err as Error).toString());
return ws.close(1012, 'Track not found');
}
let isDone = false;
ws.on('close', (code: number) => {
if (isDone) return;
dlObj.isCanceled = true;
logger.debug(`client left unexpectedly with code ${code}; cancelling download`);
});
let track;
try {
track = trackcache[req.query.id] || (await deezerInstance.api.get_track(req.query.id));
if (!trackcache[req.query.id]) trackcache[req.query.id] = track;
} catch(err) {
logger.error((err as Error).toString());
return ws.close(1012, 'Track not found');
}
// @ts-expect-error
await deemixDownloadWrapper(dlObj, ws, track.album.cover_medium, {id: track.id, title: track.title, artist: track.artist.name});
isDone = true;
logger.debug('download done');
ws.close(1000);
});
export default router;

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules"
],
"compilerOptions": {
"incremental": true,
"module": "commonjs",
"target": "es6",
"strict": true,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
}
}