initial commit
This commit is contained in:
commit
b28a136c58
27 changed files with 4495 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
npm-debug.log
|
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
@ -0,0 +1 @@
|
|||
17
|
31
.vscode/launch.json
vendored
Normal file
31
.vscode/launch.json
vendored
Normal file
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "pwa-node",
|
||||
"runtimeVersion": "17",
|
||||
"request": "launch",
|
||||
"name": "kannon-server",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}/kannon.js",
|
||||
"args": [
|
||||
"${workspaceFolder}/example_config_server.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "pwa-node",
|
||||
"runtimeVersion": "17",
|
||||
"request": "launch",
|
||||
"name": "kannon-library",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${workspaceFolder}/kannon.js",
|
||||
"args": [
|
||||
"${workspaceFolder}/example_config_library.json"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
20
LICENSE.md
Normal file
20
LICENSE.md
Normal file
|
@ -0,0 +1,20 @@
|
|||
# MIT License
|
||||
**Copyright (c) 2022 Daniel Sommer \<daniel.sommer@velvettear.de\>**
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished
|
||||
to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice (including the next
|
||||
paragraph) shall be included in all copies or substantial portions of the
|
||||
Software.
|
||||
|
||||
**THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
|
||||
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF
|
||||
OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.**
|
8
README.md
Normal file
8
README.md
Normal file
|
@ -0,0 +1,8 @@
|
|||
# kannon
|
||||
|
||||
a multi room audio player
|
||||
|
||||
## requirements
|
||||
|
||||
- node.js
|
||||
- [nvm](https://github.com/nvm-sh/nvm)
|
174
classes/AudioServer.js
Normal file
174
classes/AudioServer.js
Normal file
|
@ -0,0 +1,174 @@
|
|||
const sleep = require('../libs/util.js').sleep;
|
||||
|
||||
const net = require('net');
|
||||
const fs = require('fs');
|
||||
const stat = require('fs/promises').stat;
|
||||
|
||||
const Message = require('./Message.js');
|
||||
const EventParser = require('./EventParser.js');
|
||||
|
||||
class AudioServer {
|
||||
|
||||
constructor(file) {
|
||||
this.listen = config?.server?.listen || '0.0.0.0';
|
||||
this.port = 0;
|
||||
this.file = file;
|
||||
this.clients = [];
|
||||
this.broadcastClients = [];
|
||||
this.server = net.createServer();
|
||||
this.eventParser = new EventParser();
|
||||
this.#prepare();
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.aborted === true) {
|
||||
return;
|
||||
}
|
||||
const buffer = await this.#waitForBuffer();
|
||||
await this.#waitForAllClients();
|
||||
this.#handleClientConnections();
|
||||
const promises = [];
|
||||
for (let index = 0; index < this.clients.length; index++) {
|
||||
const client = this.clients[index];
|
||||
client.audiostart = Date.now();
|
||||
promises.push(new Promise((resolve, reject) => {
|
||||
client.audiosocket.end(buffer, () => {
|
||||
logger.debug(client.getTag() + ' sent audio file \'' + this.file + '\' after ' + (Date.now() - client.audiostart) + 'ms...');
|
||||
resolve();
|
||||
});
|
||||
}));
|
||||
}
|
||||
await Promise.allSettled(promises);
|
||||
await this.destroy();
|
||||
}
|
||||
|
||||
async #prepare() {
|
||||
if (server?.clients === undefined || server.clients.length === 0) {
|
||||
logger.warn('there are currently no clients connected, aborting preparation of audio server...')
|
||||
this.aborted = true;
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
this.server.listen(this.port, this.listen).on('listening', () => {
|
||||
this.port = this.server.address().port;
|
||||
logger.info('audio server listening on ' + this.listen + ':' + this.port + '...');
|
||||
resolve();
|
||||
});
|
||||
this.server.on('connection', (socket) => {
|
||||
this.#handleConnection(socket);
|
||||
});
|
||||
this.server.on('error', (err) => {
|
||||
reject('an error occured preparing the audio server for file \'' + this.file + '\' > ' + err);
|
||||
});
|
||||
});
|
||||
const stats = await stat(this.file);
|
||||
const broadcastedTo = await new Message('audiostream-initialize', { port: this.server.address().port, size: stats.size }).broadcast(true);
|
||||
for (let index = 0; index < broadcastedTo.length; index++) {
|
||||
if (broadcastedTo[index]?.status !== 'fulfilled') {
|
||||
continue;
|
||||
}
|
||||
this.broadcastClients.push(broadcastedTo[index].value);
|
||||
}
|
||||
logger.debug('sent broadcast for audio server to client(s) \'' + this.broadcastClients.toString() + '\'...');
|
||||
this.#bufferFile();
|
||||
}
|
||||
|
||||
#handleConnection(socket) {
|
||||
socket.on('data', (data) => {
|
||||
this.eventParser.parse(data, socket);
|
||||
});
|
||||
this.eventParser.on('audiostream-ready', (clientId, socket) => {
|
||||
let client;
|
||||
for (let index = 0; index < server.clients.length; index++) {
|
||||
if (server.clients[index].id === clientId) {
|
||||
client = server.clients[index];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (client === undefined) {
|
||||
return;
|
||||
}
|
||||
client.audiosocket = socket;
|
||||
this.clients.push(client);
|
||||
logger.debug(client.getTag() + ' connected to audio server...');
|
||||
this.broadcastClients.splice(this.broadcastClients.indexOf(clientId), 1);
|
||||
});
|
||||
}
|
||||
|
||||
#handleClientConnections() {
|
||||
for (let index = 0; index < this.clients.length; index++) {
|
||||
const client = this.clients[index];
|
||||
client.audiosocket.on('error', (error) => {
|
||||
logger.error(client.getTag() + ' encountered an error: ' + error);
|
||||
});
|
||||
client.audiosocket.on('end', () => {
|
||||
logger.debug(client.getTag() + ' ended audio socket');
|
||||
});
|
||||
client.audiosocket.on('close', (hadError) => {
|
||||
let msg = client.getTag() + ' closed audio socket';
|
||||
if (hadError === true) {
|
||||
msg += ' after an error';
|
||||
}
|
||||
logger.debug(msg);
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
async #waitForAllClients() {
|
||||
while (this.broadcastClients.length > 0) {
|
||||
await sleep(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async #waitForBuffer() {
|
||||
while (this.buffer === undefined) {
|
||||
await sleep(1);
|
||||
}
|
||||
return this.buffer;
|
||||
}
|
||||
|
||||
async #bufferFile() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timestamp = Date.now();
|
||||
const buffer = [];
|
||||
const stream = fs.createReadStream(this.file);
|
||||
stream.on('data', (data) => {
|
||||
buffer.push(data);
|
||||
});
|
||||
stream.on('close', () => {
|
||||
this.buffer = Buffer.concat(buffer);
|
||||
logger.debug('buffering file \'' + this.file + '\' took ' + (Date.now() - timestamp) + 'ms (length: ' + this.buffer.length + ' bytes)');
|
||||
resolve();
|
||||
});
|
||||
stream.on('error', (error) => {
|
||||
// TODO: handle with try/catch
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.eventParser.removeAllListeners('audiostream-ready');
|
||||
for (let index = 0; index < this.clients.length; index++) {
|
||||
const audiosocket = this.clients[index].audiosocket;
|
||||
if (audiosocket.destroyed === true) {
|
||||
continue;
|
||||
}
|
||||
audiosocket.destroy();
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
this.server.close((err) => {
|
||||
if (err !== undefined) {
|
||||
reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = AudioServer;
|
94
classes/Client.js
Normal file
94
classes/Client.js
Normal file
|
@ -0,0 +1,94 @@
|
|||
const Heartbeat = require('./Heartbeat.js');
|
||||
const EventParser = require('./EventParser.js');
|
||||
|
||||
let clientId = -1;
|
||||
|
||||
class Client {
|
||||
|
||||
constructor(socket) {
|
||||
clientId++;
|
||||
this.id = clientId;
|
||||
this.socket = socket;
|
||||
this.eventParser = new EventParser();
|
||||
this.heartbeat = new Heartbeat(this);
|
||||
this.#listenForEvents();
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
getAddress() {
|
||||
return this.socket.remoteAddress;
|
||||
}
|
||||
|
||||
getPort() {
|
||||
return this.socket.remotePort;
|
||||
}
|
||||
|
||||
getTag() {
|
||||
return '[' + this.getId() + '] ' + this.getAddress() + ':' + this.getPort() + ' >>';
|
||||
}
|
||||
|
||||
#listenForEvents() {
|
||||
logger.debug(this.getTag() + ' connected to communication server...');
|
||||
this.socket.on('timeout', () => {
|
||||
this.#handleEventTimeout();
|
||||
});
|
||||
this.socket.on('close', () => {
|
||||
this.#handleEventClose()
|
||||
});
|
||||
this.socket.on('end', () => {
|
||||
this.#handleEventEnd()
|
||||
});
|
||||
this.socket.on('data', (data) => {
|
||||
this.#handleEventData(data)
|
||||
});
|
||||
this.heartbeat.on('timeout', () => {
|
||||
this.#handleEventHeartbeatTimeout();
|
||||
});
|
||||
this.heartbeat.on('latency', (data) => {
|
||||
this.#handleEventLatency(data);
|
||||
});
|
||||
}
|
||||
|
||||
async #handleEventData(data) {
|
||||
this.eventParser.parse(data);
|
||||
}
|
||||
|
||||
#handleEventTimeout() {
|
||||
logger.warn(this.getTag() + ' timed out');
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
#handleEventHeartbeatTimeout() {
|
||||
logger.warn(this.getTag() + ' heartbeat timed out');
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
#handleEventClose() {
|
||||
logger.debug(this.getTag() + ' closed socket to communication server');
|
||||
server.removeClient(this);
|
||||
}
|
||||
|
||||
#handleEventEnd() {
|
||||
logger.debug(this.getTag() + ' ended socket to communication server');
|
||||
this.destroy();
|
||||
}
|
||||
|
||||
#handleEventLatency(data) {
|
||||
this.latency = data;
|
||||
logger.debug(this.getTag() + ' latency: ' + JSON.stringify(data));
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.heartbeat.destroy();
|
||||
this.heartbeat.removeAllListeners('timeout');
|
||||
this.heartbeat.removeAllListeners('latency');
|
||||
this.socket.end();
|
||||
this.socket.destroy();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Client;
|
75
classes/Database.js
Normal file
75
classes/Database.js
Normal file
|
@ -0,0 +1,75 @@
|
|||
const Sequelize = require('sequelize');
|
||||
const path = require('path');
|
||||
const readdir = require('fs/promises').readdir;
|
||||
|
||||
class Database {
|
||||
|
||||
constructor() {
|
||||
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.connection = await this.#connect();
|
||||
this.models = await this.#getModels();
|
||||
await this.#createTables();
|
||||
}
|
||||
|
||||
async #connect() {
|
||||
if (this.connection !== undefined) {
|
||||
return this.connection;
|
||||
}
|
||||
if (config?.database === undefined) {
|
||||
throw new Error('missing database config');
|
||||
}
|
||||
let connection = new Sequelize(config.database.database, config.database.username, config.database.password, {
|
||||
dialect: config.database.dialect,
|
||||
host: config.database.host,
|
||||
port: config.database.port,
|
||||
storage: config.database.storage,
|
||||
logging: false
|
||||
});
|
||||
await connection.authenticate();
|
||||
logger.info('successfully connected to the database');
|
||||
return connection;
|
||||
}
|
||||
|
||||
async #getModels() {
|
||||
let modelsDirectory = path.resolve(path.join(__dirname, '../models'));
|
||||
const files = await readdir(modelsDirectory);
|
||||
const models = {};
|
||||
for (let index = 0; index < files.length; index++) {
|
||||
let modelFile = path.join(modelsDirectory, files[index]);
|
||||
if (path.extname(modelFile) !== '.js') {
|
||||
continue;
|
||||
}
|
||||
const model = require(modelFile);
|
||||
if (model.tableName === undefined || model.sync === undefined) {
|
||||
continue;
|
||||
}
|
||||
modelFile = path.basename(modelFile);
|
||||
modelFile = modelFile.substring(0, modelFile.indexOf(path.extname(modelFile)));
|
||||
models[modelFile] = model;
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
async #createTables() {
|
||||
if (this.models === undefined || this.models.size === 0) {
|
||||
return;
|
||||
}
|
||||
const models = Object.values(this.models);
|
||||
for (let index = 0; index < models.length; index++) {
|
||||
const model = models[index];
|
||||
try {
|
||||
const timestamp = new Date().getTime();
|
||||
await model.sync({ alter: true });
|
||||
logger.debug('creation/alteration of table \'' + model.tableName + '\' finished after ' + (new Date().getTime() - timestamp) + 'ms');
|
||||
} catch (err) {
|
||||
logger.error('an error occured creating table \'' + model.tableName + '\' > ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Database;
|
31
classes/EventParser.js
Normal file
31
classes/EventParser.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
const { EVENT_DELIMITER } = require('../libs/constants.js');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
class EventParser extends EventEmitter {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.buffer = '';
|
||||
}
|
||||
|
||||
parse(data, socket) {
|
||||
if (data === undefined) {
|
||||
return;
|
||||
}
|
||||
this.buffer += data;
|
||||
const indexOfEnd = this.buffer.indexOf(EVENT_DELIMITER);
|
||||
if (indexOfEnd === -1) {
|
||||
return;
|
||||
}
|
||||
const event = JSON.parse(this.buffer.substring(0, indexOfEnd));
|
||||
this.buffer = '';
|
||||
if (event.id === undefined) {
|
||||
return;
|
||||
}
|
||||
const eventId = event.id.toLowerCase();
|
||||
this.emit(eventId, event.data, socket);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = EventParser;
|
60
classes/Heartbeat.js
Normal file
60
classes/Heartbeat.js
Normal file
|
@ -0,0 +1,60 @@
|
|||
const sleep = require('../libs/util.js').sleep;
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const Message = require('./Message.js');
|
||||
|
||||
class Heartbeat extends EventEmitter {
|
||||
|
||||
constructor(client) {
|
||||
super();
|
||||
this.interval = config?.server?.heartbeat || 10000;
|
||||
this.client = client;
|
||||
this.#listenForPingPong();
|
||||
this.#sendPing();
|
||||
}
|
||||
|
||||
async #sendPing() {
|
||||
if (this.timeout !== undefined) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
if (this.alive === false) {
|
||||
this.emit('timeout');
|
||||
return;
|
||||
} else if (this.alive === undefined) {
|
||||
await sleep(this.interval);
|
||||
}
|
||||
this.alive = false;
|
||||
await new Message('ping', { server: Date.now() }).send(this.client);
|
||||
this.timeout = setTimeout(() => {
|
||||
this.#sendPing();
|
||||
}, this.interval);
|
||||
}
|
||||
|
||||
async #listenForPingPong() {
|
||||
this.client.eventParser.on('ping', () => {
|
||||
logger.debug(this.client.getTag() + ' handling event \'ping\', responding with \'pong\'...');
|
||||
new Message('pong').send(this.client);
|
||||
});
|
||||
this.client.eventParser.on('pong', (data) => {
|
||||
logger.debug(this.client.getTag() + ' handling event \'pong\'...');
|
||||
const now = Date.now();
|
||||
this.alive = true;
|
||||
this.emit('latency', {
|
||||
toClient: (data.client - data.server),
|
||||
fromClient: (now - data.client),
|
||||
roundtrip: (now - data.server)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.timeout !== undefined) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
this.client.eventParser.removeAllListeners('ping');
|
||||
this.client.eventParser.removeAllListeners('pong');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Heartbeat;
|
145
classes/Logger.js
Normal file
145
classes/Logger.js
Normal file
|
@ -0,0 +1,145 @@
|
|||
const moment = require('moment');
|
||||
|
||||
// constants
|
||||
const LOG_PREFIX_DEBUG = 'debug';
|
||||
const LOG_PREFIX_INFO = 'info';
|
||||
const LOG_PREFIX_WARNING = 'warning';
|
||||
const LOG_PREFIX_ERROR = 'error';
|
||||
const LOGLEVEL_DEBUG = 0;
|
||||
const LOGLEVEL_INFO = 1;
|
||||
const LOGLEVEL_WARNING = 2;
|
||||
const LOGLEVEL_ERROR = 3;
|
||||
|
||||
|
||||
class Logger {
|
||||
|
||||
constructor(loglevel, timestamp) {
|
||||
this.setLogLevel(loglevel);
|
||||
this.setTimestamp(timestamp);
|
||||
}
|
||||
|
||||
// set the loglevel
|
||||
setLogLevel(value) {
|
||||
switch (value) {
|
||||
case LOG_PREFIX_DEBUG:
|
||||
case LOGLEVEL_DEBUG:
|
||||
this.loglevel = LOGLEVEL_DEBUG;
|
||||
break;
|
||||
case LOG_PREFIX_INFO:
|
||||
case LOGLEVEL_INFO:
|
||||
this.loglevel = LOGLEVEL_INFO;
|
||||
break;
|
||||
case LOG_PREFIX_WARNING:
|
||||
case LOGLEVEL_WARNING:
|
||||
this.loglevel = LOGLEVEL_WARNING;
|
||||
break;
|
||||
case LOG_PREFIX_ERROR:
|
||||
case LOGLEVEL_ERROR:
|
||||
this.loglevel = LOGLEVEL_ERROR;
|
||||
break;
|
||||
default:
|
||||
this.loglevel = LOGLEVEL_INFO;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// get the timestamp format
|
||||
setTimestamp(value) {
|
||||
this.timestamp = value || 'DD.MM.YYYY HH:mm:ss:SS';
|
||||
}
|
||||
|
||||
// log a http request - response object
|
||||
http(object) {
|
||||
if (object === undefined) {
|
||||
return;
|
||||
}
|
||||
let message = '[' + object.request.method + ':' + object.code + '] url: \'' + object.request.url + '\'';
|
||||
let counter = 1;
|
||||
for (let param in object.request.body) {
|
||||
message += ', parameter ' + counter + ': \'' + param + '=' + object.request.body[param] + '\'';
|
||||
counter++;
|
||||
}
|
||||
if (object.request.timestamp) {
|
||||
message += ' > ' + (new Date().getTime() - object.request.timestamp) + 'ms';
|
||||
}
|
||||
if (object.data) {
|
||||
message += ' > data: ' + object.data;
|
||||
}
|
||||
if (object.code != 200) {
|
||||
error(message.trim());
|
||||
return;
|
||||
}
|
||||
this.debug(message.trim());
|
||||
}
|
||||
|
||||
// prefix log with 'info'
|
||||
info(message) {
|
||||
if (this.loglevel > LOGLEVEL_INFO) {
|
||||
return;
|
||||
}
|
||||
this.trace(message);
|
||||
}
|
||||
|
||||
// prefix log with 'info'
|
||||
warn(message) {
|
||||
if (this.loglevel > LOGLEVEL_WARNING) {
|
||||
return;
|
||||
}
|
||||
this.trace(message, 'warning');
|
||||
}
|
||||
|
||||
// prefix log with 'debug'
|
||||
debug(message) {
|
||||
if (this.loglevel > LOGLEVEL_DEBUG) {
|
||||
return;
|
||||
}
|
||||
this.trace(message, 'debug');
|
||||
}
|
||||
|
||||
// prefix log with 'error'
|
||||
error(message) {
|
||||
if (this.loglevel > LOGLEVEL_ERROR) {
|
||||
return;
|
||||
}
|
||||
if (message.stack) {
|
||||
this.trace(message.stack, 'error');
|
||||
return;
|
||||
}
|
||||
if (message.errors !== undefined) {
|
||||
for (let index = 0; index < message.errors.length; index++) {
|
||||
this.trace(message.errors[index], 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.trace(message.toString(), 'error');
|
||||
}
|
||||
|
||||
// default logging function
|
||||
trace(message, prefix) {
|
||||
if (message === undefined || message === null || message.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (prefix === undefined || prefix === null || prefix.length === 0) {
|
||||
prefix = 'info';
|
||||
}
|
||||
let print;
|
||||
switch (prefix) {
|
||||
case 'error':
|
||||
print = console.error;
|
||||
break;
|
||||
case 'debug':
|
||||
print = console.debug;
|
||||
break;
|
||||
case 'warning':
|
||||
print = console.warn;
|
||||
break;
|
||||
default:
|
||||
print = console.log;
|
||||
}
|
||||
message = moment().format(this.timestamp) + ' | ' + prefix + ' > ' + message;
|
||||
print(message);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Logger;
|
47
classes/Message.js
Normal file
47
classes/Message.js
Normal file
|
@ -0,0 +1,47 @@
|
|||
const { EVENT_DELIMITER } = require('../libs/constants.js');
|
||||
|
||||
class Message {
|
||||
|
||||
constructor(id, data) {
|
||||
this.id = id;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
getData() {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
toString() {
|
||||
return JSON.stringify(this);
|
||||
}
|
||||
|
||||
async send(client, addClientId) {
|
||||
if (client === undefined) {
|
||||
return this.broadcast();
|
||||
}
|
||||
if (addClientId) {
|
||||
this.data.clientId = client.id;
|
||||
}
|
||||
const data = this.toString();
|
||||
logger.debug(client.getTag() + ' sending data: ' + data);
|
||||
await new Promise((resolve, reject) => {
|
||||
client.socket.write(this.toString() + EVENT_DELIMITER, resolve);
|
||||
});
|
||||
return client.id;
|
||||
}
|
||||
|
||||
async broadcast(addClientId) {
|
||||
const promises = [];
|
||||
for (let index = 0; index < server.clients.length; index++) {
|
||||
promises.push(this.send(server.clients[index], addClientId));
|
||||
}
|
||||
return await Promise.allSettled(promises);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Message;
|
213
classes/Queue.js
Normal file
213
classes/Queue.js
Normal file
|
@ -0,0 +1,213 @@
|
|||
const metadata = require('../libs/metadata.js');
|
||||
const ext = require('path').extname;
|
||||
|
||||
const { FS_EVENT_ADD: EVENT_ADD, FS_EVENT_UNLINK: EVENT_UNLINK, FS_EVENT_CHANGE: EVENT_CHANGE } = require('../libs/constants.js');
|
||||
|
||||
class Queue {
|
||||
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.filter = this.getFilter();
|
||||
this.handleQueue();
|
||||
}
|
||||
|
||||
add(event, file, stats) {
|
||||
if (file === undefined || !this.filter.includes(ext(file))) {
|
||||
return;
|
||||
}
|
||||
const element = { file: file };
|
||||
switch (event) {
|
||||
case EVENT_ADD:
|
||||
element.event = EVENT_ADD;
|
||||
break;
|
||||
case EVENT_UNLINK:
|
||||
element.event = EVENT_UNLINK;
|
||||
break;
|
||||
case EVENT_CHANGE:
|
||||
element.event = EVENT_CHANGE;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
this.queue.push(element);
|
||||
}
|
||||
|
||||
async handleQueue() {
|
||||
if (this.queue.length === 0) {
|
||||
if (this.timeout === undefined) {
|
||||
this.timeout = 10;
|
||||
} else {
|
||||
if (this.timeout > 60000) {
|
||||
this.timeout = 60000;
|
||||
} else {
|
||||
this.timeout += 10;
|
||||
}
|
||||
}
|
||||
logger.debug('queue is currently empty - sleeping for ' + this.timeout + 'ms...');
|
||||
setTimeout(() => {
|
||||
this.handleQueue()
|
||||
}, this.timeout);
|
||||
return;
|
||||
}
|
||||
if (this.timeout !== undefined) {
|
||||
this.timeout = undefined;
|
||||
}
|
||||
const element = this.queue[0];
|
||||
this.queue.shift();
|
||||
const timestamp = new Date().getTime();
|
||||
logger.debug('handling event \'' + element.event + '\' for queued file \'' + element.file + '\'...');
|
||||
switch (element.event) {
|
||||
case EVENT_ADD:
|
||||
await this.eventAdd(element.file);
|
||||
break;
|
||||
case EVENT_UNLINK:
|
||||
await this.eventUnlink(element.file);
|
||||
break;
|
||||
case EVENT_CHANGE:
|
||||
await this.eventChange(element.file);
|
||||
break;
|
||||
}
|
||||
logger.debug('event \'' + element.event + '\' for file \'' + element.file + '\' handled after ' + (new Date().getTime() - timestamp) + 'ms');
|
||||
this.handleQueue();
|
||||
}
|
||||
|
||||
async eventAdd(file) {
|
||||
if (file === undefined) {
|
||||
return;
|
||||
}
|
||||
const tags = await metadata.parseFile(file);
|
||||
const artists = await this.addArtist(tags);
|
||||
const album = await this.addAlbum(tags);
|
||||
const track = await this.addTrack(tags, file);
|
||||
}
|
||||
|
||||
async eventUnlink(file) {
|
||||
if (file === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await database.models.Track.destroy({
|
||||
where: { file: file }
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async eventChange(file) {
|
||||
if (file === undefined) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async addArtist(tags) {
|
||||
if (tags?.common?.artist === undefined && tags?.common?.artists === undefined) {
|
||||
return;
|
||||
}
|
||||
let artist = tags.common.artist;
|
||||
const artists = tags.common.artists || [];
|
||||
if (artist !== undefined && !artists.includes(artist)) {
|
||||
artists.push(artist);
|
||||
}
|
||||
for (let index = 0; index < artists.length; index++) {
|
||||
artist = artists[index].trim();
|
||||
try {
|
||||
const [element, created] = await database.models.Artist.findOrCreate({
|
||||
where: { name: artist }
|
||||
});
|
||||
if (!created) {
|
||||
continue;
|
||||
}
|
||||
logger.debug('created artist ' + JSON.stringify(element));
|
||||
} catch (err) {
|
||||
logger.error('error finding or creating artist \'' + JSON.stringify(tags) + '\' > ' + err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async addAlbum(tags) {
|
||||
if (tags?.common?.album === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [element, created] = await database.models.Album.findOrCreate({
|
||||
where: {
|
||||
name: tags.common.album
|
||||
}
|
||||
});
|
||||
if (!created) {
|
||||
return;
|
||||
}
|
||||
logger.debug('created album ' + JSON.stringify(element));
|
||||
} catch (err) {
|
||||
logger.error('error finding or creating album \'' + JSON.stringify(tags) + '\' > ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
async addTrack(tags, file) {
|
||||
if (tags?.common?.title === undefined || file === undefined) {
|
||||
return;
|
||||
}
|
||||
const where = {
|
||||
file: file,
|
||||
title: tags.common.title
|
||||
};
|
||||
if (tags?.common?.year !== undefined) {
|
||||
where.year = tags.common.year;
|
||||
}
|
||||
if (tags?.common?.duration !== undefined) {
|
||||
where.duration = tags.common.duration;
|
||||
}
|
||||
if (tags?.common?.comment !== undefined) {
|
||||
let comment = '';
|
||||
if (Array.isArray(tags.common.comment)) {
|
||||
for (let index = 0; index < tags.common.comment.length; index++) {
|
||||
if (comment.length > 0) {
|
||||
comment += '\n';
|
||||
}
|
||||
comment += tags.common.comment[index];
|
||||
}
|
||||
} else {
|
||||
comment = tags.common.comment;
|
||||
}
|
||||
where.comment = comment;
|
||||
}
|
||||
if (tags?.common?.disk?.no !== undefined) {
|
||||
where.diskno = tags.common.disk.no;
|
||||
}
|
||||
if (tags?.common?.disk?.of !== undefined) {
|
||||
where.diskof = tags.common.disk.of;
|
||||
}
|
||||
if (tags?.common?.track?.no !== undefined) {
|
||||
where.trackno = tags.common.track.no;
|
||||
}
|
||||
if (tags?.common?.track?.of !== undefined) {
|
||||
where.trackof = tags.common.track.of;
|
||||
}
|
||||
try {
|
||||
const [element, created] = await database.models.Track.findOrCreate({
|
||||
where: where
|
||||
});
|
||||
if (!created) {
|
||||
return;
|
||||
}
|
||||
logger.debug('created track ' + JSON.stringify(element));
|
||||
} catch (err) {
|
||||
logger.error('error finding or creating track \'' + JSON.stringify(tags) + '\' > ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
getFilter() {
|
||||
let filter = config?.library?.formats || ['.mp3'];
|
||||
for (let index = 0; index < filter.length; index++) {
|
||||
if (filter[index].startsWith(".")) {
|
||||
continue;
|
||||
}
|
||||
filter[index] = '.' + filter[index];
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Queue;
|
62
classes/Server.js
Normal file
62
classes/Server.js
Normal file
|
@ -0,0 +1,62 @@
|
|||
const net = require('net');
|
||||
|
||||
const Client = require('./Client.js');
|
||||
|
||||
const AudioServer = require('./AudioServer.js');
|
||||
|
||||
class Server {
|
||||
|
||||
constructor() {
|
||||
this.listen = config?.server?.listen || '0.0.0.0';
|
||||
this.port = config?.server?.port || 0;
|
||||
this.clients = [];
|
||||
this.server = net.createServer();
|
||||
}
|
||||
|
||||
start() {
|
||||
// setInterval(() => {
|
||||
// const audioServer = new AudioServer('/mnt/kingston/downloads/DOPESMOKER.flac');
|
||||
// audioServer.start();
|
||||
// }, 10000);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server.listen(this.port, this.listen).on('listening', () => {
|
||||
this.port = this.server.address().port;
|
||||
logger.info('communication server listening on ' + this.listen + ':' + this.port + '...');
|
||||
});
|
||||
this.server.on('connection', (socket) => {
|
||||
this.#addClient(socket);
|
||||
});
|
||||
this.server.on('error', (err) => {
|
||||
reject('an unexpected error occured: ' + err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.server === undefined) {
|
||||
return;
|
||||
}
|
||||
for (let index = 0; index < this.connections.length; index++) {
|
||||
this.connections[index].destroy();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
this.server.close();
|
||||
this.server = undefined;
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
#addClient(socket) {
|
||||
this.clients.push(new Client(socket));
|
||||
const audioServer = new AudioServer('/mnt/kingston/downloads/DOPESMOKER.flac');
|
||||
audioServer.start();
|
||||
}
|
||||
|
||||
removeClient(client) {
|
||||
client.destroy();
|
||||
this.clients.splice(this.clients.indexOf(client), 1);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Server;
|
42
classes/Watcher.js
Normal file
42
classes/Watcher.js
Normal file
|
@ -0,0 +1,42 @@
|
|||
const path = require('path');
|
||||
const chokidar = require('chokidar');
|
||||
|
||||
const { FS_EVENT_ADD: EVENT_ADD, FS_EVENT_UNLINK: EVENT_UNLINK, FS_EVENT_CHANGE: EVENT_CHANGE } = require('../libs/constants.js');
|
||||
|
||||
class Watcher {
|
||||
|
||||
constructor() {
|
||||
this.#initialize();
|
||||
}
|
||||
|
||||
#initialize() {
|
||||
if (config?.library === undefined) {
|
||||
throw new Error('library not defined');
|
||||
}
|
||||
if (config?.library?.sources === undefined || config?.library?.sources.length === 0) {
|
||||
throw new Error('no library sources defined');
|
||||
}
|
||||
for (let index = 0; index < config.library.sources.length; index++) {
|
||||
const directory = path.resolve(config.library.sources[index]);
|
||||
logger.debug('watching directory \'' + directory + '\'...');
|
||||
this.#handleEvents(chokidar.watch(directory));
|
||||
}
|
||||
}
|
||||
|
||||
#handleEvents(watcher) {
|
||||
if (watcher === undefined) {
|
||||
return;
|
||||
}
|
||||
watcher.on(EVENT_ADD, (file, stats) => {
|
||||
queue.add(EVENT_ADD, file, stats);
|
||||
});
|
||||
watcher.on(EVENT_UNLINK, (file, stats) => {
|
||||
queue.add(EVENT_UNLINK, file, stats);
|
||||
});
|
||||
watcher.on(EVENT_CHANGE, (file, stats) => {
|
||||
queue.add(EVENT_CHANGE, file, stats);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Watcher, EVENT_ADD, EVENT_UNLINK;
|
28
example_config_library.json
Normal file
28
example_config_library.json
Normal file
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"server": {
|
||||
"enabled": false
|
||||
},
|
||||
"log": {
|
||||
"level": "debug",
|
||||
"timestamp": "DD.MM.YYYY HH:mm:ss:SS"
|
||||
},
|
||||
"library": {
|
||||
"enabled": true,
|
||||
"sources": [
|
||||
"/home/velvettear/downloads"
|
||||
],
|
||||
"formats": [
|
||||
"mp3",
|
||||
"flac"
|
||||
]
|
||||
},
|
||||
"database": {
|
||||
"dialect": "postgres",
|
||||
"storage": "/tmp/kannon.sqlite",
|
||||
"host": "192.168.104.135",
|
||||
"port": 5432,
|
||||
"database": "kannon",
|
||||
"username": "postgres",
|
||||
"password": "$Velvet90"
|
||||
}
|
||||
}
|
24
example_config_server.json
Normal file
24
example_config_server.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"server": {
|
||||
"enabled": true,
|
||||
"listen": "0.0.0.0",
|
||||
"port": 3000,
|
||||
"heartbeat": 10000
|
||||
},
|
||||
"log": {
|
||||
"level": "debug",
|
||||
"timestamp": "DD.MM.YYYY HH:mm:ss:SS"
|
||||
},
|
||||
"library": {
|
||||
"enabled": false
|
||||
},
|
||||
"database": {
|
||||
"dialect": "postgres",
|
||||
"storage": "/tmp/kannon.sqlite",
|
||||
"host": "192.168.104.135",
|
||||
"port": 5432,
|
||||
"database": "kannon",
|
||||
"username": "postgres",
|
||||
"password": "$Velvet90"
|
||||
}
|
||||
}
|
68
kannon.js
Normal file
68
kannon.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
const util = require('./libs/util.js');
|
||||
const packageJSON = require('./package.json');
|
||||
const path = require('path');
|
||||
|
||||
const Logger = require('./classes/Logger.js');
|
||||
const Server = require('./classes/Server.js');
|
||||
const Database = require('./classes/Database.js');
|
||||
const Queue = require('./classes/Queue.js');
|
||||
const Watcher = require('./classes/Watcher.js');
|
||||
|
||||
const INTERRUPTS = ['beforeExit', 'SIGINT', 'SIGTERM'];
|
||||
|
||||
main();
|
||||
|
||||
async function main() {
|
||||
global.logger = new Logger();
|
||||
let configPath = path.resolve(process.argv[2] || __dirname + '/config.json');
|
||||
try {
|
||||
global.config = require(configPath);
|
||||
global.logger.setLogLevel(global.config?.log?.level);
|
||||
global.logger.setTimestamp(global.config?.log?.timestamp);
|
||||
} catch (err) {
|
||||
exit('could not read config file at \'' + configPath + '\'');
|
||||
}
|
||||
handleExit();
|
||||
global.logger.info("launching " + packageJSON.name + " " + packageJSON.version + "...");
|
||||
try {
|
||||
// connect to the database and create/alter tables
|
||||
global.database = new Database();
|
||||
await global.database.initialize();
|
||||
// socket server
|
||||
if (util.isEnabled(global.config.server)) {
|
||||
global.server = new Server();
|
||||
await global.server.start();
|
||||
}
|
||||
// queue and watcher
|
||||
if (util.isEnabled(global.config.library)) {
|
||||
global.queue = new Queue();
|
||||
global.watcher = new Watcher();
|
||||
}
|
||||
} catch (err) {
|
||||
exit(err);
|
||||
}
|
||||
};
|
||||
|
||||
function handleExit() {
|
||||
for (var index = 0; index < INTERRUPTS.length; index++) {
|
||||
process.on(INTERRUPTS[index], async (code) => {
|
||||
exit(undefined, code);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function exit(err, code) {
|
||||
if (code === undefined) {
|
||||
code = 0;
|
||||
if (err !== undefined) {
|
||||
code = 1;
|
||||
}
|
||||
}
|
||||
if (err) {
|
||||
logger.error(err);
|
||||
logger.error(packageJSON.name + ' ' + packageJSON.version + ' ended due to an error');
|
||||
} else {
|
||||
logger.info(packageJSON.name + ' ' + packageJSON.version + ' shutting down gracefully')
|
||||
}
|
||||
process.exit(code);
|
||||
}
|
12
kannon.service
Normal file
12
kannon.service
Normal file
|
@ -0,0 +1,12 @@
|
|||
[Unit]
|
||||
Description=kannon
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/kannon
|
||||
ExecStart=/opt/nvm/nvm-exec node kannon.js
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
10
libs/constants.js
Normal file
10
libs/constants.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
module.exports = {
|
||||
FS_EVENT_ADD: 'add',
|
||||
FS_EVENT_UNLINK: 'unlink',
|
||||
FS_EVENT_CHANGE: 'change',
|
||||
|
||||
SOCKET_EVENT_PING: 'ping',
|
||||
SOCKET_EVENT_PONG: 'pong',
|
||||
|
||||
EVENT_DELIMITER: '<<< kannon >>>'
|
||||
}
|
16
libs/metadata.js
Normal file
16
libs/metadata.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
const metadata = require('music-metadata');
|
||||
|
||||
async function parseFile(file) {
|
||||
if (file === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return await metadata.parseFile(file);
|
||||
} catch (err) {
|
||||
logger.error('an error occurred parsing the file \'' + file + '\' > ' + err);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseFile
|
||||
}
|
32
libs/util.js
Normal file
32
libs/util.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
function isEnabled(parameter) {
|
||||
return isSet(parameter?.enabled) && parameter.enabled === true;
|
||||
}
|
||||
|
||||
function isDisabled(parameter) {
|
||||
return isSet(parameter?.enabled) && parameter.enabled === false;
|
||||
}
|
||||
|
||||
function isSet(parameter) {
|
||||
return parameter !== undefined;
|
||||
}
|
||||
|
||||
function isUnset(parameter) {
|
||||
return !isSet(parameter);
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
if (isNaN(ms)) {
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isEnabled,
|
||||
isDisabled,
|
||||
isSet,
|
||||
isUnset,
|
||||
sleep
|
||||
}
|
7
models/Album.js
Normal file
7
models/Album.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
const Album = database.connection.define("album", {
|
||||
name: DataTypes.TEXT
|
||||
});
|
||||
|
||||
module.exports = Album;
|
7
models/Artist.js
Normal file
7
models/Artist.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
const Artist = database.connection.define("artist", {
|
||||
name: DataTypes.TEXT
|
||||
});
|
||||
|
||||
module.exports = Artist;
|
15
models/Track.js
Normal file
15
models/Track.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
const Track = database.connection.define("track", {
|
||||
title: DataTypes.TEXT,
|
||||
year: DataTypes.INTEGER,
|
||||
duration: DataTypes.FLOAT,
|
||||
comment: DataTypes.TEXT,
|
||||
diskno: DataTypes.INTEGER,
|
||||
diskof: DataTypes.INTEGER,
|
||||
trackno: DataTypes.INTEGER,
|
||||
trackof: DataTypes.INTEGER,
|
||||
file: DataTypes.TEXT
|
||||
});
|
||||
|
||||
module.exports = Track;
|
3243
package-lock.json
generated
Normal file
3243
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
package.json
Normal file
27
package.json
Normal file
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "kannon",
|
||||
"version": "0.0.1",
|
||||
"description": "a multi room audio player",
|
||||
"main": "kannon.js",
|
||||
"scripts": {},
|
||||
"keywords": [
|
||||
"audio",
|
||||
"player",
|
||||
"multi room"
|
||||
],
|
||||
"author": "Daniel Sommer <daniel.sommer@velvettear.de>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.velvettear.de/velvettear/kannon.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.3",
|
||||
"moment": "^2.29.1",
|
||||
"music-metadata": "^7.12.3",
|
||||
"pg": "^8.7.3",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"sequelize": "^6.18.0",
|
||||
"sqlite3": "^5.0.2"
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue