initial commit

This commit is contained in:
Daniel Sommer 2022-04-14 14:23:41 +02:00
commit b28a136c58
27 changed files with 4495 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
config.json
node_modules/
npm-debug.log

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
17

31
.vscode/launch.json vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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"
}
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

27
package.json Normal file
View 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"
}
}