import kill().
[pyTivo.git] / plugins / music / music.py
blob74b5eaee021429791ec50dca3c936e2143db907a
1 import subprocess, os, random, re, shutil, socket, sys, urllib, time
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 o = urlparse("http://fake.host" + handler.path)
93 path = unquote(o[2])
94 fname = container['path'] + path[len(name) + 1:]
95 fname = unicode(fname, 'utf-8')
96 needs_transcode = os.path.splitext(fname)[1].lower() in TRANSCODE
97 handler.send_response(200)
98 handler.send_header('Content-Type', 'audio/mpeg')
99 if not needs_transcode:
100 fsize = os.path.getsize(fname)
101 handler.send_header('Content-Length', fsize)
102 handler.send_header('Connection', 'close')
103 handler.end_headers()
104 if needs_transcode:
105 cmd = [FFMPEG, '-i', fname, '-acodec', 'libmp3lame', '-ab',
106 '320k', '-ar', '44100', '-f', 'mp3', '-']
107 ffmpeg = subprocess.Popen(cmd, stdout=subprocess.PIPE)
108 try:
109 shutil.copyfileobj(ffmpeg.stdout, handler.wfile)
110 except:
111 kill(ffmpeg.pid)
112 else:
113 f = file(fname, 'rb')
114 shutil.copyfileobj(f, handler.wfile)
116 def QueryContainer(self, handler, query):
118 def AudioFileFilter(f, filter_type=None):
120 if filter_type:
121 filter_start = filter_type.split('/')[0]
122 else:
123 filter_start = filter_type
125 if os.path.isdir(f):
126 ftype = self.DIRECTORY
128 elif eyeD3.isMp3File(f):
129 ftype = self.AUDIO
130 elif os.path.splitext(f)[1].lower() in PLAYLISTS:
131 ftype = self.PLAYLIST
132 elif os.path.splitext(f)[1].lower() in TRANSCODE:
133 ftype = self.AUDIO
134 else:
135 ftype = False
137 if filter_start == self.AUDIO:
138 if ftype == self.AUDIO:
139 return ftype
140 else:
141 return False
142 else:
143 return ftype
145 def media_data(f):
146 if f.name in self.media_data_cache:
147 return self.media_data_cache[f.name]
149 item = {}
150 item['path'] = f.name
151 item['part_path'] = f.name.replace(local_base_path, '', 1)
152 item['name'] = os.path.split(f.name)[1]
153 item['is_dir'] = f.isdir
154 item['is_playlist'] = f.isplay
156 if f.title:
157 item['Title'] = f.title
159 if f.duration > 0:
160 item['Duration'] = f.duration
162 if f.isdir or f.isplay or '://' in f.name:
163 self.media_data_cache[f.name] = item
164 return item
166 if os.path.splitext(f.name)[1].lower() in TRANSCODE:
167 # If the format is: (track #) Song name...
168 #artist, album, track = f.name.split(os.path.sep)[-3:]
169 #track = os.path.splitext(track)[0]
170 #if track[0].isdigit:
171 # track = ' '.join(track.split(' ')[1:])
173 #item['SongTitle'] = track
174 #item['AlbumTitle'] = album
175 #item['ArtistName'] = artist
176 cmd = [FFMPEG, '-i', f.name]
177 ffmpeg = subprocess.Popen(cmd, stderr=subprocess.PIPE,
178 stdout=subprocess.PIPE,
179 stdin=subprocess.PIPE)
181 # wait 10 sec if ffmpeg is not back give up
182 for i in xrange(200):
183 time.sleep(.05)
184 if not ffmpeg.poll() == None:
185 break
187 if ffmpeg.poll() != None:
188 output = ffmpeg.stderr.read()
189 d = durre(output)
190 if d:
191 millisecs = (int(d.group(1)) * 3600 + \
192 int(d.group(2)) * 60 + \
193 int(d.group(3))) * 1000 + \
194 int(d.group(4)) * 100
195 else:
196 millisecs = 0
197 item['Duration'] = millisecs
199 self.media_data_cache[f.name] = item
200 return item
202 try:
203 audioFile = eyeD3.Mp3AudioFile(unicode(f.name, 'utf-8'))
204 item['Duration'] = audioFile.getPlayTime() * 1000
206 tag = audioFile.getTag()
207 artist = tag.getArtist()
208 title = tag.getTitle()
209 if artist == 'Various Artists' and '/' in title:
210 artist, title = title.split('/')
211 item['ArtistName'] = artist.strip()
212 item['SongTitle'] = title.strip()
213 item['AlbumTitle'] = tag.getAlbum()
214 item['AlbumYear'] = tag.getYear()
215 item['MusicGenre'] = tag.getGenre().getName()
216 except Exception, msg:
217 print msg
219 self.media_data_cache[f.name] = item
220 return item
222 subcname = query['Container'][0]
223 cname = subcname.split('/')[0]
224 local_base_path = self.get_local_base_path(handler, query)
226 if not handler.server.containers.has_key(cname) or \
227 not self.get_local_path(handler, query):
228 handler.send_response(404)
229 handler.end_headers()
230 return
232 if os.path.splitext(subcname)[1].lower() in PLAYLISTS:
233 t = Template(playlist_template, filter=EncodeUnicode)
234 t.files, t.total, t.start = self.get_playlist(handler, query)
235 else:
236 t = Template(folder_template, filter=EncodeUnicode)
237 t.files, t.total, t.start = self.get_files(handler, query,
238 AudioFileFilter)
239 t.files = map(media_data, t.files)
240 t.container = cname
241 t.name = subcname
242 t.quote = quote
243 t.escape = escape
244 page = str(t)
246 handler.send_response(200)
247 handler.send_header('Content-Type', 'text/xml')
248 handler.send_header('Content-Length', len(page))
249 handler.send_header('Connection', 'close')
250 handler.end_headers()
251 handler.wfile.write(page)
253 def parse_playlist(self, list_name, recurse):
255 ext = os.path.splitext(list_name)[1].lower()
257 try:
258 url = list_name.index('http://')
259 list_name = list_name[url:]
260 list_file = urllib.urlopen(list_name)
261 except:
262 list_file = open(unicode(list_name, 'utf-8'))
263 local_path = os.path.sep.join(list_name.split(os.path.sep)[:-1])
265 if ext in ('.m3u', '.pls'):
266 charset = 'iso-8859-1'
267 else:
268 charset = 'utf-8'
270 if ext in ('.wpl', '.asx', '.wax', '.wvx', '.b4s'):
271 playlist = []
272 for line in list_file:
273 line = unicode(line, charset).encode('utf-8')
274 if ext == '.wpl':
275 s = wplfile(line)
276 elif ext == '.b4s':
277 s = b4sfile(line)
278 else:
279 s = asxfile(line)
280 if s:
281 playlist.append(FileData(s.group(1), False))
283 elif ext == '.pls':
284 names, titles, lengths = {}, {}, {}
285 for line in list_file:
286 line = unicode(line, charset).encode('utf-8')
287 s = plsfile(line)
288 if s:
289 names[s.group(1)] = s.group(2)
290 else:
291 s = plstitle(line)
292 if s:
293 titles[s.group(1)] = s.group(2)
294 else:
295 s = plslength(line)
296 if s:
297 lengths[s.group(1)] = int(s.group(2))
298 playlist = []
299 for key in names:
300 f = FileData(names[key], False)
301 if key in titles:
302 f.title = titles[key]
303 if key in lengths:
304 f.duration = lengths[key]
305 playlist.append(f)
307 else: # ext == '.m3u' or '.m3u8' or '.ram'
308 duration, title = 0, ''
309 playlist = []
310 for line in list_file:
311 line = unicode(line.strip(), charset).encode('utf-8')
312 if line:
313 if line.startswith('#EXTINF:'):
314 try:
315 duration, title = line[8:].split(',')
316 duration = int(duration)
317 except ValueError:
318 duration = 0
320 elif not line.startswith('#'):
321 f = FileData(line, False)
322 f.title = title.strip()
323 f.duration = duration
324 playlist.append(f)
325 duration, title = 0, ''
327 list_file.close()
329 # Expand relative paths
330 for i in xrange(len(playlist)):
331 if not '://' in playlist[i].name:
332 name = playlist[i].name
333 if not os.path.isabs(name):
334 name = os.path.join(local_path, name)
335 playlist[i].name = os.path.normpath(name)
337 if recurse:
338 newlist = []
339 for i in playlist:
340 if i.isplay:
341 newlist.extend(self.parse_playlist(i.name, recurse))
342 else:
343 newlist.append(i)
345 playlist = newlist
347 return playlist
349 def get_files(self, handler, query, filterFunction=None):
351 class SortList:
352 def __init__(self, files):
353 self.files = files
354 self.unsorted = True
355 self.sortby = None
356 self.last_start = 0
358 def build_recursive_list(path, recurse=True):
359 files = []
360 path = unicode(path, 'utf-8')
361 for f in os.listdir(path):
362 f = os.path.join(path, f)
363 isdir = os.path.isdir(f)
364 f = f.encode('utf-8')
365 if recurse and isdir:
366 files.extend(build_recursive_list(f))
367 else:
368 fd = FileData(f, isdir)
369 if recurse and fd.isplay:
370 files.extend(self.parse_playlist(f, recurse))
371 elif isdir or filterFunction(f, file_type):
372 files.append(fd)
373 return files
375 def dir_sort(x, y):
376 if x.isdir == y.isdir:
377 if x.isplay == y.isplay:
378 return name_sort(x, y)
379 else:
380 return y.isplay - x.isplay
381 else:
382 return y.isdir - x.isdir
384 def name_sort(x, y):
385 return cmp(x.name, y.name)
387 subcname = query['Container'][0]
388 cname = subcname.split('/')[0]
389 path = self.get_local_path(handler, query)
391 file_type = query.get('Filter', [''])[0]
393 recurse = query.get('Recurse',['No'])[0] == 'Yes'
395 if recurse and path in self.recurse_cache:
396 filelist = self.recurse_cache[path]
397 elif not recurse and path in self.dir_cache:
398 filelist = self.dir_cache[path]
399 else:
400 filelist = SortList(build_recursive_list(path, recurse))
402 if recurse:
403 self.recurse_cache[path] = filelist
404 else:
405 self.dir_cache[path] = filelist
407 # Sort it
408 seed = ''
409 start = ''
410 sortby = query.get('SortOrder', ['Normal'])[0]
411 if 'Random' in sortby:
412 if 'RandomSeed' in query:
413 seed = query['RandomSeed'][0]
414 sortby += seed
415 if 'RandomStart' in query:
416 start = query['RandomStart'][0]
417 sortby += start
419 if filelist.unsorted or filelist.sortby != sortby:
420 if 'Random' in sortby:
421 self.random_lock.acquire()
422 if seed:
423 random.seed(seed)
424 random.shuffle(filelist.files)
425 self.random_lock.release()
426 if start:
427 local_base_path = self.get_local_base_path(handler, query)
428 start = unquote(start)
429 start = start.replace(os.path.sep + cname,
430 local_base_path, 1)
431 filenames = [x.name for x in filelist.files]
432 try:
433 index = filenames.index(start)
434 i = filelist.files.pop(index)
435 filelist.files.insert(0, i)
436 except ValueError:
437 print 'Start not found:', start
438 else:
439 filelist.files.sort(dir_sort)
441 filelist.sortby = sortby
442 filelist.unsorted = False
444 files = filelist.files[:]
446 # Trim the list
447 files, total, start = self.item_count(handler, query, cname, files,
448 filelist.last_start)
449 filelist.last_start = start
450 return files, total, start
452 def get_playlist(self, handler, query):
453 subcname = query['Container'][0]
454 cname = subcname.split('/')[0]
456 try:
457 url = subcname.index('http://')
458 list_name = subcname[url:]
459 except:
460 list_name = self.get_local_path(handler, query)
462 recurse = query.get('Recurse',['No'])[0] == 'Yes'
463 playlist = self.parse_playlist(list_name, recurse)
465 # Trim the list
466 return self.item_count(handler, query, cname, playlist)