Theme changes, fix for unicode conversion errors, misc
[rox-musicbox.git] / playlist.py
blob5afc3cf77421f4f7c591913b7859c352e6da837e
1 """
2 playlist.py
3 Implement a playlist for music player apps.
5 Copyright 2004 Kenneth Hayber <ken@hayber.us>
6 All rights reserved.
8 This program is free software; you can redistribute it and/or modify
9 it under the terms of the GNU General Public License as published by
10 the Free Software Foundation; either version 2 of the License.
12 This program is distributed in the hope that it will be useful
13 but WITHOUT ANY WARRANTY; without even the implied warranty of
14 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 GNU General Public License for more details.
17 You should have received a copy of the GNU General Public License
18 along with this program; if not, write to the Free Software
19 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
20 """
21 from __future__ import generators
23 import rox, os, sys, re, time, string, gtk, gobject
24 from rox import saving
25 from urllib import quote, unquote
27 from random import Random
28 from xml.dom.minidom import parse, parseString, Document
30 import plugins
33 try:
34 import xattr
35 HAVE_XATTR = True
36 except:
37 HAVE_XATTR = False
38 print 'No xattr support'
40 def strip_padding(s):
41 while len(s) > 0 and s[-1] in string.whitespace + "\0":
42 s = s[:-1]
43 return s
46 #Column indicies
47 COL_FILE = 0
48 COL_TITLE = 1
49 COL_TRACK = 2
50 COL_ALBUM = 3
51 COL_ARTIST = 4
52 COL_GENRE = 5
53 COL_LENGTH = 6
54 COL_TYPE = 7
55 COL_ICON = 8
57 FILENAME_RE = re.compile('^.*/(?P<artist>.*)/(?P<album>.*)/(?P<title>.*)')
59 class Song:
60 def __init__(self, filename=None, title=None, track=None, album=None, artist=None,
61 genre=None, length=None, type=None):
62 """Constructor for one song"""
63 self.filename = filename
64 self.title = title
65 self.track = track
66 self.album = album
67 self.artist = artist
68 self.genre = genre
69 self.length = length
70 self.type = type
73 class Playlist(saving.Saveable, gobject.GObject):
74 """A class to find and process mp3 and ogg files for a music player"""
76 def __init__(self, CacheSize, guess_re=None):
77 """Constructor for the song list"""
78 self.rndm = Random(time.time()) # for shuffle
79 self.curr_index = -1
80 self.shuffle_cache = []
81 self.shuffle_cache_size = CacheSize
82 self.library = []
83 self.guess_re = guess_re
85 self.filter_col = None
86 self.filter_data = None
88 #filename, title, track, album, artist, genre, length, type
89 self.model = gtk.ListStore(str, str, int, str, str, str, int, str, str)
90 self.song_list_filter = self.model.filter_new()
91 self.song_list_filter.set_visible_func(self.the_filter)
92 self.song_list = gtk.TreeModelSort(self.song_list_filter)
93 self.song_list.set_sort_func(COL_TRACK, self.comparemethod, COL_TRACK)
96 def __len__(self):
97 return len(self.song_list)
99 def shuffle(self):
100 """Randomize the iterator index (so the next song is random)"""
101 try:
102 self.shuffle_cache.append(self.get_index())
103 if len(self.shuffle_cache) > self.shuffle_cache_size:
104 self.shuffle_cache.pop(0)
105 except:
106 pass
108 num_songs = len(self)
109 if len(self.shuffle_cache) >= num_songs:
110 self.shuffle_cache = [] #we used them all up, so reset the cache
112 while True:
113 n = self.rndm.randrange(0, num_songs)
114 if n not in self.shuffle_cache:
115 break
116 self.set_index(n)
118 def get_model(self):
119 return self.song_list
121 def get_song(self, index):
122 """Create a Song object from the data at index"""
123 iter = self.song_list.get_iter((index,))
124 filename = self.song_list.get_value(iter, COL_FILE)
125 title = self.song_list.get_value(iter, COL_TITLE)
126 track = self.song_list.get_value(iter, COL_TRACK)
127 album = self.song_list.get_value(iter, COL_ALBUM)
128 artist = self.song_list.get_value(iter, COL_ARTIST)
129 genre = self.song_list.get_value(iter, COL_GENRE)
130 length = self.song_list.get_value(iter, COL_LENGTH)
131 type = self.song_list.get_value(iter, COL_TYPE)
132 return Song(filename, title, track, album, artist, genre, length, type)
134 def set(self, index):
135 try:
136 self.shuffle_cache.append(self.get_index())
137 if len(self.shuffle_cache) > self.shuffle_cache_size:
138 self.shuffle_cache.pop(0)
139 except:
140 pass
141 self.set_index(index)
143 def get(self, index=None):
144 if index == None:
145 try:
146 index = self.get_index()
147 except:
148 index = 0
149 self.set_index(index)
150 return self.get_song(index)
152 def delete(self, index):
153 try:
154 del self.song_list[index]
155 except:
156 rox.report_exception()
158 def set_index(self, index):
159 self.curr_index = index
161 def get_index(self):
162 if self.curr_index == -1:
163 self.curr_index = 0
164 return self.curr_index
166 def first(self):
167 self.set_index(0)
168 return self.get_song(self.get_index())
170 def last(self):
171 self.set_index(len(self)-1)
172 return self.get_song(self.get_index())
174 def next(self):
175 try:
176 self.shuffle_cache.append(self.get_index())
177 if len(self.shuffle_cache) > self.shuffle_cache_size:
178 self.shuffle_cache.pop(0)
179 except:
180 pass
182 try:
183 self.set_index(self.get_index()+1)
184 return self.get_song(self.get_index())
185 except:
186 self.set_index(len(self)-1)
187 raise StopIteration
189 def prev(self):
190 try:
191 self.set_index(self.shuffle_cache.pop())
192 return self.get_song(self.get_index())
193 except:
194 raise StopIteration
196 def get_previous(self):
197 return len(self.shuffle_cache)
199 def the_filter(self, model, iter):
200 """Implement a simple filter for the playlist"""
201 if self.filter_col:
202 if model.get_value(iter, self.filter_col) == self.filter_data:
203 return True
204 else:
205 return False
206 else:
207 return True
209 def set_filter(self, column, data):
210 """The filter function above is a callback. This is the control interface"""
211 self.filter_col = column
212 self.filter_data = data
213 self.song_list_filter.refilter()
215 def save(self, f):
216 """Save the current (filtered?) playlist in xml format"""
217 f.write("<?xml version='1.0'?>\n<SongList>\n")
219 for index in range(len(self)):
220 song = self.get_song(index)
221 f.write("\t<Song>\n")
222 f.write("\t\t<Title>%s</Title>\n" % quote(song.title))
223 f.write("\t\t<Track>%s</Track>\n" % str(song.track))
224 f.write("\t\t<Album>%s</Album>\n" % quote(song.album))
225 f.write("\t\t<Artist>%s</Artist>\n" % quote(song.artist))
226 f.write("\t\t<Genre>%s</Genre>\n" % quote(song.genre))
227 f.write("\t\t<Type>%s</Type>\n" % quote(song.type))
228 f.write("\t\t<Location>%s</Location>\n" % quote(song.filename))
229 f.write("\t</Song>\n")
230 f.write("</SongList>")
231 f.close()
233 def load(self, filename):
234 """Read an xml file of Songs and tag info"""
235 dom1 = parse(filename)
236 songs = dom1.getElementsByTagName("Song")
238 for song in songs:
239 while gtk.events_pending():
240 gtk.main_iteration()
242 try: title = unquote(song.getElementsByTagName("Title")[0].childNodes[0].data)
243 except: pass
244 try: track = int(unquote(song.getElementsByTagName("Track")[0].childNodes[0].data))
245 except: pass
246 try: artist = unquote(song.getElementsByTagName("Artist")[0].childNodes[0].data)
247 except: pass
248 try: album = unquote(song.getElementsByTagName("Album")[0].childNodes[0].data)
249 except: pass
250 try: genre = unquote(song.getElementsByTagName("Genre")[0].childNodes[0].data)
251 except: pass
252 try: filename = unquote(song.getElementsByTagName("Location")[0].childNodes[0].data)
253 except: pass
254 try: type = unquote(song.getElementsByTagName("Type")[0].childNodes[0].data)
255 except: pass
256 length = 0
258 iter_new = self.model.append()
259 self.model.set(iter_new,
260 COL_FILE, filename,
261 COL_TITLE, title,
262 COL_TRACK, track,
263 COL_ALBUM, album,
264 COL_ARTIST, artist,
265 COL_GENRE, genre,
266 COL_LENGTH, length,
267 COL_TYPE, type)
268 self.callback()
270 def get_tag_info(self):
271 """Update the entire song_list with the tag info from each file"""
272 for index in len(self):
273 song = self.get_song(index)
274 self.get_tag_info_from_file(song)
276 def get_tag_info_from_file(self, song):
277 """Get the tag info from specified filename"""
278 song.type = str(rox.mime.get_type(song.filename))
280 try:
281 if not self.get_xattr_info(song):
282 plugins.get_info(song)
283 except:
284 rox.info('Unsupported format: %s' % song.filename)
286 try: song.title = song.title.encode('utf8')
287 except: pass
288 try: song.artist = song.artist.encode('utf8')
289 except: pass
290 try: song.album = song.album.encode('utf8')
291 except: pass
292 try: song.genre = song.genre.encode('utf8')
293 except: pass
295 song.title = strip_padding(song.title)
296 song.artist = strip_padding(song.artist)
297 song.album = strip_padding(song.album)
298 song.genre = strip_padding(song.genre)
299 song.length = 0
301 return song
304 def get_xattr_info(self, song):
305 if (HAVE_XATTR):
306 try:
307 song.title = xattr.getxattr(song.filename, 'user.title')
308 song.track = int(xattr.getxattr(song.filename, 'user.track'))
309 song.album = xattr.getxattr(song.filename, 'user.album')
310 song.artist = xattr.getxattr(song.filename, 'user.artist')
311 song.genre = xattr.getxattr(song.filename, 'user.genre')
312 # song.length = xattr.getxattr(song.filename, 'user.time')
313 # print song.title, song.album, song.artist, song.genre
314 return True
315 except:
316 return False
317 return False
319 def get_songs(self, library, callback, replace=True):
320 """load all songs found by iterating over library into song_list..."""
321 if replace:
322 self.curr_index = -1 #reset cuz we don't know how many songs we're gonna load
324 self.callback = callback
326 if replace:
327 self.library = library
328 else:
329 self.library.extend(library)
331 self.model.clear()
332 for library_element in self.library:
333 library_element = os.path.expanduser(library_element)
334 if os.access(library_element, os.R_OK):
335 #check if the element is a folder
336 if os.path.isdir(library_element):
337 self.process_dir(library_element)
338 else:
339 #check for playlist files...
340 (root, ext) = os.path.splitext(library_element)
341 if ext == '.pls':
342 self.process_pls(library_element)
343 elif ext == '.m3u':
344 self.process_m3u(library_element)
345 elif ext == '.xml' or ext == '.music':
346 self.load(library_element)
347 else:
348 #assume the element is just a song...
349 self.add_song(library_element)
351 def add_song(self, filename):
352 """Add a file to the song_list if the mime_type is acceptable"""
354 while gtk.events_pending():
355 gtk.main_iteration()
357 type = str(rox.mime.get_type(filename))
358 if type in plugins.TYPE_LIST and os.access(filename, os.R_OK):
359 song = self.guess(filename, type)
360 if song != None:
361 self.get_tag_info_from_file(song)
362 if song.track == None:
363 song.track = 0
364 if song.length == None:
365 song.length = 0
367 iter_new = self.model.append(None)
368 self.model.set(iter_new,
369 COL_FILE, song.filename,
370 COL_TITLE, song.title,
371 COL_TRACK, song.track,
372 COL_ALBUM, song.album,
373 COL_ARTIST, song.artist,
374 COL_GENRE, song.genre,
375 COL_LENGTH, song.length,
376 COL_TYPE, song.type)
377 self.callback()
379 def comparemethod(self, model, iter1, iter2, user_data):
380 """Method to sort by Track and others"""
381 try:
382 if user_data == COL_TRACK:
383 artist1 = model.get_value(iter1, COL_ARTIST)
384 artist2 = model.get_value(iter2, COL_ARTIST)
385 if artist1 == artist2:
386 album1 = model.get_value(iter1, COL_ALBUM)
387 album2 = model.get_value(iter2, COL_ALBUM)
388 if album1 == album2:
389 item1 = model.get_value(iter1, COL_TRACK)
390 item2 = model.get_value(iter2, COL_TRACK)
391 else:
392 item1 = album1
393 item2 = album2
394 else:
395 item1 = artist1
396 item2 = artist2
398 if item1 < item2:
399 return -1
400 elif item1 > item2:
401 return 1
402 else:
403 return 0
404 except:
405 return 0
407 def guess(self, filename, type):
408 """Guess some info about the file based on path/filename"""
409 try: m = re.match(self.guess_re, os.path.abspath(filename))
410 except: m = DEFAULT_FILENAME_RE.match(filename)
411 try: title = m.group('title')
412 except: title = filename
413 try: album = m.group('album')
414 except: album = 'unknown'
415 try: artist = m.group('artist')
416 except: artist = 'unknown'
417 try: track = int(m.group('track'))
418 except: track = 0
420 (title, ext) = os.path.splitext(title)
421 genre = 'unknown'
422 length = 0
424 #Ignore hidden files
425 if title[0] == '.':
426 return None
427 return Song(filename, title, track, album, artist, genre, length, type)
429 def process_pls(self, pls_file):
430 """Open and read a playlist (.pls) file."""
431 pls = open(pls_file, 'r')
432 if pls:
433 for line in pls.xreadlines():
434 filename = re.match('^File[0-9]+=(.*)', line)
435 if filename:
436 self.add_song(filename.group(1))
438 def process_m3u(self, m3u_file):
439 """Open and read a playlist (.m3u) file."""
441 dir = os.path.dirname(m3u_file)
442 m3u = open(m3u_file, 'r')
443 if m3u:
444 for line in m3u.xreadlines():
445 filename = line.strip()
446 if filename and not filename.startswith('#'):
447 if filename[0] != '/':
448 filename = os.path.join(dir, filename)
449 self.add_song(filename)
451 def process_dir(self, directory):
452 """Walk a directory adding all files found"""
453 # (Note: add_song filters the list by mime_type)
454 def visit(self, dirname, names):
455 names.sort()
456 for filename in names:
457 self.add_song(dirname+'/'+filename)
459 os.path.walk(directory, visit, self)
461 def save_to_stream(self, stream):
462 self.save(stream)
464 def set_uri(self, uri):
465 #print uri
466 pass