Add example code for scrolling widget
[panucci.git] / src / panucci / playlist.py
blob0f83a9529a954ce6d30a1b391d96f50584992d39
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
36 def is_supported( filename ):
37 if settings.supported_extensions:
38 supported = settings.supported_extensions.lower().split(',')
39 filepath, extension = os.path.splitext(filename)
40 return extension.lower().replace('.','') in supported
41 else:
42 return True # if the string is empty, allow anything
44 class Playlist(ObservableService):
45 signals = [ 'new-track-playing', 'new-metadata-available', 'file_queued',
46 'bookmark_added', 'seek-requested', 'end-of-playlist',
47 'playlist-to-be-overwritten', 'stop-requested' ]
49 def __init__(self):
50 self.__log = logging.getLogger('panucci.playlist.Playlist')
51 ObservableService.__init__(self, self.signals, self.__log)
53 self.__queue = Queue(None)
54 self.__queue.register(
55 'current_item_changed', self.on_queue_current_item_changed )
57 self.reset_playlist()
59 def reset_playlist(self):
60 """ Sets the playlist to a default "known" state """
62 self.filepath = None
63 self._id = None
64 self.__queue.clear()
66 @property
67 def id(self):
68 if self.filepath is None:
69 self.__log.warning("Can't get playlist id without having filepath")
70 elif self._id is None:
71 self._id = db.get_playlist_id( self.filepath, True, True )
73 return self._id
75 @property
76 def current_filepath(self):
77 """ Get the current file """
78 if not self.is_empty:
79 return self.__queue.current_item.filepath
81 @property
82 def queue_modified(self):
83 return self.__queue.modified
85 @property
86 def queue_length(self):
87 return len(self.__queue)
89 @property
90 def is_empty(self):
91 return not self.__queue
93 def print_queue_layout(self):
94 """ This helps with debugging ;) """
95 for item in self.__queue:
96 print str(item), item.playlist_reported_filepath
97 for bookmark in item.bookmarks:
98 print '\t', str(bookmark), bookmark.bookmark_filepath
100 def save_to_new_playlist(self, filepath, playlist_type='m3u'):
101 self.filepath = filepath
102 self._id = None
104 playlist = { 'm3u': M3U_Playlist, 'pls': PLS_Playlist }
105 if not playlist.has_key(playlist_type):
106 playlist_type = 'm3u' # use m3u by default
107 self.filepath += '.m3u'
109 playlist = playlist[playlist_type](self.filepath, self.__queue)
110 if not playlist.export_items( filepath ):
111 self.__log.error('Error exporting playlist to %s', self.filepath)
112 return False
114 # copy the bookmarks over to new playlist
115 db.remove_all_bookmarks(self.id)
116 self.__queue.set_new_playlist_id(self.id)
118 return True
120 def save_temp_playlist(self):
121 filepath = os.path.expanduser(settings.temp_playlist)
122 return self.save_to_new_playlist(filepath)
124 def on_queue_current_item_changed(self):
125 self.notify( 'new-track-playing',
126 caller=self.on_queue_current_item_changed )
127 self.notify( 'new-metadata-available',
128 caller=self.on_queue_current_item_changed )
130 def send_metadata(self):
131 self.notify( 'new-metadata-available', caller=self.send_metadata )
133 def quit(self):
134 self.__log.debug('quit() called.')
135 if self.__queue.modified:
136 self.__log.info('Queue modified, saving temporary playlist')
137 self.save_temp_playlist()
139 ######################################
140 # Bookmark-related functions
141 ######################################
143 def __load_from_bookmark( self, item_id, bookmark ):
144 new_pos = self.__queue.index(item_id)
145 same_pos = self.__queue.current_item_position == new_pos
146 self.__queue.current_item_position = new_pos
148 if bookmark is None:
149 self.__queue.current_item.seek_to = 0
150 else:
151 self.__queue.current_item.seek_to = bookmark.seek_position
153 # if we don't request a seek nothing will happen
154 if same_pos:
155 self.notify( 'seek-requested', bookmark.seek_position,
156 caller=self.__load_from_bookmark )
158 return True
160 def load_from_bookmark_id( self, item_id=None, bookmark_id=None ):
161 item, bookmark = self.__queue.get_bookmark(item_id, bookmark_id)
163 if item is not None:
164 return self.__load_from_bookmark( str(item), bookmark )
165 else:
166 self.__log.warning(
167 'item_id=%s,bookmark_id=%s not found', item_id, bookmark_id )
168 return False
170 def find_resume_bookmark(self):
171 """ Find a resume bookmark in the queue """
172 for item in self.__queue:
173 for bookmark in item.bookmarks:
174 if bookmark.is_resume_position:
175 return str(item), str(bookmark)
176 else:
177 return None, None
179 def load_from_resume_bookmark(self):
180 item_id, bookmark_id = self.find_resume_bookmark()
181 if None in ( item_id, bookmark_id ):
182 self.__log.info('No resume bookmark found.')
183 return False
184 else:
185 return self.load_from_bookmark_id( item_id, bookmark_id )
187 def save_bookmark( self, bookmark_name, position ):
188 if self.__queue.current_item is not None:
189 self.__queue.current_item.save_bookmark( bookmark_name, position,
190 resume_pos=False )
191 self.notify( 'bookmark_added', str(self.__queue.current_item),
192 bookmark_name, position, caller=self.save_bookmark )
194 def update_bookmark(self, item_id, bookmark_id, name=None, seek_pos=None):
195 item, bookmark = self.__queue.get_bookmark(item_id, bookmark_id)
197 if item is None:
198 self.__log.warning('No such item id (%s)', item_id)
199 return False
201 if bookmark_id is not None and bookmark is None:
202 self.__log.warning('No such bookmark id (%s)', bookmark_id)
203 return False
205 if bookmark_id is None:
206 if name and item.title != name:
207 item.title = name
208 self.__queue.modified = True
209 if self.__queue.current_item == item:
210 self.notify( 'new-metadata-available',
211 caller=self.update_bookmark )
212 else:
213 bookmark.timestamp = time.time()
215 if name is not None:
216 bookmark.bookmark_name = name
218 if seek_pos is not None:
219 bookmark.seek_position = seek_pos
221 db.update_bookmark(bookmark)
223 return True
225 def update_bookmarks(self):
226 """ Updates the database entries for items that have been modified """
227 for item in self.__queue:
228 if item.is_modified:
229 self.__log.debug(
230 'Playlist Item "%s" is modified, updating bookmarks', item)
231 item.update_bookmarks()
232 item.is_modified = False
234 def remove_bookmark( self, item_id, bookmark_id ):
235 item = self.__queue.get_item(item_id)
237 if item is None:
238 self.__log.info('Cannot find item with id: %s', item_id)
239 return False
241 if bookmark_id is None:
242 if self.__queue.current_item_position == self.__queue.index(item_id):
243 self.next()
245 item.delete_bookmark(None)
246 self.__queue.remove(item_id)
247 else:
248 item.delete_bookmark(bookmark_id)
250 return True
252 def remove_resume_bookmarks(self):
253 item_id, bookmark_id = self.find_resume_bookmark()
255 if None in ( item_id, bookmark_id ):
256 return False
257 else:
258 return self.remove_bookmark( item_id, bookmark_id )
260 def move_item( self, from_row, to_row ):
261 self.__log.info('Moving item from position %d to %d', from_row, to_row)
262 assert isinstance(from_row, int) and isinstance(to_row, int)
263 self.__queue.move_item(from_row, to_row)
265 #####################################
266 # Model-builder functions
267 #####################################
269 def get_item_by_id(self, item_id):
270 """ Gets a PlaylistItem from it's unique id """
272 item, bookmark = self.__queue.get_bookmark(item_id, None)
273 if item is None:
274 self.__log.warning('Cannot get item for id: %s', item_id)
276 return item
278 def get_playlist_item_ids(self):
279 """ Returns an iterator which yields a tuple which contains the
280 item's unique ID and a dict of interesting data (currently
281 just the title). """
283 for item in self.__queue:
284 yield str(item), { 'title' : item.title }
286 def get_bookmarks_from_item_id(self, item_id, include_resume_marks=False):
287 """ Returns an iterator which yields the following data regarding a
288 bookmark: ( bookmark id, a custom name, the seek position ) """
290 item = self.get_item_by_id( item_id )
291 if item is not None:
292 for bkmk in item.bookmarks:
293 if not bkmk.is_resume_position or include_resume_marks:
294 yield str(bkmk), bkmk.bookmark_name, bkmk.seek_position
296 ######################################
297 # File-related convenience functions
298 ######################################
300 def get_current_position(self):
301 """ Returns the saved position for the current
302 file or 0 if no file is available"""
303 if not self.is_empty:
304 return self.__queue.current_item.seek_to
305 else:
306 return 0
308 def get_current_filetype(self):
309 """ Returns the filetype of the current
310 file or None if no file is available """
312 if not self.is_empty:
313 return self.__queue.current_item.filetype
315 def get_file_metadata(self):
316 """ Return the metadata associated with the current FileObject """
317 if not self.is_empty:
318 return self.__queue.current_item.metadata
319 else:
320 return {}
322 def get_current_filepath(self):
323 if not self.is_empty:
324 return self.__queue.current_item.filepath
326 def get_recent_files(self, max_files=10):
327 files = db.get_latest_files()
329 if len(files) > max_files:
330 return files[:max_files]
331 else:
332 return files
334 ##################################
335 # File importing functions
336 ##################################
338 def load(self, filepath, play=True):
339 """ Detects filepath's filetype then loads it using
340 the appropriate loader function """
341 self.__log.debug('Attempting to load %s', filepath)
343 if ( self.queue_modified and
344 not self.notify( 'playlist-to-be-overwritten', caller=self.load )):
346 self.__log.info('Loading file aborted by user.')
347 return False
349 self.notify( 'stop-requested', caller=self.load )
351 error = False
352 self.reset_playlist()
353 self.filepath = filepath
354 self.__queue.playlist_id = self.id
356 parsers = { 'm3u': M3U_Playlist, 'pls': PLS_Playlist }
357 extension = util.detect_filetype(filepath)
358 if parsers.has_key(extension): # importing a playlist
359 self.__log.info('Loading playlist file (%s)', extension)
360 parser = parsers[extension](self.filepath, self.__queue)
362 if parser.parse(filepath):
363 self.__queue = parser.get_queue()
364 self.__file_queued( filepath, True, False )
365 else:
366 return False
367 else: # importing a single file
368 error = not self.append(filepath, notify=False)
370 # if we let the queue emit a current_item_changed signal (which will
371 # happen if load_from_bookmark changes the current track), the player
372 # will start playing and ingore the resume point
373 self.__queue.disable_notifications = True
374 self.load_from_resume_bookmark()
375 self.__queue.disable_notifications = False
377 self.__queue.modified = os.path.expanduser(
378 settings.temp_playlist ) == self.filepath
380 # This is hacky: don't send 'new-track-playing' when first started
381 # otherwise the player will start playing, just send the metadata.
382 if play:
383 self.notify( 'new-track-playing', caller=self.load )
385 self.notify( 'new-metadata-available', caller=self.load )
387 return not error
389 def load_last_played(self):
390 recent = self.get_recent_files(max_files=1)
391 if recent:
392 self.load(recent[0], play=False)
394 return bool(recent)
396 def __file_queued(self, filepath, successfull, notify):
397 if successfull:
398 self.notify( 'file_queued', filepath, successfull, notify,
399 caller=self.__file_queued )
401 return successfull
403 def append(self, filepath, notify=True):
404 self.__log.debug('Attempting to queue file: %s', filepath)
405 success = self.__queue.append(
406 PlaylistItem.create_by_filepath(filepath, filepath) )
408 return self.__file_queued( filepath, success, notify)
410 def insert(self, position, filepath ):
411 self.__log.debug(
412 'Attempting to insert %s at position %s', filepath, position )
413 return self.__file_queued( filepath, self.__queue.insert( position,
414 PlaylistItem.create_by_filepath(filepath, filepath)), True )
416 def load_directory(self, directory, append=False):
417 self.__log.debug('Attempting to load directory "%s"', directory)
419 if not os.path.isdir(directory):
420 self.__log.warning('"%s" is not a directory.', directory)
421 return False
423 if not append:
424 if self.notify( 'playlist-to-be-overwritten',
425 caller=self.load_directory ):
426 self.reset_playlist()
427 else:
428 self.__log.info('Directory load aborted by user.')
429 return False
431 self.filepath = settings.temp_playlist
432 self.__queue.playlist_id = self.id
434 items = []
435 potential_items = os.listdir(directory)
436 potential_items.sort()
438 for item in potential_items:
439 filepath = os.path.join( directory, item )
440 if os.path.isfile(filepath) and is_supported(filepath):
441 items.append(filepath)
443 items.sort()
444 for item in items:
445 self.append( item, notify=False )
447 if not append:
448 self.on_queue_current_item_changed()
450 return True
452 ##################################
453 # Playlist controls
454 ##################################
456 def play(self):
457 """ This gets called by the player to get
458 the last time the file was paused """
459 pos = self.__queue.current_item.seek_to
460 self.__queue.current_item.seek_to = 0
461 return pos
463 def pause(self, position):
464 """ Called whenever the player is paused """
465 self.__queue.current_item.seek_to = position
467 def stop(self, position, save_resume_point=True):
468 """ This should be run when the program is closed
469 or if the user switches playlists """
471 self.remove_resume_bookmarks()
472 if not self.is_empty and save_resume_point:
473 self.__queue.current_item.save_bookmark(
474 _('Auto Bookmark'), position, True )
476 def skip(self, skip_by=None, skip_to=None, dont_loop=False):
477 """ Skip to another track in the playlist.
478 Use either skip_by or skip_to, skip_by has precedence.
479 skip_to: skip to a known playlist position
480 skip_by: skip by n number of episodes (positive or negative)
481 dont_loop: prevents looping if the track requested lays out of
482 the 0 to queue_length-1 boundary.
484 if not self.__queue:
485 return False
487 current_item = self.__queue.current_item_position
489 if skip_by is not None:
490 skip = current_item + skip_by
491 elif skip_to is not None:
492 skip = skip_to
493 else:
494 skip = 0
495 self.__log.warning('No skip method provided...')
497 if not 0 <= skip < self.queue_length:
498 self.notify( 'end-of-playlist', not dont_loop, caller=self.skip )
500 if dont_loop:
501 self.__log.warning( "Can't skip to non-existant file w/o loop."
502 " (requested=%d, total=%d)", skip,
503 self.queue_length )
504 return False
505 else:
506 # If skip_by is given as an argument, we assume the user knows
507 # what they're doing. Ie. if the queue length is 5, current
508 # track is 3 and they pass skip_by=-9, then they'll end up
509 # at 4. On the other hand, if skip_to is passed then we skip
510 # back to 0 because in that case the user must enter a number
511 # from 0 to queue_length-1, anything else is an error.
512 if skip_by is not None:
513 skip %= self.queue_length
514 else:
515 skip = 0
517 self.__queue.current_item_position = skip
518 self.__log.debug( 'Skipping to file %d (%s)', skip,
519 self.__queue.current_item.filepath )
521 return True
523 def get_current_item(self):
524 return self.__queue.current_item
526 def next(self):
527 """ Move the playlist to the next track.
528 False indicates end of playlist. """
529 return self.skip( skip_by=1, dont_loop=True )
531 def prev(self):
532 """ Same as next() except moves to the previous track. """
533 return self.skip( skip_by=-1, dont_loop=True )
536 class Queue(list, ObservableService):
537 """ A Simple list of PlaylistItems """
539 signals = [ 'current_item_changed', ]
541 def __init__(self, playlist_id):
542 self.__log = logging.getLogger('panucci.playlist.Queue')
543 ObservableService.__init__(self, self.signals, self.__log)
545 self.playlist_id = playlist_id
546 self.modified = False # Has the queue been modified?
547 self.disable_notifications = False
548 self.__current_item_position = 0
549 # This is a hack and WILL BE REPLACED WITH SOMETHING BETTER.
550 # it's here to speed up the get_item function
551 self.__mapping_dict = {}
552 list.__init__(self)
554 def __get_current_item_position(self):
555 return self.__current_item_position
557 def __set__current_item_position(self, new_value):
559 # set the new position before notify()'ing
560 # or else we'll end up load the old file's metadata
561 old_value = self.__current_item_position
562 self.__current_item_position = new_value
564 if old_value != new_value:
565 self.__log.debug( 'Current item changed from %d to %d',
566 old_value, new_value )
567 if not self.disable_notifications:
568 self.notify( 'current_item_changed',
569 caller=self.__set__current_item_position )
571 current_item_position = property(
572 __get_current_item_position, __set__current_item_position )
574 def __count_dupe_items(self, subset, item):
575 # Count the number of duplicate items (by filepath only) in a list
576 tally = 0
577 for i in subset:
578 tally += int( i.filepath == item.filepath )
579 return tally
581 def __prep_item(self, item):
582 """ Do some error checking and other stuff that's
583 common to the insert and append functions """
585 assert isinstance( item, PlaylistItem )
586 item.playlist_id = self.playlist_id
588 if os.path.isfile(item.filepath) and is_supported(item.filepath):
589 self.modified = True
590 return True
591 else:
592 self.__log.warning(
593 'File not found or not supported: %s', item.filepath )
595 return False
597 @property
598 def current_item(self):
599 if len(self) > 0:
600 if self.current_item_position >= len(self):
601 self.__log.info( 'Current item position is greater '
602 'than queue length, resetting to 0.' )
603 self.current_item_position = 0
605 return self[self.current_item_position]
606 else:
607 self.__log.info('Queue is empty...')
609 def move_item(self, from_pos, to_pos):
610 old_current_item = self.current_item_position
612 temp = self[from_pos]
613 self.remove(str(temp))
614 self.insert(to_pos, temp)
616 if old_current_item == from_pos:
617 self.__current_item_position = to_pos
619 def clear(self):
620 """ Reset the the queue to a known state """
622 self[:] = []
623 self.playlist_id = None
624 self.modified = False
625 self.__current_item_position = 0
626 self.__mapping_dict = {}
628 def get_item(self, item_id):
629 return self.__mapping_dict.get(item_id)
631 def get_bookmark(self, item_id, bookmark_id):
632 item = self.get_item(item_id)
634 if item is None:
635 self.__log.warning(
636 'Item with id "%s" not found, scanning for item...', item_id )
638 for item_ in self:
639 if item_.bookmarks.count(bookmark_id):
640 item = item_
641 break
643 if item is None: return None, None
645 if item.bookmarks.count(bookmark_id):
646 return item, item.bookmarks[item.bookmarks.index(bookmark_id)]
647 else:
648 return item, None
650 def set_new_playlist_id(self, id):
651 self.playlist_id = id
652 for item in self:
653 item.playlist_id = id
654 for bookmark in item.bookmarks:
655 bookmark.playlist_id = id
656 bookmark.save()
658 def insert(self, position, item):
659 if not self.__prep_item(item):
660 return False
662 item.duplicate_id = self[:position].count(item)
664 if self.__count_dupe_items(self[position:], item):
665 for i in self[position:]:
666 if i.filepath == item.filepath:
667 i.is_modified = True
668 i.duplicate_id += 1
670 # to be safe rebuild self.__mapping_dict
671 self.__mapping_dict = dict([(str(i),i) for i in self])
672 elif not self.__count_dupe_items(self[:position], item):
673 # there are no other items like this one so it's *safe* to load
674 # bookmarks without a potential conflict, but there's a good chance
675 # that there aren't any bookmarks to load (might be useful in the
676 # event of a crash)...
677 item.load_bookmarks()
679 if position <= self.current_item_position:
680 self.__current_item_position += 1
682 self.__mapping_dict[str(item)] = item
683 list.insert(self, position, item)
684 return True
686 def append(self, item):
687 if not self.__prep_item(item):
688 return False
690 item.duplicate_id = self.__count_dupe_items(self, item)
691 item.load_bookmarks()
693 self.__mapping_dict[str(item)] = item
694 list.append(self, item)
695 return True
697 def remove(self, item):
698 if self.count(item):
699 self.modified = True
701 if self.index(item) < self.current_item_position:
702 self.__current_item_position -= 1
704 del self.__mapping_dict[str(item)]
705 list.remove(self, item)
707 def extend(self, items):
708 self.__log.warning('FIXME: extend not supported yet...')
710 def pop(self, item):
711 self.__log.warning('FIXME: pop not supported yet...')
713 class PlaylistItem(object):
714 """ A (hopefully) lightweight object to hold the bare minimum amount of
715 data about a single item in a playlist and it's bookmark objects. """
717 def __init__(self):
718 self.__log = logging.getLogger('panucci.playlist.PlaylistItem')
720 self.__filepath = None
721 self.__metadata = None
722 self.playlist_id = None
723 self.duplicate_id = 0
724 self.seek_to = 0
726 # metadata that's pulled from the playlist file (pls/extm3u)
727 self.playlist_reported_filepath = None
728 self.playlist_title = None
729 self.playlist_length = None
731 # a flag to determine whether the item's bookmarks need updating
732 # ( used for example, if the duplicate_id is changed )
733 self.is_modified = False
734 self.bookmarks = []
736 def __set_filepath(self, fp):
737 if fp != self.__filepath:
738 self.__filepath = fp
739 self.__metadata = FileMetadata(self.filepath)
740 # Don't extract Metadata right away, this makes opening large
741 # playlists _very_ slow. TODO: do this intelligently AND
742 # perhaps del the Metadata object if the file is no longer
743 # being used.
744 #self.__metadata.extract_metadata()
746 def __get_filepath(self):
747 return self.__filepath
749 filepath = property( __get_filepath, __set_filepath )
751 @staticmethod
752 def create_by_filepath(reported_filepath, filepath):
753 item = PlaylistItem()
754 item.playlist_reported_filepath = reported_filepath
755 item.filepath = filepath
756 return item
758 def __eq__(self, b):
759 if isinstance( b, PlaylistItem ):
760 return ( self.filepath == b.filepath and
761 self.duplicate_id == b.duplicate_id )
762 elif isinstance( b, str ):
763 return str(self) == b
764 else:
765 self.__log.warning('Unsupported comparison: %s', type(b))
766 return False
768 def __str__(self):
769 uid = self.filepath + str(self.duplicate_id)
770 return md5(uid).hexdigest()
772 @property
773 def metadata(self):
774 """ Returns a dict of metadata, wooo. """
776 metadata = self.__metadata.get_metadata()
777 metadata['title'] = self.title
778 return metadata
780 @property
781 def filetype(self):
782 return util.detect_filetype(self.filepath)
784 def __get_title(self):
785 """ Get the title of item, priority is (most important first):
786 1. the title given in playlist metadata
787 2. the title in the file's metadata (ex. ID3)
788 3. a "pretty" version of the filename """
790 if self.playlist_title is not None:
791 return self.playlist_title
792 elif self.__metadata.title:
793 return self.__metadata.title
794 else:
795 return util.pretty_filename(self.filepath)
797 # For now set the "playlist_title" because it has highest priority in the
798 # __get_title method. We might evenually want to create a separate
799 # __custom_title to preserve the playlist_title.
800 title = property(__get_title, lambda s,v: setattr(s, 'playlist_title', v))
802 @property
803 def length(self):
804 """ Get the lenght of the item priority is (most important first):
805 1. length as reported by mutagen
806 2. length found in playlist metadata
807 3. otherwise -1 when unknown """
809 if self.__metadata.length:
810 return self.__metadata.length
811 elif self.playlist_length:
812 return self.playlist_length
813 else:
814 return -1
816 def load_bookmarks(self):
817 self.bookmarks = db.load_bookmarks(
818 factory = Bookmark().load_from_dict,
819 playlist_id = self.playlist_id,
820 bookmark_filepath = self.filepath,
821 playlist_duplicate_id = self.duplicate_id,
822 request_resume_bookmark = None )
824 def save_bookmark(self, name, position, resume_pos=False):
825 b = Bookmark()
826 b.playlist_id = self.playlist_id
827 b.bookmark_name = name
828 b.bookmark_filepath = self.filepath
829 b.seek_position = position
830 b.timestamp = time.time()
831 b.is_resume_position = resume_pos
832 b.playlist_duplicate_id = self.duplicate_id
833 b.save()
834 self.bookmarks.append(b)
836 def delete_bookmark(self, bookmark_id):
837 """ WARNING: if bookmark_id is None, ALL bookmarks will be deleted """
838 if bookmark_id is None:
839 self.__log.debug( 'Deleting all bookmarks for %s',
840 self.playlist_reported_filepath )
842 for bkmk in self.bookmarks:
843 bkmk.delete()
844 else:
845 bkmk = self.bookmarks.index(bookmark_id)
846 if bkmk >= 0:
847 self.bookmarks[bkmk].delete()
848 self.bookmarks.remove(bookmark_id)
849 else:
850 self.__log.info('Cannot find bookmark with id: %s',bookmark_id)
851 return False
852 return True
854 def update_bookmarks(self):
855 for bookmark in self.bookmarks:
856 bookmark.playlist_duplicate_id = self.duplicate_id
857 bookmark.bookmark_filepath = self.filepath
858 db.update_bookmark(bookmark)
860 class Bookmark(object):
861 """ A single bookmark, nothing more, nothing less. """
863 def __init__(self):
864 self.__log = logging.getLogger('panucci.playlist.Bookmark')
866 self.id = 0
867 self.playlist_id = None
868 self.bookmark_name = ''
869 self.bookmark_filepath = ''
870 self.seek_position = 0
871 self.timestamp = 0
872 self.is_resume_position = False
873 self.playlist_duplicate_id = 0
875 @staticmethod
876 def load_from_dict(bkmk_dict):
877 bkmkobj = Bookmark()
879 for key,value in bkmk_dict.iteritems():
880 if hasattr( bkmkobj, key ):
881 setattr( bkmkobj, key, value )
882 else:
883 self.__log.info('Attr: %s doesn\'t exist...', key)
885 return bkmkobj
887 def save(self):
888 self.id = db.save_bookmark(self)
889 return self.id
891 def delete(self):
892 return db.remove_bookmark(self.id)
894 def __eq__(self, b):
895 if isinstance(b, str):
896 return str(self) == b
897 elif b is None:
898 return False
899 else:
900 self.__log.warning('Unsupported comparison: %s', type(b))
901 return False
903 def __str__(self):
904 uid = self.bookmark_filepath
905 uid += str(self.playlist_duplicate_id)
906 uid += str(self.seek_position)
907 return md5(uid).hexdigest()
909 def __cmp__(self, b):
910 if self.bookmark_filepath == b.bookmark_filepath:
911 if self.seek_position == b.seek_position:
912 return 0
913 else:
914 return -1 if self.seek_position < b.seek_position else 1
915 else:
916 self.__log.info(
917 'Can\'t compare bookmarks from different files:\n\tself: %s'
918 '\n\tb: %s', self.bookmark_filepath, b.bookmark_filepath )
919 return 0
921 class FileMetadata(object):
922 """ A class to hold all information about the file that's currently being
923 played. Basically it takes care of metadata extraction... """
925 coverart_names = ['cover', 'front', 'albumart', 'back']
926 coverart_extensions = ['.png', '.jpg', '.jpeg']
927 tag_mappings = {
928 'mp4': { '\xa9nam': 'title',
929 '\xa9ART': 'artist',
930 '\xa9alb': 'album',
931 'covr': 'coverart' },
932 'mp3': { 'TIT2': 'title',
933 'TPE1': 'artist',
934 'TALB': 'album',
935 'APIC': 'coverart' },
936 'ogg': { 'title': 'title',
937 'artist': 'artist',
938 'album': 'album' },
940 tag_mappings['m4a'] = tag_mappings['mp4']
941 tag_mappings['flac'] = tag_mappings['ogg']
943 def __init__(self, filepath):
944 self.__log = logging.getLogger('panucci.playlist.FileMetadata')
945 self.__filepath = filepath
947 self.title = ''
948 self.artist = ''
949 self.album = ''
950 self.length = 0
951 self.coverart = None
953 self.__metadata_extracted = False
955 def extract_metadata(self):
956 self.__log.debug('Extracting metadata for %s', self.__filepath)
957 filetype = util.detect_filetype(self.__filepath)
959 if filetype == 'mp3':
960 import mutagen.mp3 as meta_parser
961 elif filetype == 'ogg':
962 import mutagen.oggvorbis as meta_parser
963 elif filetype == 'flac':
964 import mutagen.flac as meta_parser
965 elif filetype in ['mp4', 'm4a']:
966 import mutagen.mp4 as meta_parser
967 else:
968 self.__log.info(
969 'Extracting metadata not supported for %s files.', filetype )
970 return False
972 try:
973 metadata = meta_parser.Open(self.__filepath)
974 self.__metadata_extracted = True
975 except Exception, e:
976 self.title = util.pretty_filename(self.__filepath)
977 self.__log.exception('Error running metadata parser...')
978 self.__metadata_extracted = False
979 return False
981 self.length = metadata.info.length * 10**9
982 for tag,value in metadata.iteritems():
983 if tag.find(':') != -1: # hack for weirdly named coverart tags
984 tag = tag.split(':')[0]
986 if self.tag_mappings[filetype].has_key(tag):
987 if isinstance( value, list ):
988 if len(value):
989 # Here we could either join the list or just take one
990 # item. I chose the latter simply because some ogg
991 # files have several messed up titles...
992 value = value[0]
993 else:
994 continue
996 if self.tag_mappings[filetype][tag] != 'coverart':
997 try:
998 value = escape(str(value).strip())
999 except Exception, e:
1000 self.__log.exception(
1001 'Could not convert tag (%s) to escaped string', tag )
1002 else:
1003 # some coverart classes store the image in the data
1004 # attribute whereas others do not :S
1005 if hasattr( value, 'data' ):
1006 value = value.data
1008 setattr( self, self.tag_mappings[filetype][tag], value )
1010 if self.coverart is None:
1011 self.coverart = self.__find_coverart()
1013 def __find_coverart(self):
1014 """ Find coverart in the same directory as the filepath """
1016 directory = os.path.dirname(self.__filepath)
1017 for cover in self.__find_coverart_filepath(directory):
1018 self.__log.debug('Trying to load coverart from %s', cover)
1019 try:
1020 f = open(cover,'r')
1021 except:
1022 self.__log.exception('Could not open coverart file %s', cover )
1023 continue
1025 binary_coverart = f.read()
1026 f.close()
1028 if self.__test_coverart(binary_coverart):
1029 return binary_coverart
1031 return None
1033 def __test_coverart(self, data):
1034 """ tests to see if the file is a proper image file that can be loaded
1035 into a gtk.gdk.Pixbuf """
1037 import gtk.gdk
1038 l = gtk.gdk.PixbufLoader()
1039 try:
1040 l.write(data)
1041 l.close()
1042 rtn = True
1043 except:
1044 rtn = False
1046 return rtn
1048 def __find_coverart_filepath(self, directory):
1049 """ finds the path of potential coverart files """
1050 dir_filelist = []
1051 possible_matches = []
1053 # build the filelist
1054 for f in os.listdir(directory):
1055 if os.path.isfile(os.path.join(directory,f)):
1056 dir_filelist.append(os.path.splitext(f))
1058 # first pass, check for known filenames
1059 for f,ext in dir_filelist[:]:
1060 if f.lower() in self.coverart_names and \
1061 ext.lower() in self.coverart_extensions:
1062 possible_matches.append((f,ext))
1063 dir_filelist.remove((f,ext))
1065 # second pass, check for known filenames without extensions
1066 for f,ext in dir_filelist[:]:
1067 if f.lower() in self.coverart_names and ext == '':
1068 possible_matches.append((f,ext))
1069 dir_filelist.remove((f,ext))
1071 # third pass, check for any image file
1072 for f,ext in dir_filelist[:]:
1073 if ext.lower() in self.coverart_extensions:
1074 possible_matches.append((f,ext))
1075 dir_filelist.remove((f,ext))
1077 # yield the results
1078 for f,ext in possible_matches:
1079 yield os.path.join( directory, f+ext )
1081 def get_metadata(self):
1082 """ Returns a dict of metadata """
1084 if not self.__metadata_extracted:
1085 self.extract_metadata()
1087 metadata = {
1088 'title': self.title,
1089 'artist': self.artist,
1090 'album': self.album,
1091 'image': self.coverart,
1092 'length': self.length
1095 return metadata
1097 class PlaylistFile(object):
1098 """ The base class for playlist file parsers/exporters,
1099 this should not be used directly but instead subclassed. """
1101 def __init__(self, filepath, queue):
1102 self.__log = logging.getLogger('panucci.playlist.PlaylistFile')
1103 self._filepath = filepath
1104 self._file = None
1105 self._items = queue
1107 def __open_file(self, filepath, mode):
1108 if self._file is not None:
1109 self.close_file()
1111 try:
1112 self._file = open( filepath, mode )
1113 self._filepath = filepath
1114 except Exception, e:
1115 self._filepath = None
1116 self._file = None
1118 self.__log.exception( 'Error opening file: %s', filepath)
1119 return False
1121 return True
1123 def __close_file(self):
1124 error = False
1126 if self._file is not None:
1127 try:
1128 self._file.close()
1129 except Exception, e:
1130 self.__log.exception( 'Error closing file: %s', self.filepath )
1131 error = True
1133 self._filepath = None
1134 self._file = None
1136 return not error
1138 def get_absolute_filepath(self, item_filepath):
1139 if item_filepath is None: return
1141 if item_filepath.startswith('/'):
1142 path = item_filepath
1143 else:
1144 path = os.path.join(os.path.dirname(self._filepath), item_filepath)
1146 if os.path.exists( path ):
1147 return path
1149 def get_filelist(self):
1150 return [ item.filepath for item in self._items ]
1152 def get_filedicts(self):
1153 dict_list = []
1154 for item in self._items:
1155 d = { 'title': item.title,
1156 'length': item.length,
1157 'filepath': item.filepath }
1159 dict_list.append(d)
1160 return dict_list
1162 def get_queue(self):
1163 return self._items
1165 def export_items(self, filepath=None):
1166 if filepath is not None:
1167 self._filepath = filepath
1169 if self.__open_file(filepath, 'w'):
1170 self.export_hook(self._items)
1171 self.__close_file()
1172 return True
1173 else:
1174 return False
1176 def export_hook(self, playlist_items):
1177 pass
1179 def parse(self, filepath):
1180 if self.__open_file( filepath, mode='r' ):
1181 current_line = self._file.readline()
1182 while current_line:
1183 self.parse_line_hook( current_line.strip() )
1184 current_line = self._file.readline()
1185 self.__close_file()
1186 self.parse_eof_hook()
1187 else:
1188 return False
1189 return True
1191 def parse_line_hook(self, line):
1192 pass
1194 def parse_eof_hook(self):
1195 pass
1197 def _add_playlist_item(self, item):
1198 path = self.get_absolute_filepath(item.playlist_reported_filepath)
1199 if path is not None and os.path.isfile(path):
1200 item.filepath = path
1201 self._items.append(item)
1203 class M3U_Playlist(PlaylistFile):
1204 """ An (extended) m3u parser/writer """
1206 def __init__(self, *args):
1207 self.__log = logging.getLogger('panucci.playlist.M3U_Playlist')
1208 PlaylistFile.__init__( self, *args )
1209 self.extended_m3u = False
1210 self.current_item = PlaylistItem()
1212 def parse_line_hook(self, line):
1213 if line.startswith('#EXTM3U'):
1214 self.extended_m3u = True
1215 elif self.extended_m3u and line.startswith('#EXTINF'):
1216 match = re.match('#EXTINF:([^,]+),(.*)', line)
1217 if match is not None:
1218 length, title = match.groups()
1219 try: length = int(length)
1220 except: length = -1
1221 self.current_item.playlist_length = length
1222 self.current_item.playlist_title = title
1223 elif line.startswith('#'):
1224 pass # skip comments
1225 elif line:
1226 path = self.get_absolute_filepath( line )
1227 if path is not None:
1228 if os.path.isfile( path ):
1229 self.current_item.playlist_reported_filepath = line
1230 self._add_playlist_item(self.current_item)
1231 self.current_item = PlaylistItem()
1232 elif os.path.isdir( path ):
1233 files = os.listdir( path )
1234 files.sort()
1235 for file in files:
1236 item = PlaylistItem()
1237 item.playlist_reported_filepath=os.path.join(line,file)
1238 self._add_playlist_item(item)
1240 def export_hook(self, playlist_items):
1241 self._file.write('#EXTM3U\n\n')
1243 for item in playlist_items:
1244 string = ''
1245 if not ( item.length is None and item.title is None ):
1246 length = -1 if item.length is None else int(item.length)
1247 title = '' if item.title is None else item.title
1248 string += '#EXTINF:%d,%s\n' % ( length, title )
1250 string += '%s\n' % item.filepath
1251 self._file.write(string)
1253 class PLS_Playlist(PlaylistFile):
1254 """ A somewhat simple pls parser/writer """
1256 def __init__(self, *args):
1257 self.__log = logging.getLogger('panucci.playlist.PLS_Playlist')
1258 PlaylistFile.__init__( self, *args )
1259 self.current_item = PlaylistItem()
1260 self.in_playlist_section = False
1261 self.current_item_number = None
1263 def __add_current_item(self):
1264 self._add_playlist_item(self.current_item)
1266 def parse_line_hook(self, line):
1267 sect_regex = '\[([^\]]+)\]'
1268 item_regex = '[^\d]+([\d]+)=(.*)'
1270 if re.search(item_regex, line) is not None:
1271 current = re.search(item_regex, line).group(1)
1272 if self.current_item_number is None:
1273 self.current_item_number = current
1274 elif self.current_item_number != current:
1275 self.__add_current_item()
1277 self.current_item = PlaylistItem()
1278 self.current_item_number = current
1280 if re.search(sect_regex, line) is not None:
1281 section = re.match(sect_regex, line).group(1).lower()
1282 self.in_playlist_section = section == 'playlist'
1283 elif not self.in_playlist_section:
1284 pass # don't do anything if we're not in [playlist]
1285 elif line.lower().startswith('file'):
1286 self.current_item.playlist_reported_filepath = re.search(
1287 item_regex, line).group(2)
1288 elif line.lower().startswith('title'):
1289 self.current_item.playlist_title = re.search(
1290 item_regex, line).group(2)
1291 elif line.lower().startswith('length'):
1292 try: length = int(re.search(item_regex, line).group(2))
1293 except: length = -1
1294 self.current_item.playlist_length = length
1296 def parse_eof_hook(self):
1297 self.__add_current_item()
1299 def export_hook(self, playlist_items):
1300 self._file.write('[playlist]\n')
1301 self._file.write('NumberOfEntries=%d\n\n' % len(playlist_items))
1303 for i,item in enumerate(playlist_items):
1304 title = '' if item.title is None else item.title
1305 length = -1 if item.length is None else item.length
1306 self._file.write('File%d=%s\n' % (i+1, item.filepath))
1307 self._file.write('Title%d=%s\n' % (i+1, title))
1308 self._file.write('Length%d=%s\n\n' % (i+1, length))
1310 self._file.write('Version=2\n')