Common ffmpeg_path().
[pyTivo/TheBayer.git] / plugins / music / music.py
blob2fcadca7d90d18ada87bd3d40439ba92cc9e644b
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 urllib
11 from urlparse import urlparse
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 # 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 FOLDER_TEMPLATE = file(tfname, 'rb').read()
54 PLAYLIST_TEMPLATE = file(tpname, 'rb').read()
56 # XXX BIG HACK
57 # subprocess is broken for me on windows so super hack
58 def patchSubprocess():
59 o = subprocess.Popen._make_inheritable
61 def _make_inheritable(self, handle):
62 if not handle: return subprocess.GetCurrentProcess()
63 return o(self, handle)
65 subprocess.Popen._make_inheritable = _make_inheritable
67 mswindows = (sys.platform == "win32")
68 if mswindows:
69 patchSubprocess()
71 class FileData:
72 def __init__(self, name, isdir):
73 self.name = name
74 self.isdir = isdir
75 self.isplay = os.path.splitext(name)[1].lower() in PLAYLISTS
76 self.title = ''
77 self.duration = 0
79 class Music(Plugin):
81 CONTENT_TYPE = 'x-container/tivo-music'
83 AUDIO = 'audio'
84 DIRECTORY = 'dir'
85 PLAYLIST = 'play'
87 media_data_cache = LRUCache(300)
88 recurse_cache = LRUCache(5)
89 dir_cache = LRUCache(10)
91 def send_file(self, handler, container, name):
92 seek, duration = 0, 0
94 try:
95 path, query = handler.path.split('?')
96 except ValueError:
97 path = handler.path
98 else:
99 opts = cgi.parse_qs(query)
100 seek = int(opts.get('Seek', [0])[0])
101 duration = int(opts.get('Duration', [0])[0])
103 fname = os.path.join(os.path.normpath(container['path']),
104 unquote(path)[len(name) + 2:])
105 fname = unicode(fname, 'utf-8')
107 needs_transcode = (os.path.splitext(fname)[1].lower() in TRANSCODE
108 or seek or duration)
110 handler.send_response(200)
111 handler.send_header('Content-Type', 'audio/mpeg')
112 if not needs_transcode:
113 fsize = os.path.getsize(fname)
114 handler.send_header('Content-Length', fsize)
115 handler.send_header('Connection', 'close')
116 handler.end_headers()
118 if needs_transcode:
119 if mswindows:
120 fname = fname.encode('iso8859-1')
121 cmd = [config.ffmpeg_path(), '-i', fname, '-ab',
122 '320k', '-ar', '44100', '-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=(64 * 1024),
129 stdout=subprocess.PIPE)
130 try:
131 shutil.copyfileobj(ffmpeg.stdout, handler.wfile)
132 except:
133 kill(ffmpeg)
134 else:
135 f = open(fname, 'rb')
136 try:
137 shutil.copyfileobj(f, handler.wfile)
138 except:
139 pass
140 f.close()
142 def QueryContainer(self, handler, query):
144 def AudioFileFilter(f, filter_type=None):
145 ext = os.path.splitext(f)[1].lower()
147 if ext in ('.mp3', '.mp2') or ext in TRANSCODE:
148 return self.AUDIO
149 else:
150 file_type = False
152 if not filter_type or filter_type.split('/')[0] != self.AUDIO:
153 if ext in PLAYLISTS:
154 file_type = self.PLAYLIST
155 elif os.path.isdir(f):
156 file_type = self.DIRECTORY
158 return file_type
160 def media_data(f):
161 if f.name in self.media_data_cache:
162 return self.media_data_cache[f.name]
164 item = {}
165 item['path'] = f.name
166 item['part_path'] = f.name.replace(local_base_path, '', 1)
167 item['name'] = os.path.split(f.name)[1]
168 item['is_dir'] = f.isdir
169 item['is_playlist'] = f.isplay
170 item['params'] = 'No'
172 if f.title:
173 item['Title'] = f.title
175 if f.duration > 0:
176 item['Duration'] = f.duration
178 if f.isdir or f.isplay or '://' in f.name:
179 self.media_data_cache[f.name] = item
180 return item
182 # If the format is: (track #) Song name...
183 #artist, album, track = f.name.split(os.path.sep)[-3:]
184 #track = os.path.splitext(track)[0]
185 #if track[0].isdigit:
186 # track = ' '.join(track.split(' ')[1:])
188 #item['SongTitle'] = track
189 #item['AlbumTitle'] = album
190 #item['ArtistName'] = artist
192 ext = os.path.splitext(f.name)[1].lower()
194 try:
195 # If the file is an mp3, let's load the EasyID3 interface
196 if ext == '.mp3':
197 audioFile = MP3(f.name, ID3=EasyID3)
198 else:
199 # Otherwise, let mutagen figure it out
200 audioFile = mutagen.File(f.name)
202 # Pull the length from the FileType, if present
203 if audioFile.info.length > 0:
204 item['Duration'] = int(audioFile.info.length * 1000)
206 # Grab our other tags, if present
207 def get_tag(tagname, d):
208 for tag in ([tagname] + TAGNAMES[tagname]):
209 try:
210 if tag in d:
211 return d[tag][0]
212 except:
213 pass
214 return ''
216 artist = get_tag('artist', audioFile)
217 title = get_tag('title', audioFile)
218 if artist == 'Various Artists' and '/' in title:
219 artist, title = [x.strip() for x in title.split('/')]
220 item['ArtistName'] = artist
221 item['SongTitle'] = title
222 item['AlbumTitle'] = get_tag('album', audioFile)
223 item['AlbumYear'] = get_tag('date', audioFile)
224 item['MusicGenre'] = get_tag('genre', audioFile)
225 except Exception, msg:
226 print msg
228 if 'Duration' not in item:
229 fname = unicode(f.name, 'utf-8')
230 if mswindows:
231 fname = fname.encode('iso8859-1')
232 cmd = [config.ffmpeg_path(), '-i', fname]
233 ffmpeg = subprocess.Popen(cmd, stderr=subprocess.PIPE,
234 stdout=subprocess.PIPE,
235 stdin=subprocess.PIPE)
237 # wait 10 sec if ffmpeg is not back give up
238 for i in xrange(200):
239 time.sleep(.05)
240 if not ffmpeg.poll() == None:
241 break
243 if ffmpeg.poll() != None:
244 output = ffmpeg.stderr.read()
245 d = durre(output)
246 if d:
247 millisecs = ((int(d.group(1)) * 3600 +
248 int(d.group(2)) * 60 +
249 int(d.group(3))) * 1000 +
250 int(d.group(4)) *
251 (10 ** (3 - len(d.group(4)))))
252 else:
253 millisecs = 0
254 item['Duration'] = millisecs
256 if 'Duration' in item:
257 item['params'] = 'Yes'
259 self.media_data_cache[f.name] = item
260 return item
262 subcname = query['Container'][0]
263 cname = subcname.split('/')[0]
264 local_base_path = self.get_local_base_path(handler, query)
266 if (not cname in handler.server.containers or
267 not self.get_local_path(handler, query)):
268 handler.send_error(404)
269 return
271 if os.path.splitext(subcname)[1].lower() in PLAYLISTS:
272 t = Template(PLAYLIST_TEMPLATE, filter=EncodeUnicode)
273 t.files, t.total, t.start = self.get_playlist(handler, query)
274 else:
275 t = Template(FOLDER_TEMPLATE, filter=EncodeUnicode)
276 t.files, t.total, t.start = self.get_files(handler, query,
277 AudioFileFilter)
278 t.files = map(media_data, t.files)
279 t.container = cname
280 t.name = subcname
281 t.quote = quote
282 t.escape = escape
283 page = str(t)
285 handler.send_response(200)
286 handler.send_header('Content-Type', 'text/xml')
287 handler.send_header('Content-Length', len(page))
288 handler.send_header('Connection', 'close')
289 handler.end_headers()
290 handler.wfile.write(page)
292 def parse_playlist(self, list_name, recurse):
294 ext = os.path.splitext(list_name)[1].lower()
296 try:
297 url = list_name.index('http://')
298 list_name = list_name[url:]
299 list_file = urllib.urlopen(list_name)
300 except:
301 list_file = open(unicode(list_name, 'utf-8'))
302 local_path = os.path.sep.join(list_name.split(os.path.sep)[:-1])
304 if ext in ('.m3u', '.pls'):
305 charset = 'iso-8859-1'
306 else:
307 charset = 'utf-8'
309 if ext in ('.wpl', '.asx', '.wax', '.wvx', '.b4s'):
310 playlist = []
311 for line in list_file:
312 line = unicode(line, charset).encode('utf-8')
313 if ext == '.wpl':
314 s = wplfile(line)
315 elif ext == '.b4s':
316 s = b4sfile(line)
317 else:
318 s = asxfile(line)
319 if s:
320 playlist.append(FileData(s.group(1), False))
322 elif ext == '.pls':
323 names, titles, lengths = {}, {}, {}
324 for line in list_file:
325 line = unicode(line, charset).encode('utf-8')
326 s = plsfile(line)
327 if s:
328 names[s.group(1)] = s.group(2)
329 else:
330 s = plstitle(line)
331 if s:
332 titles[s.group(1)] = s.group(2)
333 else:
334 s = plslength(line)
335 if s:
336 lengths[s.group(1)] = int(s.group(2))
337 playlist = []
338 for key in names:
339 f = FileData(names[key], False)
340 if key in titles:
341 f.title = titles[key]
342 if key in lengths:
343 f.duration = lengths[key]
344 playlist.append(f)
346 else: # ext == '.m3u' or '.m3u8' or '.ram'
347 duration, title = 0, ''
348 playlist = []
349 for line in list_file:
350 line = unicode(line.strip(), charset).encode('utf-8')
351 if line:
352 if line.startswith('#EXTINF:'):
353 try:
354 duration, title = line[8:].split(',')
355 duration = int(duration)
356 except ValueError:
357 duration = 0
359 elif not line.startswith('#'):
360 f = FileData(line, False)
361 f.title = title.strip()
362 f.duration = duration
363 playlist.append(f)
364 duration, title = 0, ''
366 list_file.close()
368 # Expand relative paths
369 for i in xrange(len(playlist)):
370 if not '://' in playlist[i].name:
371 name = playlist[i].name
372 if not os.path.isabs(name):
373 name = os.path.join(local_path, name)
374 playlist[i].name = os.path.normpath(name)
376 if recurse:
377 newlist = []
378 for i in playlist:
379 if i.isplay:
380 newlist.extend(self.parse_playlist(i.name, recurse))
381 else:
382 newlist.append(i)
384 playlist = newlist
386 return playlist
388 def get_files(self, handler, query, filterFunction=None):
390 class SortList:
391 def __init__(self, files):
392 self.files = files
393 self.unsorted = True
394 self.sortby = None
395 self.last_start = 0
397 def build_recursive_list(path, recurse=True):
398 files = []
399 path = unicode(path, 'utf-8')
400 try:
401 for f in os.listdir(path):
402 if f.startswith('.'):
403 continue
404 f = os.path.join(path, f)
405 isdir = os.path.isdir(f)
406 f = f.encode('utf-8')
407 if recurse and isdir:
408 files.extend(build_recursive_list(f))
409 else:
410 fd = FileData(f, isdir)
411 if recurse and fd.isplay:
412 files.extend(self.parse_playlist(f, recurse))
413 elif isdir or filterFunction(f, file_type):
414 files.append(fd)
415 except:
416 pass
417 return files
419 def dir_sort(x, y):
420 if x.isdir == y.isdir:
421 if x.isplay == y.isplay:
422 return name_sort(x, y)
423 else:
424 return y.isplay - x.isplay
425 else:
426 return y.isdir - x.isdir
428 def name_sort(x, y):
429 return cmp(x.name, y.name)
431 subcname = query['Container'][0]
432 cname = subcname.split('/')[0]
433 path = self.get_local_path(handler, query)
435 file_type = query.get('Filter', [''])[0]
437 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
439 filelist = []
440 if recurse and path in self.recurse_cache:
441 if self.recurse_cache.mtime(path) + 3600 >= time.time():
442 filelist = self.recurse_cache[path]
443 elif not recurse and path in self.dir_cache:
444 if self.dir_cache.mtime(path) >= os.stat(path)[8]:
445 filelist = self.dir_cache[path]
447 if not filelist:
448 filelist = SortList(build_recursive_list(path, recurse))
450 if recurse:
451 self.recurse_cache[path] = filelist
452 else:
453 self.dir_cache[path] = filelist
455 # Sort it
456 seed = ''
457 start = ''
458 sortby = query.get('SortOrder', ['Normal'])[0]
459 if 'Random' in sortby:
460 if 'RandomSeed' in query:
461 seed = query['RandomSeed'][0]
462 sortby += seed
463 if 'RandomStart' in query:
464 start = query['RandomStart'][0]
465 sortby += start
467 if filelist.unsorted or filelist.sortby != sortby:
468 if 'Random' in sortby:
469 self.random_lock.acquire()
470 if seed:
471 random.seed(seed)
472 random.shuffle(filelist.files)
473 self.random_lock.release()
474 if start:
475 local_base_path = self.get_local_base_path(handler, query)
476 start = unquote(start)
477 start = start.replace(os.path.sep + cname,
478 local_base_path, 1)
479 filenames = [x.name for x in filelist.files]
480 try:
481 index = filenames.index(start)
482 i = filelist.files.pop(index)
483 filelist.files.insert(0, i)
484 except ValueError:
485 handler.server.logger.warning('Start not found: ' +
486 start)
487 else:
488 filelist.files.sort(dir_sort)
490 filelist.sortby = sortby
491 filelist.unsorted = False
493 files = filelist.files[:]
495 # Trim the list
496 files, total, start = self.item_count(handler, query, cname, files,
497 filelist.last_start)
498 filelist.last_start = start
499 return files, total, start
501 def get_playlist(self, handler, query):
502 subcname = query['Container'][0]
503 cname = subcname.split('/')[0]
505 try:
506 url = subcname.index('http://')
507 list_name = subcname[url:]
508 except:
509 list_name = self.get_local_path(handler, query)
511 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
512 playlist = self.parse_playlist(list_name, recurse)
514 # Shuffle?
515 if 'Random' in query.get('SortOrder', ['Normal'])[0]:
516 seed = query.get('RandomSeed', [''])[0]
517 start = query.get('RandomStart', [''])[0]
519 self.random_lock.acquire()
520 if seed:
521 random.seed(seed)
522 random.shuffle(playlist)
523 self.random_lock.release()
524 if start:
525 local_base_path = self.get_local_base_path(handler, query)
526 start = unquote(start)
527 start = start.replace(os.path.sep + cname,
528 local_base_path, 1)
529 filenames = [x.name for x in playlist]
530 try:
531 index = filenames.index(start)
532 i = playlist.pop(index)
533 playlist.insert(0, i)
534 except ValueError:
535 handler.server.logger.warning('Start not found: ' + start)
537 # Trim the list
538 return self.item_count(handler, query, cname, playlist)