#! /bin/sh # -*- mode: python; -*- # by pts@fazekas.hu at Sat Feb 26 22:39:44 CET 2011 """:" #mplaylist: Audio playlist player using mplayer, with checkpointing type python2.7 >/dev/null 2>&1 && exec python2.7 -- "$0" ${1+"$@"} type python2.6 >/dev/null 2>&1 && exec python2.6 -- "$0" ${1+"$@"} type python2.5 >/dev/null 2>&1 && exec python2.5 -- "$0" ${1+"$@"} type python2.4 >/dev/null 2>&1 && exec python2.4 -- "$0" ${1+"$@"} exec python -- ${1+"$@"}; exit 1 mplaylist is Python script which can play audio playlists (.m3u files), remembering the current playback position (file and time) even when killed, so it will resume playback at the proper position upon restart. The playback position is saved as an .m3u.pos file next to the .m3u file. mplaylist uses mplayer for playing the audio files. Requirements: Python (2.4, 2.5, 2.6 or 2.7; doesn't work with 3.x) and mplayer. This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. TODO(pts): Properly resume TTY input after a SIGSTOP. """ import errno import os import os.path import re import signal import sys def ShellQuote(string): string = str(string) if string and not re.search('[^-_.+,:/a-zA-Z0-9]', string): return string else: return "'%s'" % string.replace("'", "'\\''") def CrLfLines(fd): """Yields lines ending with CR or LF.""" buf = [] while True: data = None data = os.read(fd, 8192) if not data: break i = data.find('\r') + 1 j = data.find('\n') + 1 if i <= 0 or (j > 0 and j < i): i = j if i <= 0: buf.append(data) continue if buf: if len(buf) > 1: buf[:] = [''.join(buf)] yield buf.pop() + data[:i] else: yield data[:i] while len(data) > i: k = data.find('\r', i) + 1 j = data.find('\n', i) + 1 if k <= 0 or (j > 0 and j < k): k = j if k <= 0: buf.append(data[i:]) break yield data[i : k] i = k data = ''.join(buf) if data: yield data def Play(mediafns, posfn, media_prefix, seek_decisec, mplayer_flags): assert mediafns, 'no files to play' ss_flag = '' speed = None if seek_decisec: i = int(seek_decisec) if i > 0: ss_flag = '-ss %d.%d ' % (i / 10, i % 10) assert len(mediafns) == 1, 'preseeking in muliple files does not work' # We need an exec here to get rid of the shell, and to get a proper status # code. cmd = ( 'DISPLAY= ' 'exec mplayer ' # '-vc null ' # Prevents decoding of .mp4 files. -vc black does work. '-vo null -noautosub -af-add scaletempo %s%s -- %s' % (ss_flag, ' '.join(map(ShellQuote, mplayer_flags)), ' '.join(map(ShellQuote, mediafns)))) print >>sys.stderr, 'info: running mplayer command: %s' % cmd logf = None f = os.popen(cmd) try: mediafn = shortfn = None is_all_played = False for line in CrLfLines(f.fileno()): # Example: 'Playing Radiokabare.2010.Meggyuruztek.Bodocs.Tibor.I.resz-szaszkievics/09.Koszonto.Kohalmi.Zoltan-szaszkievics.mp3.\n' # Example: 'Starting playback...\n' # Example: 'A: 4.5 (04.5) of 370.0 (06:10.0) 1.4% \x1b[J\r' # Example: 'A: 4.6 V: 4.6 A-V: -0.001 ct: -0.007 117/117 0% 0% 1.4% 0 0 \x1b[J\r' # Example: 'A:1092.9 (18:12.9) of 3289.0 (54:49.0) 5.1% 1.50x \x1b[J\r' # Example: 'Exiting... (End of file)\n' # Example: 'Exiting... (Quit)\n' sys.stdout.write(line) sys.stdout.flush() if mediafn is not None and line.startswith('A:'): match = re.match(r'A: *(\d+)[.](\d)\d* ', line) if match: decisec = int(match.group(1)) * 10 + int(match.group(2)) assert decisec <= 999999999 decisec_str = '%09d' % decisec if logf is None: logf = open(posfn, 'w+') # TODO(pts): Be more atomic. logf.write('%s %s\n' % (decisec_str, shortfn)) else: logf.seek(0, 0) logf.write(decisec_str) logf.flush() match = re.search(r' (\d+[.]\d\d)x ', line) if match: speed = match.group(1) else: speed = '1.0' # Implicit, not reported by mplayer. elif line.startswith('Playing '): match = re.match(r'Playing ([^\r\n]+)[.]\n\Z', line) if match: mediafn = match.group(1) if mediafn.startswith(media_prefix): shortfn = mediafn[len(media_prefix):] else: shortfn = mediafn if logf: logf.seek(0, 0) data = '000000000 %s\n' % shortfn logf.write(data) logf.flush() logf.truncate(len(data)) elif line.startswith('Exiting... (End of file)'): is_all_played = True finally: if logf: logf.close() status = f.close() or 0 print >>sys.stderr, 'info: mplayer exit status=0x%x' % status return mediafn, status, is_all_played, speed def main(argv): if len(argv) <= 1 or argv[1] == '--help': print 'Usage: %s [ ...] ' % argv[0] return 1 playlistfn = argv[-1] mediafns = [] mediafns_set = set() f = open(playlistfn) try: if '/' in playlistfn: media_prefix = playlistfn[:playlistfn.rfind('/') + 1] else: media_prefix = '' for line in f: line = line.rstrip('\r\n') if line == '#EXTM3U' or not line: continue mediafn = line if media_prefix and not line.startswith('/'): mediafn = media_prefix + line if not os.path.isfile(mediafn): # TODO(pts): Maybe skip later, the user might create the # file while a previous file is being played. print >>sys.stderr, ( 'warning: media file not found, skipping: %s' % mediafn) continue if mediafn in mediafns_set: print >>sys.stderr, ( 'warning: duplicate media file, skipping: %s' % mediafn) continue mediafns.append(mediafn) mediafns_set.add(mediafn) finally: f.close() if not mediafns: print >>sys.stderr, 'error: no media files to play, exiting' return 2 posfn = playlistfn + '.pos' mplayer_flags = argv[1 : -1] # TODO(pts): Remember playback speed in posfn. old_speed = None speed_idx = None for i in xrange(len(mplayer_flags) - 1): if mplayer_flags[i] == '--': break # This argument parser is not correct, because it doesn't skip an argument # if it's a flag value. if mplayer_flags[i] == '-speed': speed_idx = i + 1 old_speed = mplayer_flags[speed_idx] # Read saved playback position from posfn. f = None data = '' try: try: f = open(posfn) except IOError, e: if e[0] != errno.ENOENT: raise if f: data = f.read() finally: if f: f.close() if data.startswith('FINISHED'): print >>sys.stderr, 'info: playlist had finished, exiting' return 3 if not data: print >>sys.stderr, 'info: starting from the beginning' seek_decisec = 0 mediafn = mediafns[0] i = 0 else: match = re.match(r'(\d{9}) ([^\r\n]+)\n\Z', data) if not match: print >>sys.stderr, 'error: syntax error in pos file: %s' % posfn return 4 seek_decisec = int(match.group(1)) mediafn = match.group(2) if not mediafn.startswith('/'): mediafn = media_prefix + mediafn print >>sys.stderr, 'info: starting at %d.%d of %s\n' % ( seek_decisec / 10, seek_decisec % 10, mediafn) if mediafn in mediafns_set: i = mediafns.index(mediafn) else: print >>sys.stderr, ( 'warning: start file not in playlist, starting from the beginning') seek_decisec = 0 mediafn = mediafns[0] i = 0 data = None while i < len(mediafns): if seek_decisec > 0: # Play only one file at a time if preseeking is required, because # mplayer -ss would preseek in all files. mediafns_to_play = mediafns[i : i + 1] i += 1 else: # Play as many files as fits into the command-line. bytes_remaining = 30000 # TODO(pts): Subtract the size of os.environ?. for flag in mplayer_flags: bytes_remaining -= 20 + len(flag) j = i + 1 while j < len(mediafns) and bytes_remaining >= 20 + len(mediafns[j]): bytes_remaining -= 20 + len(mediafns[j]) j += 1 mediafns_to_play = mediafns[i : j] i = j mediafn, status, is_all_played, speed = Play( mediafns_to_play, posfn, media_prefix, seek_decisec, mplayer_flags) if speed is not None: # Propagate playback speed to subsequent files. if speed_idx is None: mplayer_flags[:0] = ['-speed', speed] speed_idx = 1 old_speed = speed elif abs(float(old_speed) - float(speed)) > 0.02: # Adjust playback speed for subsequent files, but only if new speed # is considerable different from the old one -- to prevent rounding # errors. mplayer_flags[speed_idx] = old_speed = speed seek_decisec = 0 # Start playing the next file from the beginning. if is_all_played: if i == len(mediafns): data = 'FINISHED\n' else: data = '000000000 %s\n' % mediafns[i:] f = open(posfn, 'w+') try: f.write(data) finally: f.close() if not is_all_played or status: print >>sys.stderr, 'info: exiting with status=0x%x' % status if os.WIFSIGNALED(status): sig = os.WTERMSIG(status) if sig != signal.SIGKILL: signal.signal(sig, signal.SIG_DFL) os.kill(os.getpid(), sig) if os.WIFEXITED(status): os._exit(os.WEXITSTATUS(status)) os._exit(127) # Failsafe. print >>sys.stderr, 'info: finished playlist' if __name__ == '__main__': sys.exit(main(sys.argv))