qtfaststart's "free" atom stripping mangled some files.
[pyTivo/wmcbrine.git] / plugins / music / music.py
blob1190f5350c4031270fbb5683f5368cc8f58362d8
1 import os
2 import random
3 import re
4 import shutil
5 import subprocess
6 import sys
7 import time
8 import unicodedata
9 import urllib
10 from xml.sax.saxutils import escape
12 import mutagen
13 from mutagen.easyid3 import EasyID3
14 from mutagen.mp3 import MP3
15 from Cheetah.Template import Template
16 from lrucache import LRUCache
17 import config
18 from plugin import EncodeUnicode, Plugin, quote, unquote
19 from plugins.video.transcode import kill
21 SCRIPTDIR = os.path.dirname(__file__)
23 CLASS_NAME = 'Music'
25 PLAYLISTS = ('.m3u', '.m3u8', '.ram', '.pls', '.b4s', '.wpl', '.asx',
26 '.wax', '.wvx')
28 TRANSCODE = ('.mp4', '.m4a', '.flc', '.ogg', '.wma', '.aac', '.wav',
29 '.aif', '.aiff', '.au', '.flac')
31 TAGNAMES = {'artist': ['\xa9ART', 'Author'],
32 'title': ['\xa9nam', 'Title'],
33 'album': ['\xa9alb', u'WM/AlbumTitle'],
34 'date': ['\xa9day', u'WM/Year'],
35 'genre': ['\xa9gen', u'WM/Genre']}
37 BLOCKSIZE = 64 * 1024
39 # Search strings for different playlist types
40 asxfile = re.compile('ref +href *= *"([^"]*)"', re.IGNORECASE).search
41 wplfile = re.compile('media +src *= *"([^"]*)"', re.IGNORECASE).search
42 b4sfile = re.compile('Playstring="file:([^"]*)"').search
43 plsfile = re.compile('[Ff]ile(\d+)=(.+)').match
44 plstitle = re.compile('[Tt]itle(\d+)=(.+)').match
45 plslength = re.compile('[Ll]ength(\d+)=(\d+)').match
47 # Duration -- parse from ffmpeg output
48 durre = re.compile(r'.*Duration: ([0-9]+):([0-9]+):([0-9]+)\.([0-9]+),').search
50 # Preload the templates
51 tfname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
52 tpname = os.path.join(SCRIPTDIR, 'templates', 'm3u.tmpl')
53 iname = os.path.join(SCRIPTDIR, 'templates', 'item.tmpl')
54 FOLDER_TEMPLATE = file(tfname, 'rb').read()
55 PLAYLIST_TEMPLATE = file(tpname, 'rb').read()
56 ITEM_TEMPLATE = file(iname, 'rb').read()
58 # XXX BIG HACK
59 # subprocess is broken for me on windows so super hack
60 def patchSubprocess():
61 o = subprocess.Popen._make_inheritable
63 def _make_inheritable(self, handle):
64 if not handle: return subprocess.GetCurrentProcess()
65 return o(self, handle)
67 subprocess.Popen._make_inheritable = _make_inheritable
69 mswindows = (sys.platform == "win32")
70 if mswindows:
71 patchSubprocess()
73 class FileData:
74 def __init__(self, name, isdir):
75 self.name = name
76 self.isdir = isdir
77 self.isplay = os.path.splitext(name)[1].lower() in PLAYLISTS
78 self.title = ''
79 self.duration = 0
81 class Music(Plugin):
83 CONTENT_TYPE = 'x-container/tivo-music'
85 AUDIO = 'audio'
86 DIRECTORY = 'dir'
87 PLAYLIST = 'play'
89 media_data_cache = LRUCache(300)
90 recurse_cache = LRUCache(5)
91 dir_cache = LRUCache(10)
93 def send_file(self, handler, path, query):
94 seek = int(query.get('Seek', [0])[0])
95 duration = int(query.get('Duration', [0])[0])
96 always = (handler.container.getboolean('force_ffmpeg') and
97 config.get_bin('ffmpeg'))
98 fname = unicode(path, 'utf-8')
100 ext = os.path.splitext(fname)[1].lower()
101 needs_transcode = ext in TRANSCODE or seek or duration or always
103 if not needs_transcode:
104 fsize = os.path.getsize(fname)
105 handler.send_response(200)
106 handler.send_header('Content-Length', fsize)
107 else:
108 handler.send_response(206)
109 handler.send_header('Transfer-Encoding', 'chunked')
110 handler.send_header('Content-Type', 'audio/mpeg')
111 handler.end_headers()
113 if needs_transcode:
114 if mswindows:
115 fname = fname.encode('cp1252')
117 cmd = [config.get_bin('ffmpeg'), '-i', fname, '-vn']
118 if ext in ['.mp3', '.mp2']:
119 cmd += ['-acodec', 'copy']
120 else:
121 cmd += ['-ab', '320k', '-ar', '44100']
122 cmd += ['-f', 'mp3', '-']
123 if seek:
124 cmd[-1:] = ['-ss', '%.3f' % (seek / 1000.0), '-']
125 if duration:
126 cmd[-1:] = ['-t', '%.3f' % (duration / 1000.0), '-']
128 ffmpeg = subprocess.Popen(cmd, bufsize=BLOCKSIZE,
129 stdout=subprocess.PIPE)
130 while True:
131 try:
132 block = ffmpeg.stdout.read(BLOCKSIZE)
133 handler.wfile.write('%x\r\n' % len(block))
134 handler.wfile.write(block)
135 handler.wfile.write('\r\n')
136 except Exception, msg:
137 handler.server.logger.info(msg)
138 kill(ffmpeg)
139 break
141 if not block:
142 break
143 else:
144 f = open(fname, 'rb')
145 try:
146 shutil.copyfileobj(f, handler.wfile)
147 except:
148 pass
149 f.close()
151 try:
152 handler.wfile.flush()
153 except Exception, msg:
154 handler.server.logger.info(msg)
156 def QueryContainer(self, handler, query):
158 def AudioFileFilter(f, filter_type=None):
159 ext = os.path.splitext(f)[1].lower()
161 if ext in ('.mp3', '.mp2') or (ext in TRANSCODE and
162 config.get_bin('ffmpeg')):
163 return self.AUDIO
164 else:
165 file_type = False
167 if not filter_type or filter_type.split('/')[0] != self.AUDIO:
168 if ext in PLAYLISTS:
169 file_type = self.PLAYLIST
170 elif os.path.isdir(f):
171 file_type = self.DIRECTORY
173 return file_type
175 def media_data(f):
176 if f.name in self.media_data_cache:
177 return self.media_data_cache[f.name]
179 item = {}
180 item['path'] = f.name
181 item['part_path'] = f.name.replace(local_base_path, '', 1)
182 item['name'] = os.path.basename(f.name)
183 item['is_dir'] = f.isdir
184 item['is_playlist'] = f.isplay
185 item['params'] = 'No'
187 if f.title:
188 item['Title'] = f.title
190 if f.duration > 0:
191 item['Duration'] = f.duration
193 if f.isdir or f.isplay or '://' in f.name:
194 self.media_data_cache[f.name] = item
195 return item
197 # If the format is: (track #) Song name...
198 #artist, album, track = f.name.split(os.path.sep)[-3:]
199 #track = os.path.splitext(track)[0]
200 #if track[0].isdigit:
201 # track = ' '.join(track.split(' ')[1:])
203 #item['SongTitle'] = track
204 #item['AlbumTitle'] = album
205 #item['ArtistName'] = artist
207 ext = os.path.splitext(f.name)[1].lower()
208 fname = unicode(f.name, 'utf-8')
210 try:
211 # If the file is an mp3, let's load the EasyID3 interface
212 if ext == '.mp3':
213 audioFile = MP3(fname, ID3=EasyID3)
214 else:
215 # Otherwise, let mutagen figure it out
216 audioFile = mutagen.File(fname)
218 if audioFile:
219 # Pull the length from the FileType, if present
220 if audioFile.info.length > 0:
221 item['Duration'] = int(audioFile.info.length * 1000)
223 # Grab our other tags, if present
224 def get_tag(tagname, d):
225 for tag in ([tagname] + TAGNAMES[tagname]):
226 try:
227 if tag in d:
228 value = d[tag][0]
229 if type(value) not in [str, unicode]:
230 value = str(value)
231 return value
232 except:
233 pass
234 return ''
236 artist = get_tag('artist', audioFile)
237 title = get_tag('title', audioFile)
238 if artist == 'Various Artists' and '/' in title:
239 artist, title = [x.strip() for x in title.split('/')]
240 item['ArtistName'] = artist
241 item['SongTitle'] = title
242 item['AlbumTitle'] = get_tag('album', audioFile)
243 item['AlbumYear'] = get_tag('date', audioFile)[:4]
244 item['MusicGenre'] = get_tag('genre', audioFile)
245 except Exception, msg:
246 print msg
248 ffmpeg_path = config.get_bin('ffmpeg')
249 if 'Duration' not in item and ffmpeg_path:
250 if mswindows:
251 fname = fname.encode('cp1252')
252 cmd = [ffmpeg_path, '-i', fname]
253 ffmpeg = subprocess.Popen(cmd, stderr=subprocess.PIPE,
254 stdout=subprocess.PIPE,
255 stdin=subprocess.PIPE)
257 # wait 10 sec if ffmpeg is not back give up
258 for i in xrange(200):
259 time.sleep(.05)
260 if not ffmpeg.poll() == None:
261 break
263 if ffmpeg.poll() != None:
264 output = ffmpeg.stderr.read()
265 d = durre(output)
266 if d:
267 millisecs = ((int(d.group(1)) * 3600 +
268 int(d.group(2)) * 60 +
269 int(d.group(3))) * 1000 +
270 int(d.group(4)) *
271 (10 ** (3 - len(d.group(4)))))
272 else:
273 millisecs = 0
274 item['Duration'] = millisecs
276 if 'Duration' in item and ffmpeg_path:
277 item['params'] = 'Yes'
279 self.media_data_cache[f.name] = item
280 return item
282 subcname = query['Container'][0]
283 local_base_path = self.get_local_base_path(handler, query)
285 if not self.get_local_path(handler, query):
286 handler.send_error(404)
287 return
289 if os.path.splitext(subcname)[1].lower() in PLAYLISTS:
290 t = Template(PLAYLIST_TEMPLATE, filter=EncodeUnicode)
291 t.files, t.total, t.start = self.get_playlist(handler, query)
292 else:
293 t = Template(FOLDER_TEMPLATE, filter=EncodeUnicode)
294 t.files, t.total, t.start = self.get_files(handler, query,
295 AudioFileFilter)
296 t.files = map(media_data, t.files)
297 t.container = handler.cname
298 t.name = subcname
299 t.quote = quote
300 t.escape = escape
302 handler.send_xml(str(t))
304 def QueryItem(self, handler, query):
305 uq = urllib.unquote_plus
306 splitpath = [x for x in uq(query['Url'][0]).split('/') if x]
307 path = os.path.join(handler.container['path'], *splitpath[1:])
309 if path in self.media_data_cache:
310 t = Template(ITEM_TEMPLATE, filter=EncodeUnicode)
311 t.file = self.media_data_cache[path]
312 t.escape = escape
313 handler.send_xml(str(t))
314 else:
315 handler.send_error(404)
317 def parse_playlist(self, list_name, recurse):
319 ext = os.path.splitext(list_name)[1].lower()
321 try:
322 url = list_name.index('http://')
323 list_name = list_name[url:]
324 list_file = urllib.urlopen(list_name)
325 except:
326 list_file = open(unicode(list_name, 'utf-8'))
327 local_path = os.path.sep.join(list_name.split(os.path.sep)[:-1])
329 if ext in ('.m3u', '.pls'):
330 charset = 'cp1252'
331 else:
332 charset = 'utf-8'
334 if ext in ('.wpl', '.asx', '.wax', '.wvx', '.b4s'):
335 playlist = []
336 for line in list_file:
337 line = unicode(line, charset).encode('utf-8')
338 if ext == '.wpl':
339 s = wplfile(line)
340 elif ext == '.b4s':
341 s = b4sfile(line)
342 else:
343 s = asxfile(line)
344 if s:
345 playlist.append(FileData(s.group(1), False))
347 elif ext == '.pls':
348 names, titles, lengths = {}, {}, {}
349 for line in list_file:
350 line = unicode(line, charset).encode('utf-8')
351 s = plsfile(line)
352 if s:
353 names[s.group(1)] = s.group(2)
354 else:
355 s = plstitle(line)
356 if s:
357 titles[s.group(1)] = s.group(2)
358 else:
359 s = plslength(line)
360 if s:
361 lengths[s.group(1)] = int(s.group(2))
362 playlist = []
363 for key in names:
364 f = FileData(names[key], False)
365 if key in titles:
366 f.title = titles[key]
367 if key in lengths:
368 f.duration = lengths[key]
369 playlist.append(f)
371 else: # ext == '.m3u' or '.m3u8' or '.ram'
372 duration, title = 0, ''
373 playlist = []
374 for line in list_file:
375 line = unicode(line.strip(), charset).encode('utf-8')
376 if line:
377 if line.startswith('#EXTINF:'):
378 try:
379 duration, title = line[8:].split(',', 1)
380 duration = int(duration)
381 except ValueError:
382 duration = 0
384 elif not line.startswith('#'):
385 f = FileData(line, False)
386 f.title = title.strip()
387 f.duration = duration
388 playlist.append(f)
389 duration, title = 0, ''
391 list_file.close()
393 # Expand relative paths
394 for i in xrange(len(playlist)):
395 if not '://' in playlist[i].name:
396 name = playlist[i].name
397 if not os.path.isabs(name):
398 name = os.path.join(local_path, name)
399 playlist[i].name = os.path.normpath(name)
401 if recurse:
402 newlist = []
403 for i in playlist:
404 if i.isplay:
405 newlist.extend(self.parse_playlist(i.name, recurse))
406 else:
407 newlist.append(i)
409 playlist = newlist
411 return playlist
413 def get_files(self, handler, query, filterFunction=None):
415 class SortList:
416 def __init__(self, files):
417 self.files = files
418 self.unsorted = True
419 self.sortby = None
420 self.last_start = 0
422 def build_recursive_list(path, recurse=True):
423 files = []
424 path = unicode(path, 'utf-8')
425 try:
426 for f in os.listdir(path):
427 if f.startswith('.'):
428 continue
429 f = os.path.join(path, f)
430 isdir = os.path.isdir(f)
431 if sys.platform == 'darwin':
432 f = unicodedata.normalize('NFC', f)
433 f = f.encode('utf-8')
434 if recurse and isdir:
435 files.extend(build_recursive_list(f))
436 else:
437 fd = FileData(f, isdir)
438 if isdir or filterFunction(f, file_type):
439 files.append(fd)
440 except:
441 pass
442 return files
444 def dir_sort(x, y):
445 if x.isdir == y.isdir:
446 if x.isplay == y.isplay:
447 return name_sort(x, y)
448 else:
449 return y.isplay - x.isplay
450 else:
451 return y.isdir - x.isdir
453 def name_sort(x, y):
454 return cmp(x.name, y.name)
456 path = self.get_local_path(handler, query)
458 file_type = query.get('Filter', [''])[0]
460 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
462 filelist = []
463 rc = self.recurse_cache
464 dc = self.dir_cache
465 if recurse:
466 if path in rc:
467 filelist = rc[path]
468 else:
469 updated = os.path.getmtime(unicode(path, 'utf-8'))
470 if path in dc and dc.mtime(path) >= updated:
471 filelist = dc[path]
472 for p in rc:
473 if path.startswith(p) and rc.mtime(p) < updated:
474 del rc[p]
476 if not filelist:
477 filelist = SortList(build_recursive_list(path, recurse))
479 if recurse:
480 rc[path] = filelist
481 else:
482 dc[path] = filelist
484 # Sort it
485 seed = ''
486 start = ''
487 sortby = query.get('SortOrder', ['Normal'])[0]
488 if 'Random' in sortby:
489 if 'RandomSeed' in query:
490 seed = query['RandomSeed'][0]
491 sortby += seed
492 if 'RandomStart' in query:
493 start = query['RandomStart'][0]
494 sortby += start
496 if filelist.unsorted or filelist.sortby != sortby:
497 if 'Random' in sortby:
498 self.random_lock.acquire()
499 if seed:
500 random.seed(seed)
501 random.shuffle(filelist.files)
502 self.random_lock.release()
503 if start:
504 local_base_path = self.get_local_base_path(handler, query)
505 start = unquote(start)
506 start = start.replace(os.path.sep + handler.cname,
507 local_base_path, 1)
508 filenames = [x.name for x in filelist.files]
509 try:
510 index = filenames.index(start)
511 i = filelist.files.pop(index)
512 filelist.files.insert(0, i)
513 except ValueError:
514 handler.server.logger.warning('Start not found: ' +
515 start)
516 else:
517 filelist.files.sort(dir_sort)
519 filelist.sortby = sortby
520 filelist.unsorted = False
522 files = filelist.files[:]
524 # Trim the list
525 files, total, start = self.item_count(handler, query, handler.cname,
526 files, filelist.last_start)
527 filelist.last_start = start
528 return files, total, start
530 def get_playlist(self, handler, query):
531 subcname = query['Container'][0]
533 try:
534 url = subcname.index('http://')
535 list_name = subcname[url:]
536 except:
537 list_name = self.get_local_path(handler, query)
539 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
540 playlist = self.parse_playlist(list_name, recurse)
542 # Shuffle?
543 if 'Random' in query.get('SortOrder', ['Normal'])[0]:
544 seed = query.get('RandomSeed', [''])[0]
545 start = query.get('RandomStart', [''])[0]
547 self.random_lock.acquire()
548 if seed:
549 random.seed(seed)
550 random.shuffle(playlist)
551 self.random_lock.release()
552 if start:
553 local_base_path = self.get_local_base_path(handler, query)
554 start = unquote(start)
555 start = start.replace(os.path.sep + handler.cname,
556 local_base_path, 1)
557 filenames = [x.name for x in playlist]
558 try:
559 index = filenames.index(start)
560 i = playlist.pop(index)
561 playlist.insert(0, i)
562 except ValueError:
563 handler.server.logger.warning('Start not found: ' + start)
565 # Trim the list
566 return self.item_count(handler, query, handler.cname, playlist)