Vesion 019
[rox-musicbox.git] / playlist.py
blobc73038daffe882b885cdef7f890161526fc2b944
1 """
2 playlist.py
3 Implement a mp3/ogg playlist for music player apps.
5 Copyright 2004 Kenneth Hayber <khayber@socal.rr.com>
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, re, stat, time, string, gtk, gobject
24 from rox import saving
25 from urllib import quote, unquote
27 import genres
28 from random import Random
29 from xml.dom.minidom import parse, parseString, Document
31 try:
32 import xattr
33 HAVE_XATTR = True
34 except:
35 HAVE_XATTR = False
36 print 'No xattr support'
38 try:
39 from pyid3lib import *
40 HAVE_ID3V2 = True
41 except:
42 from ID3 import *
43 HAVE_ID3V2 = False
44 print 'No id3v2 support'
46 try:
47 import ogg.vorbis
48 HAVE_OGG = True
49 except:
50 HAVE_OGG = False
51 print 'No OGG support!'
53 try:
54 import mad
55 HAVE_MAD = True
56 except:
57 HAVE_MAD = False
58 print 'No MP3 support!'
60 if not HAVE_MAD and not HAVE_OGG:
61 raise ImportError, 'You must have at least one of either Ogg Vorbis or\nMAD libraries installed with the python bindings.'
64 def strip_padding(s):
65 while len(s) > 0 and s[-1] in string.whitespace + "\0":
66 s = s[:-1]
67 return s
70 TYPE_OGG = 'application/ogg'
71 TYPE_MP3 = 'audio/x-mp3'
72 TYPE_LIST = [TYPE_OGG, TYPE_MP3]
74 #Column indicies
75 COL_FILE = 0
76 COL_TITLE = 1
77 COL_TRACK = 2
78 COL_ALBUM = 3
79 COL_ARTIST = 4
80 COL_GENRE = 5
81 COL_LENGTH = 6
82 COL_TYPE = 7
85 class Song:
86 def __init__(self, filename=None, title=None, track=None, album=None, artist=None,
87 genre=None, length=None, type=None):
88 """Constructor for one song"""
89 self.filename = filename
90 self.title = title
91 self.track = track
92 self.album = album
93 self.artist = artist
94 self.genre = genre
95 self.length = length
96 self.type = type
99 class Playlist(saving.Saveable):
100 """A class to find and process mp3 and ogg files for a music player"""
102 def __init__(self, CacheSize, guess_re=None):
103 """Constructor for the song list"""
104 self.rndm = Random(time.time()) # for shuffle
105 self.iter_curr = -1
106 self.shuffle_cache = []
107 self.shuffle_cache_size = CacheSize
108 self.library = []
109 self.guess_re = guess_re
111 #filename, title, track, album, artist, genre, length, type
112 self.song_list = gtk.ListStore(str, str, int, str, str, str, int, str)
113 self.song_list.set_sort_func(COL_TRACK, self.comparemethod, COL_TRACK)
115 def __len__(self):
116 return len(self.song_list)
118 def shuffle(self):
119 """Randomize the iterator index (so the next song is random)"""
120 if self.iter_curr != -1:
121 self.shuffle_cache.append(self.iter_curr)
122 if len(self.shuffle_cache) > self.shuffle_cache_size:
123 self.shuffle_cache.pop(0)
125 #shuffle the list?
126 num_songs = len(self.song_list)
127 while True:
128 n = self.rndm.randrange(0, num_songs)
129 if self.shuffle_cache_size >= num_songs:
130 break
131 if n not in self.shuffle_cache:
132 break
133 self.iter_curr = n
135 def get_song(self, index):
136 """Create a Song object from the data at index"""
137 iter = self.song_list.get_iter((index,))
138 filename = self.song_list.get_value(iter, COL_FILE)
139 title = self.song_list.get_value(iter, COL_TITLE)
140 track = self.song_list.get_value(iter, COL_TRACK)
141 album = self.song_list.get_value(iter, COL_ALBUM)
142 artist = self.song_list.get_value(iter, COL_ARTIST)
143 genre = self.song_list.get_value(iter, COL_GENRE)
144 length = self.song_list.get_value(iter, COL_LENGTH)
145 type = self.song_list.get_value(iter, COL_TYPE)
146 return Song(filename, title, track, album, artist, genre, length, type)
148 def set(self, index):
149 if self.iter_curr != -1:
150 self.shuffle_cache.append(self.iter_curr)
151 if len(self.shuffle_cache) > self.shuffle_cache_size:
152 self.shuffle_cache.pop(0)
153 self.iter_curr = index
155 def get(self, index=None):
156 if self.iter_curr == -1:
157 self.iter_curr = 0
158 if index == None:
159 return self.get_song(self.iter_curr)
160 else:
161 return self.get_song(index)
163 def get_index(self):
164 if self.iter_curr == -1:
165 raise 'No index set'
166 return self.iter_curr
168 def first(self):
169 self.iter_curr = 0
170 return self.get_song(0)
172 def last(self):
173 self.iter_curr = len(self.song_list)-1
174 return self.get_song(self.iter_curr)
176 def next(self):
177 if self.iter_curr != -1:
178 self.shuffle_cache.append(self.iter_curr)
179 if len(self.shuffle_cache) > self.shuffle_cache_size:
180 self.shuffle_cache.pop(0)
181 try:
182 self.iter_curr += 1
183 return self.get_song(self.iter_curr)
184 except:
185 self.iter_curr = len(self.song_list)-1
186 raise StopIteration
188 def prev(self):
189 try:
190 self.iter_curr = self.shuffle_cache.pop()
191 return self.get_song(self.iter_curr)
192 except:
193 raise StopIteration
195 def get_previous(self):
196 return len(self.shuffle_cache)
198 def save(self, f):
199 """Save the current (filtered?) playlist in xml format"""
200 f.write("<?xml version='1.0'?>\n<SongList>\n")
202 for index in range(len(self.song_list)):
203 song = self.get_song(index)
204 f.write("\t<Song>\n")
205 f.write("\t\t<Title>%s</Title>\n" % quote(song.title))
206 f.write("\t\t<Track>%s</Track>\n" % str(song.track))
207 f.write("\t\t<Album>%s</Album>\n" % quote(song.album))
208 f.write("\t\t<Artist>%s</Artist>\n" % quote(song.artist))
209 f.write("\t\t<Genre>%s</Genre>\n" % quote(song.genre))
210 f.write("\t\t<Type>%s</Type>\n" % quote(song.type))
211 f.write("\t\t<Location>%s</Location>\n" % quote(song.filename))
212 f.write("\t</Song>\n")
213 f.write("</SongList>")
214 f.close()
216 def load(self, filename):
217 """Read an xml file of Songs and tag info"""
218 dom1 = parse(filename)
219 songs = dom1.getElementsByTagName("Song")
221 for song in songs:
223 while gtk.events_pending():
224 gtk.main_iteration()
226 try: title = unquote(song.getElementsByTagName("Title")[0].childNodes[0].data)
227 except: pass
228 try: track = int(unquote(song.getElementsByTagName("Track")[0].childNodes[0].data))
229 except: pass
230 try: artist = unquote(song.getElementsByTagName("Artist")[0].childNodes[0].data)
231 except: pass
232 try: album = unquote(song.getElementsByTagName("Album")[0].childNodes[0].data)
233 except: pass
234 try: genre = unquote(song.getElementsByTagName("Genre")[0].childNodes[0].data)
235 except: pass
236 try: filename = unquote(song.getElementsByTagName("Location")[0].childNodes[0].data)
237 except: pass
238 try: type = unquote(song.getElementsByTagName("Type")[0].childNodes[0].data)
239 except: pass
240 length = 0
242 self.album_list[album] = True
243 self.artist_list[artist] = True
244 self.genre_list[genre] = True
246 iter_new = self.song_list.append()
247 self.song_list.set(iter_new,
248 COL_FILE, filename,
249 COL_TITLE, title,
250 COL_TRACK, track,
251 COL_ALBUM, album,
252 COL_ARTIST, artist,
253 COL_GENRE, genre,
254 COL_LENGTH, length,
255 COL_TYPE, type)
256 self.callback()
258 def get_tag_info(self):
259 """Update the entire song_list with the tag info from each file"""
260 for index in len(self.song_list):
261 song = self.get_song(index)
262 self.get_tag_info_from_file(song)
264 def get_tag_info_from_file(self, song):
265 """Get the tag info from specified filename"""
266 song.type = str(rox.mime.get_type(song.filename))
268 if not self.get_xattr_info(song):
269 if song.type == TYPE_MP3 and HAVE_MAD:
270 #print 'using mp3 tags'
271 self.get_id3_tag_info(song)
272 elif song.type == TYPE_OGG and HAVE_OGG:
273 #print 'using ogg info'
274 self.get_ogg_info(song)
275 else:
276 print song.filename
278 try:
279 song.title = unicode(song.title,'latin-1')
280 song.title = song.title.encode('utf8')
281 except: pass
282 try:
283 song.artist = unicode(song.artist,'latin-1')
284 song.artist = song.artist.encode('utf8')
285 except: pass
286 try:
287 song.album = unicode(song.album,'latin-1')
288 song.album = song.album.encode('utf8')
289 except: pass
290 try:
291 song.genre = unicode(song.genre,'latin-1')
292 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
303 def get_id3_tag_info(self, song):
304 if (HAVE_ID3V2):
305 try: tag_info = tag(song.filename)
306 except: pass
307 try: song.title = tag_info.title
308 except: pass
309 try: song.track = int(tag_info.track[0]) #it is a tuple (x of y)
310 except: pass
311 try: song.album = tag_info.album
312 except: pass
313 try: song.artist = tag_info.artist
314 except: pass
315 try:
316 #ID3v2 genres are either a string/tuple index e.g. '(17)'
317 #or the actual genre string.
318 x = re.match('\(([0-9]+)\)', tag_info.contenttype)
319 if x:
320 genre = genres.genre_list[int(x.group(1))]
321 else:
322 genre = tag_info.contenttype
323 song.genre = genre
324 except: pass
325 try: song.length = tag_info.songlen
326 except: pass
327 else: #ID3V1
328 try:
329 tag_info = ID3(song.filename)
330 except: pass
331 if tag_info.has_key('TITLE'): song.title = tag_info['TITLE']
332 if tag_info.has_key('TRACKNUMBER'): song.track = int(tag_info['TRACKNUMBER'])
333 if tag_info.has_key('ALBUM'): song.album = tag_info['ALBUM']
334 if tag_info.has_key('ARTIST'): song.artist = tag_info['ARTIST']
335 if tag_info.has_key('GENRE'): song.genre = tag_info['GENRE']
336 song.length = 0
338 def get_ogg_info(self, song):
339 try:
340 tag_info = ogg.vorbis.VorbisFile(song.filename).comment().as_dict()
341 if tag_info.has_key('TITLE'): song.title = tag_info['TITLE'][0]
342 if tag_info.has_key('TRACKNUMBER'): song.track = int(tag_info['TRACKNUMBER'][0])
343 if tag_info.has_key('ALBUM'): song.album = tag_info['ALBUM'][0]
344 if tag_info.has_key('ARTIST'): song.artist = tag_info['ARTIST'][0]
345 if tag_info.has_key('GENRE'): song.genre = tag_info['GENRE'][0]
346 song.length = 0
347 except:
348 pass
350 def get_xattr_info(self, song):
351 if (HAVE_XATTR):
352 try:
353 song.title = xattr.getxattr(song.filename, 'user.Title')
354 song.track = int(xattr.getxattr(song.filename, 'user.Track'))
355 song.album = xattr.getxattr(song.filename, 'user.Album')
356 song.artist = xattr.getxattr(song.filename, 'user.Artist')
357 song.genre = xattr.getxattr(song.filename, 'user.Genre')
358 # song.length = xattr.getxattr(song.filename, 'user.Time')
359 # print song.title, song.album, song.artist, song.genre
360 return True
361 except:
362 return False
363 return False
365 def get_songs(self, library, callback, replace=True):
366 """load all songs found by iterating over library into song_list..."""
367 if replace:
368 self.iter_curr = -1 #reset cuz we don't know how many songs we're gonna load
370 self.callback = callback
372 if replace:
373 self.library = library
374 else:
375 self.library.extend(library)
377 self.song_list.clear()
378 self.album_list = {} #album: True
379 self.artist_list = {} #artist: True
380 self.genre_list = {} #genre: True
382 for library_element in self.library:
383 library_element = os.path.expanduser(library_element)
384 if os.access(library_element, os.R_OK):
385 #check if the element is a folder
386 if stat.S_ISDIR(os.stat(library_element)[stat.ST_MODE]):
387 self.process_dir(library_element)
388 else:
389 #check for playlist files...
390 (root, ext) = os.path.splitext(library_element)
391 if ext == '.pls':
392 self.process_pls(library_element)
393 elif ext == '.m3u':
394 self.process_m3u(library_element)
395 elif ext == '.xml':
396 self.load(library_element)
397 else:
398 #assume the element is just a song...
399 self.add_song(library_element)
400 #print self.album_list.keys()
401 #print self.artist_list.keys()
402 #print self.genre_list.keys()
404 def add_song(self, filename):
405 """Add a file to the song_list if the mime_type is acceptable"""
407 while gtk.events_pending():
408 gtk.main_iteration()
410 type = str(rox.mime.get_type(filename))
411 if type in TYPE_LIST and os.access(filename, os.R_OK):
412 song = self.guess(filename, type)
413 if song != None:
414 self.get_tag_info_from_file(song)
415 if song.track == None:
416 song.track = 0
417 if song.length == None:
418 song.length = 0
420 self.album_list[song.album] = True
421 self.artist_list[song.artist] = True
422 self.genre_list[song.genre] = True
424 iter_new = self.song_list.append()
425 self.song_list.set(iter_new,
426 COL_FILE, song.filename,
427 COL_TITLE, song.title,
428 COL_TRACK, song.track,
429 COL_ALBUM, song.album,
430 COL_ARTIST, song.artist,
431 COL_GENRE, song.genre,
432 COL_LENGTH, song.length,
433 COL_TYPE, song.type)
434 self.callback()
436 def comparemethod(self, model, iter1, iter2, user_data):
437 """Method to sort by Track and others"""
438 try:
439 if user_data == COL_TRACK:
440 artist1 = model.get_value(iter1, COL_ARTIST)
441 artist2 = model.get_value(iter2, COL_ARTIST)
442 if artist1 == artist2:
443 album1 = model.get_value(iter1, COL_ALBUM)
444 album2 = model.get_value(iter2, COL_ALBUM)
445 if album1 == album2:
446 item1 = model.get_value(iter1, COL_TRACK)
447 item2 = model.get_value(iter2, COL_TRACK)
448 else:
449 item1 = album1
450 item2 = album2
451 else:
452 item1 = artist1
453 item2 = artist2
454 #print item1, item2
456 if item1 < item2:
457 return -1
458 elif item1 > item2:
459 return 1
460 else:
461 return 0
462 except:
463 return 0
465 def guess(self, filename, type):
466 """Guess some info about the file based on path/filename"""
467 try: m = re.match(self.guess_re, filename)
468 except: m = re.match('^.*/(?P<artist>.*)/(?P<album>.*)/(?P<title>.*)', filename)
469 try: title = m.group('title')
470 except: title = filename
471 try: album = m.group('album')
472 except: album = 'unknown'
473 try: artist = m.group('artist')
474 except: artist = 'unknown'
475 try: track = int(m.group('track'))
476 except: track = 0
478 (title, ext) = os.path.splitext(title)
479 genre = 'unknown'
480 length = 0
482 #Ignore hidden files
483 if title[0] == '.':
484 return None
485 return Song(filename, title, track, album, artist, genre, length, type)
487 def process_pls(self, pls_file):
488 """Open and read a playlist (.pls) file."""
489 pls = open(pls_file, 'r')
490 if pls:
491 for line in pls.xreadlines():
492 filename = re.match('^File[0-9]+=(.*)', line)
493 if filename:
494 self.add_song(filename.group(1))
496 def process_m3u(self, m3u_file):
497 """Open and read a playlist (.m3u) file."""
499 dir = os.path.dirname(m3u_file)
500 m3u = open(m3u_file, 'r')
501 if m3u:
502 for line in m3u.xreadlines():
503 filename = line.strip()
504 if filename:
505 if filename[0] == '/':
506 self.add_song(filename)
507 else:
508 self.add_song('/'.join((dir,
509 filename)))
511 def process_dir(self, directory):
512 """Walk a directory adding all files found"""
513 # (Note: add_song filters the list by mime_type)
514 def visit(self, dirname, names):
515 for filename in names:
516 self.add_song(dirname+'/'+filename)
518 os.path.walk(directory, visit, self)
520 def save_to_stream(self, stream):
521 self.save(stream)
523 def set_uri(self, uri):
524 #print uri
525 pass