Compare commits

..

16 Commits

Author SHA1 Message Date
9ad5795475 Add hover effects for missing and shared tags 2019-12-17 22:31:27 -05:00
06e1e19f72 Add "db" key to config.json.sample 2019-12-17 22:30:43 -05:00
ee5dd22f5f Add escape button handler for form (reset) 2019-12-17 22:30:24 -05:00
1f9a0ea586 Add header link to site root 2019-12-17 13:55:31 -05:00
38f832e8d8 Add script to migrate from file to sqlite backend
This is the script I used to convert all existing media from the file
backend to the sqlite backend. I'm saving it for posterity.

It outputs shell commands to stdout, and SQL commands to stderr. These
can be piped to different files, examined, and run independently, e.g.

    $ ./file-to-sqlite.sh 1>files.sh 2>files.sql
    $ sh files.sh
    $ sqlite3 jukebox.sqlite3 files.sql
2019-12-17 13:50:11 -05:00
c0e2ab0129 Add more logging to sqlite backend 2019-12-17 13:39:18 -05:00
14d915fb7c Switch to less complicated track randomizer 2019-12-17 13:38:55 -05:00
087a06a20c Fix media player not running in media dir 2019-12-17 12:38:55 -05:00
88fc77f0a5 Add file upload support 2019-12-17 12:12:53 -05:00
4c79a7859a Add hightlight style for unused tags in editor 2019-12-17 10:41:24 -05:00
bc48e231ee Add "Library" page with tag/label editor 2019-12-17 10:22:58 -05:00
232929a47a Update FileTag for multi-field
We are separating an identifier (uuid) from the human-readable label.
2019-12-15 12:14:55 -05:00
e23f994894 Add dev files to gitignore
*.log matches the Vagrant log file
2019-12-15 11:52:02 -05:00
7e8f11a33c Change template engine from mustache to handlebars 2019-12-15 11:50:53 -05:00
5782510af1 Add sqlite3 library backend 2019-12-14 21:11:21 -05:00
9576373dbf Fix bug where nodemon would not kill child procs 2019-12-14 21:08:08 -05:00
24 changed files with 13282 additions and 275 deletions

4
.gitignore vendored
View File

@ -3,3 +3,7 @@ lib
.vagrant
config.json
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 upgrade -y"
config.vm.provision "shell", inline: "sudo apt-get install -y make nodejs npm"
config.vm.provision "shell", inline: "sudo apt-get install -y make nodejs npm sqlite3"
end
# vim:set ts=2 sw=2 et:

14
archive/file-to-sqlite.sh Executable file
View File

@ -0,0 +1,14 @@
#!/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,6 +5,7 @@
"media_path": "/path/to/media",
"script_path": "/path/to/scripts",
"db": "/path/to/jukebox.sqlite3",
"play_throttle": 5000,
"pause_throttle": 2000,

9
create.sql Normal file
View File

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

View File

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

View File

@ -0,0 +1,29 @@
"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 {
constructor(tag, path) {
constructor(tag, track) {
super(tag);
this.path = path;
this.track = track;
}
toString() {
return basename(this.path);
return this.track.label;
}
}

11
jukebox/library/track.js Normal file
View File

