From a3e86f3be768c8fa1fc2af12d5e5d66d9d9b82e8 Mon Sep 17 00:00:00 2001 From: Jon Bergli Heier Date: Tue, 16 Aug 2011 14:49:40 +0200 Subject: Implemented basic cuesheet support, some playlist fixes. --- app.py | 12 ++++-- cuesheet.py | 103 ++++++++++++++++++++++++++++++++++++++++++++++++ directory.py | 39 ++++++++++++++---- events.py | 17 +++++--- recode.py | 23 +++++------ static/icons/delete.png | Bin 0 -> 715 bytes static/index.html | 4 +- static/player.js | 30 ++++++++++---- static/playlist.js | 52 +++++++++++++++++++++--- static/style.css | 4 +- 10 files changed, 239 insertions(+), 45 deletions(-) create mode 100644 cuesheet.py create mode 100644 static/icons/delete.png diff --git a/app.py b/app.py index c2ef0a3..3b887d8 100755 --- a/app.py +++ b/app.py @@ -35,8 +35,10 @@ class Application(object): return open(filename, 'rb') def cache(self, environ, start_response, path): + args = cgi.FieldStorage(environ = environ) path = os.path.join(*path[1:]) - cache_path = File(path).get_cache_path() + track = args.getvalue('track') if 'track' in args else None + cache_path = File(path, track = track).get_cache_path() if not os.path.exists(cache_path) or '..' in path: start_response('404 Not Found', []) @@ -61,8 +63,9 @@ class Application(object): def json_recode(self, environ, start_response, path): args = cgi.FieldStorage(environ = environ) path = args.getvalue('path') if 'path' in args else None + track = args.getvalue('track') if 'track' in args else None - f = File(path) + f = File(path, track = track) # see json_play() if not os.path.splitext(path)[1] in ('.mp3', '.ogg'): decoder = 'ffmpeg' @@ -76,8 +79,9 @@ class Application(object): args = cgi.FieldStorage(environ = environ) path = args.getvalue('path') + track = args.getvalue('track') if 'track' in args else None - f = File(path) + f = File(path, track = track) # TODO: replace this with some sane logic if not os.path.splitext(path)[1] in ('.mp3', '.ogg'): cache_path = f.get_cache_path() @@ -86,7 +90,7 @@ class Application(object): if not os.path.exists(cache_path): f.start_recode(decoder, encoder, environ['sessionid']) else: - events.event_pub.play(environ['sessionid'], '/cache/{0}'.format(path)) + events.event_pub.play(environ['sessionid'], '/cache/{0}{1}'.format(path, ('?track=' + track if track else ''))) else: events.event_pub.play(environ['sessionid'], '/files/{0}'.format(path)) diff --git a/cuesheet.py b/cuesheet.py new file mode 100644 index 0000000..827dbd9 --- /dev/null +++ b/cuesheet.py @@ -0,0 +1,103 @@ +import re + +cdtext_re = { + 'REM': r'^(REM) (.+)$', + 'PERFORMER': r'^(PERFORMER) "?(.+?)"?$', + 'TITLE': r'^(TITLE) "?(.+?)"?$', + 'FILE': r'^(FILE) "?(.+?)"? (BINARY|MOTOROLA|AIFF|WAVE|MP3)$', + 'TRACK': r'^(TRACK) (\d+) (AUDIO|CDG|MODE1/2048|MODE1/2352|MODE2/2336|MODE2/2352|CDI/2336|CDI2352)$', + 'INDEX': r'^(INDEX) (\d+) (\d+):(\d+):(\d+)$', + 'FLAGS': r'^((?:DCP|4CH|PRE|SCMS) ?){1,4}$', +} + +for k, v in cdtext_re.iteritems(): + cdtext_re[k] = re.compile(v) + +class CDText(object): + def __init__(self, str): + name = str.split()[0] + self.re = cdtext_re[name] + l = self.parse(str) + self.type, self.value = l[0], l[1:] + if type(self.value) == tuple and len(self.value) == 1: + self.value = self.value[0] + + def __repr__(self): + return '' % (self.type, self.value) + + def __str__(self): + return repr(self) + + def parse(self, str): + r = self.re.match(str) + if not r: + return None, None + return r.groups() + +class FieldDescriptor(object): + def __init__(self, field): + self.field = field + + def __get__(self, instance, owner): + def find(name): + for l in instance.cdtext: + if l.type == name: + return l + cdtext = find(self.field) + return cdtext.value if cdtext else None + +class Track(object): + def __init__(self): + self.cdtext = [] + self.abs_tot, self.abs_end, self.nextstart = 0, 0, None + + def add(self, cdtext): + self.cdtext.append(cdtext) + + def set_abs_tot(self, tot): + self.abs_tot = tot + + def set_abs_end(self, end): + self.abs_end + + def get_start_time(self): + index = self.index + return int(index[1])*60 + int(index[2]) + + def get_length(self): + return self.nextstart - self.get_start_time() + +for f in cdtext_re.keys(): + setattr(Track, f.lower(), FieldDescriptor(f)) + +class Cuesheet(object): + def __init__(self, filename = None, fileobj = None): + if not fileobj and filename: + fileobj = open(filename, 'rb') + if fileobj: + self.parse(fileobj) + + def parse(self, f): + info = [] + tracks = [] + track = Track() + info.append(track) + if not f.read(3) == '\xef\xbb\xbf': + f.seek(0) + for line in f: + cdtext = CDText(line.strip()) + if cdtext.type == 'TRACK': + track = Track() + tracks.append(track) + track.add(cdtext) + self.info = info + self.tracks = tracks + + def get_next(self, track): + found = False + for i in self.tracks: + if found: + return i + elif i == track: + found = True + return None diff --git a/directory.py b/directory.py index 6860155..79024f4 100644 --- a/directory.py +++ b/directory.py @@ -1,12 +1,13 @@ -import os, mimetypes, recode, events +import os, mimetypes, recode, events, cuesheet from config import config class DirectoryEntry(object): '''Base class for directory entries.''' - def __init__(self, path, isabs = False): + def __init__(self, path, isabs = False, track = None): self.path = path + self.track = track if isabs: self.abs_path = path @@ -27,7 +28,7 @@ class DirectoryEntry(object): return '{name}
'.format(path = self.path, name = os.path.basename(self.path)) def json(self): - return {'type': self.entry_type, 'name': self.path} + return {'type': self.entry_type, 'name': self.path, 'track': self.track} class Directory(DirectoryEntry): '''A directory entry inside a directory.''' @@ -44,7 +45,12 @@ class Directory(DirectoryEntry): if os.path.isdir(abs_path): directories.append(Directory(rel_path)) elif os.path.isfile(abs_path): - files.append(File(rel_path)) + if os.path.splitext(f)[1] == '.cue': + cue = cuesheet.Cuesheet(abs_path) + for t in cue.tracks: + files.append(File(rel_path, track = t.track[0])) + else: + files.append(File(rel_path)) return sorted(directories) + sorted(files) class File(DirectoryEntry): @@ -86,13 +92,30 @@ class File(DirectoryEntry): def get_cache_path(self): cache_path = os.path.join(config.get('cache_dir'), self.path) - cache_path = os.path.splitext(cache_path)[0] + '.ogg' + cache_path = os.path.splitext(cache_path)[0] + if self.track: + cache_path += '.' + self.track + cache_path += '.ogg' return cache_path def get_cache_file(self): return File(self.get_cache_path(), True) def recode(self, decoder, encoder, sessionid = None): + if self.track: + cue = cuesheet.Cuesheet(self.abs_path) + t = cue.tracks[int(self.track)-1] + start_time = t.get_start_time() + next = cue.get_next(t) + if next: + end_time = next.get_start_time() + else: + end_time = None + path = os.path.join(os.path.dirname(self.abs_path), cue.info[0].file[0]) + else: + path = self.abs_path + start_time, end_time = None, None + decoder = recode.decoders[decoder]() encoder = recode.encoders[encoder]() recoder = recode.Recoder(decoder, encoder) @@ -104,9 +127,9 @@ class File(DirectoryEntry): os.makedirs(cache_path_dir) # check if file is cached if not os.path.exists(cache_path): - events.event_pub.recoding(self.path) - recoder.recode(self.abs_path, cache_path) - events.event_pub.cached(self.path) + events.event_pub.recoding(self.path, self.track) + recoder.recode(path, cache_path, start_time = start_time, end_time = end_time) + events.event_pub.cached(self.path, self.track) if sessionid: events.event_pub.play(sessionid, '/cache/{0}'.format(self.path)) diff --git a/events.py b/events.py index 451e6f0..0d0360d 100644 --- a/events.py +++ b/events.py @@ -25,8 +25,15 @@ def EventSubscriber(app, environ, start_response, path): if address == session: address, message = message.split(None, 1) - if address in ('cached', 'recoding', 'play'): + data = None + if address in ('cached', 'recoding'): + track, path = message.split(None, 1) + data = json.dumps({'type': address, 'path': path, 'track': track}) + yield 'data: {0}\n\n'.format(data) + elif address in ('play',): data = json.dumps({'type': address, 'path': message}) + + if data: yield 'data: {0}\n\n'.format(data) socket.close() @@ -37,11 +44,11 @@ class EventPublisher(object): 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 recoding(self, path, track): + self.socket.send('recoding {0} {1}'.format(track or '_', path)) - def cached(self, path): - self.socket.send('cached {0}'.format(path)) + def cached(self, path, track): + self.socket.send('cached {0} {1}'.format(track or '_', path)) def play(self, session, path): self.socket.send('session-{0} play {1}'.format(session, path)) diff --git a/recode.py b/recode.py index 8b3ebb0..e67328e 100644 --- a/recode.py +++ b/recode.py @@ -33,8 +33,14 @@ class Codec(object): class FFmpeg(Decoder): decoder_name = 'ffmpeg' - def decode(self, source, dest, *args): - cmd = (x.format(infile = source, outfile = dest) for x in 'ffmpeg -loglevel quiet -i {infile} -y {outfile}'.split()) + def decode(self, source, dest, *args, **kwargs): + cmd = 'ffmpeg -loglevel quiet'.split() + if 'start_time' in kwargs: + cmd += ['-ss', str(kwargs['start_time'])] + if 'end_time' in kwargs and kwargs['end_time']: + cmd += ['-t', str(kwargs['end_time'] - kwargs['start_time'])] + cmd += ['-i', source, '-y', dest] + #cmd = (x.format(infile = source, outfile = dest) for x in 'ffmpeg -loglevel quiet -i {infile} -y {outfile}'.split()) p = subprocess.Popen(cmd, stderr = subprocess.PIPE, close_fds = True) p.stderr.close() p.wait() @@ -42,7 +48,7 @@ class FFmpeg(Decoder): class Ogg(Encoder): encoder_name = 'ogg' - def encode(self, source, dest, *args): + def encode(self, source, dest, *args, **kwargs): options = config.get('options', 'encoder/ogg', '') cmd = ['oggenc', '-Q'] + options.split() + [source, '-o', dest] subprocess.call(cmd) @@ -52,18 +58,13 @@ class Recoder(object): self.decoder = decoder self.encoder = encoder - def recode(self, source, dest): + def recode(self, source, dest, **kwargs): if self.decoder.__class__ == self.encoder.__class__ and hasattr(self.decoder, 'recode'): - print 'recoding' - print self.decoder self.decoder.recode(source, dest) else: with tempfile.NamedTemporaryFile(mode = 'wb', prefix = 'foo', suffix = '.wav', delete = True) as temp: - print 'temp file:', temp.name - print 'decoding' - self.decoder.decode(source, temp.name) - print 'encoding' - self.encoder.encode(temp.name, dest) + self.decoder.decode(source, temp.name, **kwargs) + self.encoder.encode(temp.name, dest, **kwargs) class RecodeThread(threading.Thread): lock = threading.Lock() diff --git a/static/icons/delete.png b/static/icons/delete.png new file mode 100644 index 0000000..08f2493 Binary files /dev/null and b/static/icons/delete.png differ diff --git a/static/index.html b/static/index.html index 6f24aba..c1d75db 100644 --- a/static/index.html +++ b/static/index.html @@ -16,12 +16,12 @@
- + Browse directory Add all - + Song queue
    diff --git a/static/player.js b/static/player.js index 90acf0b..b358eb5 100644 --- a/static/player.js +++ b/static/player.js @@ -12,22 +12,28 @@ for(var i = 0; i < cache_images.length; i++) { img.src = '/static/icons/' + cache_images[i]; } -function MusicListing(type, path, name, cached) { +function MusicListing(type, path, name, track, cached) { this.type = type; this.path = path; this.name = name ? name : path.split('/').pop(); + this.track = track; this.a = document.createElement('a'); this.a.ml = this; this.play = function() { + var path = '/play?path=' + encodeURIComponent(this.path); + if(this.track) + path += '&track=' + this.track; log('playing ' + path); xmlhttp = new XMLHttpRequest(); - xmlhttp.open('GET', '/play?path=' + encodeURIComponent(path)); + xmlhttp.open('GET', path); xmlhttp.send(null); } this.recode = function() { var path = '/recode?path=' + encodeURIComponent(this.path); + if(this.track) + path += '&track=' + this.track; var a = this.a; var ml = this; var xmlhttp = new XMLHttpRequest(); @@ -43,7 +49,10 @@ function MusicListing(type, path, name, cached) { className += ' file-cached' a.setAttribute('class', className); a.setAttribute('href', '#'); - a.appendChild(document.createTextNode(this.name)); + var name = this.name; + if(this.track) + name += ' (track ' + this.track + ')'; + a.appendChild(document.createTextNode(name)); var ml = this; a.onclick = function() { @@ -104,9 +113,10 @@ function list(root) { for(var i = 0; i < json.length; i++) { var type = json[i]["type"]; var path = json[i]["name"]; + var track = json[i]["track"]; var name = path.substring(path.lastIndexOf('/')+1); var cached = type == "file" ? json[i]["cached"] : false; - var l = new MusicListing(type, path, name, cached); + var l = new MusicListing(type, path, name, track, cached); output_link(l); } } @@ -131,11 +141,11 @@ function add_directory() { var source = null; -function get_a(path) { +function get_a(path, track) { var as = document.getElementById('song-links').getElementsByTagName('a'); for(var i = 0; i < as.length; i++) { var a = as[i]; - if(a.ml.path == path) + if(a.ml.path == path && (!track || track == a.ml.track)) return a; } } @@ -146,12 +156,16 @@ function event_handler(event) { case 'cached': case 'recoding': log('[' + data['type'] + '] ' + data['path']); + var track = null; + if('track' in data) + track = data['track']; + log('track: ' + track); // update directory browser - var a = get_a(data['path']); + var a = get_a(data['path'], track); if(a) a.setAttribute('class', 'file file-' + data['type']); // update song queue - var li = playlist.get(data['path']); + var li = playlist.get(data['path'], track); if(li) { a = li.getElementsByTagName('a')[0]; if(data['type'] == 'cached') { diff --git a/static/playlist.js b/static/playlist.js index 38db463..d2ac424 100644 --- a/static/playlist.js +++ b/static/playlist.js @@ -3,12 +3,15 @@ function Playlist(pl, audio) { this.audio = audio; this.current = null; - this.get = function(path) { + this.get = function(path, track) { var li = this.pl.getElementsByTagName('li')[0]; while(li) { - var a = li.getElementsByTagName('a')[0]; - if(a.ml.path == path) - return li; + var as = li.getElementsByTagName('a'); + if(as.length > 0) { + var a = as[0]; + if(a.ml.path == path && (!track || a.ml.track == track)) + return li; + } li = li.nextElementSibling; } return null; @@ -17,7 +20,10 @@ function Playlist(pl, audio) { this.add = function(ml) { var a = document.createElement('a'); a.setAttribute('href', '#'); - a.appendChild(document.createTextNode(ml.path)); + var name = ml.name; + if(ml.track) + name += ' (track ' + ml.track + ')'; + a.appendChild(document.createTextNode(name)); a.ml = ml; var li = document.createElement('li'); var pl = this; @@ -30,9 +36,13 @@ function Playlist(pl, audio) { var nextsong = li.nextElementSibling; log('nextsong: ' + nextsong); + // avoid breaking when a track has been removed + while(nextsong && nextsong.childElementCount == 0) + nextsong = nextsong.nextElementSibling; if(nextsong) { var nexta = nextsong.getElementsByTagName('a')[0]; log('nexta: ' + nexta); + log(nexta.onclick); log('next ml: ' + nexta.ml); nexta.ml.recode(); } @@ -41,7 +51,34 @@ function Playlist(pl, audio) { } log(a.click); - li.appendChild(a); + var span = document.createElement('div'); + + // anchor to remove track from playlist + var a_del = document.createElement('a'); + a_del.setAttribute('class', 'delete'); + a_del.setAttribute('href', '#'); + a_del.setAttribute('title', 'Remove'); + var del_img = new Image(); + del_img.src = '/static/icons/delete.png'; + a_del.appendChild(del_img); + a_del.onclick = function() { + li.removeChild(span); + return false; + } + a_del.hidden = true; + + span.onmouseover = function() { + a_del.hidden = false; + } + span.onmouseout = function() { + a_del.hidden = true; + } + + span.appendChild(a); + span.appendChild(document.createTextNode(' ')); + span.appendChild(a_del); + + li.appendChild(span); this.pl.appendChild(li); if(this.pl.firstChild == li || (this.current && this.current.nextElementSibling == li)) @@ -58,6 +95,9 @@ function Playlist(pl, audio) { this.current = this.current.nextElementSibling; old.parentElement.removeChild(old); } + // avoid breaking when a track has been removed + while(this.current && this.current.childElementCount == 0) + this.current = this.current.nextElementSibling; if(this.current) { var a = this.current.getElementsByTagName('a')[0]; a.onclick(); diff --git a/static/style.css b/static/style.css index 35319c0..8f3b636 100644 --- a/static/style.css +++ b/static/style.css @@ -4,7 +4,8 @@ ul#song-links a:hover, ul#playlist 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, ul#playlist li a { background-repeat: no-repeat; padding-left: 20px; } +a.dir, a.file, ul#playlist li a:first-child { background-repeat: no-repeat; padding-left: 20px; } +ul#playlist img { vertical-align: middle; } a.file-recoding { background-image: url('/static/icons/loading.gif'); } a.file-queued { background-image: url('/static/icons/music-queued.png'); } div#hpanel { margin-top: 1em; } @@ -14,3 +15,4 @@ ul#playlist li a.playing { background-image: url('/static/icons/sound.png'); } span.list-header { font-size: large; } ul#song-links { width: 30em; overflow: auto; white-space: nowrap; resize: both; } a#add-dir { font-size: small; } +span#playlist-span { position: absolute; left: 30em; right: 1em; } -- cgit v1.2.3