From 97d7c9014855449fe04162308feac66a35e007ea Mon Sep 17 00:00:00 2001 From: Jon Bergli Heier Date: Mon, 8 Aug 2011 00:11:01 +0200 Subject: 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 --- .gitignore | 4 + app.py | 112 ++++++++++++++++++++++++++++ config.py | 19 +++++ directory.py | 112 ++++++++++++++++++++++++++++ events.py | 39 ++++++++++ recode.py | 71 ++++++++++++++++++ static/audio.js | 19 +++++ static/icons/folder.png | Bin 0 -> 537 bytes static/icons/loading.gif | Bin 0 -> 1737 bytes static/icons/music-cached.png | Bin 0 -> 633 bytes static/icons/music-queued.png | Bin 0 -> 646 bytes static/icons/music.png | Bin 0 -> 385 bytes static/icons/readme.txt | 22 ++++++ static/index.html | 28 +++++++ static/player.js | 170 ++++++++++++++++++++++++++++++++++++++++++ static/style.css | 9 +++ 16 files changed, 605 insertions(+) create mode 100644 .gitignore create mode 100755 app.py create mode 100644 config.py create mode 100644 directory.py create mode 100644 events.py create mode 100644 recode.py create mode 100644 static/audio.js create mode 100644 static/icons/folder.png create mode 100644 static/icons/loading.gif create mode 100644 static/icons/music-cached.png create mode 100644 static/icons/music-queued.png create mode 100644 static/icons/music.png create mode 100644 static/icons/readme.txt create mode 100644 static/index.html create mode 100644 static/player.js create mode 100644 static/style.css 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 '{name}
'.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 Binary files /dev/null and b/static/icons/folder.png differ diff --git a/static/icons/loading.gif b/static/icons/loading.gif new file mode 100644 index 0000000..1560b64 Binary files /dev/null and b/static/icons/loading.gif differ diff --git a/static/icons/music-cached.png b/static/icons/music-cached.png new file mode 100644 index 0000000..6802f99 Binary files /dev/null and b/static/icons/music-cached.png differ diff --git a/static/icons/music-queued.png b/static/icons/music-queued.png new file mode 100644 index 0000000..5034c06 Binary files /dev/null and b/static/icons/music-queued.png differ diff --git a/static/icons/music.png b/static/icons/music.png new file mode 100644 index 0000000..a8b3ede Binary files /dev/null and b/static/icons/music.png 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 @@ + + + + foo + + + + + +
+ + from + + to + +
+ +
+
+ +
none
+ + + 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('&', '&'); +} + +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'); } -- cgit v1.2.3