@ -0,0 +1,11 @@
"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 {
constructor(config, logger) {
super(config.mpg321, logger);
super(config.mpg321, logger, config.media_path);
this.config = config;
@ -52,7 +52,7 @@ class MediaPlayer extends ChildProcessEmitter {
_playFile(tag) {
this.emit('command', tag);
this.send("LOAD " + tag.path);
this.send("LOAD " + tag.track.uuid);
}
_unknown(tag) {

View File

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

115
player.js
View File

@ -2,16 +2,29 @@
const config = require('./config.json')
const views = require('./jukebox/views')(__dirname + '/templates/');
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 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');
@ -29,6 +42,12 @@ const MediaPlayer = require('./jukebox/media-player')(config, logger);
const TagReader = require('./jukebox/tag-reader')(config, logger);
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();
var app = express();
@ -55,6 +74,7 @@ function exitHandler(options, err) {
process.on('exit', exitHandler.bind(null));
process.on('SIGINT', exitHandler.bind(null, {exit:true}));
process.on('SIGUSR2', exitHandler.bind(null, {exit:true}));
const throttledTag = throttle(config.global_throttle, true, tag => {
ScriptRunner.find(tag).then((fulfilled) => {
@ -85,37 +105,74 @@ MediaPlayer.on('command', tag => {
PlayLog.updateLog(tag);
});
PlayLog.on('update', tag => {
var data = {
html: views.log.render({ play_log: PlayLog.getLog() }),
tag: tag.tag
};
wss.broadcast(JSON.stringify(data));
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'))
app.get('/', function (req, res, next) {
try {
var index = views.index.render({
last_tag: PlayLog.getLastTag()
, config: config
, play_log: PlayLog.getLog()
PlayLog.on('update', tag => {
app.render("log", { layout: false, play_log: PlayLog.getLog(), }, (err, html) => {
var data = {
html: html,
tag: tag.tag
};
wss.broadcast(JSON.stringify(data));
});
});
app.get('/', function(req, res, next) {
res.render('index', {
last_tag: PlayLog.getLastTag(),
config: config,
play_log: PlayLog.getLog(),
});
});
app.get('/api/tags', function(req, res, next) {
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) => {
res.send(rows);
});
});
app.patch('/api/tracks/:track', function(req, res, next) {
var track_uuid = req.params.track,
label = req.body.label,
tag = req.body.tag;
db.run("UPDATE library SET label = ?, tag = ? WHERE uuid = ?", label, tag, track_uuid, err => {
db.get("SELECT * FROM library WHERE uuid = ?", track_uuid, (err, row) => {
res.send(row);
});
});
});
var html = views.base.render({
title: 'Home'
, content: index
})
app.get('/api/tracks', function(req, res, next) {
db.all("SELECT * FROM library ORDER BY label ASC", (err, rows) => {
res.send(rows);
});
});
res.send(html)
} catch (e) {
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) {
@ -126,16 +183,16 @@ wss.broadcast = function broadcast(data) {
});
};
wss.on('connection', function (ws) {
wss.on('connection', function(ws) {
logger.info('websocket client connected');
ws.on('close', function () {
ws.on('close', function() {
logger.info('websocket client disconnected');
});
});
server.on('request', app);
server.listen(config.port, function () {
server.listen(config.port, function() {
logger.info('express listening on http://localhost:' + config.port)
})

View File

@ -1,56 +1,54 @@
@import url("https://fonts.googleapis.com/css?family=Source+Code+Pro:400,200,700");
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Source Code Pro', monospace;
font-family: Arial;
margin: 1em;
}
.header {
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");
background-position: center;
h1 {
font-size: 2rem;
margin: 0.5em 0;
}
.header .page-title {
font-weight: 200;
font-size: 80px;
text-align: center;
padding: 100px;
text-transform: uppercase;
color: #fff;
h1 a, h1 a:active, h1 a:visited, h1 a:hover {
color: hsl(182.8, 70.2%, 42.2%);
text-decoration: none;
}
.nav {
background: #000;
text-align: center;
h2 {
margin-bottom: 0.5ex;
font-size: 1.25rem;
}
.nav li {
display: inline-block;
li {
margin-left: 1em;
}
.nav a {
display: inline-block;
padding: 20px;
color: #fff;
text-decoration: none;
p {
margin: 1em 0;
}
.nav a:hover {
background-color: #3ab795;
text-decoration: underline;
footer {
margin-top: 2em;
font-size: 0.75rem;
}
.main-content {
margin: 50px auto;
max-width: 600px;
.media-track {
margin: 1ex 0;
padding: 1ex;
border: 1px solid hsl(0, 0%, 88.2%);
}
.main-content p {
line-height: 1.6;
margin-bottom: 1.7em;
.media-track-highlighted {
background-color: hsl(81, 100%, 96.1%);
border-color: hsl(118.1, 72%, 58%);
border-style: dotted;
}
.footer {
margin: 50px auto;
max-width: 600px;
border-top: 1px solid #444;
padding: 20px 0;
.media-track-tagless {
background-color: hsl(0, 100%, 95.7%);
border-color: hsl(0, 79.5%, 67.5%);
border-style: dashed;
}
.footer p {
color: #444;
font-size: 12px;
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() {
if (ws.readyState == WebSocket.CLOSED) {
if (ws && ws.readyState == WebSocket.CLOSED) {
connectWebsocket();
}
}, 1000);

161
static/library.js Normal file
View File

@ -0,0 +1,161 @@
(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:

11965
static/vue.debug.js Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

10
views/library.hbs Normal file
View File

@ -0,0 +1,10 @@
<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>

2
views/log.hbs Normal file
View File

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