summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJon Bergli Heier <snakebite@jvnv.net>2011-08-08 00:11:01 +0200
committerJon Bergli Heier <snakebite@jvnv.net>2011-08-08 00:11:01 +0200
commit97d7c9014855449fe04162308feac66a35e007ea (patch)
tree8133c59cb0f066d992f434109818dd771e73f544
Initial commit.
app.py - WSGI application and handlers. config.py - Config helper class. directory.py - Directory and file helper classes. events.py - zeromq event publisher and subscriber. recode.py - Codecs and stuff static/ - Web interface
-rw-r--r--.gitignore4
-rwxr-xr-xapp.py112
-rw-r--r--config.py19
-rw-r--r--directory.py112
-rw-r--r--events.py39
-rw-r--r--recode.py71
-rw-r--r--static/audio.js19
-rw-r--r--static/icons/folder.pngbin0 -> 537 bytes
-rw-r--r--static/icons/loading.gifbin0 -> 1737 bytes
-rw-r--r--static/icons/music-cached.pngbin0 -> 633 bytes
-rw-r--r--static/icons/music-queued.pngbin0 -> 646 bytes
-rw-r--r--static/icons/music.pngbin0 -> 385 bytes
-rw-r--r--static/icons/readme.txt22
-rw-r--r--static/index.html28
-rw-r--r--static/player.js170
-rw-r--r--static/style.css9
16 files changed, 605 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c064cc9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+*.swp
+*.vim
+*.pyc
+/config
diff --git a/app.py b/app.py
new file mode 100755
index 0000000..1da175f
--- /dev/null
+++ b/app.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python2
+
+import os, mimetypes, json, cgi, recode, time, urllib, events, threading
+
+from config import config
+from directory import Directory, File
+
+class Application(object):
+ # Application handlers
+
+ def files(self, environ, start_response, path):
+ full_path = os.path.join(config.get('music_root'), *path[1:])
+ rel_path = os.path.join(*path[1:] or '.')
+ if os.path.isdir(full_path):
+ start_response('200 OK', [('Content-Type', 'text/html; charset=UTF-8')])
+ return (str(x) for x in Directory(rel_path).listdir())
+ else:
+ args = cgi.FieldStorage(environ = environ)
+
+ decoder = args.getvalue('decoder') if 'decoder' in args else None
+ encoder = args.getvalue('encoder') if 'encoder' in args else None
+
+ if decoder and encoder:
+ cache_file = File(rel_path).get_cache_file()
+ return File(cache_file, True).send(environ, start_response)
+ else:
+ return File(rel_path).send(environ, start_response)
+
+ def static(self, environ, start_response, path):
+ filename = os.path.join('static', *path[1:])
+
+ if not os.access(filename, os.F_OK) or '..' in path:
+ start_response('404 Not Found', [])
+ return []
+
+ mime = mimetypes.guess_type(filename, strict = False)[0] or 'application/octet-stream'
+ start_response('200 OK', [('Content-Type', mime)])
+ return open(filename, 'rb')
+
+
+ # JSON handlers
+
+ def json_list(self, environ, start_response, path):
+ args = cgi.FieldStorage(environ = environ)
+ directory = args.getvalue('directory') if 'directory' in args else '/'
+ d = Directory(directory)
+
+ contents = d.listdir()
+
+ start_response('200 OK', [('Content-Type', 'text/plain')])
+ s = json.dumps([x.json() for x in contents])
+ return s
+
+ def json_cache(self, environ, start_response, path):
+ args = cgi.FieldStorage(environ = environ)
+ path = args.getvalue('path') if 'path' in args else None
+ decoder = args.getvalue('decoder') if 'decoder' in args else None
+ encoder = args.getvalue('encoder') if 'encoder' in args else None
+
+ f = File(path)
+ t = threading.Thread(target = f.recode, args = (decoder, encoder))
+ t.start()
+
+ start_response('200 OK', [('Content-Type', 'text/plain')])
+ return []
+
+ def json_is_cached(self, environ, start_response, path):
+ args = cgi.FieldStorage(environ = environ)
+ path = args.getvalue('path') if 'path' in args else None
+
+ cache_file = os.path.join(config.get('cache_dir'), path)
+ cache_file = os.path.splitext(cache_file)[0] + '.mp3'
+
+ start_response('200 OK', [('Content-Type', 'text/plain')])
+ return json.dumps(os.path.exists(cache_file))
+
+ handlers = {
+ 'files': files,
+ 'static': static,
+ 'list': json_list,
+ 'cache': json_cache,
+ 'is_cached': json_is_cached,
+ 'events': events.EventSubscriber,
+ }
+
+ # WSGI handler
+
+ def __call__(self, environ, start_response):
+ path = urllib.unquote(environ['PATH_INFO'])
+ path = path.split('/')[1:]
+ module = path[0] or None
+ if not module:
+ module = 'static'
+ path = ['static', 'index.html']
+
+ if module in self.handlers:
+ return self.handlers[module](self, environ, start_response, path)
+
+ start_response('404 Not Found', [('Content-Type', 'text/plain')])
+ return [str(path)]
+
+if __name__ == '__main__':
+ import sys
+ if len(sys.argv) == 3:
+ from flup.server.fcgi import WSGIServer
+ WSGIServer(Application(), bindAddress = (sys.argv[1], int(sys.argv[2]))).run()
+ else:
+ from wsgiref.simple_server import make_server, WSGIServer
+ # enable IPv6
+ WSGIServer.address_family |= 10
+ httpd = make_server('', 8000, Application())
+ httpd.serve_forever()
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..88826cb
--- /dev/null
+++ b/config.py
@@ -0,0 +1,19 @@
+try:
+ from configparser import ConfigParser
+except ImportError:
+ from ConfigParser import ConfigParser
+
+class Config(object):
+ def __init__(self, filename = 'config'):
+ self.config_section = 'foo'
+
+ self.config = ConfigParser()
+ self.config.read(filename)
+
+ def get(self, key):
+ return self.config.get(self.config_section, key)
+
+ def getint(self, key):
+ return self.config.getint(self.config_section, key)
+
+config = Config()
diff --git a/directory.py b/directory.py
new file mode 100644
index 0000000..a93e00c
--- /dev/null
+++ b/directory.py
@@ -0,0 +1,112 @@
+import os, mimetypes, recode, events
+
+from config import config
+
+class DirectoryEntry(object):
+ '''Base class for directory entries.'''
+
+ def __init__(self, path, isabs = False):
+ self.path = path
+
+ if isabs:
+ self.abs_path = path
+ else:
+ path_list = os.path.split(config.get('music_root')) + os.path.split(self.path)
+ if '..' in path_list:
+ raise Exception('Invalid path')
+
+ self.abs_path = os.path.normpath(os.path.sep.join(path_list))
+
+ def __cmp__(self, other):
+ return cmp(self.path, other.path)
+
+ def __lt__(self, other):
+ return self.path < other.path
+
+ def __str__(self):
+ return '<a href="/files/{path}">{name}</a><br />'.format(path = self.path, name = os.path.basename(self.path))
+
+ def json(self):
+ return {'type': self.entry_type, 'name': self.path}
+
+class Directory(DirectoryEntry):
+ '''A directory entry inside a directory.'''
+
+ entry_type = 'dir'
+
+ def listdir(self):
+ directories = []
+ files = []
+
+ for f in os.listdir(self.abs_path):
+ abs_path = os.path.join(self.abs_path, f)
+ rel_path = os.path.relpath(abs_path, config.get('music_root'))
+ if os.path.isdir(abs_path):
+ directories.append(Directory(rel_path))
+ elif os.path.isfile(abs_path):
+ files.append(File(rel_path))
+ return sorted(directories) + sorted(files)
+
+class File(DirectoryEntry):
+ '''A file entry inside a directory.'''
+
+ entry_type = 'file'
+
+ def send(self, environ, start_response):
+ do_range = 'HTTP_RANGE' in environ
+ if do_range:
+ file_range = environ['HTTP_RANGE'].split('bytes=')[1]
+
+ mime = mimetypes.guess_type(self.abs_path, strict = False)[0] or 'application/octet-stream'
+ size = os.path.getsize(self.abs_path)
+ if do_range:
+ start, end = [int(x or 0) for x in file_range.split('-')]
+ if end == 0:
+ end = size-1
+
+ write_out = start_response('206 Partial Content', [
+ ('Content-Type', mime),
+ ('Content-Range', 'bytes {start}-{end}/{size}'.format(start = start, end = end, size = size)),
+ ('Content-Length', str(end - start + 1))])
+
+ f = open(self.abs_path, 'rb')
+ f.seek(start)
+ remaining = end-start+1
+ s = f.read(min(remaining, 1024))
+ while s:
+ write_out(s)
+ remaining -= len(s)
+ s = f.read(min(remaining, 1024))
+ return []
+
+ start_response('200 OK', [
+ ('Content-Type', mime),
+ ('Content-Length', str(size))])
+ return open(self.abs_path, 'rb')
+
+ def get_cache_file(self):
+ cache_file = os.path.join(config.get('cache_dir'), self.path)
+ cache_file = os.path.splitext(cache_file)[0] + '.mp3'
+ return cache_file
+
+ def recode(self, decoder, encoder):
+ decoder = recode.decoders[decoder]()
+ encoder = recode.encoders[encoder]()
+ recoder = recode.Recoder(decoder, encoder)
+
+ cache_file = self.get_cache_file()
+ cache_file_dir = os.path.dirname(cache_file)
+ # check and create cache directory
+ if not os.path.exists(cache_file_dir):
+ os.mkdir(cache_file_dir)
+ # check if file is cached
+ if not os.path.exists(cache_file):
+ events.event_pub.recoding(self.path)
+ recoder.recode(self.abs_path, cache_file)
+ events.event_pub.cached(self.path)
+
+ def json(self):
+ cache_file = self.get_cache_file()
+ d = DirectoryEntry.json(self)
+ d.update({'cached': os.path.exists(cache_file)})
+ return d
diff --git a/events.py b/events.py
new file mode 100644
index 0000000..4759a19
--- /dev/null
+++ b/events.py
@@ -0,0 +1,39 @@
+import zmq, json
+from config import config
+
+def EventSubscriber(app, environ, start_response, path):
+ context = zmq.Context()
+ socket = context.socket(zmq.SUB)
+ socket.connect(config.get('event_subscriber'))
+ socket.setsockopt(zmq.SUBSCRIBE, 'cached')
+ socket.setsockopt(zmq.SUBSCRIBE, 'recoding')
+
+ start_response('200 OK', [('Content-Type', 'text/event-stream')])
+ yield ': event source stream\n\n'
+ poller = zmq.Poller()
+ poller.register(socket, zmq.POLLIN)
+ while True:
+ socks = dict(poller.poll(config.getint('event_timeout') * 1000))
+ if not socket in socks:
+ break
+ message = socket.recv()
+ address, message = message.split(None, 1)
+ if address in ('cached', 'recoding'):
+ data = json.dumps({'type': address, 'path': message})
+ yield 'data: {0}\n\n'.format(data)
+
+ socket.close()
+
+class EventPublisher(object):
+ def __init__(self):
+ self.context = zmq.Context()
+ self.socket = self.context.socket(zmq.PUB)
+ self.socket.bind(config.get('event_publisher'))
+
+ def recoding(self, path):
+ self.socket.send('recoding {0}'.format(path))
+
+ def cached(self, path):
+ self.socket.send('cached {0}'.format(path))
+
+event_pub = EventPublisher()
diff --git a/recode.py b/recode.py
new file mode 100644
index 0000000..5f5a623
--- /dev/null
+++ b/recode.py
@@ -0,0 +1,71 @@
+import subprocess, tempfile
+
+decoders = {}
+encoders = {}
+
+class DecoderMeta(type):
+ def __init__(cls, name, bases, dict):
+ if not name in ('Decoder', 'Codec'):
+ decoders[cls.decoder_name] = cls
+
+class Decoder(object):
+ __metaclass__ = DecoderMeta
+
+class EncoderMeta(type):
+ def __init__(cls, name, bases, dict):
+ if not name in ('Encoder', 'Codec'):
+ encoders[cls.encoder_name] = cls
+
+class Encoder(object):
+ __metaclass__ = EncoderMeta
+
+class CodecMeta(DecoderMeta, EncoderMeta):
+ def __init__(cls, name, bases, dict):
+ DecoderMeta.__init__(cls, name, bases, dict)
+ EncoderMeta.__init__(cls, name, bases, dict)
+
+class Codec(object):
+ __metaclass__ = CodecMeta
+
+# end of metastuff
+
+class FFmpeg(Codec):
+ decoder_name = 'ffmpeg'
+ encoder_name = 'ffmpeg'
+
+ def decode(self, source, dest, *args):
+ ret = subprocess.call((x.format(infile = source, outfile = dest) for x in 'ffmpeg -loglevel quiet -i {infile} -y {outfile}'.split()))
+ print 'decoding returned', ret
+
+ def encode(self, source, dest, *args):
+ ret = subprocess.call((x.format(infile = source, outfile = dest) for x in 'ffmpeg -loglevel quiet -i {infile} -y {outfile}'.split()))
+ print 'encoding returned', ret
+
+ def recode(self, source, dest, *args):
+ ret = subprocess.call((x.format(infile = source, outfile = dest) for x in 'ffmpeg -loglevel quiet -i {infile} -y {outfile}'.split()))
+ print 'recoding returned', ret
+
+class Recoder(object):
+ def __init__(self, decoder, encoder):
+ self.decoder = decoder
+ self.encoder = encoder
+
+ def recode(self, source, dest):
+ if self.decoder.__class__ == self.encoder.__class__ and hasattr(self.decoder, 'recode'):
+ print 'recoding'
+ print self.decoder
+ self.decoder.recode(source, dest)
+ else:
+ temp = tempfile.NamedTemporaryFile(mode = 'wb', prefix = 'foo', suffix = os.path.splitext(dest)[1], delete = True)
+ print 'decoding'
+ self.decoder.decode(source, temp.name)
+ print 'encoding'
+ self.encoder.encode(temp.name, dest)
+ temp.close()
+
+if __name__ == '__main__':
+ import sys
+ ffmpeg = FFmpeg()
+ print ffmpeg
+ r = Recoder(ffmpeg, ffmpeg)
+ r.recode(sys.argv[1], sys.argv[2])
diff --git a/static/audio.js b/static/audio.js
new file mode 100644
index 0000000..6eaec56
--- /dev/null
+++ b/static/audio.js
@@ -0,0 +1,19 @@
+function Audio() {
+ this.audio = document.getElementById('audio');
+
+ this.set_src = function(src) {
+ this.audio.setAttribute('src', src);
+ }
+
+ this.play = function() {
+ this.audio.play();
+ }
+
+ this.pause = function() {
+ this.audio.pause();
+ }
+
+ this.stop = function() {
+ this.audio.stop();
+ }
+}
diff --git a/static/icons/folder.png b/static/icons/folder.png
new file mode 100644
index 0000000..784e8fa
--- /dev/null
+++ b/static/icons/folder.png
Binary files differ
diff --git a/static/icons/loading.gif b/static/icons/loading.gif
new file mode 100644
index 0000000..1560b64
--- /dev/null
+++ b/static/icons/loading.gif
Binary files differ
diff --git a/static/icons/music-cached.png b/static/icons/music-cached.png
new file mode 100644
index 0000000..6802f99
--- /dev/null
+++ b/static/icons/music-cached.png
Binary files differ
diff --git a/static/icons/music-queued.png b/static/icons/music-queued.png
new file mode 100644
index 0000000..5034c06
--- /dev/null
+++ b/static/icons/music-queued.png
Binary files differ
diff --git a/static/icons/music.png b/static/icons/music.png
new file mode 100644
index 0000000..a8b3ede
--- /dev/null
+++ b/static/icons/music.png
Binary files differ
diff --git a/static/icons/readme.txt b/static/icons/readme.txt
new file mode 100644
index 0000000..400a64d
--- /dev/null
+++ b/static/icons/readme.txt
@@ -0,0 +1,22 @@
+Silk icon set 1.3
+
+_________________________________________
+Mark James
+http://www.famfamfam.com/lab/icons/silk/
+_________________________________________
+
+This work is licensed under a
+Creative Commons Attribution 2.5 License.
+[ http://creativecommons.org/licenses/by/2.5/ ]
+
+This means you may use it for any purpose,
+and make any changes you like.
+All I ask is that you include a link back
+to this page in your credits.
+
+Are you using this icon set? Send me an email
+(including a link or picture if available) to
+mjames@gmail.com
+
+Any other questions about this icon set please
+contact mjames@gmail.com \ No newline at end of file
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..ea20792
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>foo</title>
+ <script src="/static/audio.js" type="text/javascript"></script>
+ <script src="/static/player.js" type="text/javascript"></script>
+ <link rel="stylesheet" href="/static/style.css" type="text/css" />
+</head>
+<body>
+ <div id="transcode-div">
+ <label for="trans_enabled"><input type="checkbox" id="trans_enabled" /> Transcode</label>
+ from
+ <select id="trans_from">
+ <option value="ffmpeg">FFmpeg</option>
+ </select>
+ to
+ <select id="trans_to">
+ <option value="ffmpeg">FFmpeg</option>
+ </select>
+ </div>
+ <textarea id="logbox" readonly="readonly" rows="10" cols="50"></textarea>
+ <div><input type="button" value="Close event source" onclick="source.close()" /></div>
+ <div id="current-dir"></div>
+ <ul id="song-links"></ul>
+ <div id="audio-src-url">none</div>
+ <audio id="audio" controls="controls"></audio>
+</body>
+</html>
diff --git a/static/player.js b/static/player.js
new file mode 100644
index 0000000..70b5a6d
--- /dev/null
+++ b/static/player.js
@@ -0,0 +1,170 @@
+audio = null;
+
+// pre-load some icons
+cache_images = new Array(
+ 'music-cached.png',
+ 'music-queued.png',
+ 'loading.gif'
+);
+var img = new Image();
+for(var i = 0; i < cache_images.length; i++) {
+ img.src = '/static/icons/' + cache_images[i];
+}
+
+function MusicListing(type, path, name, cached) {
+ this.type = type;
+ this.path = path;
+ this.name = name ? name : path.split('/').pop();
+ this.a = document.createElement('a');
+ this.a.tag = path;
+
+ this.play = function() {
+ var transcode = document.getElementById('trans_enabled').checked;
+ var trans_from = document.getElementById('trans_from').value;
+ var trans_to = document.getElementById('trans_to').value;
+ var p = path;
+ if(transcode)
+ p += '?decoder=' + trans_from + '&encoder=' + trans_to;
+ log('playing ' + p);
+ change_url('/files/' + p);
+ audio.play();
+ }
+
+ this.cache = function() {
+ var path = '/cache?path=' + encodeURIComponent(this.path) +
+ '&decoder=' + document.getElementById('trans_from').value +
+ '&encoder=' + document.getElementById('trans_to').value;
+ var a = this.a;
+ var ml = this;
+ var xmlhttp = new XMLHttpRequest();
+ a.setAttribute('class', 'file file-queued');
+ xmlhttp.open('GET', path);
+ xmlhttp.send(null);
+ }
+
+ this.get_anchor = function() {
+ var a = this.a;
+ var className = type;
+ if(cached)
+ className += ' file-cached'
+ a.setAttribute('class', className);
+ a.setAttribute('href', '#');
+ a.appendChild(document.createTextNode(this.name));
+ var ml = this;
+
+ a.onclick = function() {
+ if(type == 'dir') {
+ list(path);
+ } else if(type == 'file') {
+ var transcode = document.getElementById('trans_enabled').checked;
+ var trans_from = document.getElementById('trans_from').value;
+ var trans_to = document.getElementById('trans_to').value;
+
+ var p = path;
+ if(transcode) {
+ ml.cache(p);
+ } else {
+ ml.play();
+ }
+ }
+ return false;
+ }
+ return a;
+ }
+}
+
+function set_current(path) {
+ document.getElementById('current-dir').innerHTML = 'Directory: ' + path;
+}
+
+function log(s) {
+ logbox = document.getElementById('logbox');
+ logbox.value += s + '\n';
+ logbox.scrollTop = logbox.scrollHeight;
+}
+
+function change_url(url) {
+ audio.set_src(url);
+
+ var audio_src_url = document.getElementById('audio-src-url');
+ audio_src_url.innerHTML = 'Playing: ' + url.replace('&', '&amp;');
+}
+
+function output_link(obj) {
+ var a = obj.get_anchor();
+
+ var li = document.createElement('li');
+ li.appendChild(a);
+
+ var song_links = document.getElementById('song-links');
+ song_links.appendChild(li);
+}
+
+function list(root) {
+ log('listing ' + root);
+ var xmlhttp = new XMLHttpRequest();
+ xmlhttp.onreadystatechange = function() {
+ if(xmlhttp.readyState == 4) {
+ set_current(root);
+ var json = JSON.parse(xmlhttp.responseText);
+ document.getElementById('song-links').innerHTML = '';
+ // add "up" link
+ if(root.length > 1) {
+ up = root.substr(0, root.lastIndexOf('/'));
+ if(up.length == 0)
+ up = '/';
+ l = new MusicListing('dir', up, '..');
+ output_link(l);
+ }
+ for(var i = 0; i < json.length; i++) {
+ var type = json[i]["type"];
+ var path = json[i]["name"];
+ var name = path.substring(path.lastIndexOf('/')+1);
+ var cached = type == "file" ? json[i]["cached"] : false;
+ var l = new MusicListing(type, path, name, cached);
+ output_link(l);
+ }
+ }
+ }
+
+ path = '/list?directory=' + encodeURIComponent(root);
+ xmlhttp.open('GET', path);
+ xmlhttp.send();
+}
+
+var source = null;
+
+function get_a(path) {
+ var as = document.getElementsByTagName('a');
+ for(var i = 0; i < as.length; i++) {
+ var a = as[i];
+ if(a.tag == path)
+ return a;
+ }
+}
+
+function event_handler(event) {
+ data = JSON.parse(event.data);
+ switch(data['type']) {
+ case 'cached':
+ case 'recoding':
+ log('[' + data['type'] + '] ' + data['path']);
+ var a = get_a(data['path']);
+ if(a)
+ a.setAttribute('class', 'file file-' + data['type']);
+ break;
+ default:
+ log('[event] unknown type: ' + data['type']);
+ }
+}
+
+window.onload = function() {
+ source = new EventSource('/events');
+ source.onopen = function() { log('event source opened'); }
+ source.onmessage = event_handler;
+ source.onerror = function(event) { log('event source error'); }
+ log('event source status: ' + source.readyState);
+
+ audio = new Audio();
+ list('/');
+}
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..d086227
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,9 @@
+ul#song-links { list-style-type: none; }
+ul#song-links a { text-decoration: none; color: inherit; }
+ul#song-links a:hover { text-decoration: underline; }
+a.dir { background-image: url('/static/icons/folder.png'); }
+a.file { background-image: url('/static/icons/music.png'); }
+a.file-cached { background-image: url('/static/icons/music-cached.png'); }
+a.dir, a.file { background-repeat: no-repeat; padding-left: 20px; }
+a.file-recoding { background-image: url('/static/icons/loading.gif'); }
+a.file-queued { background-image: url('/static/icons/music-queued.png'); }