Compare commits

..

No commits in common. "database-backend" and "master" have entirely different histories.

24 changed files with 275 additions and 13282 deletions

4
.gitignore vendored
View File

@ -3,7 +3,3 @@ lib
.vagrant .vagrant
config.json config.json
npm-debug.log npm-debug.log
db.sqlite3
.vagrant-cache
*.log

2
Vagrantfile vendored
View File

@ -17,7 +17,7 @@ Vagrant.configure("2") do |config|
config.vm.provision "shell", inline: "sudo apt-get update -y" config.vm.provision "shell", inline: "sudo apt-get update -y"
config.vm.provision "shell", inline: "sudo apt-get upgrade -y" config.vm.provision "shell", inline: "sudo apt-get upgrade -y"
config.vm.provision "shell", inline: "sudo apt-get install -y make nodejs npm sqlite3" config.vm.provision "shell", inline: "sudo apt-get install -y make nodejs npm"
end end
# vim:set ts=2 sw=2 et: # vim:set ts=2 sw=2 et:

View File

@ -1,14 +0,0 @@
#!/bin/bash
ls media/*\ -\ *.mp3 | while read line ; do
UUID=$(./jukebox/node_modules/.bin/uuid v4)
echo "mv \"$line\" \"media/$UUID\""
FILE=$(basename "$line" ".mp3")
set -- $FILE
TAG=${@:1:1}
LABEL=${@:3}
echo "INSERT INTO tags (tag) VALUES (\"$TAG\");" 1>>/dev/stderr;
echo "INSERT INTO library (tag, label, uuid) VALUES (\"$TAG\", \"$LABEL\", \"$UUID\");" 1>>/dev/stderr;
done

View File

@ -5,7 +5,6 @@
"media_path": "/path/to/media", "media_path": "/path/to/media",
"script_path": "/path/to/scripts", "script_path": "/path/to/scripts",
"db": "/path/to/jukebox.sqlite3",
"play_throttle": 5000, "play_throttle": 5000,
"pause_throttle": 2000, "pause_throttle": 2000,

View File

@ -1,9 +0,0 @@
CREATE TABLE tags (tag TEXT, seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP);
INSERT INTO tags (tag) VALUES
("12121212"),
("badc0ffee");
CREATE TABLE library (uuid TEXT PRIMARY KEY, tag TEXT, label TEXT);
INSERT INTO library (uuid, tag, label) VALUES
("585687cb-1ff7-4106-a888-a40ff09b7fa1", "12121212", "OK Then"),
("85a6f785-51e1-4220-be75-fd306b973221", "12121212", "Other"),
("b4eabe35-9f96-4455-9294-56cc375cfbc4", "badc0ffee", " Brazil Theme");

View File

@ -7,7 +7,7 @@ const { spawn } = require('child_process');
const { EventEmitter } = require('events'); const { EventEmitter } = require('events');
module.exports.ChildProcessEmitter = class ChildProcessEmitter extends EventEmitter { module.exports.ChildProcessEmitter = class ChildProcessEmitter extends EventEmitter {
constructor(command, logger, cwd) { constructor(command, logger) {
super(); super();
var emitter = this; var emitter = this;
@ -17,14 +17,10 @@ module.exports.ChildProcessEmitter = class ChildProcessEmitter extends EventEmit
this.logger = logger; this.logger = logger;
this.stderrFilters = []; this.stderrFilters = [];
var options = {
cwd: cwd,
};
var cmd = command[0], var cmd = command[0],
args = command.slice(1); args = command.slice(1);
this.childProcess = spawn(cmd, args, options); this.childProcess = spawn(cmd, args);
this.childProcess.on('error', function(err) { this.childProcess.on('error', function(err) {
emitter.logger.error("process error", { class: emitter.constructor.name, err: String(err).trim() }); emitter.logger.error("process error", { class: emitter.constructor.name, err: String(err).trim() });

View File

@ -22,13 +22,13 @@ class Library extends EventEmitter {
return; return;
} }
this.backend.find(tag, track => { this.backend.find(tag, path => {
if (!track) { if (!path) {
this.emit('action', new tags.NotFoundTag(tag)); this.emit('action', new tags.NotFoundTag(tag));
return; return;
} }
this.emit('action', new tags.FileTag(tag, track)); this.emit('action', new tags.FileTag(tag, path));
}); });
} }
} }

View File

@ -1,29 +0,0 @@
"use strict";
const Track = require('./track');
class SqliteBackend {
constructor(config, db, logger) {
this.config = config;
this.db = db;
this.logger = logger;
}
find(tag, callback) {
this.db.run("INSERT INTO tags (tag) VALUES (?)", tag);
this.db.get("SELECT uuid, tag, label FROM library WHERE tag = ? ORDER BY RANDOM() LIMIT 1", tag, (err, row) => {
if (typeof row === 'undefined') {
this.logger.debug("no matching tag in sqlite backend", { tag: tag });
return callback();
}
this.logger.debug("found a matching tag", { tag: tag });
callback(new Track(row['tag'], row['uuid'], row['label']));
});
}
}
module.exports = function(config, db, logger) {
return new SqliteBackend(config, db, logger);
};
// vim:ts=2 sw=2 et:

View File

@ -29,13 +29,13 @@ class NotFoundTag extends Tag {
} }
class FileTag extends Tag { class FileTag extends Tag {
constructor(tag, track) { constructor(tag, path) {
super(tag); super(tag);
this.track = track; this.path = path;
} }
toString() { toString() {
return this.track.label; return basename(this.path);
} }
} }

View File

@ -1,11 +0,0 @@
"use strict";
class Track {
constructor(tag, uuid, label) {
this.tag = tag;
this.uuid = uuid;
this.label = label;
}
}
module.exports = Track;

View File

@ -13,7 +13,7 @@ const DEFAULT_UNKNOWN_THROTTLE = 2000;
class MediaPlayer extends ChildProcessEmitter { class MediaPlayer extends ChildProcessEmitter {
constructor(config, logger) { constructor(config, logger) {
super(config.mpg321, logger, config.media_path); super(config.mpg321, logger);
this.config = config; this.config = config;
@ -52,7 +52,7 @@ class MediaPlayer extends ChildProcessEmitter {
_playFile(tag) { _playFile(tag) {
this.emit('command', tag); this.emit('command', tag);
this.send("LOAD " + tag.track.uuid); this.send("LOAD " + tag.path);
} }
_unknown(tag) { _unknown(tag) {

61
jukebox/views.js Normal file
View File

@ -0,0 +1,61 @@
"use strict";
const mustache = require('mustache');
const readFileSync = require('fs').readFileSync;
var template_dir = undefined;
class View {
constructor() {
this.template = readFileSync(template_dir + this.path(), {encoding: 'utf8'})
mustache.parse(this.template)
}
render(vars) {
return mustache.render(this.template, vars, this.partials());
}
path() {
throw 'Subclasses must define a template path';
}
partials() {
return {};
}
}
class BaseView extends View {
path() {
return 'base.mustache';
}
}
class IndexView extends View {
path() {
return 'index.mustache';
}
partials() {
return {
log: views.log.template
}
}
}
class LogView extends View {
path() {
return 'log.mustache';
}
}
const views = {};
module.exports = function(tpl_dir) {
template_dir = tpl_dir;
views.base = new BaseView();
views.index = new IndexView();
views.log = new LogView();
return views;
}

1053
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -17,14 +17,11 @@
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"bufferutil": "^4.0.1", "bufferutil": "^4.0.1",
"express": "^4.17.1", "express": "^4.17.1",
"express-handlebars": "^3.1.0",
"glob": "^7.1.6", "glob": "^7.1.6",
"morgan": "^1.7.0", "morgan": "^1.7.0",
"multer": "^1.4.2", "mustache": "^3.1.0",
"sqlite": "^3.0.3",
"throttle-debounce": "^2.1.0", "throttle-debounce": "^2.1.0",
"utf-8-validate": "^5.0.2", "utf-8-validate": "^5.0.2",
"uuid": "^3.3.3",
"winston": "^3.2.1", "winston": "^3.2.1",
"ws": "^7.2.1" "ws": "^7.2.1"
}, },

111
player.js
View File

@ -2,29 +2,16 @@
const config = require('./config.json') const config = require('./config.json')
const views = require('./jukebox/views')(__dirname + '/templates/');
const library = require('./jukebox/library'); const library = require('./jukebox/library');
const MediaLibraryFileBackend = require('./jukebox/library/file-backend')(config);
const MediaLibrary = new library.Library(config, MediaLibraryFileBackend);
const ScriptRunner = require('./jukebox/scripts')(config); const ScriptRunner = require('./jukebox/scripts')(config);
const express = require('express'); const express = require('express');
const morgan = require('morgan'); const morgan = require('morgan')
const handlebars = require('express-handlebars');
const multer = require('multer');
const uuid = require('uuid/v4');
var storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, config.media_path)
},
filename: function (req, file, cb) {
var track_uuid = uuid();
cb(null, track_uuid)
}
});
var upload = multer({
storage: storage,
});
const { createLogger, format, transports } = require('winston'); const { createLogger, format, transports } = require('winston');
@ -42,12 +29,6 @@ const MediaPlayer = require('./jukebox/media-player')(config, logger);
const TagReader = require('./jukebox/tag-reader')(config, logger); const TagReader = require('./jukebox/tag-reader')(config, logger);
const play_log = require('./jukebox/library/play-log'); const play_log = require('./jukebox/library/play-log');
const sqlite3 = require('sqlite3');
var db = new sqlite3.Database(config.db);
const MediaLibrarySqliteBackend = require('./jukebox/library/sqlite-backend')(config, db, logger);
const MediaLibrary = new library.Library(config, MediaLibrarySqliteBackend);
const PlayLog = new play_log.PlayLog(); const PlayLog = new play_log.PlayLog();
var app = express(); var app = express();
@ -74,7 +55,6 @@ function exitHandler(options, err) {
process.on('exit', exitHandler.bind(null)); process.on('exit', exitHandler.bind(null));
process.on('SIGINT', exitHandler.bind(null, {exit:true})); process.on('SIGINT', exitHandler.bind(null, {exit:true}));
process.on('SIGUSR2', exitHandler.bind(null, {exit:true}));
const throttledTag = throttle(config.global_throttle, true, tag => { const throttledTag = throttle(config.global_throttle, true, tag => {
ScriptRunner.find(tag).then((fulfilled) => { ScriptRunner.find(tag).then((fulfilled) => {
@ -105,74 +85,37 @@ MediaPlayer.on('command', tag => {
PlayLog.updateLog(tag); PlayLog.updateLog(tag);
}); });
var hbs = handlebars.create({
extname: ".hbs",
});
MediaLibrary.on('action', MediaPlayer.handleTag.bind(MediaPlayer));
app.engine(".hbs", hbs.engine);
app.set('view engine', '.hbs');
app.set('views', __dirname + '/views');
app.use(morgan('dev'))
app.use(express.json())
app.use(express.static(__dirname + '/static'))
PlayLog.on('update', tag => { PlayLog.on('update', tag => {
app.render("log", { layout: false, play_log: PlayLog.getLog(), }, (err, html) => {
var data = { var data = {
html: html, html: views.log.render({ play_log: PlayLog.getLog() }),
tag: tag.tag tag: tag.tag
}; };
wss.broadcast(JSON.stringify(data)); wss.broadcast(JSON.stringify(data));
});
}); });
app.get('/', function(req, res, next) { MediaLibrary.on('action', MediaPlayer.handleTag.bind(MediaPlayer));
res.render('index', {
last_tag: PlayLog.getLastTag(),
config: config,
play_log: PlayLog.getLog(),
});
});
app.get('/api/tags', function(req, res, next) { app.use(morgan('dev'))
db.all("SELECT tags.tag, count(library.tag) tag_count FROM (SELECT DISTINCT tag FROM tags) tags LEFT JOIN library ON tags.tag = library.tag GROUP BY tags.tag", (err, rows) => { app.use(express.static(__dirname + '/static'))
res.send(rows);
});
});
app.patch('/api/tracks/:track', function(req, res, next) { app.get('/', function (req, res, next) {
var track_uuid = req.params.track, try {
label = req.body.label, var index = views.index.render({
tag = req.body.tag; last_tag: PlayLog.getLastTag()
, config: config
, play_log: PlayLog.getLog()
});
db.run("UPDATE library SET label = ?, tag = ? WHERE uuid = ?", label, tag, track_uuid, err => { var html = views.base.render({
db.get("SELECT * FROM library WHERE uuid = ?", track_uuid, (err, row) => { title: 'Home'
res.send(row); , content: index
}); })
});
});
app.get('/api/tracks', function(req, res, next) { res.send(html)
db.all("SELECT * FROM library ORDER BY label ASC", (err, rows) => { } catch (e) {
res.send(rows); next(e)
}); }
});
app.post('/api/tracks', upload.single('track'), function(req, res, next) {
db.run("INSERT INTO library (label, uuid) VALUES (?, ?)", req.file.originalname, req.file.filename, err => {
res.redirect('/library');
});
});
app.get('/library', function(req, res, next) {
res.render('library', {
title: 'Library',
});
}); });
wss.broadcast = function broadcast(data) { wss.broadcast = function broadcast(data) {
@ -183,16 +126,16 @@ wss.broadcast = function broadcast(data) {
}); });
}; };
wss.on('connection', function(ws) { wss.on('connection', function (ws) {
logger.info('websocket client connected'); logger.info('websocket client connected');
ws.on('close', function() { ws.on('close', function () {
logger.info('websocket client disconnected'); logger.info('websocket client disconnected');
}); });
}); });
server.on('request', app); server.on('request', app);
server.listen(config.port, function() { server.listen(config.port, function () {
logger.info('express listening on http://localhost:' + config.port) logger.info('express listening on http://localhost:' + config.port)
}) })

View File

@ -1,54 +1,56 @@
@import url("https://fonts.googleapis.com/css?family=Source+Code+Pro:400,200,700");
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: Arial; font-family: 'Source Code Pro', monospace;
margin: 1em;
} }
h1 { .header {
font-size: 2rem; background: url("https://images.unsplash.com/photo-1451337516015-6b6e9a44a8a3?crop=entropy&fit=crop&fm=jpg&h=975&ixjsv=2.1.0&ixlib=rb-0.3.5&q=80&w=1925");
margin: 0.5em 0; background-position: center;
} }
h1 a, h1 a:active, h1 a:visited, h1 a:hover { .header .page-title {
color: hsl(182.8, 70.2%, 42.2%); font-weight: 200;
font-size: 80px;
text-align: center;
padding: 100px;
text-transform: uppercase;
color: #fff;
}
.nav {
background: #000;
text-align: center;
}
.nav li {
display: inline-block;
}
.nav a {
display: inline-block;
padding: 20px;
color: #fff;
text-decoration: none; text-decoration: none;
} }
h2 { .nav a:hover {
margin-bottom: 0.5ex; background-color: #3ab795;
font-size: 1.25rem; text-decoration: underline;
} }
li { .main-content {
margin-left: 1em; margin: 50px auto;
max-width: 600px;
} }
p { .main-content p {
margin: 1em 0; line-height: 1.6;
margin-bottom: 1.7em;
} }
footer { .footer {
margin-top: 2em; margin: 50px auto;
font-size: 0.75rem; max-width: 600px;
border-top: 1px solid #444;
padding: 20px 0;
} }
.media-track { .footer p {
margin: 1ex 0; color: #444;
padding: 1ex; font-size: 12px;
border: 1px solid hsl(0, 0%, 88.2%);
}
.media-track-highlighted {
background-color: hsl(81, 100%, 96.1%);
border-color: hsl(118.1, 72%, 58%);
border-style: dotted;
}
.media-track-tagless {
background-color: hsl(0, 100%, 95.7%);
border-color: hsl(0, 79.5%, 67.5%);
border-style: dashed;
}
option.unused {
font-style: italic;
color: red;
}
.upload-box {
margin: 2em 0;
padding: 1ex;
} }

View File

@ -22,7 +22,7 @@ window.onload = function() {
} }
setInterval(function() { setInterval(function() {
if (ws && ws.readyState == WebSocket.CLOSED) { if (ws.readyState == WebSocket.CLOSED) {
connectWebsocket(); connectWebsocket();
} }
}, 1000); }, 1000);

View File

@ -1,161 +0,0 @@
(function() {
var appData = {
tracks: [],
tags: [],
highlightedTag: null,
};
const MediaTrack = {
props: [
'track',
'tags',
'highlightedTag',
],
data: function() {
return {
editing: false,
saving: false,
trackForReset: {},
}
},
computed: {
patch_url: function() {
return '/api/tracks/' + this.track.uuid;
},
},
methods: {
edit: function() {
if (this.editing) {
return;
}
this.trackForReset = Object.assign({}, this.track);
this.saving = false;
this.editing = true;
},
cancel: function() {
this.track = Object.assign({}, this.trackForReset);
this.editing = false;
},
save: function() {
this.saving = true;
var request = new Request(this.patch_url, {
method: "PATCH",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
tag: this.track.tag,
label: this.track.label,
})
});
fetch(request)
.then(response => {
return response.json();
})
.then(response => {
this.editing = false;
this.track.label = response.label;
this.track.tag = response.tag;
})
.catch(error => {
console.error(error)
});
},
highlightTag: function() {
this.$root.highlightedTag = this.track.tag;
},
highlightNone: function() {
this.$root.highlightedTag = null;
},
},
template: `
<div class="media-track"
:class="{ 'media-track-highlighted': track.tag === highlightedTag, 'media-track-tagless': track.tag === '' }"
@click="edit()"
@mouseover="highlightTag()"
@mouseout="highlightNone()">
<template v-if="editing">
<form method="patch" :action="patch_url" @keyup.27="cancel">
<select v-model="track.tag" :disabled="saving">
<option value=""></option>
<option :class="{unused: tag.tag_count == 0}" v-for="tag in tags">
{{ tag.tag }}
</option>
</select>
<input type="text" v-model="track.label" :disabled="saving">
<input type="submit" value="Save" @click.prevent.stop.once="save()" :disabled="saving">
</form>
</template>
<template v-else>{{ track.label }}</template>
</div>
`
};
const MediaLibrary = {
props: ['tracks', 'tags', 'highlightedTag'],
components: {
'media-track': MediaTrack,
},
template: `
<div class="media-library">
<media-track v-for="track in tracks"
:key="track.uuid"
:track="track"
:tags="tags"
:highlightedTag="highlightedTag"
></media-track>
</div>
`,
};
const UploadBox = {
template: `
<div class="upload-box">
<h2>Upload File</h2>
<form method="post" action="/api/tracks" enctype="multipart/form-data">
<input name="track" type="file">
<input type="submit" value="Upload">
</form>
</div>
`
};
var app = new Vue({
el: '#app',
data: appData,
components: {
'media-library': MediaLibrary,
'upload-box': UploadBox,
},
});
var request = new Request('/api/tags');
fetch(request)
.then(response => {
return response.json();
})
.then(response => {
appData.tags = response;
})
.catch(error => {
console.error(error);
});
var request = new Request('/api/tracks');
fetch(request)
.then(response => {
return response.json();
})
.then(response => {
appData.tracks = response;
})
.catch(error => {
console.error(error);
});
})();
// vim:ts=2 sw=2 et:

File diff suppressed because it is too large Load Diff

View File

@ -4,19 +4,19 @@
<head> <head>
<title>jukebox.stop.wtf</title> <title>jukebox.stop.wtf</title>
<link rel=stylesheet href=/css/index.css> <link rel=stylesheet href=/css/index.css>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head> </head>
<body> <body>
<div class=header> <div class=header>
<h1 class=page-title><a href="/">jukebox.stop.wtf</a></h1> <h1 class=page-title>jukebox.stop.wtf</h1>
</div> </div>
<div class="main-content"> <div class="main-content">
{{{body}}} {{{ content }}}
</div> </div>
<footer> <div class="footer">
<p>made with love by annika</p> <p>made with love by annika</p>
</footer> </div>
<script src="/jukebox.js"></script>
</body> </body>
</html> </html>

View File

@ -4,9 +4,6 @@
{{> log}} {{> log}}
</ul> </ul>
<p><a href="/library">Manage library</a></p>
<script src="/jukebox.js"></script>
<script> <script>
var jukebox = { var jukebox = {
port: {{ config.port }} port: {{ config.port }}

View File

@ -1,10 +0,0 @@
<div id="app">
<media-library
:tracks="tracks"
:tags="tags"
:highlighted-tag="highlightedTag"
></media-library>
<upload-box></upload-box>
</div>
<script src="/vue.debug.js"></script>
<script src="/library.js"></script>

View File

@ -1,2 +0,0 @@
{{! Used for rending *just* the log line partial. }}
{{> log}}