Allow for a variable number of digits after the decimal point in
[pyTivo/wgw.git] / plugins / music / music.py
bloba2caeabe8b29f5de6b03b5b6b8bfb791347cffbe
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: ([0-9]+):([0-9]+):([0-9]+)\.([0-9]+),').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 encoding = kw.get('encoding', 'utf8')
71 if type(val) == type(u''):
72 filtered = val.encode(encoding)
73 else:
74 filtered = str(val)
75 return filtered
77 class Music(Plugin):
79 CONTENT_TYPE = 'x-container/tivo-music'
81 AUDIO = 'audio'
82 DIRECTORY = 'dir'
83 PLAYLIST = 'play'
85 media_data_cache = LRUCache(300)
86 recurse_cache = LRUCache(5)
87 dir_cache = LRUCache(10)
89 def send_file(self, handler, container, name):
90 seek, duration = 0, 0
92 try:
93 path, query = handler.path.split('?')
94 except ValueError:
95 path = handler.path
96 else:
97 opts = cgi.parse_qs(query)
98 seek = int(opts.get('Seek', [0])[0])
99 duration = int(opts.get('Duration', [0])[0])
101 fname = os.path.join(os.path.normpath(container['path']),
102 unquote(path)[len(name) + 2:])
103 fname = unicode(fname, 'utf-8')
105 needs_transcode = os.path.splitext(fname)[1].lower() in TRANSCODE \
106 or seek or duration
108 handler.send_response(200)
109 handler.send_header('Content-Type', 'audio/mpeg')
110 if not needs_transcode:
111 fsize = os.path.getsize(fname)
112 handler.send_header('Content-Length', fsize)
113 handler.send_header('Connection', 'close')
114 handler.end_headers()
116 if needs_transcode:
117 if mswindows:
118 fname = fname.encode('iso8859-1')
119 cmd = [ffmpeg_path(), '-i', fname, '-ab',
120 '320k', '-ar', '44100', '-f', 'mp3', '-']
121 if seek:
122 cmd[-1:] = ['-ss', '%.3f' % (seek / 1000.0), '-']
123 if duration:
124 cmd[-1:] = ['-t', '%.3f' % (duration / 1000.0), '-']
126 ffmpeg = subprocess.Popen(cmd, stdout=subprocess.PIPE)
127 try:
128 shutil.copyfileobj(ffmpeg.stdout, handler.wfile)
129 except:
130 kill(ffmpeg.pid)
131 else:
132 f = file(fname, 'rb')
133 try:
134 shutil.copyfileobj(f, handler.wfile)
135 except:
136 pass
138 def QueryContainer(self, handler, query):
140 def AudioFileFilter(f, filter_type=None):
141 ext = os.path.splitext(f)[1].lower()
143 if ext in ('.mp3', '.mp2') or ext in TRANSCODE:
144 return self.AUDIO
145 else:
146 file_type = False
148 if not filter_type or filter_type.split('/')[0] != self.AUDIO:
149 if ext in PLAYLISTS:
150 file_type = self.PLAYLIST
151 elif os.path.isdir(f):
152 file_type = self.DIRECTORY
154 return file_type
156 def media_data(f):
157 if f.name in self.media_data_cache:
158 return self.media_data_cache[f.name]
160 item = {}
161 item['path'] = f.name
162 item['part_path'] = f.name.replace(local_base_path, '', 1)
163 item['name'] = os.path.split(f.name)[1]
164 item['is_dir'] = f.isdir
165 item['is_playlist'] = f.isplay
166 item['params'] = 'No'
168 if f.title:
169 item['Title'] = f.title
171 if f.duration > 0:
172 item['Duration'] = f.duration
174 if f.isdir or f.isplay or '://' in f.name:
175 self.media_data_cache[f.name] = item
176 return item
178 if os.path.splitext(f.name)[1].lower() in TRANSCODE:
179 # If the format is: (track #) Song name...
180 #artist, album, track = f.name.split(os.path.sep)[-3:]
181 #track = os.path.splitext(track)[0]
182 #if track[0].isdigit:
183 # track = ' '.join(track.split(' ')[1:])
185 #item['SongTitle'] = track
186 #item['AlbumTitle'] = album
187 #item['ArtistName'] = artist
188 fname = unicode(f.name, 'utf-8')
189 if mswindows:
190 fname = fname.encode('iso8859-1')
191 cmd = [ffmpeg_path(), '-i', fname]
192 ffmpeg = subprocess.Popen(cmd, stderr=subprocess.PIPE,
193 stdout=subprocess.PIPE,
194 stdin=subprocess.PIPE)
196 # wait 10 sec if ffmpeg is not back give up
197 for i in xrange(200):
198 time.sleep(.05)
199 if not ffmpeg.poll() == None:
200 break
202 if ffmpeg.poll() != None:
203 output = ffmpeg.stderr.read()
204 d = durre(output)
205 if d:
206 millisecs = ((int(d.group(1)) * 3600 +
207 int(d.group(2)) * 60 +
208 int(d.group(3))) * 1000 +
209 int(d.group(4)) *
210 (10 ** (3 - len(d.group(4)))))
211 else:
212 millisecs = 0
213 item['Duration'] = millisecs
214 else:
215 try:
216 audioFile = eyeD3.Mp3AudioFile(unicode(f.name, 'utf-8'))
217 item['Duration'] = audioFile.getPlayTime() * 1000
219 tag = audioFile.getTag()
220 artist = tag.getArtist()
221 title = tag.getTitle()
222 if artist == 'Various Artists' and '/' in title:
223 artist, title = title.split('/')
224 item['ArtistName'] = artist.strip()
225 item['SongTitle'] = title.strip()
226 item['AlbumTitle'] = tag.getAlbum()
227 item['AlbumYear'] = tag.getYear()
228 item['MusicGenre'] = tag.getGenre().getName()
229 except Exception, msg:
230 print msg
232 if 'Duration' in item:
233 item['params'] = 'Yes'
235 self.media_data_cache[f.name] = item
236 return item
238 subcname = query['Container'][0]
239 cname = subcname.split('/')[0]
240 local_base_path = self.get_local_base_path(handler, query)
242 if not handler.server.containers.has_key(cname) or \
243 not self.get_local_path(handler, query):
244 handler.send_response(404)
245 handler.end_headers()
246 return
248 if os.path.splitext(subcname)[1].lower() in PLAYLISTS:
249 t = Template(PLAYLIST_TEMPLATE, filter=EncodeUnicode)
250 t.files, t.total, t.start = self.get_playlist(handler, query)
251 else:
252 t = Template(FOLDER_TEMPLATE, filter=EncodeUnicode)
253 t.files, t.total, t.start = self.get_files(handler, query,
254 AudioFileFilter)
255 t.files = map(media_data, t.files)
256 t.container = cname
257 t.name = subcname
258 t.quote = quote
259 t.escape = escape
260 page = str(t)
262 handler.send_response(200)
263 handler.send_header('Content-Type', 'text/xml')
264 handler.send_header('Content-Length', len(page))
265 handler.send_header('Connection', 'close')
266 handler.end_headers()
267 handler.wfile.write(page)
269 def parse_playlist(self, list_name, recurse):
271 ext = os.path.splitext(list_name)[1].lower()
273 try:
274 url = list_name.index('http://')
275 list_name = list_name[url:]
276 list_file = urllib.urlopen(list_name)
277 except:
278 list_file = open(unicode(list_name, 'utf-8'))
279 local_path = os.path.sep.join(list_name.split(os.path.sep)[:-1])
281 if ext in ('.m3u', '.pls'):
282 charset = 'iso-8859-1'
283 else:
284 charset = 'utf-8'
286 if ext in ('.wpl', '.asx', '.wax', '.wvx', '.b4s'):
287 playlist = []
288 for line in list_file:
289 line = unicode(line, charset).encode('utf-8')
290 if ext == '.wpl':
291 s = wplfile(line)
292 elif ext == '.b4s':
293 s = b4sfile(line)
294 else:
295 s = asxfile(line)
296 if s:
297 playlist.append(FileData(s.group(1), False))
299 elif ext == '.pls':
300 names, titles, lengths = {}, {}, {}
301 for line in list_file:
302 line = unicode(line, charset).encode('utf-8')
303 s = plsfile(line)
304 if s:
305 names[s.group(1)] = s.group(2)
306 else:
307 s = plstitle(line)
308 if s:
309 titles[s.group(1)] = s.group(2)
310 else:
311 s = plslength(line)
312 if s:
313 lengths[s.group(1)] = int(s.group(2))
314 playlist = []
315 for key in names:
316 f = FileData(names[key], False)
317 if key in titles:
318 f.title = titles[key]
319 if key in lengths:
320 f.duration = lengths[key]
321 playlist.append(f)
323 else: # ext == '.m3u' or '.m3u8' or '.ram'
324 duration, title = 0, ''
325 playlist = []
326 for line in list_file:
327 line = unicode(line.strip(), charset).encode('utf-8')
328 if line:
329 if line.startswith('#EXTINF:'):
330 try:
331 duration, title = line[8:].split(',')
332 duration = int(duration)
333 except ValueError:
334 duration = 0
336 elif not line.startswith('#'):
337 f = FileData(line, False)
338 f.title = title.strip()
339 f.duration = duration
340 playlist.append(f)
341 duration, title = 0, ''
343 list_file.close()
345 # Expand relative paths
346 for i in xrange(len(playlist)):
347 if not '://' in playlist[i].name:
348 name = playlist[i].name
349 if not os.path.isabs(name):
350 name = os.path.join(local_path, name)
351 playlist[i].name = os.path.normpath(name)
353 if recurse:
354 newlist = []
355 for i in playlist:
356 if i.isplay:
357 newlist.extend(self.parse_playlist(i.name, recurse))
358 else:
359 newlist.append(i)
361 playlist = newlist
363 return playlist
365 def get_files(self, handler, query, filterFunction=None):
367 class SortList:
368 def __init__(self, files):
369 self.files = files
370 self.unsorted = True
371 self.sortby = None
372 self.last_start = 0
374 def build_recursive_list(path, recurse=True):
375 files = []
376 path = unicode(path, 'utf-8')
377 try:
378 for f in os.listdir(path):
379 if f.startswith('.'):
380 continue
381 f = os.path.join(path, f)
382 isdir = os.path.isdir(f)
383 f = f.encode('utf-8')
384 if recurse and isdir:
385 files.extend(build_recursive_list(f))
386 else:
387 fd = FileData(f, isdir)
388 if recurse and fd.isplay:
389 files.extend(self.parse_playlist(f, recurse))
390 elif isdir or filterFunction(f, file_type):
391 files.append(fd)
392 except:
393 pass
394 return files
396 def dir_sort(x, y):
397 if x.isdir == y.isdir:
398 if x.isplay == y.isplay:
399 return name_sort(x, y)
400 else:
401 return y.isplay - x.isplay
402 else:
403 return y.isdir - x.isdir
405 def name_sort(x, y):
406 return cmp(x.name, y.name)
408 subcname = query['Container'][0]
409 cname = subcname.split('/')[0]
410 path = self.get_local_path(handler, query)
412 file_type = query.get('Filter', [''])[0]
414 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
416 filelist = []
417 if recurse and path in self.recurse_cache:
418 if self.recurse_cache.mtime(path) + 3600 >= time.time():
419 filelist = self.recurse_cache[path]
420 elif not recurse and path in self.dir_cache:
421 if self.dir_cache.mtime(path) >= os.stat(path)[8]:
422 filelist = self.dir_cache[path]
424 if not filelist:
425 filelist = SortList(build_recursive_list(path, recurse))
427 if recurse:
428 self.recurse_cache[path] = filelist
429 else:
430 self.dir_cache[path] = filelist
432 # Sort it
433 seed = ''
434 start = ''
435 sortby = query.get('SortOrder', ['Normal'])[0]
436 if 'Random' in sortby:
437 if 'RandomSeed' in query:
438 seed = query['RandomSeed'][0]
439 sortby += seed
440 if 'RandomStart' in query:
441 start = query['RandomStart'][0]
442 sortby += start
444 if filelist.unsorted or filelist.sortby != sortby:
445 if 'Random' in sortby:
446 self.random_lock.acquire()
447 if seed:
448 random.seed(seed)
449 random.shuffle(filelist.files)
450 self.random_lock.release()
451 if start:
452 local_base_path = self.get_local_base_path(handler, query)
453 start = unquote(start)
454 start = start.replace(os.path.sep + cname,
455 local_base_path, 1)
456 filenames = [x.name for x in filelist.files]
457 try:
458 index = filenames.index(start)
459 i = filelist.files.pop(index)
460 filelist.files.insert(0, i)
461 except ValueError:
462 print 'Start not found:', start
463 else:
464 filelist.files.sort(dir_sort)
466 filelist.sortby = sortby
467 filelist.unsorted = False
469 files = filelist.files[:]
471 # Trim the list
472 files, total, start = self.item_count(handler, query, cname, files,
473 filelist.last_start)
474 filelist.last_start = start
475 return files, total, start
477 def get_playlist(self, handler, query):
478 subcname = query['Container'][0]
479 cname = subcname.split('/')[0]
481 try:
482 url = subcname.index('http://')
483 list_name = subcname[url:]
484 except:
485 list_name = self.get_local_path(handler, query)
487 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
488 playlist = self.parse_playlist(list_name, recurse)
490 # Shuffle?
491 if 'Random' in query.get('SortOrder', ['Normal'])[0]:
492 seed = query.get('RandomSeed', [''])[0]
493 start = query.get('RandomStart', [''])[0]
495 self.random_lock.acquire()
496 if seed:
497 random.seed(seed)
498 random.shuffle(playlist)
499 self.random_lock.release()
500 if start:
501 local_base_path = self.get_local_base_path(handler, query)
502 start = unquote(start)
503 start = start.replace(os.path.sep + cname,
504 local_base_path, 1)
505 filenames = [x.name for x in playlist]
506 try:
507 index = filenames.index(start)
508 i = playlist.pop(index)
509 playlist.insert(0, i)
510 except ValueError:
511 print 'Start not found:', start
513 # Trim the list
514 return self.item_count(handler, query, cname, playlist)