Make the playlist tab work
[panucci.git] / src / panucci / playlist.py
blobb8d57b87b3bce25dfdf4d6c5744f2cd70fe3cd26
1 #!/usr/bin/env python
3 # This file is part of Panucci.
4 # Copyright (c) 2008-2009 The Panucci Audiobook and Podcast Player Project
6 # Panucci is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
11 # Panucci is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU General Public License for more details.
16 # You should have received a copy of the GNU General Public License
17 # along with Panucci. If not, see <http://www.gnu.org/licenses/>.
20 import gobject, gtk
21 import time
22 import os.path
23 import os
24 import re
25 import logging
26 from hashlib import md5
27 from xml.sax.saxutils import escape
29 import util
30 from dbsqlite import db
31 from settings import settings
32 from simplegconf import gconf
33 from services import ObservableService
35 _ = lambda x: x
37 class Playlist(ObservableService):
38 signals = [ 'new_track', 'new_track_metadata', 'file_queued',
39 'bookmark_added' ]
41 def __init__(self):
42 self.__log = logging.getLogger('panucci.playlist.Playlist')
43 ObservableService.__init__(self, self.signals, self.__log)
45 self.__queue = Queue(None)
46 self.__queue.register(
47 'current_item_changed', self.on_queue_current_item_changed )
49 self.reset_playlist()
51 def reset_playlist(self):
52 """ Sets the playlist to a default "known" state """
54 self.filepath = None
55 self._id = None
56 self.__queue.clear()
57 self.__bookmarks_model = None
58 self.__bookmarks_model_changed = True
60 @property
61 def id(self):
62 if self.filepath is None:
63 self.__log.warning("Can't get playlist id without having filepath")
64 elif self._id is None:
65 self._id = db.get_playlist_id( self.filepath, True, True )
67 return self._id
69 @property
70 def current_filepath(self):
71 """ Get the current file """
72 if not self.is_empty:
73 return self.__queue.current_item.filepath
75 @property
76 def queue_modified(self):
77 return self.__queue.modified
79 @property
80 def queue_length(self):
81 return len(self.__queue)
83 @property
84 def is_empty(self):
85 return not self.__queue
87 def print_queue_layout(self):
88 """ This helps with debugging ;) """
89 for item in self.__queue:
90 print str(item), item.reported_filepath
91 for bookmark in item.bookmarks:
92 print '\t', str(bookmark), bookmark.bookmark_filepath
94 def save_to_new_playlist(self, filepath, playlist_type='m3u'):
95 self.filepath = filepath
96 self._id = None
98 playlist = { 'm3u': M3U_Playlist, 'pls': PLS_Playlist }
99 if not playlist.has_key(playlist_type):
100 playlist_type = 'm3u' # use m3u by default
101 self.filepath += '.m3u'
103 playlist = playlist[playlist_type](self.filepath, self.__queue)
104 if not playlist.export_items( filepath ):
105 self.__log.error('Error exporting playlist to %s', self.filepath)
106 return False
108 # copy the bookmarks over to new playlist
109 db.remove_all_bookmarks(self.id)
110 self.__queue.set_new_playlist_id(self.id)
112 return True
114 def save_temp_playlist(self):
115 filepath = os.path.expanduser(settings.temp_playlist)
116 return self.save_to_new_playlist(filepath)
118 def on_queue_current_item_changed(self):
119 self.send_new_metadata( self.on_queue_current_item_changed )
120 self.notify( 'new_track', caller=self.on_queue_current_item_changed )
122 def send_new_metadata(self, caller=None):
123 self.notify( 'new_track_metadata', self.get_file_metadata(),
124 caller=caller or self.send_new_metadata )
126 def quit(self):
127 self.__log.debug('quit() called.')
128 if self.__queue.modified:
129 self.__log.info('Queue modified, saving temporary playlist')
130 self.save_temp_playlist()
132 ######################################
133 # Bookmark-related functions
134 ######################################
136 def __load_from_bookmark( self, item_id, bookmark ):
137 self.__queue.current_item_position = self.__queue.index(item_id)
139 if bookmark is None:
140 self.__queue.current_item.seek_to = 0
141 else:
142 self.__queue.current_item.seek_to = bookmark.seek_position
144 return True
146 def load_from_bookmark_id( self, item_id=None, bookmark_id=None ):
147 item, bookmark = self.__queue.get_bookmark(item_id, bookmark_id)
149 if item is not None:
150 return self.__load_from_bookmark( str(item), bookmark )
151 else:
152 self.__log.warning(
153 'item_id=%s,bookmark_id=%s not found', item_id, bookmark_id )
154 return False
156 def find_resume_bookmark(self):
157 """ Find a resume bookmark in the queue """
158 for item in self.__queue:
159 for bookmark in item.bookmarks:
160 if bookmark.is_resume_position:
161 return str(item), str(bookmark)
162 else:
163 return None, None
165 def load_from_resume_bookmark(self):
166 item_id, bookmark_id = self.find_resume_bookmark()
167 if None in ( item_id, bookmark_id ):
168 self.__log.info('No resume bookmark found.')
169 return False
170 else:
171 return self.load_from_bookmark_id( item_id, bookmark_id )
173 def save_bookmark( self, bookmark_name, position ):
174 self.notify( 'bookmark_added', caller=self.save_bookmark )
175 self.__queue.current_item.save_bookmark(
176 bookmark_name, position, resume_pos=False )
177 self.__bookmarks_model_changed = True
179 def update_bookmark(self, item_id, bookmark_id, name=None, seek_pos=None):
180 item, bookmark = self.__queue.get_bookmark(item_id, bookmark_id)
182 if item is None:
183 self.__log.warning('No such item id (%s)', item_id)
184 return False
186 if bookmark_id is not None and bookmark is None:
187 self.__log.warning('No such bookmark id (%s)', bookmark_id)
188 return False
190 if bookmark_id is None:
191 if name and item.title != name:
192 item.title = name
193 self.__queue.modified = True
194 else:
195 bookmark.timestamp = time.time()
197 if name is not None:
198 bookmark.bookmark_name = name
200 if seek_pos is not None:
201 bookmark.seek_position = seek_pos
203 db.update_bookmark(bookmark)
205 return True
207 def update_bookmarks(self):
208 """ Updates the database entries for items that have been modified """
209 for item in self.__queue:
210 if item.is_modified:
211 self.__log.debug(
212 'Playlist Item "%s" is modified, updating bookmarks', item)
213 item.update_bookmarks()
214 item.is_modified = False
216 def remove_bookmark( self, item_id, bookmark_id ):
217 item = self.__queue.get_item(item_id)
219 if item is None:
220 self.__log.info('Cannot find item with id: %s', item_id)
221 return False
223 if bookmark_id is None:
224 if self.__queue.current_item_position == self.__queue.index(item_id):
225 self.next()
227 item.delete_bookmark(None)
228 self.__queue.remove(item_id)
229 else:
230 item.delete_bookmark(bookmark_id)
232 return True
234 def remove_resume_bookmarks(self):
235 item_id, bookmark_id = self.find_resume_bookmark()
237 if None in ( item_id, bookmark_id ):
238 return False
239 else:
240 return self.remove_bookmark( item_id, bookmark_id )
243 def generate_bookmark_model(self, include_resume_marks=False):
244 self.__bookmarks_model = gtk.TreeStore(
245 # uid, name, position
246 gobject.TYPE_STRING, gobject.TYPE_STRING, gobject.TYPE_STRING )
248 for item in self.__queue:
249 title = util.pretty_filename(
250 item.filepath ) if item.title is None else item.title
251 row = [ str(item), title, None ]
252 parent = self.__bookmarks_model.append( None, row )
254 for bkmk in item.bookmarks:
255 if not bkmk.is_resume_position or include_resume_marks:
256 row = [ str(bkmk), bkmk.bookmark_name,
257 util.convert_ns(bkmk.seek_position) ]
258 self.__bookmarks_model.append( parent, row )
260 def get_bookmark_model(self, include_resume_marks=False):
261 if self.__bookmarks_model is None or self.__bookmarks_model_changed:
262 self.__log.debug('Generating new bookmarks model')
263 self.generate_bookmark_model(include_resume_marks)
264 self.__bookmarks_model_changed = False
265 else:
266 self.__log.debug('Using cached bookmarks model')
268 return self.__bookmarks_model
270 def move_item( self, from_row, to_row ):
271 self.__log.info('Moving item from position %d to %d', from_row, to_row)
272 assert isinstance(from_row, int) and isinstance(to_row, int)
273 self.__queue.move_item(from_row, to_row)
275 ######################################
276 # File-related convenience functions
277 ######################################
279 def get_current_position(self):
280 """ Returns the saved position for the current
281 file or 0 if no file is available"""
282 if not self.is_empty:
283 return self.__queue.current_item.seek_to
284 else:
285 return 0
287 def get_current_filetype(self):
288 """ Returns the filetype of the current
289 file or None if no file is available """
291 if not self.is_empty:
292 return self.__queue.current_item.filetype
294 def get_file_metadata(self):
295 """ Return the metadata associated with the current FileObject """
296 if not self.is_empty:
297 return self.__queue.current_item.metadata
298 else:
299 return {}
301 def get_current_filepath(self):
302 if not self.is_empty:
303 return self.__queue.current_item.filepath
305 def get_recent_files(self, max_files=10):
306 files = db.get_latest_files()
308 if len(files) > max_files:
309 return files[:max_files]
310 else:
311 return files
313 ##################################
314 # File importing functions
315 ##################################
317 def load(self, filepath):
318 """ Detects filepath's filetype then loads it using
319 the appropriate loader function """
320 self.__log.debug('Attempting to load %s', filepath)
322 error = False
323 self.reset_playlist()
324 self.filepath = filepath
325 self.__queue.playlist_id = self.id
327 parsers = { 'm3u': M3U_Playlist, 'pls': PLS_Playlist }
328 extension = util.detect_filetype(filepath)
329 if parsers.has_key(extension): # importing a playlist
330 self.__log.info('Loading playlist file (%s)', extension)
331 parser = parsers[extension](self.filepath, self.__queue)
333 if parser.parse(filepath):
334 self.__queue = parser.get_queue()
335 self.__file_queued( filepath, True, False )
336 else:
337 return False
338 else: # importing a single file
339 error = not self.append(filepath, notify=False)
341 self.__queue.disable_notifications = True
342 self.load_from_resume_bookmark()
343 self.__queue.disable_notifications = False
345 self.__queue.modified = os.path.expanduser(
346 settings.temp_playlist ) == self.filepath
348 self.send_new_metadata( caller=self.load )
350 return not error
352 def load_last_played(self):
353 recent = self.get_recent_files(max_files=1)
354 if recent:
355 self.load(recent[0])
357 return bool(recent)
359 def __file_queued(self, filepath, successfull, notify):
360 if successfull:
361 self.__bookmarks_model_changed = True
362 self.notify( 'file_queued', filepath, successfull, notify,
363 caller=self.__file_queued )
365 return successfull
367 def append(self, filepath, notify=True):
368 self.__log.debug('Attempting to queue file: %s', filepath)
369 success = self.__queue.append(
370 PlaylistItem.create_by_filepath(filepath, filepath) )
372 return self.__file_queued( filepath, success, notify)
374 def insert(self, position, filepath ):
375 self.__log.debug(
376 'Attempting to insert %s at position %s', filepath, position )
377 return self.__file_queued( filepath, self.__queue.insert( position,
378 PlaylistItem.create_by_filepath(filepath, filepath)), True )
380 def load_directory(self, directory, append=False):
381 self.__log.debug('Attempting to load directory "%s"', directory)
383 if not append:
384 self.reset_playlist()
386 if os.path.isdir(directory):
387 self.filepath = settings.temp_playlist
388 self.__queue.playlist_id = self.id
389 items = []
391 for item in os.listdir(directory):
392 filepath = os.path.join( directory, item )
393 if os.path.isfile(filepath) and util.is_supported(filepath):
394 items.append(filepath)
396 items.sort()
397 for item in items:
398 self.append( item, notify=False )
399 else:
400 self.__log.warning('"%s" is not a directory.', directory)
401 return False
403 return True
405 ##################################
406 # Playlist controls
407 ##################################
409 def play(self):
410 """ This gets called by the player to get
411 the last time the file was paused """
412 pos = self.__queue.current_item.seek_to
413 self.__queue.current_item.seek_to = 0
414 return pos
416 def pause(self, position):
417 """ Called whenever the player is paused """
418 self.__queue.current_item.seek_to = position
420 def stop(self, position, save_resume_point=True):
421 """ This should be run when the program is closed
422 or if the user switches playlists """
424 self.remove_resume_bookmarks()
425 if not self.is_empty and save_resume_point:
426 self.__queue.current_item.save_bookmark(
427 _('Auto Bookmark'), position, True )
429 def skip(self, skip_by=None, skip_to=None, dont_loop=False):
430 """ Skip to another track in the playlist.
431 Use either skip_by or skip_to, skip_by has precedence.
432 skip_to: skip to a known playlist position
433 skip_by: skip by n number of episodes (positive or negative)
434 dont_loop: applies only to skip_by, if we're skipping past
435 the last track loop back to the begining.
437 if not self.__queue:
438 return False
440 current_item = self.__queue.current_item_position
442 if skip_by is not None:
443 if dont_loop:
444 skip = current_item + skip_by
445 else:
446 skip = ( current_item + skip_by ) % self.queue_length
447 elif skip_to is not None:
448 skip = skip_to
449 else:
450 self.__log.warning('No skip method provided...')
452 if not ( 0 <= skip < self.queue_length ):
453 self.__log.warning(
454 'Can\'t skip to non-existant file. (requested=%d, total=%d)',
455 skip, self.queue_length )
456 return False
458 self.__queue.current_item_position = skip
459 self.__log.debug('Skipping to file %d (%s)', skip,
460 self.__queue.current_item.filepath )
462 return True
464 def next(self):
465 """ Move the playlist to the next track.
466 False indicates end of playlist. """
467 return self.skip( skip_by=1, dont_loop=True )
469 def prev(self):
470 """ Same as next() except moves to the previous track. """
471 return self.skip( skip_by=-1, dont_loop=True )
474 class Queue(list, ObservableService):
475 """ A Simple list of PlaylistItems """
477 signals = [ 'current_item_changed', ]
479 def __init__(self, playlist_id):
480 self.__log = logging.getLogger('panucci.playlist.Queue')
481 ObservableService.__init__(self, self.signals, self.__log)
483 self.playlist_id = playlist_id
484 self.modified = False # Has the queue been modified?
485 self.disable_notifications = False
486 self.__current_item_position = 0
487 list.__init__(self)
489 def __get_current_item_position(self):
490 return self.__current_item_position
492 def __set__current_item_position(self, new_value):
494 # set the new position before notify()'ing
495 # or else we'll end up load the old file's metadata
496 old_value = self.__current_item_position
497 self.__current_item_position = new_value
499 if old_value != new_value:
500 self.__log.debug( 'Current item changed from %d to %d',
501 old_value, new_value )
502 if not self.disable_notifications:
503 self.notify( 'current_item_changed',
504 caller=self.__set__current_item_position )
506 current_item_position = property(
507 __get_current_item_position, __set__current_item_position )
509 def __count_dupe_items(self, subset, item):
510 # Count the number of duplicate items (by filepath only) in a list
511 tally = 0
512 for i in subset:
513 tally += int( i.filepath == item.filepath )
514 return tally
516 def __prep_item(self, item):
517 """ Do some error checking and other stuff that's
518 common to the insert and append functions """
520 assert isinstance( item, PlaylistItem )
521 item.playlist_id = self.playlist_id
523 if os.path.isfile(item.filepath) and util.is_supported(item.filepath):
524 self.modified = True
525 return True
526 else:
527 self.__log.warning(
528 'File not found or not supported: %s', item.filepath )
530 return False
532 @property
533 def current_item(self):
534 if len(self) > 0:
535 if self.current_item_position >= len(self):
536 self.__log.info( 'Current item position is greater '
537 'than queue length, resetting to 0.' )
538 self.current_item_position = 0
540 return self[self.current_item_position]
541 else:
542 self.__log.info('Queue is empty...')
544 def move_item(self, from_pos, to_pos):
545 old_current_item = self.current_item_position
547 temp = self[from_pos]
548 self.remove(str(temp))
549 self.insert(to_pos, temp)
551 if old_current_item == from_pos:
552 self.__current_item_position = to_pos
554 def clear(self):
555 """ Reset the the queue to a known state """
557 self[:] = []
558 self.playlist_id = None
559 self.modified = False
560 self.__current_item_position = 0
562 def get_item(self, item_id):
563 if self.count(item_id):
564 return self[self.index(item_id)]
566 def get_bookmark(self, item_id, bookmark_id):
567 item = self.get_item(item_id)
569 if item is None:
570 self.__log.warning(
571 'Item with id "%s" not found, scanning for item...', item_id )
573 for item_ in self:
574 if item_.bookmarks.count(bookmark_id):
575 item = item_
576 break
578 if item is None: return None, None
580 if item.bookmarks.count(bookmark_id):
581 return item, item.bookmarks[item.bookmarks.index(bookmark_id)]
582 else:
583 return item, None
585 def set_new_playlist_id(self, id):
586 self.playlist_id = id
587 for item in self:
588 item.playlist_id = id
589 for bookmark in item.bookmarks:
590 bookmark.playlist_id = id
591 bookmark.save()
593 def insert(self, position, item):
594 if not self.__prep_item(item):
595 return False
597 item.duplicate_id = self[:position].count(item)
599 if self.__count_dupe_items(self[position:], item):
600 for i in self[position:]:
601 if i.filepath == item.filepath:
602 i.is_modified = True
603 i.duplicate_id += 1
604 elif not self.__count_dupe_items(self[:position], item):
605 # there are no other items like this one so it's *safe* to load
606 # bookmarks without a potential conflict, but there's a good chance
607 # that there aren't any bookmarks to load (might be useful in the
608 # event of a crash)...
609 item.load_bookmarks()
611 if position <= self.current_item_position:
612 self.__current_item_position += 1
614 list.insert(self, position, item)
615 return True
617 def append(self, item):
618 if not self.__prep_item(item):
619 return False
621 item.duplicate_id = self.__count_dupe_items(self, item)
622 item.load_bookmarks()
624 list.append(self, item)
625 return True
627 def remove(self, item):
628 if self.count(item):
629 self.modified = True
631 if self.index(item) < self.current_item_position:
632 self.__current_item_position -= 1
634 list.remove(self, item)
636 def extend(self, items):
637 self.__log.warning('FIXME: extend not supported yet...')
639 def pop(self, item):
640 self.__log.warning('FIXME: pop not supported yet...')
642 class PlaylistItem(object):
643 """ A (hopefully) lightweight object to hold the bare minimum amount of
644 data about a single item in a playlist and it's bookmark objects. """
646 def __init__(self):
647 self.__log = logging.getLogger('panucci.playlist.PlaylistItem')
649 # metadata that's pulled from the playlist file (pls/extm3u)
650 self.reported_filepath = None
651 self.title = None
652 self.length = None
654 self.playlist_id = None
655 self.filepath = None
656 self.duplicate_id = 0
657 self.seek_to = 0
659 # a flag to determine whether the item's bookmarks need updating
660 # ( used for example, if the duplicate_id is changed )
661 self.is_modified = False
662 self.bookmarks = []
664 @staticmethod
665 def create_by_filepath(reported_filepath, filepath):
666 item = PlaylistItem()
667 item.reported_filepath = reported_filepath
668 item.filepath = filepath
669 return item
671 def __eq__(self, b):
672 if isinstance( b, PlaylistItem ):
673 return ( self.filepath == b.filepath and
674 self.duplicate_id == b.duplicate_id )
675 elif isinstance( b, str ):
676 return str(self) == b
677 else:
678 self.__log.warning('Unsupported comparison: %s', type(b))
679 return False
681 def __str__(self):
682 uid = self.filepath + str(self.duplicate_id)
683 return md5(uid).hexdigest()
685 @property
686 def metadata(self):
687 """ Metadata is only needed once, so fetch it on-the-fly
688 If needed this could easily be cached at the cost of wasting a
689 bunch of memory """
691 m = FileMetadata(self.filepath)
692 metadata = m.get_metadata()
693 del m # *hopefully* save some memory
694 return metadata
696 @property
697 def filetype(self):
698 return util.detect_filetype(self.filepath)
700 def load_bookmarks(self):
701 self.bookmarks = db.load_bookmarks(
702 factory = Bookmark().load_from_dict,
703 playlist_id = self.playlist_id,
704 bookmark_filepath = self.filepath,
705 playlist_duplicate_id = self.duplicate_id,
706 request_resume_bookmark = None )
708 def save_bookmark(self, name, position, resume_pos=False):
709 b = Bookmark()
710 b.playlist_id = self.playlist_id
711 b.bookmark_name = name
712 b.bookmark_filepath = self.filepath
713 b.seek_position = position
714 b.timestamp = time.time()
715 b.is_resume_position = resume_pos
716 b.playlist_duplicate_id = self.duplicate_id
717 b.save()
718 self.bookmarks.append(b)
720 def delete_bookmark(self, bookmark_id):
721 """ WARNING: if bookmark_id is None, ALL bookmarks will be deleted """
722 if bookmark_id is None:
723 self.__log.debug(
724 'Deleting all bookmarks for %s', self.reported_filepath )
725 for bkmk in self.bookmarks:
726 bkmk.delete()
727 else:
728 bkmk = self.bookmarks.index(bookmark_id)
729 if bkmk >= 0:
730 self.bookmarks[bkmk].delete()
731 self.bookmarks.remove(bookmark_id)
732 else:
733 self.__log.info('Cannot find bookmark with id: %s',bookmark_id)
734 return False
735 return True
737 def update_bookmarks(self):
738 for bookmark in self.bookmarks:
739 bookmark.playlist_duplicate_id = self.duplicate_id
740 bookmark.bookmark_filepath = self.filepath
741 db.update_bookmark(bookmark)
743 class Bookmark(object):
744 """ A single bookmark, nothing more, nothing less. """
746 def __init__(self):
747 self.__log = logging.getLogger('panucci.playlist.Bookmark')
749 self.id = 0
750 self.playlist_id = None
751 self.bookmark_name = ''
752 self.bookmark_filepath = ''
753 self.seek_position = 0
754 self.timestamp = 0
755 self.is_resume_position = False
756 self.playlist_duplicate_id = 0
758 @staticmethod
759 def load_from_dict(bkmk_dict):
760 bkmkobj = Bookmark()
762 for key,value in bkmk_dict.iteritems():
763 if hasattr( bkmkobj, key ):
764 setattr( bkmkobj, key, value )
765 else:
766 self.__log.info('Attr: %s doesn\'t exist...', key)
768 return bkmkobj
770 def save(self):
771 self.id = db.save_bookmark(self)
772 return self.id
774 def delete(self):
775 return db.remove_bookmark(self.id)
777 def __eq__(self, b):
778 if isinstance(b, str):
779 return str(self) == b
780 else:
781 self.__log.warning('Unsupported comparison: %s', type(b))
782 return False
784 def __str__(self):
785 uid = self.bookmark_filepath
786 uid += str(self.playlist_duplicate_id)
787 uid += str(self.seek_position)
788 return md5(uid).hexdigest()
790 def __cmp__(self, b):
791 if self.bookmark_filepath == b.bookmark_filepath:
792 if self.seek_position == b.seek_position:
793 return 0
794 else:
795 return -1 if self.seek_position < b.seek_position else 1
796 else:
797 self.__log.info(
798 'Can\'t compare bookmarks from different files:\n\tself: %s'
799 '\n\tb: %s', self.bookmark_filepath, b.bookmark_filepath )
800 return 0
802 class FileMetadata(object):
803 """ A class to hold all information about the file that's currently being
804 played. Basically it takes care of metadata extraction... """
806 coverart_names = ['cover', 'cover.jpg', 'cover.png']
807 tag_mappings = {
808 'mp4': { '\xa9nam': 'title',
809 '\xa9ART': 'artist',
810 '\xa9alb': 'album',
811 'covr': 'coverart' },
812 'mp3': { 'TIT2': 'title',
813 'TPE1': 'artist',
814 'TALB': 'album',
815 'APIC': 'coverart' },
816 'ogg': { 'title': 'title',
817 'artist': 'artist',
818 'album': 'album' },
820 tag_mappings['m4a'] = tag_mappings['mp4']
821 tag_mappings['flac'] = tag_mappings['ogg']
823 def __init__(self, filepath):
824 self.__log = logging.getLogger('panucci.playlist.FileMetadata')
825 self.__filepath = filepath
827 self.title = ''
828 self.artist = ''
829 self.album = ''
830 self.length = 0
831 self.coverart = None
833 self.__metadata_extracted = False
835 def extract_metadata(self):
836 filetype = util.detect_filetype(self.__filepath)
838 if filetype == 'mp3':
839 import mutagen.mp3 as meta_parser
840 elif filetype == 'ogg':
841 import mutagen.oggvorbis as meta_parser
842 elif filetype == 'flac':
843 import mutagen.flac as meta_parser
844 elif filetype in ['mp4', 'm4a']:
845 import mutagen.mp4 as meta_parser
846 else:
847 self.__log.info(
848 'Extracting metadata not supported for %s files.', filetype )
849 return False
851 try:
852 metadata = meta_parser.Open(self.__filepath)
853 except Exception, e:
854 self.title = util.pretty_filename(self.__filepath)
855 self.__log.exception('Error running metadata parser...')
856 return False
858 self.length = metadata.info.length * 10**9
859 for tag,value in metadata.iteritems():
860 if tag.find(':') != -1: # hack for weirdly named coverart tags
861 tag = tag.split(':')[0]
863 if self.tag_mappings[filetype].has_key(tag):
864 if isinstance( value, list ):
865 if len(value):
866 # Here we could either join the list or just take one
867 # item. I chose the latter simply because some ogg
868 # files have several messed up titles...
869 value = value[0]
870 else:
871 continue
873 if self.tag_mappings[filetype][tag] != 'coverart':
874 try:
875 value = escape(str(value))
876 except Exception, e:
877 self.__log.exception(
878 'Could not convert tag (%s) to escaped string', tag )
879 else:
880 # some coverart classes store the image in the data
881 # attribute whereas others do not :S
882 if hasattr( value, 'data' ):
883 value = value.data
885 setattr( self, self.tag_mappings[filetype][tag], value )
887 if not str(self.title).strip():
888 self.title = util.pretty_filename(self.__filepath)
890 if self.coverart is None:
891 self.coverart = self.__find_coverart()
893 def __find_coverart(self):
894 """ Find coverart in the same directory as the filepath """
895 directory = os.path.dirname(self.__filepath)
896 for cover in self.coverart_names:
897 c = os.path.join( directory, cover )
898 if os.path.isfile(c):
899 try:
900 f.open(c,'r')
901 binary_coverart = f.read()
902 f.close()
903 return binary_coverart
904 except:
905 pass
906 return None
908 def get_metadata(self):
909 """ Returns a dict of metadata """
911 if not self.__metadata_extracted:
912 self.__log.debug('Extracting metadata for %s', self.__filepath)
913 self.extract_metadata()
914 self.__metadata_extracted = True
916 metadata = {
917 'title': self.title,
918 'artist': self.artist,
919 'album': self.album,
920 'image': self.coverart,
921 'length': self.length
924 return metadata
926 class PlaylistFile(object):
927 """ The base class for playlist file parsers/exporters,
928 this should not be used directly but instead subclassed. """
930 def __init__(self, filepath, queue):
931 self.__log = logging.getLogger('panucci.playlist.PlaylistFile')
932 self._filepath = filepath
933 self._file = None
934 self._items = queue
936 def __open_file(self, filepath, mode):
937 if self._file is not None:
938 self.close_file()
940 try:
941 self._file = open( filepath, mode )
942 self._filepath = filepath
943 except Exception, e:
944 self._filepath = None
945 self._file = None
947 self.__log.exception( 'Error opening file: %s', filepath)
948 return False
950 return True
952 def __close_file(self):
953 error = False
955 if self._file is not None:
956 try:
957 self._file.close()
958 except Exception, e:
959 self.__log.exception( 'Error closing file: %s', self.filepath )
960 error = True
962 self._filepath = None
963 self._file = None
965 return not error
967 def get_absolute_filepath(self, item_filepath):
968 if item_filepath is None: return
970 if item_filepath.startswith('/'):
971 path = item_filepath
972 else:
973 path = os.path.join(os.path.dirname(self._filepath), item_filepath)
975 if os.path.exists( path ):
976 return path
978 def get_filelist(self):
979 return [ item.filepath for item in self._items ]
981 def get_filedicts(self):
982 dict_list = []
983 for item in self._items:
984 d = { 'title': item.title,
985 'length': item.length,
986 'filepath': item.filepath }
988 dict_list.append(d)
989 return dict_list
991 def get_queue(self):
992 return self._items
994 def export_items(self, filepath=None):
995 if filepath is not None:
996 self._filepath = filepath
998 if self.__open_file(filepath, 'w'):
999 self.export_hook(self._items)
1000 self.__close_file()
1001 return True
1002 else:
1003 return False
1005 def export_hook(self, playlist_items):
1006 pass
1008 def parse(self, filepath):
1009 if self.__open_file( filepath, mode='r' ):
1010 current_line = self._file.readline()
1011 while current_line:
1012 self.parse_line_hook( current_line.strip() )
1013 current_line = self._file.readline()
1014 self.__close_file()
1015 self.parse_eof_hook()
1016 else:
1017 return False
1018 return True
1020 def parse_line_hook(self, line):
1021 pass
1023 def parse_eof_hook(self):
1024 pass
1026 def _add_playlist_item(self, item):
1027 path = self.get_absolute_filepath(item.reported_filepath)
1028 if path is not None and os.path.isfile(path):
1029 item.filepath = path
1030 self._items.append(item)
1032 class M3U_Playlist(PlaylistFile):
1033 """ An (extended) m3u parser/writer """
1035 def __init__(self, *args):
1036 self.__log = logging.getLogger('panucci.playlist.M3U_Playlist')
1037 PlaylistFile.__init__( self, *args )
1038 self.extended_m3u = False
1039 self.current_item = PlaylistItem()
1041 def parse_line_hook(self, line):
1042 if line.startswith('#EXTM3U'):
1043 self.extended_m3u = True
1044 elif self.extended_m3u and line.startswith('#EXTINF'):
1045 match = re.match('#EXTINF:([^,]+),(.*)', line)
1046 if match is not None:
1047 length, title = match.groups()
1048 try: length = int(length)
1049 except: pass
1050 self.current_item.length = length
1051 self.current_item.title = title
1052 elif line.startswith('#'):
1053 pass # skip comments
1054 elif line:
1055 path = self.get_absolute_filepath( line )
1056 if path is not None:
1057 if os.path.isfile( path ):
1058 self.current_item.reported_filepath = line
1059 self._add_playlist_item(self.current_item)
1060 self.current_item = PlaylistItem()
1061 elif os.path.isdir( path ):
1062 files = os.listdir( path )
1063 for file in files:
1064 item = PlaylistItem()
1065 item.reported_filepath = os.path.join(line, file)
1066 self._add_playlist_item(item)
1068 def export_hook(self, playlist_items):
1069 self._file.write('#EXTM3U\n\n')
1071 for item in playlist_items:
1072 string = ''
1073 if not ( item.length is None and item.title is None ):
1074 length = -1 if item.length is None else item.length
1075 title = '' if item.title is None else item.title
1076 string += '#EXTINF:%d,%s\n' % ( length, title )
1078 string += '%s\n' % item.filepath
1079 self._file.write(string)
1081 class PLS_Playlist(PlaylistFile):
1082 """ A somewhat simple pls parser/writer """
1084 def __init__(self, *args):
1085 self.__log = logging.getLogger('panucci.playlist.PLS_Playlist')
1086 PlaylistFile.__init__( self, *args )
1087 self.current_item = PlaylistItem()
1088 self.in_playlist_section = False
1089 self.current_item_number = None
1091 def __add_current_item(self):
1092 self._add_playlist_item(self.current_item)
1094 def parse_line_hook(self, line):
1095 sect_regex = '\[([^\]]+)\]'
1096 item_regex = '[^\d]+([\d]+)=(.*)'
1098 if re.search(item_regex, line) is not None:
1099 current = re.search(item_regex, line).group(1)
1100 if self.current_item_number is None:
1101 self.current_item_number = current
1102 elif self.current_item_number != current:
1103 self.__add_current_item()
1105 self.current_item = PlaylistItem()
1106 self.current_item_number = current
1108 if re.search(sect_regex, line) is not None:
1109 section = re.match(sect_regex, line).group(1).lower()
1110 self.in_playlist_section = section == 'playlist'
1111 elif not self.in_playlist_section:
1112 pass # don't do anything if we're not in [playlist]
1113 elif line.lower().startswith('file'):
1114 self.current_item.reported_filepath = re.search(
1115 item_regex, line).group(2)
1116 elif line.lower().startswith('title'):
1117 self.current_item.title = re.search(item_regex, line).group(2)
1118 elif line.lower().startswith('length'):
1119 try: length = int(re.search(item_regex, line).group(2))
1120 except: pass
1121 self.current_item.length = length
1123 def parse_eof_hook(self):
1124 self.__add_current_item()
1126 def export_hook(self, playlist_items):
1127 self._file.write('[playlist]\n')
1128 self._file.write('NumberOfEntries=%d\n\n' % len(playlist_items))
1130 for i,item in enumerate(playlist_items):
1131 title = '' if item.title is None else item.title
1132 length = -1 if item.length is None else item.length
1133 self._file.write('File%d=%s\n' % (i+1, item.filepath))
1134 self._file.write('Title%d=%s\n' % (i+1, title))
1135 self._file.write('Length%d=%s\n\n' % (i+1, length))
1137 self._file.write('Version=2\n')