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