Tighter AudioFileFilter(). (Extension comparison is actually all that
[pyTivo/wgw.git] / plugins / music / music.py
blob9693b08bab4dd1a1b84c6c5afbb58e74e3ede9f0
1 import subprocess, os, random, re, shutil, socket, sys, urllib, time, cgi
2 import config
3 from plugins.video.transcode import kill
4 from Cheetah.Template import Template
5 from Cheetah.Filters import Filter
6 from plugin import Plugin, quote, unquote
7 from xml.sax.saxutils import escape
8 from lrucache import LRUCache
9 from urlparse import urlparse
10 import eyeD3
12 SCRIPTDIR = os.path.dirname(__file__)
14 def ffmpeg_path():
15 return config.get('Server', 'ffmpeg')
17 CLASS_NAME = 'Music'
19 PLAYLISTS = ('.m3u', '.m3u8', '.ram', '.pls', '.b4s', '.wpl', '.asx',
20 '.wax', '.wvx')
22 TRANSCODE = ('.mp4', '.m4a', '.flc', '.ogg', '.wma', '.aac', '.wav',
23 '.aif', '.aiff', '.au', '.flac')
25 # Search strings for different playlist types
26 asxfile = re.compile('ref +href *= *"(.+)"', re.IGNORECASE).search
27 wplfile = re.compile('media +src *= *"(.+)"', re.IGNORECASE).search
28 b4sfile = re.compile('Playstring="file:(.+)"').search
29 plsfile = re.compile('[Ff]ile(\d+)=(.+)').match
30 plstitle = re.compile('[Tt]itle(\d+)=(.+)').match
31 plslength = re.compile('[Ll]ength(\d+)=(\d+)').match
33 # Duration -- parse from ffmpeg output
34 durre = re.compile(r'.*Duration: (.{2}):(.{2}):(.{2})\.(.),').search
36 # Preload the templates
37 tfname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
38 tpname = os.path.join(SCRIPTDIR, 'templates', 'm3u.tmpl')
39 folder_template = file(tfname, 'rb').read()
40 playlist_template = file(tpname, 'rb').read()
42 # XXX BIG HACK
43 # subprocess is broken for me on windows so super hack
44 def patchSubprocess():
45 o = subprocess.Popen._make_inheritable
47 def _make_inheritable(self, handle):
48 if not handle: return subprocess.GetCurrentProcess()
49 return o(self, handle)
51 subprocess.Popen._make_inheritable = _make_inheritable
53 mswindows = (sys.platform == "win32")
54 if mswindows:
55 patchSubprocess()
57 class FileData:
58 def __init__(self, name, isdir):
59 self.name = name
60 self.isdir = isdir
61 self.isplay = os.path.splitext(name)[1].lower() in PLAYLISTS
62 self.title = ''
63 self.duration = 0
65 class EncodeUnicode(Filter):
66 def filter(self, val, **kw):
67 """Encode Unicode strings, by default in UTF-8"""
69 if kw.has_key('encoding'):
70 encoding = kw['encoding']
71 else:
72 encoding='utf8'
74 if type(val) == type(u''):
75 filtered = val.encode(encoding)
76 else:
77 filtered = str(val)
78 return filtered
80 class Music(Plugin):
82 CONTENT_TYPE = 'x-container/tivo-music'
84 AUDIO = 'audio'
85 DIRECTORY = 'dir'
86 PLAYLIST = 'play'
88 media_data_cache = LRUCache(300)
89 recurse_cache = LRUCache(5)
90 dir_cache = LRUCache(10)
92 def send_file(self, handler, container, name):
93 seek, duration = 0, 0
95 try:
96 path, query = handler.path.split('?')
97 except ValueError:
98 path = handler.path
99 else:
100 opts = cgi.parse_qs(query)
101 if 'Seek' in opts:
102 seek = int(opts['Seek'][0])
103 if 'Duration' in opts:
104 seek = int(opts['Duration'][0])
106 fname = os.path.join(os.path.normpath(container['path']),
107 unquote(path)[len(name) + 2:])
108 fname = unicode(fname, 'utf-8')
110 needs_transcode = os.path.splitext(fname)[1].lower() in TRANSCODE \
111 or seek or duration
113 handler.send_response(200)
114 handler.send_header('Content-Type', 'audio/mpeg')
115 if not needs_transcode:
116 fsize = os.path.getsize(fname)
117 handler.send_header('Content-Length', fsize)
118 handler.send_header('Connection', 'close')
119 handler.end_headers()
121 if needs_transcode:
122 if mswindows:
123 fname = fname.encode('iso8859-1')
124 cmd = [ffmpeg_path(), '-i', fname, '-ab',
125 '320k', '-ar', '44100', '-f', 'mp3', '-']
126 if seek:
127 cmd[-1:] = ['-ss', '%.3f' % (seek / 1000.0), '-']
128 if duration:
129 cmd[-1:] = ['-t', '%.3f' % (duration / 1000.0), '-']
131 ffmpeg = subprocess.Popen(cmd, stdout=subprocess.PIPE)
132 try:
133 shutil.copyfileobj(ffmpeg.stdout, handler.wfile)
134 except:
135 kill(ffmpeg.pid)
136 else:
137 f = file(fname, 'rb')
138 try:
139 shutil.copyfileobj(f, handler.wfile)
140 except:
141 pass
143 def QueryContainer(self, handler, query):
145 def AudioFileFilter(f, filter_type=None):
146 ext = os.path.splitext(f)[1].lower()
148 if ext in ('.mp3', '.mp2') or ext in TRANSCODE:
149 return self.AUDIO
150 else:
151 file_type = False
153 if not filter_type or filter_type.split('/')[0] != self.AUDIO:
154 if ext in PLAYLISTS:
155 file_type = self.PLAYLIST
156 elif os.path.isdir(f):
157 file_type = self.DIRECTORY
159 return file_type
161 def media_data(f):
162 if f.name in self.media_data_cache:
163 return self.media_data_cache[f.name]
165 item = {}
166 item['path'] = f.name
167 item['part_path'] = f.name.replace(local_base_path, '', 1)
168 item['name'] = os.path.split(f.name)[1]
169 item['is_dir'] = f.isdir
170 item['is_playlist'] = f.isplay
171 item['params'] = 'No'
173 if f.title:
174 item['Title'] = f.title
176 if f.duration > 0:
177 item['Duration'] = f.duration
179 if f.isdir or f.isplay or '://' in f.name:
180 self.media_data_cache[f.name] = item
181 return item
183 if os.path.splitext(f.name)[1].lower() in TRANSCODE:
184 # If the format is: (track #) Song name...
185 #artist, album, track = f.name.split(os.path.sep)[-3:]
186 #track = os.path.splitext(track)[0]
187 #if track[0].isdigit:
188 # track = ' '.join(track.split(' ')[1:])
190 #item['SongTitle'] = track
191 #item['AlbumTitle'] = album
192 #item['ArtistName'] = artist
193 fname = unicode(f.name, 'utf-8')
194 if mswindows:
195 fname = fname.encode('iso8859-1')
196 cmd = [ffmpeg_path(), '-i', fname]
197 ffmpeg = subprocess.Popen(cmd, stderr=subprocess.PIPE,
198 stdout=subprocess.PIPE,
199 stdin=subprocess.PIPE)
201 # wait 10 sec if ffmpeg is not back give up
202 for i in xrange(200):
203 time.sleep(.05)
204 if not ffmpeg.poll() == None:
205 break
207 if ffmpeg.poll() != None:
208 output = ffmpeg.stderr.read()
209 d = durre(output)
210 if d:
211 millisecs = (int(d.group(1)) * 3600 + \
212 int(d.group(2)) * 60 + \
213 int(d.group(3))) * 1000 + \
214 int(d.group(4)) * 100
215 else:
216 millisecs = 0
217 item['Duration'] = millisecs
218 else:
219 try:
220 audioFile = eyeD3.Mp3AudioFile(unicode(f.name, 'utf-8'))
221 item['Duration'] = audioFile.getPlayTime() * 1000
223 tag = audioFile.getTag()
224 artist = tag.getArtist()
225 title = tag.getTitle()
226 if artist == 'Various Artists' and '/' in title:
227 artist, title = title.split('/')
228 item['ArtistName'] = artist.strip()
229 item['SongTitle'] = title.strip()
230 item['AlbumTitle'] = tag.getAlbum()
231 item['AlbumYear'] = tag.getYear()
232 item['MusicGenre'] = tag.getGenre().getName()
233 except Exception, msg:
234 print msg
236 if 'Duration' in item:
237 item['params'] = 'Yes'
239 self.media_data_cache[f.name] = item
240 return item
242 subcname = query['Container'][0]
243 cname = subcname.split('/')[0]
244 local_base_path = self.get_local_base_path(handler, query)
246 if not handler.server.containers.has_key(cname) or \
247 not self.get_local_path(handler, query):
248 handler.send_response(404)
249 handler.end_headers()
250 return
252 if os.path.splitext(subcname)[1].lower() in PLAYLISTS:
253 t = Template(playlist_template, filter=EncodeUnicode)
254 t.files, t.total, t.start = self.get_playlist(handler, query)
255 else:
256 t = Template(folder_template, filter=EncodeUnicode)
257 t.files, t.total, t.start = self.get_files(handler, query,
258 AudioFileFilter)
259 t.files = map(media_data, t.files)
260 t.container = cname
261 t.name = subcname
262 t.quote = quote
263 t.escape = escape
264 page = str(t)
266 handler.send_response(200)
267 handler.send_header('Content-Type', 'text/xml')
268 handler.send_header('Content-Length', len(page))
269 handler.send_header('Connection', 'close')
270 handler.end_headers()
271 handler.wfile.write(page)
273 def parse_playlist(self, list_name, recurse):
275 ext = os.path.splitext(list_name)[1].lower()
277 try:
278 url = list_name.index('http://')
279 list_name = list_name[url:]
280 list_file = urllib.urlopen(list_name)
281 except:
282 list_file = open(unicode(list_name, 'utf-8'))
283 local_path = os.path.sep.join(list_name.split(os.path.sep)[:-1])
285 if ext in ('.m3u', '.pls'):
286 charset = 'iso-8859-1'
287 else:
288 charset = 'utf-8'
290 if ext in ('.wpl', '.asx', '.wax', '.wvx', '.b4s'):
291 playlist = []
292 for line in list_file:
293 line = unicode(line, charset).encode('utf-8')
294 if ext == '.wpl':
295 s = wplfile(line)
296 elif ext == '.b4s':
297 s = b4sfile(line)
298 else:
299 s = asxfile(line)
300 if s:
301 playlist.append(FileData(s.group(1), False))
303 elif ext == '.pls':
304 names, titles, lengths = {}, {}, {}
305 for line in list_file:
306 line = unicode(line, charset).encode('utf-8')
307 s = plsfile(line)
308 if s:
309 names[s.group(1)] = s.group(2)
310 else:
311 s = plstitle(line)
312 if s:
313 titles[s.group(1)] = s.group(2)
314 else:
315 s = plslength(line)
316 if s:
317 lengths[s.group(1)] = int(s.group(2))
318 playlist = []
319 for key in names:
320 f = FileData(names[key], False)
321 if key in titles:
322 f.title = titles[key]
323 if key in lengths:
324 f.duration = lengths[key]
325 playlist.append(f)
327 else: # ext == '.m3u' or '.m3u8' or '.ram'
328 duration, title = 0, ''
329 playlist = []
330 for line in list_file:
331 line = unicode(line.strip(), charset).encode('utf-8')
332 if line:
333 if line.startswith('#EXTINF:'):
334 try:
335 duration, title = line[8:].split(',')
336 duration = int(duration)
337 except ValueError:
338 duration = 0
340 elif not line.startswith('#'):
341 f = FileData(line, False)
342 f.title = title.strip()
343 f.duration = duration
344 playlist.append(f)
345 duration, title = 0, ''
347 list_file.close()
349 # Expand relative paths
350 for i in xrange(len(playlist)):
351 if not '://' in playlist[i].name:
352 name = playlist[i].name
353 if not os.path.isabs(name):
354 name = os.path.join(local_path, name)
355 playlist[i].name = os.path.normpath(name)
357 if recurse:
358 newlist = []
359 for i in playlist:
360 if i.isplay:
361 newlist.extend(self.parse_playlist(i.name, recurse))
362 else:
363 newlist.append(i)
365 playlist = newlist
367 return playlist
369 def get_files(self, handler, query, filterFunction=None):
371 class SortList:
372 def __init__(self, files):
373 self.files = files
374 self.unsorted = True
375 self.sortby = None
376 self.last_start = 0
378 def build_recursive_list(path, recurse=True):
379 files = []
380 path = unicode(path, 'utf-8')
381 try:
382 for f in os.listdir(path):
383 if f.startswith('.'):
384 continue
385 f = os.path.join(path, f)
386 isdir = os.path.isdir(f)
387 f = f.encode('utf-8')
388 if recurse and isdir:
389 files.extend(build_recursive_list(f))
390 else:
391 fd = FileData(f, isdir)
392 if recurse and fd.isplay:
393 files.extend(self.parse_playlist(f, recurse))
394 elif isdir or filterFunction(f, file_type):
395 files.append(fd)
396 except:
397 pass
398 return files
400 def dir_sort(x, y):
401 if x.isdir == y.isdir:
402 if x.isplay == y.isplay:
403 return name_sort(x, y)
404 else:
405 return y.isplay - x.isplay
406 else:
407 return y.isdir - x.isdir
409 def name_sort(x, y):
410 return cmp(x.name, y.name)
412 subcname = query['Container'][0]
413 cname = subcname.split('/')[0]
414 path = self.get_local_path(handler, query)
416 file_type = query.get('Filter', [''])[0]
418 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
420 filelist = []
421 if recurse and path in self.recurse_cache:
422 if self.recurse_cache.mtime(path) + 3600 >= time.time():
423 filelist = self.recurse_cache[path]
424 elif not recurse and path in self.dir_cache:
425 if self.dir_cache.mtime(path) >= os.stat(path)[8]:
426 filelist = self.dir_cache[path]
428 if not filelist:
429 filelist = SortList(build_recursive_list(path, recurse))
431 if recurse:
432 self.recurse_cache[path] = filelist
433 else:
434 self.dir_cache[path] = filelist
436 # Sort it
437 seed = ''
438 start = ''
439 sortby = query.get('SortOrder', ['Normal'])[0]
440 if 'Random' in sortby:
441 if 'RandomSeed' in query:
442 seed = query['RandomSeed'][0]
443 sortby += seed
444 if 'RandomStart' in query:
445 start = query['RandomStart'][0]
446 sortby += start
448 if filelist.unsorted or filelist.sortby != sortby:
449 if 'Random' in sortby:
450 self.random_lock.acquire()
451 if seed:
452 random.seed(seed)
453 random.shuffle(filelist.files)
454 self.random_lock.release()
455 if start:
456 local_base_path = self.get_local_base_path(handler, query)
457 start = unquote(start)
458 start = start.replace(os.path.sep + cname,
459 local_base_path, 1)
460 filenames = [x.name for x in filelist.files]
461 try:
462 index = filenames.index(start)
463 i = filelist.files.pop(index)
464 filelist.files.insert(0, i)
465 except ValueError:
466 print 'Start not found:', start
467 else:
468 filelist.files.sort(dir_sort)
470 filelist.sortby = sortby
471 filelist.unsorted = False
473 files = filelist.files[:]
475 # Trim the list
476 files, total, start = self.item_count(handler, query, cname, files,
477 filelist.last_start)
478 filelist.last_start = start
479 return files, total, start
481 def get_playlist(self, handler, query):
482 subcname = query['Container'][0]
483 cname = subcname.split('/')[0]
485 try:
486 url = subcname.index('http://')
487 list_name = subcname[url:]
488 except:
489 list_name = self.get_local_path(handler, query)
491 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
492 playlist = self.parse_playlist(list_name, recurse)
494 # Shuffle?
495 if 'Random' in query.get('SortOrder', ['Normal'])[0]:
496 seed = query.get('RandomSeed', [''])[0]
497 start = query.get('RandomStart', [''])[0]
499 self.random_lock.acquire()
500 if seed:
501 random.seed(seed)
502 random.shuffle(playlist)
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 + cname,
508 local_base_path, 1)
509 filenames = [x.name for x in playlist]
510 try:
511 index = filenames.index(start)
512 i = playlist.pop(index)
513 playlist.insert(0, i)
514 except ValueError:
515 print 'Start not found:', start
517 # Trim the list
518 return self.item_count(handler, query, cname, playlist)