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