Using FS mtime to reload non recursive cache.
[pyTivo.git] / plugins / music / music.py
blob3847bb63915402c04a6b4ffac563f51b77d8708c
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, '-acodec', 'libmp3lame', '-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):
147 if filter_type:
148 filter_start = filter_type.split('/')[0]
149 else:
150 filter_start = filter_type
152 if os.path.isdir(f):
153 ftype = self.DIRECTORY
155 elif eyeD3.isMp3File(f):
156 ftype = self.AUDIO
157 elif os.path.splitext(f)[1].lower() in PLAYLISTS:
158 ftype = self.PLAYLIST
159 elif os.path.splitext(f)[1].lower() in TRANSCODE:
160 ftype = self.AUDIO
161 else:
162 ftype = False
164 if filter_start == self.AUDIO:
165 if ftype == self.AUDIO:
166 return ftype
167 else:
168 return False
169 else:
170 return ftype
172 def media_data(f):
173 if f.name in self.media_data_cache:
174 return self.media_data_cache[f.name]
176 item = {}
177 item['path'] = f.name
178 item['part_path'] = f.name.replace(local_base_path, '', 1)
179 item['name'] = os.path.split(f.name)[1]
180 item['is_dir'] = f.isdir
181 item['is_playlist'] = f.isplay
182 item['params'] = 'No'
184 if f.title:
185 item['Title'] = f.title
187 if f.duration > 0:
188 item['Duration'] = f.duration
190 if f.isdir or f.isplay or '://' in f.name:
191 self.media_data_cache[f.name] = item
192 return item
194 if os.path.splitext(f.name)[1].lower() in TRANSCODE:
195 # If the format is: (track #) Song name...
196 #artist, album, track = f.name.split(os.path.sep)[-3:]
197 #track = os.path.splitext(track)[0]
198 #if track[0].isdigit:
199 # track = ' '.join(track.split(' ')[1:])
201 #item['SongTitle'] = track
202 #item['AlbumTitle'] = album
203 #item['ArtistName'] = artist
204 fname = unicode(f.name, 'utf-8')
205 if mswindows:
206 fname = fname.encode('iso8859-1')
207 cmd = [ffmpeg_path(), '-i', fname]
208 ffmpeg = subprocess.Popen(cmd, stderr=subprocess.PIPE,
209 stdout=subprocess.PIPE,
210 stdin=subprocess.PIPE)
212 # wait 10 sec if ffmpeg is not back give up
213 for i in xrange(200):
214 time.sleep(.05)
215 if not ffmpeg.poll() == None:
216 break
218 if ffmpeg.poll() != None:
219 output = ffmpeg.stderr.read()
220 d = durre(output)
221 if d:
222 millisecs = (int(d.group(1)) * 3600 + \
223 int(d.group(2)) * 60 + \
224 int(d.group(3))) * 1000 + \
225 int(d.group(4)) * 100
226 else:
227 millisecs = 0
228 item['Duration'] = millisecs
229 else:
230 try:
231 audioFile = eyeD3.Mp3AudioFile(unicode(f.name, 'utf-8'))
232 item['Duration'] = audioFile.getPlayTime() * 1000
234 tag = audioFile.getTag()
235 artist = tag.getArtist()
236 title = tag.getTitle()
237 if artist == 'Various Artists' and '/' in title:
238 artist, title = title.split('/')
239 item['ArtistName'] = artist.strip()
240 item['SongTitle'] = title.strip()
241 item['AlbumTitle'] = tag.getAlbum()
242 item['AlbumYear'] = tag.getYear()
243 item['MusicGenre'] = tag.getGenre().getName()
244 except Exception, msg:
245 print msg
247 if 'Duration' in item:
248 item['params'] = 'Yes'
250 self.media_data_cache[f.name] = item
251 return item
253 subcname = query['Container'][0]
254 cname = subcname.split('/')[0]
255 local_base_path = self.get_local_base_path(handler, query)
257 if not handler.server.containers.has_key(cname) or \
258 not self.get_local_path(handler, query):
259 handler.send_response(404)
260 handler.end_headers()
261 return
263 if os.path.splitext(subcname)[1].lower() in PLAYLISTS:
264 t = Template(playlist_template, filter=EncodeUnicode)
265 t.files, t.total, t.start = self.get_playlist(handler, query)
266 else:
267 t = Template(folder_template, filter=EncodeUnicode)
268 t.files, t.total, t.start = self.get_files(handler, query,
269 AudioFileFilter)
270 t.files = map(media_data, t.files)
271 t.container = cname
272 t.name = subcname
273 t.quote = quote
274 t.escape = escape
275 page = str(t)
277 handler.send_response(200)
278 handler.send_header('Content-Type', 'text/xml')
279 handler.send_header('Content-Length', len(page))
280 handler.send_header('Connection', 'close')
281 handler.end_headers()
282 handler.wfile.write(page)
284 def parse_playlist(self, list_name, recurse):
286 ext = os.path.splitext(list_name)[1].lower()
288 try:
289 url = list_name.index('http://')
290 list_name = list_name[url:]
291 list_file = urllib.urlopen(list_name)
292 except:
293 list_file = open(unicode(list_name, 'utf-8'))
294 local_path = os.path.sep.join(list_name.split(os.path.sep)[:-1])
296 if ext in ('.m3u', '.pls'):
297 charset = 'iso-8859-1'
298 else:
299 charset = 'utf-8'
301 if ext in ('.wpl', '.asx', '.wax', '.wvx', '.b4s'):
302 playlist = []
303 for line in list_file:
304 line = unicode(line, charset).encode('utf-8')
305 if ext == '.wpl':
306 s = wplfile(line)
307 elif ext == '.b4s':
308 s = b4sfile(line)
309 else:
310 s = asxfile(line)
311 if s:
312 playlist.append(FileData(s.group(1), False))
314 elif ext == '.pls':
315 names, titles, lengths = {}, {}, {}
316 for line in list_file:
317 line = unicode(line, charset).encode('utf-8')
318 s = plsfile(line)
319 if s:
320 names[s.group(1)] = s.group(2)
321 else:
322 s = plstitle(line)
323 if s:
324 titles[s.group(1)] = s.group(2)
325 else:
326 s = plslength(line)
327 if s:
328 lengths[s.group(1)] = int(s.group(2))
329 playlist = []
330 for key in names:
331 f = FileData(names[key], False)
332 if key in titles:
333 f.title = titles[key]
334 if key in lengths:
335 f.duration = lengths[key]
336 playlist.append(f)
338 else: # ext == '.m3u' or '.m3u8' or '.ram'
339 duration, title = 0, ''
340 playlist = []
341 for line in list_file:
342 line = unicode(line.strip(), charset).encode('utf-8')
343 if line:
344 if line.startswith('#EXTINF:'):
345 try:
346 duration, title = line[8:].split(',')
347 duration = int(duration)
348 except ValueError:
349 duration = 0
351 elif not line.startswith('#'):
352 f = FileData(line, False)
353 f.title = title.strip()
354 f.duration = duration
355 playlist.append(f)
356 duration, title = 0, ''
358 list_file.close()
360 # Expand relative paths
361 for i in xrange(len(playlist)):
362 if not '://' in playlist[i].name:
363 name = playlist[i].name
364 if not os.path.isabs(name):
365 name = os.path.join(local_path, name)
366 playlist[i].name = os.path.normpath(name)
368 if recurse:
369 newlist = []
370 for i in playlist:
371 if i.isplay:
372 newlist.extend(self.parse_playlist(i.name, recurse))
373 else:
374 newlist.append(i)
376 playlist = newlist
378 return playlist
380 def get_files(self, handler, query, filterFunction=None):
382 class SortList:
383 def __init__(self, files):
384 self.files = files
385 self.unsorted = True
386 self.sortby = None
387 self.last_start = 0
389 def build_recursive_list(path, recurse=True):
390 files = []
391 path = unicode(path, 'utf-8')
392 try:
393 for f in os.listdir(path):
394 f = os.path.join(path, f)
395 isdir = os.path.isdir(f)
396 f = f.encode('utf-8')
397 if recurse and isdir:
398 files.extend(build_recursive_list(f))
399 else:
400 fd = FileData(f, isdir)
401 if recurse and fd.isplay:
402 files.extend(self.parse_playlist(f, recurse))
403 elif isdir or filterFunction(f, file_type):
404 files.append(fd)
405 except:
406 pass
407 return files
409 def dir_sort(x, y):
410 if x.isdir == y.isdir:
411 if x.isplay == y.isplay:
412 return name_sort(x, y)
413 else:
414 return y.isplay - x.isplay
415 else:
416 return y.isdir - x.isdir
418 def name_sort(x, y):
419 return cmp(x.name, y.name)
421 subcname = query['Container'][0]
422 cname = subcname.split('/')[0]
423 path = self.get_local_path(handler, query)
425 file_type = query.get('Filter', [''])[0]
427 recurse = query.get('Recurse',['No'])[0] == 'Yes'
429 filelist = []
430 if recurse and path in self.recurse_cache:
431 if self.dir_cache.mtime(path) + 3600 >= time.time():
432 filelist = self.recurse_cache[path]
433 elif not recurse and path in self.dir_cache:
434 if self.dir_cache.mtime(path) >= os.stat(path)[8]:
435 filelist = self.dir_cache[path]
437 if not filelist:
438 filelist = SortList(build_recursive_list(path, recurse))
440 if recurse:
441 self.recurse_cache[path] = filelist
442 else:
443 self.dir_cache[path] = filelist
445 # Sort it
446 seed = ''
447 start = ''
448 sortby = query.get('SortOrder', ['Normal'])[0]
449 if 'Random' in sortby:
450 if 'RandomSeed' in query:
451 seed = query['RandomSeed'][0]
452 sortby += seed
453 if 'RandomStart' in query:
454 start = query['RandomStart'][0]
455 sortby += start
457 if filelist.unsorted or filelist.sortby != sortby:
458 if 'Random' in sortby:
459 self.random_lock.acquire()
460 if seed:
461 random.seed(seed)
462 random.shuffle(filelist.files)
463 self.random_lock.release()
464 if start:
465 local_base_path = self.get_local_base_path(handler, query)
466 start = unquote(start)
467 start = start.replace(os.path.sep + cname,
468 local_base_path, 1)
469 filenames = [x.name for x in filelist.files]
470 try:
471 index = filenames.index(start)
472 i = filelist.files.pop(index)
473 filelist.files.insert(0, i)
474 except ValueError:
475 print 'Start not found:', start
476 else:
477 filelist.files.sort(dir_sort)
479 filelist.sortby = sortby
480 filelist.unsorted = False
482 files = filelist.files[:]
484 # Trim the list
485 files, total, start = self.item_count(handler, query, cname, files,
486 filelist.last_start)
487 filelist.last_start = start
488 return files, total, start
490 def get_playlist(self, handler, query):
491 subcname = query['Container'][0]
492 cname = subcname.split('/')[0]
494 try:
495 url = subcname.index('http://')
496 list_name = subcname[url:]
497 except:
498 list_name = self.get_local_path(handler, query)
500 recurse = query.get('Recurse',['No'])[0] == 'Yes'
501 playlist = self.parse_playlist(list_name, recurse)
503 # Trim the list
504 return self.item_count(handler, query, cname, playlist)