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/>.
26 from hashlib
import md5
27 from xml
.sax
.saxutils
import escape
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
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' ]
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
)
59 def reset_playlist(self
):
60 """ Sets the playlist to a default "known" state """
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 )
76 def current_filepath(self
):
77 """ Get the current file """
79 return self
.__queue
.current_item
.filepath
82 def queue_modified(self
):
83 return self
.__queue
.modified
86 def queue_length(self
):
87 return len(self
.__queue
)
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
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
)
114 # copy the bookmarks over to new playlist
115 db
.remove_all_bookmarks(self
.id)
116 self
.__queue
.set_new_playlist_id(self
.id)
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
)
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
149 self
.__queue
.current_item
.seek_to
= 0
151 self
.__queue
.current_item
.seek_to
= bookmark
.seek_position
153 # if we don't request a seek nothing will happen
155 self
.notify( 'seek-requested', bookmark
.seek_position
,
156 caller
=self
.__load
_from
_bookmark
)
160 def load_from_bookmark_id( self
, item_id
=None, bookmark_id
=None ):
161 item
, bookmark
= self
.__queue
.get_bookmark(item_id
, bookmark_id
)
164 return self
.__load
_from
_bookmark
( str(item
), bookmark
)
167 'item_id=%s,bookmark_id=%s not found', item_id
, bookmark_id
)
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
)
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.')
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
,
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
)
198 self
.__log
.warning('No such item id (%s)', item_id
)
201 if bookmark_id
is not None and bookmark
is None:
202 self
.__log
.warning('No such bookmark id (%s)', bookmark_id
)
205 if bookmark_id
is None:
206 if name
and 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
)
213 bookmark
.timestamp
= time
.time()
216 bookmark
.bookmark_name
= name
218 if seek_pos
is not None:
219 bookmark
.seek_position
= seek_pos
221 db
.update_bookmark(bookmark
)
225 def update_bookmarks(self
):
226 """ Updates the database entries for items that have been modified """
227 for item
in self
.__queue
:
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
)
238 self
.__log
.info('Cannot find item with id: %s', item_id
)
241 if bookmark_id
is None:
242 if self
.__queue
.current_item_position
== self
.__queue
.index(item_id
):
245 item
.delete_bookmark(None)
246 self
.__queue
.remove(item_id
)
248 item
.delete_bookmark(bookmark_id
)
252 def remove_resume_bookmarks(self
):
253 item_id
, bookmark_id
= self
.find_resume_bookmark()
255 if None in ( item_id
, bookmark_id
):
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)
274 self
.__log
.warning('Cannot get item for id: %s', item_id
)
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
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
)
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
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
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
]
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.')
349 self
.notify( 'stop-requested', caller
=self
.load
)
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 )
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.
383 self
.notify( 'new-track-playing', caller
=self
.load
)
385 self
.notify( 'new-metadata-available', caller
=self
.load
)
389 def load_last_played(self
):
390 recent
= self
.get_recent_files(max_files
=1)
392 self
.load(recent
[0], play
=False)
396 def __file_queued(self
, filepath
, successfull
, notify
):
398 self
.notify( 'file_queued', filepath
, successfull
, notify
,
399 caller
=self
.__file
_queued
)
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
):
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
)
424 if self
.notify( 'playlist-to-be-overwritten',
425 caller
=self
.load_directory
):
426 self
.reset_playlist()
428 self
.__log
.info('Directory load aborted by user.')
431 self
.filepath
= settings
.temp_playlist
432 self
.__queue
.playlist_id
= self
.id
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
)
445 self
.append( item
, notify
=False )
448 self
.on_queue_current_item_changed()
452 ##################################
454 ##################################
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
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.
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:
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
)
501 self
.__log
.warning( "Can't skip to non-existant file w/o loop."
502 " (requested=%d, total=%d)", skip
,
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
517 self
.__queue
.current_item_position
= skip
518 self
.__log
.debug( 'Skipping to file %d (%s)', skip
,
519 self
.__queue
.current_item
.filepath
)
523 def get_current_item(self
):
524 return self
.__queue
.current_item
527 """ Move the playlist to the next track.
528 False indicates end of playlist. """
529 return self
.skip( skip_by
=1, dont_loop
=True )
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
= {}
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
578 tally
+= int( i
.filepath
== item
.filepath
)
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
):
593 'File not found or not supported: %s', item
.filepath
)
598 def current_item(self
):
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
]
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
620 """ Reset the the queue to a known state """
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
)
636 'Item with id "%s" not found, scanning for item...', item_id
)
639 if item_
.bookmarks
.count(bookmark_id
):
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
)]
650 def set_new_playlist_id(self
, id):
651 self
.playlist_id
= id
653 item
.playlist_id
= id
654 for bookmark
in item
.bookmarks
:
655 bookmark
.playlist_id
= id
658 def insert(self
, position
, item
):
659 if not self
.__prep
_item
(item
):
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
:
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
)
686 def append(self
, item
):
687 if not self
.__prep
_item
(item
):
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
)
697 def remove(self
, item
):
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...')
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. """
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
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
736 def __set_filepath(self
, fp
):
737 if fp
!= self
.__filepath
:
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
744 #self.__metadata.extract_metadata()
746 def __get_filepath(self
):
747 return self
.__filepath
749 filepath
= property( __get_filepath
, __set_filepath
)
752 def create_by_filepath(reported_filepath
, filepath
):
753 item
= PlaylistItem()
754 item
.playlist_reported_filepath
= reported_filepath
755 item
.filepath
= filepath
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
765 self
.__log
.warning('Unsupported comparison: %s', type(b
))
769 uid
= self
.filepath
+ str(self
.duplicate_id
)
770 return md5(uid
).hexdigest()
774 """ Returns a dict of metadata, wooo. """
776 metadata
= self
.__metadata
.get_metadata()
777 metadata
['title'] = self
.title
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
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
))
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
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):
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
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
:
845 bkmk
= self
.bookmarks
.index(bookmark_id
)
847 self
.bookmarks
[bkmk
].delete()
848 self
.bookmarks
.remove(bookmark_id
)
850 self
.__log
.info('Cannot find bookmark with id: %s',bookmark_id
)
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. """
864 self
.__log
= logging
.getLogger('panucci.playlist.Bookmark')
867 self
.playlist_id
= None
868 self
.bookmark_name
= ''
869 self
.bookmark_filepath
= ''
870 self
.seek_position
= 0
872 self
.is_resume_position
= False
873 self
.playlist_duplicate_id
= 0
876 def load_from_dict(bkmk_dict
):
879 for key
,value
in bkmk_dict
.iteritems():
880 if hasattr( bkmkobj
, key
):
881 setattr( bkmkobj
, key
, value
)
883 self
.__log
.info('Attr: %s doesn\'t exist...', key
)
888 self
.id = db
.save_bookmark(self
)
892 return db
.remove_bookmark(self
.id)
895 if isinstance(b
, str):
896 return str(self
) == b
900 self
.__log
.warning('Unsupported comparison: %s', type(b
))
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
:
914 return -1 if self
.seek_position
< b
.seek_position
else 1
917 'Can\'t compare bookmarks from different files:\n\tself: %s'
918 '\n\tb: %s', self
.bookmark_filepath
, b
.bookmark_filepath
)
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']
928 'mp4': { '\xa9nam': 'title',
931 'covr': 'coverart' },
932 'mp3': { 'TIT2': 'title',
935 'APIC': 'coverart' },
936 'ogg': { 'title': 'title',
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
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
969 'Extracting metadata not supported for %s files.', filetype
)
973 metadata
= meta_parser
.Open(self
.__filepath
)
974 self
.__metadata
_extracted
= True
976 self
.title
= util
.pretty_filename(self
.__filepath
)
977 self
.__log
.exception('Error running metadata parser...')
978 self
.__metadata
_extracted
= 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 ):
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...
996 if self
.tag_mappings
[filetype
][tag
] != 'coverart':
998 value
= escape(str(value
).strip())
1000 self
.__log
.exception(
1001 'Could not convert tag (%s) to escaped string', tag
)
1003 # some coverart classes store the image in the data
1004 # attribute whereas others do not :S
1005 if hasattr( 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
)
1022 self
.__log
.exception('Could not open coverart file %s', cover
)
1025 binary_coverart
= f
.read()
1028 if self
.__test
_coverart
(binary_coverart
):
1029 return binary_coverart
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 """
1038 l
= gtk
.gdk
.PixbufLoader()
1048 def __find_coverart_filepath(self
, directory
):
1049 """ finds the path of potential coverart files """
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
))
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()
1088 'title': self
.title
,
1089 'artist': self
.artist
,
1090 'album': self
.album
,
1091 'image': self
.coverart
,
1092 'length': self
.length
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
1107 def __open_file(self
, filepath
, mode
):
1108 if self
._file
is not None:
1112 self
._file
= open( filepath
, mode
)
1113 self
._filepath
= filepath
1114 except Exception, e
:
1115 self
._filepath
= None
1118 self
.__log
.exception( 'Error opening file: %s', filepath
)
1123 def __close_file(self
):
1126 if self
._file
is not None:
1129 except Exception, e
:
1130 self
.__log
.exception( 'Error closing file: %s', self
.filepath
)
1133 self
._filepath
= None
1138 def get_absolute_filepath(self
, item_filepath
):
1139 if item_filepath
is None: return
1141 if item_filepath
.startswith('/'):
1142 path
= item_filepath
1144 path
= os
.path
.join(os
.path
.dirname(self
._filepath
), item_filepath
)
1146 if os
.path
.exists( path
):
1149 def get_filelist(self
):
1150 return [ item
.filepath
for item
in self
._items
]
1152 def get_filedicts(self
):
1154 for item
in self
._items
:
1155 d
= { 'title': item
.title
,
1156 'length': item
.length
,
1157 'filepath': item
.filepath
}
1162 def get_queue(self
):
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
)
1176 def export_hook(self
, playlist_items
):
1179 def parse(self
, filepath
):
1180 if self
.__open
_file
( filepath
, mode
='r' ):
1181 current_line
= self
._file
.readline()
1183 self
.parse_line_hook( current_line
.strip() )
1184 current_line
= self
._file
.readline()
1186 self
.parse_eof_hook()
1191 def parse_line_hook(self
, line
):
1194 def parse_eof_hook(self
):
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
)
1221 self
.current_item
.playlist_length
= length
1222 self
.current_item
.playlist_title
= title
1223 elif line
.startswith('#'):
1224 pass # skip comments
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
)
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
:
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))
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')