Parse the iTunMOVI tag (cast, etc. from MP4).
[pyTivo/TheBayer.git] / plugins / music / music.py
blobe4a2f16011da72a3e7e3e0b088b0cfeed584b665
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 xml.sax.saxutils import escape
13 import mutagen
14 from mutagen.easyid3 import EasyID3
15 from mutagen.mp3 import MP3
16 from Cheetah.Template import Template
17 from lrucache import LRUCache
18 import config
19 from plugin import EncodeUnicode, Plugin, quote, unquote
20 from plugins.video.transcode import kill
22 SCRIPTDIR = os.path.dirname(__file__)
24 CLASS_NAME = 'Music'
26 PLAYLISTS = ('.m3u', '.m3u8', '.ram', '.pls', '.b4s', '.wpl', '.asx',
27 '.wax', '.wvx')
29 TRANSCODE = ('.mp4', '.m4a', '.flc', '.ogg', '.wma', '.aac', '.wav',
30 '.aif', '.aiff', '.au', '.flac')
32 TAGNAMES = {'artist': ['\xa9ART', 'Author'],
33 'title': ['\xa9nam', 'Title'],
34 'album': ['\xa9alb', u'WM/AlbumTitle'],
35 'date': ['\xa9day', u'WM/Year'],
36 'genre': ['\xa9gen', u'WM/Genre']}
38 # Search strings for different playlist types
39 asxfile = re.compile('ref +href *= *"(.+)"', re.IGNORECASE).search
40 wplfile = re.compile('media +src *= *"(.+)"', re.IGNORECASE).search
41 b4sfile = re.compile('Playstring="file:(.+)"').search
42 plsfile = re.compile('[Ff]ile(\d+)=(.+)').match
43 plstitle = re.compile('[Tt]itle(\d+)=(.+)').match
44 plslength = re.compile('[Ll]ength(\d+)=(\d+)').match
46 # Duration -- parse from ffmpeg output
47 durre = re.compile(r'.*Duration: ([0-9]+):([0-9]+):([0-9]+)\.([0-9]+),').search
49 # Preload the templates
50 tfname = os.path.join(SCRIPTDIR, 'templates', 'container.tmpl')
51 tpname = os.path.join(SCRIPTDIR, 'templates', 'm3u.tmpl')
52 FOLDER_TEMPLATE = file(tfname, 'rb').read()
53 PLAYLIST_TEMPLATE = file(tpname, 'rb').read()
55 # XXX BIG HACK
56 # subprocess is broken for me on windows so super hack
57 def patchSubprocess():
58 o = subprocess.Popen._make_inheritable
60 def _make_inheritable(self, handle):
61 if not handle: return subprocess.GetCurrentProcess()
62 return o(self, handle)
64 subprocess.Popen._make_inheritable = _make_inheritable
66 mswindows = (sys.platform == "win32")
67 if mswindows:
68 patchSubprocess()
70 class FileData:
71 def __init__(self, name, isdir):
72 self.name = name
73 self.isdir = isdir
74 self.isplay = os.path.splitext(name)[1].lower() in PLAYLISTS
75 self.title = ''
76 self.duration = 0
78 class Music(Plugin):
80 CONTENT_TYPE = 'x-container/tivo-music'
82 AUDIO = 'audio'
83 DIRECTORY = 'dir'
84 PLAYLIST = 'play'
86 media_data_cache = LRUCache(300)
87 recurse_cache = LRUCache(5)
88 dir_cache = LRUCache(10)
90 def send_file(self, handler, path, query):
91 seek = int(query.get('Seek', [0])[0])
92 duration = int(query.get('Duration', [0])[0])
94 fname = unicode(path, 'utf-8')
96 ext = os.path.splitext(fname)[1].lower()
97 needs_transcode = ext in TRANSCODE or seek or duration
99 handler.send_response(200)
100 handler.send_header('Content-Type', 'audio/mpeg')
101 if not needs_transcode:
102 fsize = os.path.getsize(fname)
103 handler.send_header('Content-Length', fsize)
104 handler.send_header('Connection', 'close')
105 handler.end_headers()
107 if needs_transcode:
108 if mswindows:
109 fname = fname.encode('iso8859-1')
111 cmd = [config.get_bin('ffmpeg'), '-i', fname]
112 if ext in ['.mp3', '.mp2']:
113 cmd += ['-acodec', 'copy']
114 else:
115 cmd += ['-ab', '320k', '-ar', '44100']
116 cmd += ['-f', 'mp3', '-']
117 if seek:
118 cmd[-1:] = ['-ss', '%.3f' % (seek / 1000.0), '-']
119 if duration:
120 cmd[-1:] = ['-t', '%.3f' % (duration / 1000.0), '-']
122 ffmpeg = subprocess.Popen(cmd, bufsize=(64 * 1024),
123 stdout=subprocess.PIPE)
124 try:
125 shutil.copyfileobj(ffmpeg.stdout, handler.wfile)
126 except:
127 kill(ffmpeg)
128 else:
129 f = open(fname, 'rb')
130 try:
131 shutil.copyfileobj(f, handler.wfile)
132 except:
133 pass
134 f.close()
136 def QueryContainer(self, handler, query):
138 def AudioFileFilter(f, filter_type=None):
139 ext = os.path.splitext(f)[1].lower()
141 if ext in ('.mp3', '.mp2') or (ext in TRANSCODE and
142 config.get_bin('ffmpeg')):
143 return self.AUDIO
144 else:
145 file_type = False
147 if not filter_type or filter_type.split('/')[0] != self.AUDIO:
148 if ext in PLAYLISTS:
149 file_type = self.PLAYLIST
150 elif os.path.isdir(f):
151 file_type = self.DIRECTORY
153 return file_type
155 def media_data(f):
156 if f.name in self.media_data_cache:
157 return self.media_data_cache[f.name]
159 item = {}
160 item['path'] = f.name
161 item['part_path'] = f.name.replace(local_base_path, '', 1)
162 item['name'] = os.path.split(f.name)[1]
163 item['is_dir'] = f.isdir
164 item['is_playlist'] = f.isplay
165 item['params'] = 'No'
167 if f.title:
168 item['Title'] = f.title
170 if f.duration > 0:
171 item['Duration'] = f.duration
173 if f.isdir or f.isplay or '://' in f.name:
174 self.media_data_cache[f.name] = item
175 return item
177 # If the format is: (track #) Song name...
178 #artist, album, track = f.name.split(os.path.sep)[-3:]
179 #track = os.path.splitext(track)[0]
180 #if track[0].isdigit:
181 # track = ' '.join(track.split(' ')[1:])
183 #item['SongTitle'] = track
184 #item['AlbumTitle'] = album
185 #item['ArtistName'] = artist
187 ext = os.path.splitext(f.name)[1].lower()
188 fname = unicode(f.name, 'utf-8')
190 try:
191 # If the file is an mp3, let's load the EasyID3 interface
192 if ext == '.mp3':
193 audioFile = MP3(fname, ID3=EasyID3)
194 else:
195 # Otherwise, let mutagen figure it out
196 audioFile = mutagen.File(fname)
198 # Pull the length from the FileType, if present
199 if audioFile.info.length > 0:
200 item['Duration'] = int(audioFile.info.length * 1000)
202 # Grab our other tags, if present
203 def get_tag(tagname, d):
204 for tag in ([tagname] + TAGNAMES[tagname]):
205 try:
206 if tag in d:
207 return d[tag][0]
208 except:
209 pass
210 return ''
212 artist = get_tag('artist', audioFile)
213 title = get_tag('title', audioFile)
214 if artist == 'Various Artists' and '/' in title:
215 artist, title = [x.strip() for x in title.split('/')]
216 item['ArtistName'] = artist
217 item['SongTitle'] = title
218 item['AlbumTitle'] = get_tag('album', audioFile)
219 item['AlbumYear'] = get_tag('date', audioFile)
220 item['MusicGenre'] = get_tag('genre', audioFile)
221 except Exception, msg:
222 print msg
224 ffmpeg_path = config.get_bin('ffmpeg')
225 if 'Duration' not in item and ffmpeg_path:
226 if mswindows:
227 fname = fname.encode('iso8859-1')
228 cmd = [ffmpeg_path, '-i', fname]
229 ffmpeg = subprocess.Popen(cmd, stderr=subprocess.PIPE,
230 stdout=subprocess.PIPE,
231 stdin=subprocess.PIPE)
233 # wait 10 sec if ffmpeg is not back give up
234 for i in xrange(200):
235 time.sleep(.05)
236 if not ffmpeg.poll() == None:
237 break
239 if ffmpeg.poll() != None:
240 output = ffmpeg.stderr.read()
241 d = durre(output)
242 if d:
243 millisecs = ((int(d.group(1)) * 3600 +
244 int(d.group(2)) * 60 +
245 int(d.group(3))) * 1000 +
246 int(d.group(4)) *
247 (10 ** (3 - len(d.group(4)))))
248 else:
249 millisecs = 0
250 item['Duration'] = millisecs
252 if 'Duration' in item and ffmpeg_path:
253 item['params'] = 'Yes'
255 self.media_data_cache[f.name] = item
256 return item
258 subcname = query['Container'][0]
259 cname = subcname.split('/')[0]
260 local_base_path = self.get_local_base_path(handler, query)
262 if (not cname in handler.server.containers or
263 not self.get_local_path(handler, query)):
264 handler.send_error(404)
265 return
267 if os.path.splitext(subcname)[1].lower() in PLAYLISTS:
268 t = Template(PLAYLIST_TEMPLATE, filter=EncodeUnicode)
269 t.files, t.total, t.start = self.get_playlist(handler, query)
270 else:
271 t = Template(FOLDER_TEMPLATE, filter=EncodeUnicode)
272 t.files, t.total, t.start = self.get_files(handler, query,
273 AudioFileFilter)
274 t.files = map(media_data, t.files)
275 t.container = cname
276 t.name = subcname
277 t.quote = quote
278 t.escape = escape
279 page = str(t)
281 handler.send_response(200)
282 handler.send_header('Content-Type', 'text/xml')
283 handler.send_header('Content-Length', len(page))
284 handler.send_header('Connection', 'close')
285 handler.end_headers()
286 handler.wfile.write(page)
288 def parse_playlist(self, list_name, recurse):
290 ext = os.path.splitext(list_name)[1].lower()
292 try:
293 url = list_name.index('http://')
294 list_name = list_name[url:]
295 list_file = urllib.urlopen(list_name)
296 except:
297 list_file = open(unicode(list_name, 'utf-8'))
298 local_path = os.path.sep.join(list_name.split(os.path.sep)[:-1])
300 if ext in ('.m3u', '.pls'):
301 charset = 'iso-8859-1'
302 else:
303 charset = 'utf-8'
305 if ext in ('.wpl', '.asx', '.wax', '.wvx', '.b4s'):
306 playlist = []
307 for line in list_file:
308 line = unicode(line, charset).encode('utf-8')
309 if ext == '.wpl':
310 s = wplfile(line)
311 elif ext == '.b4s':
312 s = b4sfile(line)
313 else:
314 s = asxfile(line)
315 if s:
316 playlist.append(FileData(s.group(1), False))
318 elif ext == '.pls':
319 names, titles, lengths = {}, {}, {}
320 for line in list_file:
321 line = unicode(line, charset).encode('utf-8')
322 s = plsfile(line)
323 if s:
324 names[s.group(1)] = s.group(2)
325 else:
326 s = plstitle(line)
327 if s:
328 titles[s.group(1)] = s.group(2)
329 else:
330 s = plslength(line)
331 if s:
332 lengths[s.group(1)] = int(s.group(2))
333 playlist = []
334 for key in names:
335 f = FileData(names[key], False)
336 if key in titles:
337 f.title = titles[key]
338 if key in lengths:
339 f.duration = lengths[key]
340 playlist.append(f)
342 else: # ext == '.m3u' or '.m3u8' or '.ram'
343 duration, title = 0, ''
344 playlist = []
345 for line in list_file:
346 line = unicode(line.strip(), charset).encode('utf-8')
347 if line:
348 if line.startswith('#EXTINF:'):
349 try:
350 duration, title = line[8:].split(',')
351 duration = int(duration)
352 except ValueError:
353 duration = 0
355 elif not line.startswith('#'):
356 f = FileData(line, False)
357 f.title = title.strip()
358 f.duration = duration
359 playlist.append(f)
360 duration, title = 0, ''
362 list_file.close()
364 # Expand relative paths
365 for i in xrange(len(playlist)):
366 if not '://' in playlist[i].name:
367 name = playlist[i].name
368 if not os.path.isabs(name):
369 name = os.path.join(local_path, name)
370 playlist[i].name = os.path.normpath(name)
372 if recurse:
373 newlist = []
374 for i in playlist:
375 if i.isplay:
376 newlist.extend(self.parse_playlist(i.name, recurse))
377 else:
378 newlist.append(i)
380 playlist = newlist
382 return playlist
384 def get_files(self, handler, query, filterFunction=None):
386 class SortList:
387 def __init__(self, files):
388 self.files = files
389 self.unsorted = True
390 self.sortby = None
391 self.last_start = 0
393 def build_recursive_list(path, recurse=True):
394 files = []
395 path = unicode(path, 'utf-8')
396 try:
397 for f in os.listdir(path):
398 if f.startswith('.'):
399 continue
400 f = os.path.join(path, f)
401 isdir = os.path.isdir(f)
402 f = f.encode('utf-8')
403 if recurse and isdir:
404 files.extend(build_recursive_list(f))
405 else:
406 fd = FileData(f, isdir)
407 if recurse and fd.isplay:
408 files.extend(self.parse_playlist(f, recurse))
409 elif isdir or filterFunction(f, file_type):
410 files.append(fd)
411 except:
412 pass
413 return files
415 def dir_sort(x, y):
416 if x.isdir == y.isdir:
417 if x.isplay == y.isplay:
418 return name_sort(x, y)
419 else:
420 return y.isplay - x.isplay
421 else:
422 return y.isdir - x.isdir
424 def name_sort(x, y):
425 return cmp(x.name, y.name)
427 subcname = query['Container'][0]
428 cname = subcname.split('/')[0]
429 path = self.get_local_path(handler, query)
431 file_type = query.get('Filter', [''])[0]
433 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
435 filelist = []
436 rc = self.recurse_cache
437 dc = self.dir_cache
438 if recurse:
439 if path in rc:
440 filelist = rc[path]
441 else:
442 updated = os.stat(unicode(path, 'utf-8'))[8]
443 if path in dc and dc.mtime(path) >= updated:
444 filelist = dc[path]
445 for p in rc:
446 if path.startswith(p) and rc.mtime(p) < updated:
447 del rc[p]
449 if not filelist:
450 filelist = SortList(build_recursive_list(path, recurse))
452 if recurse:
453 rc[path] = filelist
454 else:
455 dc[path] = filelist
457 # Sort it
458 seed = ''
459 start = ''
460 sortby = query.get('SortOrder', ['Normal'])[0]
461 if 'Random' in sortby:
462 if 'RandomSeed' in query:
463 seed = query['RandomSeed'][0]
464 sortby += seed
465 if 'RandomStart' in query:
466 start = query['RandomStart'][0]
467 sortby += start
469 if filelist.unsorted or filelist.sortby != sortby:
470 if 'Random' in sortby:
471 self.random_lock.acquire()
472 if seed:
473 random.seed(seed)
474 random.shuffle(filelist.files)
475 self.random_lock.release()
476 if start:
477 local_base_path = self.get_local_base_path(handler, query)
478 start = unquote(start)
479 start = start.replace(os.path.sep + cname,
480 local_base_path, 1)
481 filenames = [x.name for x in filelist.files]
482 try:
483 index = filenames.index(start)
484 i = filelist.files.pop(index)
485 filelist.files.insert(0, i)
486 except ValueError:
487 handler.server.logger.warning('Start not found: ' +
488 start)
489 else:
490 filelist.files.sort(dir_sort)
492 filelist.sortby = sortby
493 filelist.unsorted = False
495 files = filelist.files[:]
497 # Trim the list
498 files, total, start = self.item_count(handler, query, cname, files,
499 filelist.last_start)
500 filelist.last_start = start
501 return files, total, start
503 def get_playlist(self, handler, query):
504 subcname = query['Container'][0]
505 cname = subcname.split('/')[0]
507 try:
508 url = subcname.index('http://')
509 list_name = subcname[url:]
510 except:
511 list_name = self.get_local_path(handler, query)
513 recurse = query.get('Recurse', ['No'])[0] == 'Yes'
514 playlist = self.parse_playlist(list_name, recurse)
516 # Shuffle?
517 if 'Random' in query.get('SortOrder', ['Normal'])[0]:
518 seed = query.get('RandomSeed', [''])[0]
519 start = query.get('RandomStart', [''])[0]
521 self.random_lock.acquire()
522 if seed:
523 random.seed(seed)
524 random.shuffle(playlist)
525 self.random_lock.release()
526 if start:
527 local_base_path = self.get_local_base_path(handler, query)
528 start = unquote(start)
529 start = start.replace(os.path.sep + cname,
530 local_base_path, 1)
531 filenames = [x.name for x in playlist]
532 try:
533 index = filenames.index(start)
534 i = playlist.pop(index)
535 playlist.insert(0, i)
536 except ValueError:
537 handler.server.logger.warning('Start not found: ' + start)
539 # Trim the list
540 return self.item_count(handler, query, cname, playlist)