Damnit, how did this line get removed?
[pyTivo/wgw.git] / plugins / music / music.py
blobda9c1a4866e17796603133bc7df5211642a41a4b
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 FFMPEG = config.get('Server', 'ffmpeg')
16 CLASS_NAME = 'Music'
18 PLAYLISTS = ('.m3u', '.m3u8', '.ram', '.pls', '.b4s', '.wpl', '.asx',
19 '.wax', '.wvx')
21 TRANSCODE = ('.mp4', '.m4a', '.flc', '.ogg', '.wma', '.aac', '.wav',
22 '.aif', '.aiff', '.au')
24 # Search strings for different playlist types
25 asxfile = re.compile('ref +href *= *"(.+)"', re.IGNORECASE).search
26 wplfile = re.compile('media +src *= *"(.+)"', re.IGNORECASE).search
27 b4sfile = re.compile('Playstring="file:(.+)"').search
28 plsfile = re.compile('[Ff]ile(\d+)=(.+)').match
29 plstitle = re.compile('[Tt]itle(\d+)=(.+)').match
30 plslength = re.compile('[Ll]ength(\d+)=(\d+)').match
32 # Duration -- parse from ffmpeg output
33 durre = re.compile(r'.*Duration: (.{2}):(.{2}):(.{2})\.(.),').search
35 # Preload the templates
36 tfname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
37 tpname = os.path.join(SCRIPTDIR, 'templates', 'm3u.tmpl')
38 folder_template = file(tfname, 'rb').read()
39 playlist_template = file(tpname, 'rb').read()
41 # XXX BIG HACK
42 # subprocess is broken for me on windows so super hack
43 def patchSubprocess():
44 o = subprocess.Popen._make_inheritable
46 def _make_inheritable(self, handle):
47 if not handle: return subprocess.GetCurrentProcess()
48 return o(self, handle)
50 subprocess.Popen._make_inheritable = _make_inheritable
52 mswindows = (sys.platform == "win32")
53 if mswindows:
54 patchSubprocess()
56 class FileData:
57 def __init__(self, name, isdir):
58 self.name = name
59 self.isdir = isdir
60 self.isplay = os.path.splitext(name)[1].lower() in PLAYLISTS
61 self.title = ''
62 self.duration = 0
64 class EncodeUnicode(Filter):
65 def filter(self, val, **kw):
66 """Encode Unicode strings, by default in UTF-8"""
68 if kw.has_key('encoding'):
69 encoding = kw['encoding']
70 else:
71 encoding='utf8'
73 if type(val) == type(u''):
74 filtered = val.encode(encoding)
75 else:
76 filtered = str(val)
77 return filtered
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 if 'Seek' in opts:
101 seek = int(opts['Seek'][0])
102 if 'Duration' in opts:
103 seek = int(opts['Duration'][0])
105 fname = os.path.join(os.path.normpath(container['path']),
106 unquote(path)[len(name) + 2:])
107 fname = unicode(fname, 'utf-8')
109 needs_transcode = os.path.splitext(fname)[1].lower() in TRANSCODE \
110 or seek or duration
112 handler.send_response(200)
113 handler.send_header('Content-Type', 'audio/mpeg')
114 if not needs_transcode:
115 fsize = os.path.getsize(fname)
116 handler.send_header('Content-Length', fsize)
117 handler.send_header('Connection', 'close')
118 handler.end_headers()
120 if needs_transcode:
121 if mswindows:
122 fname = fname.encode('iso8859-1')
123 cmd = [FFMPEG, '-i', fname, '-acodec', 'libmp3lame', '-ab',
124 '320k', '-ar', '44100', '-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, stdout=subprocess.PIPE)
131 try:
132 shutil.copyfileobj(ffmpeg.stdout, handler.wfile)
133 except:
134 kill(ffmpeg.pid)
135 else:
136 f = file(fname, 'rb')
137 try:
138 shutil.copyfileobj(f, handler.wfile)
139 except:
140 pass
142 def QueryContainer(self, handler, query):
144 def AudioFileFilter(f, filter_type=None):
146 if filter_type:
147 filter_start = filter_type.split('/')[0]
148 else:
149 filter_start = filter_type
151 if os.path.isdir(f):
152 ftype = self.DIRECTORY
154 elif eyeD3.isMp3File(f):
155 ftype = self.AUDIO
156 elif os.path.splitext(f)[1].lower() in PLAYLISTS:
157 ftype = self.PLAYLIST
158 elif os.path.splitext(f)[1].lower() in TRANSCODE:
159 ftype = self.AUDIO
160 else:
161 ftype = False
163 if filter_start == self.AUDIO:
164 if ftype == self.AUDIO:
165 return ftype
166 else:
167 return False
168 else:
169 return ftype
171 def media_data(f):
172 if f.name in self.media_data_cache:
173 return self.media_data_cache[f.name]
175 item = {}
176 item['path'] = f.name
177 item['part_path'] = f.name.replace(local_base_path, '', 1)
178 item['name'] = os.path.split(f.name)[1]
179 item['is_dir'] = f.isdir
180 item['is_playlist'] = f.isplay
181 item['params'] = 'No'
183 if f.title:
184 item['Title'] = f.title
186 if f.duration > 0:
187 item['Duration'] = f.duration
189 if f.isdir or f.isplay or '://' in f.name:
190 self.media_data_cache[f.name] = item
191 return item
193 if os.path.splitext(f.name)[1].lower() in TRANSCODE:
194 # If the format is: (track #) Song name...
195 #artist, album, track = f.name.split(os.path.sep)[-3:]
196 #track = os.path.splitext(track)[0]
197 #if track[0].isdigit:
198 # track = ' '.join(track.split(' ')[1:])
200 #item['SongTitle'] = track
201 #item['AlbumTitle'] = album
202 #item['ArtistName'] = artist
203 fname = unicode(f.name, 'utf-8')
204 if mswindows:
205 fname = fname.encode('iso8859-1')
206 cmd = [FFMPEG, '-i', fname]
207 ffmpeg = subprocess.Popen(cmd, stderr=subprocess.PIPE,
208 stdout=subprocess.PIPE,
209 stdin=subprocess.PIPE)
211 # wait 10 sec if ffmpeg is not back give up
212 for i in xrange(200):
213 time.sleep(.05)
214 if not ffmpeg.poll() == None:
215 break
217 if ffmpeg.poll() != None:
218 output = ffmpeg.stderr.read()
219 d = durre(output)
220 if d:
221 millisecs = (int(d.group(1)) * 3600 + \
222 int(d.group(2)) * 60 + \
223 int(d.group(3))) * 1000 + \
224 int(d.group(4)) * 100
225 else:
226 millisecs = 0
227 item['Duration'] = millisecs
228 else:
229 try:
230 audioFile = eyeD3.Mp3AudioFile(unicode(f.name, 'utf-8'))
231 item['Duration'] = audioFile.getPlayTime() * 1000
233 tag = audioFile.getTag()
234 artist = tag.getArtist()
235 title = tag.getTitle()
236 if artist == 'Various Artists' and '/' in title:
237 artist, title = title.split('/')
238 item['ArtistName'] = artist.strip()
239 item['SongTitle'] = title.strip()
240 item['AlbumTitle'] = tag.getAlbum()
241 item['AlbumYear'] = tag.getYear()
242 item['MusicGenre'] = tag.getGenre().getName()
243 except Exception, msg:
244 print msg
246 if 'Duration' in item:
247 item['params'] = 'Yes'
249 self.media_data_cache[f.name] = item
250 return item
252 subcname = query['Container'][0]
253 cname = subcname.split('/')[0]
254 local_base_path = self.get_local_base_path(handler, query)
256 if not handler.server.containers.has_key(cname) or \
257 not self.get_local_path(handler, query):
258 handler.send_response(404)
259 handler.end_headers()
260 return
262 if os.path.splitext(subcname)[1].lower() in PLAYLISTS:
263 t = Template(playlist_template, filter=EncodeUnicode)
264 t.files, t.total, t.start = self.get_playlist(handler, query)
265 else:
266 t = Template(folder_template, filter=EncodeUnicode)
267 t.files, t.total, t.start = self.get_files(handler, query,
268 AudioFileFilter)
269 t.files = map(media_data, t.files)
270 t.container = cname
271 t.name = subcname
272 t.quote = quote
273 t.escape = escape
274 page = str(t)
276 handler.send_response(200)
277 handler.send_header('Content-Type', 'text/xml')
278 handler.send_header('Content-Length', len(page))
279 handler.send_header('Connection', 'close')
280 handler.end_headers()
281 handler.wfile.write(page)
283 def parse_playlist(self, list_name, recurse):
285 ext = os.path.splitext(list_name)[1].lower()
287 try:
288 url = list_name.index('http://')
289 list_name = list_name[url:]
290 list_file = urllib.urlopen(list_name)
291 except:
292 list_file = open(unicode(list_name, 'utf-8'))
293 local_path = os.path.sep.join(list_name.split(os.path.sep)[:-1])
295 if ext in ('.m3u', '.pls'):
296 charset = 'iso-8859-1'
297 else:
298 charset = 'utf-8'
300 if ext in ('.wpl', '.asx', '.wax', '.wvx', '.b4s'):
301 playlist = []
302 for line in list_file:
303 line = unicode(line, charset).encode('utf-8')
304 if ext == '.wpl':
305 s = wplfile(line)
306 elif ext == '.b4s':
307 s = b4sfile(line)
308 else:
309 s = asxfile(line)
310 if s:
311 playlist.append(FileData(s.group(1), False))
313 elif ext == '.pls':
314 names, titles, lengths = {}, {}, {}
315 for line in list_file:
316 line = unicode(line, charset).encode('utf-8')
317 s = plsfile(line)
318 if s:
319 names[s.group(1)] = s.group(2)
320 else:
321 s = plstitle(line)
322 if s:
323 titles[s.group(1)] = s.group(2)
324 else:
325 s = plslength(line)
326 if s:
327 lengths[s.group(1)] = int(s.group(2))
328 playlist = []
329 for key in names:
330 f = FileData(names[key], False)
331 if key in titles:
332 f.title = titles[key]
333 if key in lengths:
334 f.duration = lengths[key]
335 playlist.append(f)
337 else: # ext == '.m3u' or '.m3u8' or '.ram'
338 duration, title = 0, ''
339 playlist = []
340 for line in list_file:
341 line = unicode(line.strip(), charset).encode('utf-8')
342 if line:
343 if line.startswith('#EXTINF:'):
344 try:
345 duration, title = line[8:].split(',')
346 duration = int(duration)
347 except ValueError:
348 duration = 0
350 elif not line.startswith('#'):
351 f = FileData(line, False)
352 f.title = title.strip()
353 f.duration = duration
354 playlist.append(f)
355 duration, title = 0, ''
357 list_file.close()
359 # Expand relative paths
360 for i in xrange(len(playlist)):
361 if not '://' in playlist[i].name:
362 name = playlist[i].name
363 if not os.path.isabs(name):
364 name = os.path.join(local_path, name)
365 playlist[i].name = os.path.normpath(name)
367 if recurse:
368 newlist = []
369 for i in playlist:
370 if i.isplay:
371 newlist.extend(self.parse_playlist(i.name, recurse))
372 else:
373 newlist.append(i)
375 playlist = newlist
377 return playlist
379 def get_files(self, handler, query, filterFunction=None):
381 class SortList:
382 def __init__(self, files):
383 self.files = files
384 self.unsorted = True
385 self.sortby = None
386 self.last_start = 0
388 def build_recursive_list(path, recurse=True):
389 files = []
390 path = unicode(path, 'utf-8')
391 for f in os.listdir(path):
392 f = os.path.join(path, f)
393 isdir = os.path.isdir(f)
394 f = f.encode('utf-8')
395 if recurse and isdir:
396 files.extend(build_recursive_list(f))
397 else:
398 fd = FileData(f, isdir)
399 if recurse and fd.isplay:
400 files.extend(self.parse_playlist(f, recurse))
401 elif isdir or filterFunction(f, file_type):
402 files.append(fd)
403 return files
405 def dir_sort(x, y):
406 if x.isdir == y.isdir:
407 if x.isplay == y.isplay:
408 return name_sort(x, y)
409 else:
410 return y.isplay - x.isplay
411 else:
412 return y.isdir - x.isdir
414 def name_sort(x, y):
415 return cmp(x.name, y.name)
417 subcname = query['Container'][0]
418 cname = subcname.split('/')[0]
419 path = self.get_local_path(handler, query)
421 file_type = query.get('Filter', [''])[0]
423 recurse = query.get('Recurse',['No'])[0] == 'Yes'
425 if recurse and path in self.recurse_cache:
426 filelist = self.recurse_cache[path]
427 elif not recurse and path in self.dir_cache:
428 filelist = self.dir_cache[path]
429 else:
430 filelist = SortList(build_recursive_list(path, recurse))
432 if recurse:
433 self.recurse_cache[path] = filelist
434 else:
435 self.dir_cache[path] = filelist
437 # Sort it
438 seed = ''
439 start = ''
440 sortby = query.get('SortOrder', ['Normal'])[0]
441 if 'Random' in sortby:
442 if 'RandomSeed' in query:
443 seed = query['RandomSeed'][0]
444 sortby += seed
445 if 'RandomStart' in query:
446 start = query['RandomStart'][0]
447 sortby += start
449 if filelist.unsorted or filelist.sortby != sortby:
450 if 'Random' in sortby:
451 self.random_lock.acquire()
452 if seed:
453 random.seed(seed)
454 random.shuffle(filelist.files)
455 self.random_lock.release()
456 if start:
457 local_base_path = self.get_local_base_path(handler, query)
458 start = unquote(start)
459 start = start.replace(os.path.sep + cname,
460 local_base_path, 1)
461 filenames = [x.name for x in filelist.files]
462 try:
463 index = filenames.index(start)
464 i = filelist.files.pop(index)
465 filelist.files.insert(0, i)
466 except ValueError:
467 print 'Start not found:', start
468 else:
469 filelist.files.sort(dir_sort)
471 filelist.sortby = sortby
472 filelist.unsorted = False
474 files = filelist.files[:]
476 # Trim the list
477 files, total, start = self.item_count(handler, query, cname, files,
478 filelist.last_start)
479 filelist.last_start = start
480 return files, total, start
482 def get_playlist(self, handler, query):
483 subcname = query['Container'][0]
484 cname = subcname.split('/')[0]
486 try:
487 url = subcname.index('http://')
488 list_name = subcname[url:]
489 except:
490 list_name = self.get_local_path(handler, query)
492 recurse = query.get('Recurse',['No'])[0] == 'Yes'
493 playlist = self.parse_playlist(list_name, recurse)
495 # Trim the list
496 return self.item_count(handler, query, cname, playlist)