Add playlist functionality to the music module -- version 0.9 (first git version...
[pyTivo/wgw.git] / plugins / music / music.py
blobf6477998cc74046505d8193dda89bab8a3e78b97
1 import os, random, re, shutil, socket, sys, urllib
2 from Cheetah.Template import Template
3 from Cheetah.Filters import Filter
4 from plugin import Plugin
5 from xml.sax.saxutils import escape
6 from lrucache import LRUCache
7 from urlparse import urlparse
8 import eyeD3
10 SCRIPTDIR = os.path.dirname(__file__)
12 CLASS_NAME = 'Music'
14 PLAYLISTS = ('.m3u', '.ram', '.pls', '.b4s', '.wpl', '.asx', '.wax', '.wvx')
16 # Search strings for different playlist types
17 asxfile = re.compile('ref +href *= *"(.+)"', re.IGNORECASE).search
18 wplfile = re.compile('media +src *= *"(.+)"', re.IGNORECASE).search
19 b4sfile = re.compile('Playstring="file:(.+)"').search
20 plsfile = re.compile('[Ff]ile(\d+)=(.+)').match
21 plstitle = re.compile('[Tt]itle(\d+)=(.+)').match
22 plslength = re.compile('[Ll]ength(\d+)=(\d+)').match
24 if os.path.sep == '/':
25 quote = urllib.quote
26 unquote = urllib.unquote_plus
27 else:
28 quote = lambda x: urllib.quote(x.replace(os.path.sep, '/'))
29 unquote = lambda x: urllib.unquote_plus(x).replace('/', os.path.sep)
31 class FileData:
32 def __init__(self, name, isdir):
33 self.name = name
34 self.isdir = isdir
35 self.isplay = os.path.splitext(name)[1].lower() in PLAYLISTS
36 self.title = ''
37 self.duration = 0
39 class EncodeUnicode(Filter):
40 def filter(self, val, **kw):
41 """Encode Unicode strings, by default in UTF-8"""
43 if kw.has_key('encoding'):
44 encoding = kw['encoding']
45 else:
46 encoding='utf8'
48 if type(val) == type(u''):
49 filtered = val.encode(encoding)
50 else:
51 filtered = str(val)
52 return filtered
54 class Music(Plugin):
56 CONTENT_TYPE = 'x-container/tivo-music'
58 AUDIO = 'audio'
59 DIRECTORY = 'dir'
60 PLAYLIST = 'play'
62 media_data_cache = LRUCache(300)
64 def send_file(self, handler, container, name):
65 o = urlparse("http://fake.host" + handler.path)
66 path = unquote(o[2])
67 fname = container['path'] + path[len(name) + 1:]
68 fsize = os.path.getsize(fname)
69 handler.send_response(200)
70 handler.send_header('Content-Type', 'audio/mpeg')
71 handler.send_header('Content-Length', fsize)
72 handler.send_header('Connection', 'close')
73 handler.end_headers()
74 f = file(fname, 'rb')
75 shutil.copyfileobj(f, handler.wfile)
77 def QueryContainer(self, handler, query):
79 def AudioFileFilter(f, filter_type=None):
81 if filter_type:
82 filter_start = filter_type.split('/')[0]
83 else:
84 filter_start = filter_type
86 if os.path.isdir(f):
87 ftype = self.DIRECTORY
89 elif eyeD3.isMp3File(f):
90 ftype = self.AUDIO
91 elif os.path.splitext(f)[1].lower() in PLAYLISTS:
92 ftype = self.PLAYLIST
93 else:
94 ftype = False
96 if filter_start == self.AUDIO:
97 if ftype == self.AUDIO:
98 return ftype
99 else:
100 return False
101 else:
102 return ftype
104 def media_data(f):
105 if f.name in self.media_data_cache:
106 return self.media_data_cache[f.name]
108 item = {}
109 item['path'] = f.name
110 item['part_path'] = f.name.replace(local_base_path, '')
111 item['name'] = os.path.split(f.name)[1]
112 item['is_dir'] = f.isdir
113 item['is_playlist'] = f.isplay
115 if f.title:
116 item['Title'] = f.title
118 if f.duration > 0:
119 item['Duration'] = f.duration
121 if f.isdir or f.isplay or '://' in f.name:
122 self.media_data_cache[f.name] = item
123 return item
125 try:
126 audioFile = eyeD3.Mp3AudioFile(f.name)
127 item['Duration'] = audioFile.getPlayTime() * 1000
129 tag = audioFile.getTag()
130 item['ArtistName'] = tag.getArtist()
131 item['AlbumTitle'] = tag.getAlbum()
132 item['SongTitle'] = tag.getTitle()
133 item['AlbumYear'] = tag.getYear()
134 item['MusicGenre'] = tag.getGenre().getName()
135 except Exception, msg:
136 print msg
138 self.media_data_cache[f.name] = item
139 return item
141 subcname = query['Container'][0]
142 cname = subcname.split('/')[0]
143 local_base_path = self.get_local_base_path(handler, query)
145 if not handler.server.containers.has_key(cname) or \
146 not self.get_local_path(handler, query):
147 handler.send_response(404)
148 handler.end_headers()
149 return
151 if os.path.splitext(subcname)[1].lower() in PLAYLISTS:
152 t = Template(file=os.path.join(SCRIPTDIR, 'templates', 'm3u.tmpl'),
153 filter=EncodeUnicode)
154 t.files, t.total, t.start = self.get_playlist(handler, query)
155 else:
156 t = Template(file=os.path.join(SCRIPTDIR,'templates',
157 'container.tmpl'), filter=EncodeUnicode)
158 t.files, t.total, t.start = self.get_files(handler, query,
159 AudioFileFilter)
160 t.files = map(media_data, t.files)
161 t.container = cname
162 t.name = subcname
163 t.quote = quote
164 t.escape = escape
165 page = str(t)
167 handler.send_response(200)
168 handler.send_header('Content-Type', 'text/xml')
169 handler.send_header('Content-Length', len(page))
170 handler.send_header('Connection', 'close')
171 handler.end_headers()
172 handler.wfile.write(page)
174 def item_count(self, handler, query, cname, files):
175 """Return only the desired portion of the list, as specified by
176 ItemCount, AnchorItem and AnchorOffset
178 totalFiles = len(files)
179 index = 0
181 if query.has_key('ItemCount'):
182 count = int(query['ItemCount'][0])
184 if query.has_key('AnchorItem'):
185 bs = '/TiVoConnect?Command=QueryContainer&Container='
186 local_base_path = self.get_local_base_path(handler, query)
188 anchor = query['AnchorItem'][0]
189 if anchor.startswith(bs):
190 anchor = anchor.replace(bs, '/')
191 anchor = unquote(anchor)
192 anchor = anchor.replace(os.path.sep + cname, local_base_path)
193 anchor = os.path.normpath(anchor)
195 filenames = [x.name for x in files]
196 try:
197 index = filenames.index(anchor)
198 except ValueError:
199 print 'Anchor not found:', anchor # just use index = 0
201 if count > 0:
202 index += 1
204 if query.has_key('AnchorOffset'):
205 index += int(query['AnchorOffset'][0])
207 #foward count
208 if count > 0:
209 files = files[index:index + count]
210 #backwards count
211 elif count < 0:
212 if index + count < 0:
213 count = -index
214 files = files[index + count:index]
215 index += count
217 else: # No AnchorItem
219 if count >= 0:
220 files = files[:count]
221 elif count < 0:
222 index = count % len(files)
223 files = files[count:]
225 return files, totalFiles, index
227 def get_files(self, handler, query, filterFunction=None):
229 def build_recursive_list(path, recurse=True):
230 files = []
231 for f in os.listdir(path):
232 f = os.path.join(path, f)
233 isdir = os.path.isdir(f)
234 if recurse and isdir:
235 files.extend(build_recursive_list(f))
236 else:
237 if isdir or filterFunction(f, file_type):
238 files.append(FileData(f, isdir))
239 return files
241 def dir_sort(x, y):
242 if x.isdir == y.isdir:
243 if x.isplay == y.isplay:
244 return name_sort(x, y)
245 else:
246 return y.isplay - x.isplay
247 else:
248 return y.isdir - x.isdir
250 def name_sort(x, y):
251 return cmp(x.name, y.name)
253 subcname = query['Container'][0]
254 cname = subcname.split('/')[0]
255 path = self.get_local_path(handler, query)
257 file_type = query.get('Filter', [''])[0]
259 recurse = query.get('Recurse',['No'])[0] == 'Yes'
260 filelist = build_recursive_list(path, recurse)
262 # Sort
263 if query.get('SortOrder',['Normal'])[0] == 'Random':
264 seed = query.get('RandomSeed', ['1'])[0]
265 self.random_lock.acquire()
266 random.seed(seed)
267 random.shuffle(filelist)
268 self.random_lock.release()
269 else:
270 filelist.sort(dir_sort)
272 # Trim the list
273 return self.item_count(handler, query, cname, filelist)
275 def get_playlist(self, handler, query):
276 subcname = query['Container'][0]
277 cname = subcname.split('/')[0]
279 list_name = self.get_local_path(handler, query)
280 local_path = os.path.sep.join(list_name.split(os.path.sep)[:-1])
281 ext = os.path.splitext(list_name)[1].lower()
283 if ext in ('.wpl', '.asx', '.wax', '.wvx', '.b4s'):
284 playlist = []
285 for line in file(list_name):
286 if ext == '.wpl':
287 s = wplfile(line)
288 elif ext == '.b4s':
289 s = b4sfile(line)
290 else:
291 s = asxfile(line)
292 if s:
293 playlist.append(FileData(s.group(1), False))
295 elif ext == '.pls':
296 names, titles, lengths = {}, {}, {}
297 for line in file(list_name):
298 s = plsfile(line)
299 if s:
300 names[s.group(1)] = s.group(2)
301 else:
302 s = plstitle(line)
303 if s:
304 titles[s.group(1)] = s.group(2)
305 else:
306 s = plslength(line)
307 if s:
308 lengths[s.group(1)] = int(s.group(2))
309 playlist = []
310 for key in names:
311 f = FileData(names[key], False)
312 if key in titles:
313 f.title = titles[key]
314 if key in lengths:
315 f.duration = lengths[key]
316 playlist.append(f)
318 else: # ext == '.m3u' or '.ram'
319 duration, title = 0, ''
320 playlist = []
321 for x in file(list_name):
322 x = x.strip()
323 if x:
324 if x.startswith('#EXTINF:'):
325 try:
326 duration, title = x[8:].split(',')
327 duration = int(duration)
328 except ValueError:
329 duration = 0
331 elif not x.startswith('#'):
332 f = FileData(x, False)
333 f.title = title.strip()
334 f.duration = duration
335 playlist.append(f)
336 duration, title = 0, ''
338 # Expand relative paths
339 for i in xrange(len(playlist)):
340 if not '://' in playlist[i].name:
341 name = playlist[i].name
342 if not os.path.isabs(name):
343 name = os.path.join(local_path, name)
344 playlist[i].name = os.path.normpath(name)
346 # Trim the list
347 return self.item_count(handler, query, cname, playlist)