1 # -*- coding: utf-8 -*-
3 # This file is part of Panucci.
4 # Copyright (c) 2008-2011 The Panucci 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/>.
19 from __future__
import absolute_import
27 from hashlib
import md5
28 from xml
.sax
.saxutils
import escape
32 from panucci
.dbsqlite
import db
33 from panucci
.services
import ObservableService
34 from panucci
import player
35 from panucci
import util
37 def is_supported(uri
):
38 filename
, extension
= os
.path
.splitext(uri
)
39 if extension
.startswith('.'):
40 extension
= extension
[1:]
42 return (extension
.lower() in panucci
.EXTENSIONS
)
44 class Playlist(ObservableService
):
45 signals
= [ 'new-track-loaded', 'new-metadata-available', 'file_queued',
46 'bookmark_added', 'seek-requested', 'end-of-playlist',
47 'playlist-to-be-overwritten', 'stop-requested', 'reset-playlist' ]
49 def __init__(self
, config
):
50 self
.__log
= logging
.getLogger('panucci.playlist.Playlist')
51 ObservableService
.__init
__(self
, self
.signals
, self
.__log
)
54 self
.player
= player
.PanucciPlayer(self
)
55 self
.__queue
= Queue(None)
56 self
.__queue
.register(
57 'current_item_changed', self
.on_queue_current_item_changed
)
62 def init(self
, filepath
=None):
63 """ Start playing the current file in the playlist or a custom file.
64 This should be called by the UI once it has initialized.
66 Params: filepath is an optional filepath to the first file that
67 should be loaded/played
71 if filepath
is None or not self
.load( filepath
):
72 self
.load_last_played()
76 def reset_playlist(self
):
77 """ Sets the playlist to a default "known" state """
82 self
.stop(None, False)
83 self
.notify('reset-playlist', caller
=self
.reset_playlist
)
87 if self
.filepath
is None:
88 self
.__log
.warning("Can't get playlist id without having filepath")
89 elif self
._id
is None:
90 self
._id
= db
.get_playlist_id( self
.filepath
, True, True )
95 def current_filepath(self
):
96 """ Get the current file """
98 return self
.__queue
.current_item
.filepath
101 def queue_modified(self
):
102 return self
.__queue
.modified
105 def queue_length(self
):
106 return len(self
.__queue
)
110 return not self
.__queue
112 def print_queue_layout(self
):
113 """ This helps with debugging ;) """
114 for item
in self
.__queue
:
115 print str(item
), item
.playlist_reported_filepath
116 for bookmark
in item
.bookmarks
:
117 print '\t', str(bookmark
), bookmark
.bookmark_filepath
119 def save_to_new_playlist(self
, filepath
, playlist_type
='m3u'):
120 self
.filepath
= filepath
123 playlist
= { 'm3u': M3U_Playlist
, 'pls': PLS_Playlist
}
124 if not playlist
.has_key(playlist_type
):
125 playlist_type
= 'm3u' # use m3u by default
126 self
.filepath
+= '.m3u'
128 playlist
= playlist
[playlist_type
](self
.filepath
, self
.__queue
)
129 if not playlist
.export_items( filepath
):
130 self
.__log
.error('Error exporting playlist to %s', self
.filepath
)
133 # copy the bookmarks over to new playlist
134 db
.remove_all_bookmarks(self
.id)
135 self
.__queue
.set_new_playlist_id(self
.id)
139 def save_temp_playlist(self
):
140 return self
.save_to_new_playlist(panucci
.PLAYLIST_FILE
)
142 def on_queue_current_item_changed(self
):
143 self
.notify( 'new-track-loaded',
144 caller
=self
.on_queue_current_item_changed
)
145 self
.notify( 'new-metadata-available',
146 caller
=self
.on_queue_current_item_changed
)
148 def send_metadata(self
):
149 self
.notify( 'new-metadata-available', caller
=self
.send_metadata
)
151 def start_of_playlist(self
):
152 """Checks if the currently played item is the first"""
153 if self
.__queue
.current_item_position
== 0:
156 def end_of_playlist(self
):
157 """Checks if the currently played item is the last"""
158 if len(self
.__queue
.get_items()) - 1 == self
.__queue
.current_item_position
:
162 self
.__log
.debug('quit() called.')
163 self
.notify('stop-requested', caller
=self
.quit
)
164 if self
.__queue
.modified
:
165 self
.__log
.info('Queue modified, saving temporary playlist')
166 self
.save_temp_playlist()
168 ######################################
169 # Bookmark-related functions
170 ######################################
172 def __load_from_bookmark( self
, item_id
, bookmark
):
173 new_pos
= self
.__queue
.index(item_id
)
174 same_pos
= self
.__queue
.current_item_position
== new_pos
175 self
.__queue
.current_item_position
= new_pos
179 self
.__queue
.current_item
.seek_to
= 0
181 self
.__queue
.current_item
.seek_to
= bookmark
.seek_position
183 # if we don't request a seek nothing will happen
185 self
.notify( 'seek-requested', bookmark
.seek_position
,
186 caller
=self
.__load
_from
_bookmark
)
190 def load_from_bookmark_id( self
, item_id
=None, bookmark_id
=None ):
191 item
, bookmark
= self
.__queue
.get_bookmark(item_id
, bookmark_id
)
192 if str(self
.__queue
.current_item
) != item_id
:
193 self
.notify('stop-requested', caller
=self
.load_from_bookmark_id
)
196 return self
.__load
_from
_bookmark
( str(item
), bookmark
)
199 'item_id=%s,bookmark_id=%s not found', item_id
, bookmark_id
)
202 def find_resume_bookmark(self
):
203 """ Find a resume bookmark in the queue """
204 for item
in self
.__queue
:
205 for bookmark
in item
.bookmarks
:
206 if bookmark
.is_resume_position
:
207 return str(item
), str(bookmark
)
211 def load_from_resume_bookmark(self
):
212 item_id
, bookmark_id
= self
.find_resume_bookmark()
213 if None in ( item_id
, bookmark_id
):
214 self
.__log
.info('No resume bookmark found.')
217 return self
.load_from_bookmark_id( item_id
, bookmark_id
)
219 def save_bookmark( self
, bookmark_name
, position
):
220 if self
.__queue
.current_item
is not None:
221 self
.__queue
.current_item
.save_bookmark( bookmark_name
, position
,
223 self
.notify( 'bookmark_added', str(self
.__queue
.current_item
),
224 bookmark_name
, position
, caller
=self
.save_bookmark
)
226 def update_bookmark(self
, item_id
, bookmark_id
, name
=None, seek_pos
=None):
227 item
, bookmark
= self
.__queue
.get_bookmark(item_id
, bookmark_id
)
230 self
.__log
.warning('No such item id (%s)', item_id
)
233 if bookmark_id
is not None and bookmark
is None:
234 self
.__log
.warning('No such bookmark id (%s)', bookmark_id
)
237 if bookmark_id
is None:
238 if name
and item
.title
!= name
:
240 self
.__queue
.modified
= True
241 if self
.__queue
.current_item
== item
:
242 self
.notify( 'new-metadata-available',
243 caller
=self
.update_bookmark
)
245 bookmark
.timestamp
= time
.time()
248 bookmark
.bookmark_name
= name
250 if seek_pos
is not None:
251 bookmark
.seek_position
= seek_pos
253 db
.update_bookmark(bookmark
)
257 def update_bookmarks(self
):
258 """ Updates the database entries for items that have been modified """
259 for item
in self
.__queue
:
262 'Playlist Item "%s" is modified, updating bookmarks', item
)
263 item
.update_bookmarks()
264 item
.is_modified
= False
266 def remove_bookmark( self
, item_id
, bookmark_id
):
267 item
= self
.__queue
.get_item(item_id
)
270 self
.__log
.info('Cannot find item with id: %s', item_id
)
273 if bookmark_id
is None:
274 if self
.__queue
.current_item_position
== self
.__queue
.index(item
):
275 item
.delete_bookmark(None)
276 self
.__queue
.remove(item
)
277 self
.notify('stop-requested', caller
=self
.remove_bookmark
)
278 self
.__queue
.current_item_position
= self
.__queue
.current_item_position
280 item
.delete_bookmark(None)
281 self
.__queue
.remove(item
)
283 item
.delete_bookmark(bookmark_id
)
287 def delete_all_bookmarks(self
):
288 db
.delete_all_bookmarks()
289 for item
in self
.__queue
.get_items():
290 item
.delete_bookmark(None)
292 def remove_resume_bookmarks(self
):
293 item_id
, bookmark_id
= self
.find_resume_bookmark()
295 if None in ( item_id
, bookmark_id
):
298 return self
.remove_bookmark( item_id
, bookmark_id
)
300 def move_item( self
, from_row
, to_row
):
301 self
.__log
.info('Moving item from position %d to %d', from_row
, to_row
)
302 assert isinstance(from_row
, int) and isinstance(to_row
, int)
303 self
.__queue
.move_item(from_row
, to_row
)
305 #####################################
306 # Model-builder functions
307 #####################################
309 def get_item_by_id(self
, item_id
):
310 """ Gets a PlaylistItem from it's unique id """
312 item
, bookmark
= self
.__queue
.get_bookmark(item_id
, None)
314 self
.__log
.warning('Cannot get item for id: %s', item_id
)
318 def get_playlist_item_ids(self
):
319 """ Returns an iterator which yields a tuple which contains the
320 item's unique ID and a dict of interesting data (currently
323 for item
in self
.__queue
:
324 yield str(item
), { 'title' : item
.title
}
326 def get_bookmarks_from_item_id(self
, item_id
, include_resume_marks
=False):
327 """ Returns an iterator which yields the following data regarding a
328 bookmark: ( bookmark id, a custom name, the seek position ) """
330 item
= self
.get_item_by_id( item_id
)
332 for bkmk
in item
.bookmarks
:
333 if not bkmk
.is_resume_position
or include_resume_marks
:
334 yield str(bkmk
), bkmk
.bookmark_name
, bkmk
.seek_position
336 ######################################
337 # File-related convenience functions
338 ######################################
340 def get_current_position(self
):
341 """ Returns the saved position for the current
342 file or 0 if no file is available"""
343 if not self
.is_empty
:
344 return self
.__queue
.current_item
.seek_to
348 def get_current_filetype(self
):
349 """ Returns the filetype of the current
350 file or None if no file is available """
352 if not self
.is_empty
:
353 return self
.__queue
.current_item
.filetype
355 def get_file_metadata(self
):
356 """ Return the metadata associated with the current FileObject """
357 if not self
.is_empty
:
358 return self
.__queue
.current_item
.metadata
362 def get_current_filepath(self
):
363 if not self
.is_empty
:
364 return self
.__queue
.current_item
.filepath
366 def get_recent_files(self
, max_files
=10):
367 files
= db
.get_latest_files()
369 if len(files
) > max_files
:
370 return files
[:max_files
]
374 ##################################
375 # File importing functions
376 ##################################
378 def load(self
, filepath
):
379 """ Detects filepath's filetype then loads it using
380 the appropriate loader function """
381 self
.__log
.debug('Attempting to load %s', filepath
)
382 _play
= self
.__queue
.is_empty()
385 if os
.path
.isdir(filepath
):
386 self
.load_directory(filepath
, True)
388 parsers
= { 'm3u': M3U_Playlist
, 'pls': PLS_Playlist
}
389 extension
= util
.detect_filetype(filepath
)
390 if parsers
.has_key(extension
): # importing a playlist
391 self
.__log
.info('Loading playlist file (%s)', extension
)
392 parser
= parsers
[extension
](filepath
, self
.__queue
)
394 if parser
.parse(filepath
):
395 self
.__queue
= parser
.get_queue()
396 self
.__file
_queued
( filepath
, True, False )
399 else: # importing a single file
400 error
= not self
.append(filepath
, notify
=False)
402 # if we let the queue emit a current_item_changed signal (which will
403 # happen if load_from_bookmark changes the current track), the player
404 # will start playing and ingore the resume point
406 self
.filepath
= filepath
407 self
.__queue
.playlist_id
= self
.id
408 self
.__queue
.disable_notifications
= True
409 self
.load_from_resume_bookmark()
410 self
.__queue
.disable_notifications
= False
411 self
.__queue
.modified
= True
412 self
.notify( 'stop-requested', caller
=self
.load
)
413 self
.notify( 'new-track-loaded', caller
=self
.load
)
414 self
.notify( 'new-metadata-available', caller
=self
.load
)
418 def load_last_played(self
):
419 recent
= self
.get_recent_files(max_files
=1)
424 def __file_queued(self
, filepath
, successfull
, notify
):
426 self
.notify( 'file_queued', filepath
, successfull
, notify
,
427 caller
=self
.__file
_queued
)
431 def append(self
, filepath
, notify
=True):
432 self
.__log
.debug('Attempting to queue file: %s', filepath
)
433 success
= self
.__queue
.append(
434 PlaylistItem
.create_by_filepath(filepath
, filepath
) )
435 return self
.__file
_queued
( filepath
, success
, notify
)
437 def insert(self
, position
, filepath
):
439 'Attempting to insert %s at position %s', filepath
, position
)
440 return self
.__file
_queued
( filepath
, self
.__queue
.insert( position
,
441 PlaylistItem
.create_by_filepath(filepath
, filepath
)), True )
443 def load_directory(self
, directory
, append
=False):
444 self
.__log
.debug('Attempting to load directory "%s"', directory
)
446 if not os
.path
.isdir(directory
):
447 self
.__log
.warning('"%s" is not a directory.', directory
)
451 if self
.notify( 'playlist-to-be-overwritten',
452 caller
=self
.load_directory
):
453 self
.reset_playlist()
455 self
.__log
.info('Directory load aborted by user.')
458 self
.filepath
= panucci
.PLAYLIST_FILE
459 self
.__queue
.playlist_id
= self
.id
462 potential_items
= os
.listdir(directory
)
463 potential_items
.sort()
465 for item
in potential_items
:
466 filepath
= os
.path
.join( directory
, item
)
467 if os
.path
.isfile(filepath
) and is_supported(filepath
):
468 items
.append(filepath
)
472 self
.append( item
, notify
=False )
475 self
.on_queue_current_item_changed()
479 ##################################
481 ##################################
483 def set_seek_to(self
, position
):
484 """Set the seek-to position for the current track"""
485 self
.__queue
.current_item
.seek_to
= (10**9) * position
488 """ This gets called by the player to get
489 the last time the file was paused """
490 pos
= self
.__queue
.current_item
.seek_to
491 self
.__queue
.current_item
.seek_to
= 0
494 def pause(self
, position
):
495 """ Called whenever the player is paused """
496 self
.__queue
.current_item
.seek_to
= position
498 def stop(self
, position
, save_resume_point
=True):
499 """ This should be run when the program is closed
500 or if the user switches playlists """
501 self
.remove_resume_bookmarks()
502 if not self
.is_empty
and save_resume_point
:
503 self
.__queue
.current_item
.save_bookmark(
504 _('Auto Bookmark'), position
, True )
506 def skip(self
, loop
=True, skip_by
=None, skip_to
=None):
507 """ Skip to another track in the playlist.
508 Use either skip_by or skip_to, skip_by has precedence.
509 skip_to: skip to a known playlist position
510 skip_by: skip by n number of episodes (positive or negative)
511 loop: loop if the track requested lays out of
512 the 0 to queue_length-1 boundary.
517 current_item
= self
.__queue
.current_item_position
519 if skip_by
is not None:
520 skip
= current_item
+ skip_by
521 elif skip_to
is not None:
525 self
.__log
.warning('No skip method provided...')
527 if not 0 <= skip
< self
.queue_length
:
528 self
.notify( 'end-of-playlist', loop
, caller
=self
.skip
)
531 self
.__log
.warning( "Can't skip to non-existant file w/o loop."
532 " (requested=%d, total=%d)", skip
,
536 # If skip_by is given as an argument, we assume the user knows
537 # what they're doing. Ie. if the queue length is 5, current
538 # track is 3 and they pass skip_by=-9, then they'll end up
539 # at 4. On the other hand, if skip_to is passed then we skip
540 # back to 0 because in that case the user must enter a number
541 # from 0 to queue_length-1, anything else is an error.
542 if skip_by
is not None:
543 skip
%= self
.queue_length
547 self
.notify('stop-requested', caller
=self
.skip
)
548 self
.__queue
.current_item_position
= skip
549 self
.__log
.debug( 'Skipping to file %d (%s)', skip
,
550 self
.__queue
.current_item
.filepath
)
554 def get_current_item(self
):
555 return self
.__queue
.current_item
557 def next(self
, loop
=False):
558 """ Move the playlist to the next track.
559 False indicates end of playlist. """
560 return self
.skip( loop
, skip_by
=1)
563 """ Same as next() except moves to the previous track. """
564 return self
.skip( loop
=False, skip_by
=-1 )
567 """ Plays last file in queue. """
568 skip_to
= len(self
.__queue
.get_items()) - 1
569 return self
.skip( False, None, skip_to
)
572 """ Plays random file in queue. """
573 skip_to
= random
.choice(range(len(self
.__queue
.get_items())))
574 return self
.skip( False, None, skip_to
)
576 class Queue(list, ObservableService
):
577 """ A Simple list of PlaylistItems """
579 signals
= [ 'current_item_changed', ]
581 def __init__(self
, playlist_id
):
582 self
.__log
= logging
.getLogger('panucci.playlist.Queue')
583 ObservableService
.__init
__(self
, self
.signals
, self
.__log
)
585 self
.playlist_id
= playlist_id
586 self
.modified
= False # Has the queue been modified?
587 self
.disable_notifications
= False
588 self
.__current
_item
_position
= 0
589 # This is a hack and WILL BE REPLACED WITH SOMETHING BETTER.
590 # it's here to speed up the get_item function
591 self
.__mapping
_dict
= {}
594 def __get_current_item_position(self
):
595 return self
.__current
_item
_position
597 def __set__current_item_position(self
, new_value
):
599 # set the new position before notify()'ing
600 # or else we'll end up load the old file's metadata
601 old_value
= self
.__current
_item
_position
602 self
.__current
_item
_position
= new_value
604 if old_value
!= new_value
:
605 self
.__log
.debug( 'Current item changed from %d to %d',
606 old_value
, new_value
)
607 if not self
.disable_notifications
:
608 self
.notify( 'current_item_changed',
609 caller
=self
.__set
__current
_item
_position
)
611 self
.__log
.debug( 'Current item reloaded')
612 if not self
.disable_notifications
:
613 self
.notify( 'current_item_changed',
614 caller
=self
.__set
__current
_item
_position
)
616 current_item_position
= property(
617 __get_current_item_position
, __set__current_item_position
)
619 def __count_dupe_items(self
, subset
, item
):
620 # Count the number of duplicate items (by filepath only) in a list
623 tally
+= int( i
.filepath
== item
.filepath
)
626 def __prep_item(self
, item
):
627 """ Do some error checking and other stuff that's
628 common to the insert and append functions """
630 assert isinstance( item
, PlaylistItem
)
631 item
.playlist_id
= self
.playlist_id
633 if '://' in item
.filepath
or (os
.path
.isfile(item
.filepath
) and \
634 is_supported(item
.filepath
)):
639 'File not found or not supported: %s', item
.filepath
)
644 def current_item(self
):
646 if self
.current_item_position
>= len(self
):
647 self
.__log
.info( 'Current item position is greater '
648 'than queue length, resetting to 0.' )
649 self
.current_item_position
= 0
651 return self
[self
.current_item_position
]
653 self
.__log
.info('Queue is empty...')
655 def move_item(self
, from_pos
, to_pos
):
656 old_current_item
= self
.current_item_position
658 temp
= self
[from_pos
]
659 self
.remove(str(temp
))
660 self
.insert(to_pos
, temp
)
662 if old_current_item
== from_pos
:
663 self
.__current
_item
_position
= to_pos
666 """ Reset the the queue to a known state """
669 items
= self
.__mapping
_dict
.values()
671 list.remove(self
, item
)
675 self
.playlist_id
= None
677 self
.__current
_item
_position
= 0
678 self
.__mapping
_dict
= {}
680 def get_item(self
, item_id
):
681 return self
.__mapping
_dict
.get(item_id
)
684 return self
.__mapping
_dict
.values()
687 if self
.__mapping
_dict
:
692 def get_bookmark(self
, item_id
, bookmark_id
):
693 item
= self
.get_item(item_id
)
697 'Item with id "%s" not found, scanning for item...', item_id
)
700 if item_
.bookmarks
.count(bookmark_id
):
704 if item
is None: return None, None
706 if item
.get_bookmark(bookmark_id
):
707 return item
, item
.get_bookmark(bookmark_id
)
711 def set_new_playlist_id(self
, id):
712 self
.playlist_id
= id
714 item
.playlist_id
= id
715 for bookmark
in item
.bookmarks
:
716 bookmark
.playlist_id
= id
719 def insert(self
, position
, item
):
720 if not self
.__prep
_item
(item
):
723 item
.duplicate_id
= self
[:position
].count(item
)
725 if self
.__count
_dupe
_items
(self
[position
:], item
):
726 for i
in self
[position
:]:
727 if i
.filepath
== item
.filepath
:
731 # to be safe rebuild self.__mapping_dict
732 self
.__mapping
_dict
= dict([(str(i
),i
) for i
in self
])
733 elif not self
.__count
_dupe
_items
(self
[:position
], item
):
734 # there are no other items like this one so it's *safe* to load
735 # bookmarks without a potential conflict, but there's a good chance
736 # that there aren't any bookmarks to load (might be useful in the
737 # event of a crash)...
738 item
.load_bookmarks()
740 if position
<= self
.current_item_position
:
741 self
.__current
_item
_position
+= 1
743 self
.__mapping
_dict
[str(item
)] = item
744 list.insert(self
, position
, item
)
747 def append(self
, item
):
748 if not self
.__prep
_item
(item
):
751 item
.duplicate_id
= self
.__count
_dupe
_items
(self
, item
)
752 item
.load_bookmarks()
754 self
.__mapping
_dict
[str(item
)] = item
755 list.append(self
, item
)
758 def remove(self
, item
):
762 if self
.index(item
) < self
.current_item_position
:
763 self
.__current
_item
_position
-= 1
765 del self
.__mapping
_dict
[str(item
)]
766 list.remove(self
, item
)
768 def extend(self
, items
):
769 self
.__log
.warning('FIXME: extend not supported yet...')
772 self
.__log
.warning('FIXME: pop not supported yet...')
774 def set_current_item_position(self
, position
):
775 self
.__current
_item
_position
= position
777 class PlaylistItem(object):
778 """ A (hopefully) lightweight object to hold the bare minimum amount of
779 data about a single item in a playlist and it's bookmark objects. """
782 self
.__log
= logging
.getLogger('panucci.playlist.PlaylistItem')
784 self
.__filepath
= None
785 self
.__metadata
= None
786 self
.playlist_id
= None
787 self
.duplicate_id
= 0
790 # metadata that's pulled from the playlist file (pls/extm3u)
791 self
.playlist_reported_filepath
= None
792 self
.playlist_title
= None
793 self
.playlist_length
= None
795 # a flag to determine whether the item's bookmarks need updating
796 # ( used for example, if the duplicate_id is changed )
797 self
.is_modified
= False
800 def __set_filepath(self
, fp
):
801 if fp
!= self
.__filepath
:
803 self
.__metadata
= FileMetadata(self
.filepath
)
804 # Don't extract Metadata right away, this makes opening large
805 # playlists _very_ slow. TODO: do this intelligently AND
806 # perhaps del the Metadata object if the file is no longer
808 #self.__metadata.extract_metadata()
810 def __get_filepath(self
):
811 return self
.__filepath
813 filepath
= property( __get_filepath
, __set_filepath
)
816 def create_by_filepath(reported_filepath
, filepath
):
817 item
= PlaylistItem()
818 item
.playlist_reported_filepath
= reported_filepath
819 item
.filepath
= filepath
823 if isinstance( b
, PlaylistItem
):
824 return ( self
.filepath
== b
.filepath
and
825 self
.duplicate_id
== b
.duplicate_id
)
826 elif isinstance( b
, str ):
827 return str(self
) == b
829 self
.__log
.warning('Unsupported comparison: %s', type(b
))
833 uid
= self
.filepath
+ str(self
.duplicate_id
)
834 return md5(uid
).hexdigest()
838 """ Returns a dict of metadata, wooo. """
840 metadata
= self
.__metadata
.get_metadata()
841 metadata
['title'] = self
.title
846 return util
.detect_filetype(self
.filepath
)
848 def __get_title(self
):
849 """ Get the title of item, priority is (most important first):
850 1. the title given in playlist metadata
851 2. the title in the file's metadata (ex. ID3)
852 3. a "pretty" version of the filename """
854 if self
.playlist_title
is not None:
855 return self
.playlist_title
856 elif self
.__metadata
.title
:
857 return self
.__metadata
.title
859 return util
.pretty_filename(self
.filepath
)
861 # For now set the "playlist_title" because it has highest priority in the
862 # __get_title method. We might evenually want to create a separate
863 # __custom_title to preserve the playlist_title.
864 title
= property(__get_title
, lambda s
,v
: setattr(s
, 'playlist_title', v
))
868 """ Get the lenght of the item priority is (most important first):
869 1. length as reported by mutagen
870 2. length found in playlist metadata
871 3. otherwise -1 when unknown """
873 if self
.__metadata
.length
:
874 return self
.__metadata
.length
875 elif self
.playlist_length
:
876 return self
.playlist_length
880 def load_bookmarks(self
):
881 self
.bookmarks
= db
.load_bookmarks(
882 factory
= Bookmark().load_from_dict
,
883 playlist_id
= self
.playlist_id
,
884 bookmark_filepath
= self
.filepath
,
885 playlist_duplicate_id
= self
.duplicate_id
,
886 request_resume_bookmark
= None )
888 def save_bookmark(self
, name
, position
, resume_pos
=False):
890 b
.playlist_id
= self
.playlist_id
891 b
.bookmark_name
= name
892 b
.bookmark_filepath
= self
.filepath
893 b
.seek_position
= position
894 b
.timestamp
= time
.time()
895 b
.is_resume_position
= resume_pos
896 b
.playlist_duplicate_id
= self
.duplicate_id
898 self
.bookmarks
.append(b
)
900 def get_bookmark(self
, bkmk_id
):
901 for i
in self
.bookmarks
:
902 if str(i
) == bkmk_id
:
905 def delete_bookmark(self
, bookmark_id
):
906 """ WARNING: if bookmark_id is None, ALL bookmarks will be deleted """
907 if bookmark_id
is None:
908 self
.__log
.debug( 'Deleting all bookmarks for %s',
909 self
.playlist_reported_filepath
)
911 for bkmk
in self
.bookmarks
:
915 bookmark
= self
.get_bookmark(bookmark_id
)
916 pos
= self
.bookmarks
.index(bookmark
)
918 self
.bookmarks
[pos
].delete()
919 self
.bookmarks
.remove(bookmark
)
921 self
.__log
.info('Cannot find bookmark with id: %s',bookmark_id
)
925 def update_bookmarks(self
):
926 for bookmark
in self
.bookmarks
:
927 bookmark
.playlist_duplicate_id
= self
.duplicate_id
928 bookmark
.bookmark_filepath
= self
.filepath
929 db
.update_bookmark(bookmark
)
931 class Bookmark(object):
932 """ A single bookmark, nothing more, nothing less. """
935 self
.__log
= logging
.getLogger('panucci.playlist.Bookmark')
938 self
.playlist_id
= None
939 self
.bookmark_name
= ''
940 self
.bookmark_filepath
= ''
941 self
.seek_position
= 0
943 self
.is_resume_position
= False
944 self
.playlist_duplicate_id
= 0
947 def load_from_dict(bkmk_dict
):
950 for key
,value
in bkmk_dict
.iteritems():
951 if hasattr( bkmkobj
, key
):
952 setattr( bkmkobj
, key
, value
)
954 self
.__log
.info('Attr: %s doesn\'t exist...', key
)
959 self
.id = db
.save_bookmark(self
)
963 return db
.remove_bookmark(self
.id)
966 if isinstance(b
, str):
967 return str(self
) == b
971 self
.__log
.warning('Unsupported comparison: %s', type(b
))
975 uid
= self
.bookmark_filepath
976 uid
+= str(self
.playlist_duplicate_id
)
977 uid
+= str(self
.seek_position
)
978 return md5(uid
).hexdigest()
980 def __cmp__(self
, b
):
981 if self
.bookmark_filepath
== b
.bookmark_filepath
:
982 if self
.seek_position
== b
.seek_position
:
985 return -1 if self
.seek_position
< b
.seek_position
else 1
988 'Can\'t compare bookmarks from different files:\n\tself: %s'
989 '\n\tb: %s', self
.bookmark_filepath
, b
.bookmark_filepath
)
992 class FileMetadata(object):
993 """ A class to hold all information about the file that's currently being
994 played. Basically it takes care of metadata extraction... """
996 coverart_names
= ['cover', 'front', 'albumart', 'back']
997 coverart_extensions
= ['.png', '.jpg', '.jpeg']
999 'mp4': { '\xa9nam': 'title',
1000 '\xa9ART': 'artist',
1002 'covr': 'coverart' },
1003 'mp3': { 'TIT2': 'title',
1006 'APIC': 'coverart' },
1007 'ogg': { 'title': 'title',
1011 tag_mappings
['m4a'] = tag_mappings
['mp4']
1012 tag_mappings
['flac'] = tag_mappings
['ogg']
1014 def __init__(self
, filepath
):
1015 self
.__log
= logging
.getLogger('panucci.playlist.FileMetadata')
1016 self
.__filepath
= filepath
1022 self
.coverart
= None
1024 self
.__metadata
_extracted
= False
1026 def _ask_gpodder_for_metadata(self
):
1027 GPO_NAME
= 'org.gpodder'
1028 GPO_PATH
= '/podcasts'
1029 GPO_INTF
= 'org.gpodder.podcasts'
1031 bus
= dbus
.SessionBus()
1033 if bus
.name_has_owner(GPO_NAME
):
1034 o
= bus
.get_object(GPO_NAME
, GPO_PATH
)
1035 i
= dbus
.Interface(o
, GPO_INTF
)
1036 episode
, podcast
= i
.get_episode_title(self
.__filepath
)
1037 if episode
and podcast
:
1038 self
.title
= episode
1039 self
.artist
= podcast
1040 self
.__metadata
_extracted
= True
1042 except Exception, e
:
1043 self
.__log
.debug('Cannot get metadata from gPodder: %s', str(e
))
1047 def extract_metadata(self
):
1048 self
.__log
.debug('Extracting metadata for %s', self
.__filepath
)
1050 if self
._ask
_gpodder
_for
_metadata
():
1051 if self
.coverart
is None:
1052 self
.coverart
= self
.__find
_coverart
()
1055 filetype
= util
.detect_filetype(self
.__filepath
)
1057 if filetype
== 'mp3':
1058 import mutagen
.mp3
as meta_parser
1059 elif filetype
== 'ogg':
1060 import mutagen
.oggvorbis
as meta_parser
1061 elif filetype
== 'flac':
1062 import mutagen
.flac
as meta_parser
1063 elif filetype
in ['mp4', 'm4a']:
1064 import mutagen
.mp4
as meta_parser
1067 'Extracting metadata not supported for %s files.', filetype
)
1071 metadata
= meta_parser
.Open(self
.__filepath
)
1072 self
.__metadata
_extracted
= True
1073 except Exception, e
:
1074 self
.title
= util
.pretty_filename(self
.__filepath
)
1075 self
.__log
.exception('Error running metadata parser...')
1076 self
.__metadata
_extracted
= False
1079 self
.length
= metadata
.info
.length
* 10**9
1080 for tag
,value
in metadata
.iteritems():
1081 if tag
.find(':') != -1: # hack for weirdly named coverart tags
1082 tag
= tag
.split(':')[0]
1084 if self
.tag_mappings
[filetype
].has_key(tag
):
1085 if isinstance( value
, list ):
1087 # Here we could either join the list or just take one
1088 # item. I chose the latter simply because some ogg
1089 # files have several messed up titles...
1094 if self
.tag_mappings
[filetype
][tag
] != 'coverart':
1096 value
= escape(str(value
).strip())
1097 except Exception, e
:
1098 self
.__log
.exception(
1099 'Could not convert tag (%s) to escaped string', tag
)
1101 # some coverart classes store the image in the data
1102 # attribute whereas others do not :S
1103 if hasattr( value
, 'data' ):
1106 setattr( self
, self
.tag_mappings
[filetype
][tag
], value
)
1108 if self
.coverart
is None:
1109 self
.coverart
= self
.__find
_coverart
()
1111 def __find_coverart(self
):
1112 """ Find coverart in the same directory as the filepath """
1114 if '://' in self
.__filepath
and \
1115 not self
.__filepath
.startswith('file://'):
1116 # No cover art for streaming at the moment
1119 directory
= os
.path
.dirname(self
.__filepath
)
1120 for cover
in self
.__find
_coverart
_filepath
(directory
):
1121 self
.__log
.debug('Trying to load coverart from %s', cover
)
1125 self
.__log
.exception('Could not open coverart file %s', cover
)
1128 binary_coverart
= f
.read()
1131 if self
.__test
_coverart
(binary_coverart
):
1132 return binary_coverart
1136 def __test_coverart(self
, data
):
1137 """ tests to see if the file is a proper image file that can be loaded
1138 into a gtk.gdk.Pixbuf """
1142 def __find_coverart_filepath(self
, directory
):
1143 """ finds the path of potential coverart files """
1145 possible_matches
= []
1147 # build the filelist
1148 for f
in os
.listdir(directory
):
1149 if os
.path
.isfile(os
.path
.join(directory
,f
)):
1150 dir_filelist
.append(os
.path
.splitext(f
))
1152 # first pass, check for known filenames
1153 for f
,ext
in dir_filelist
[:]:
1154 if f
.lower() in self
.coverart_names
and \
1155 ext
.lower() in self
.coverart_extensions
:
1156 possible_matches
.append((f
,ext
))
1157 dir_filelist
.remove((f
,ext
))
1159 # second pass, check for known filenames without extensions
1160 for f
,ext
in dir_filelist
[:]:
1161 if f
.lower() in self
.coverart_names
and ext
== '':
1162 possible_matches
.append((f
,ext
))
1163 dir_filelist
.remove((f
,ext
))
1165 # third pass, check for any image file
1166 for f
,ext
in dir_filelist
[:]:
1167 if ext
.lower() in self
.coverart_extensions
:
1168 possible_matches
.append((f
,ext
))
1169 dir_filelist
.remove((f
,ext
))
1172 for f
,ext
in possible_matches
:
1173 yield os
.path
.join( directory
, f
+ext
)
1175 def get_metadata(self
):
1176 """ Returns a dict of metadata """
1178 if not self
.__metadata
_extracted
:
1179 self
.extract_metadata()
1182 'title': self
.title
,
1183 'artist': self
.artist
,
1184 'album': self
.album
,
1185 'image': self
.coverart
,
1186 'length': self
.length
1191 class PlaylistFile(object):
1192 """ The base class for playlist file parsers/exporters,
1193 this should not be used directly but instead subclassed. """
1195 def __init__(self
, filepath
, queue
):
1196 self
.__log
= logging
.getLogger('panucci.playlist.PlaylistFile')
1197 self
._filepath
= filepath
1201 def __open_file(self
, filepath
, mode
):
1202 if self
._file
is not None:
1206 self
._file
= open( filepath
, mode
)
1207 self
._filepath
= filepath
1208 except Exception, e
:
1209 self
._filepath
= None
1212 self
.__log
.exception( 'Error opening file: %s', filepath
)
1217 def __close_file(self
):
1220 if self
._file
is not None:
1223 except Exception, e
:
1224 self
.__log
.exception( 'Error closing file: %s', self
.filepath
)
1227 self
._filepath
= None
1232 def get_absolute_filepath(self
, item_filepath
):
1233 if item_filepath
is None: return
1235 if item_filepath
.startswith('/'):
1236 path
= item_filepath
1238 path
= os
.path
.join(os
.path
.dirname(self
._filepath
), item_filepath
)
1240 if os
.path
.exists( path
):
1243 def get_filelist(self
):
1244 return [ item
.filepath
for item
in self
._items
]
1246 def get_filedicts(self
):
1248 for item
in self
._items
:
1249 d
= { 'title': item
.title
,
1250 'length': item
.length
,
1251 'filepath': item
.filepath
}
1256 def get_queue(self
):
1259 def export_items(self
, filepath
=None):
1260 if filepath
is not None:
1261 self
._filepath
= filepath
1263 if self
.__open
_file
(filepath
, 'w'):
1264 self
.export_hook(self
._items
)
1270 def export_hook(self
, playlist_items
):
1273 def parse(self
, filepath
):
1274 if self
.__open
_file
( filepath
, mode
='r' ):
1275 current_line
= self
._file
.readline()
1277 self
.parse_line_hook( current_line
.strip() )
1278 current_line
= self
._file
.readline()
1280 self
.parse_eof_hook()
1285 def parse_line_hook(self
, line
):
1288 def parse_eof_hook(self
):
1291 def _add_playlist_item(self
, item
):
1292 path
= self
.get_absolute_filepath(item
.playlist_reported_filepath
)
1293 if path
is not None and os
.path
.isfile(path
):
1294 item
.filepath
= path
1295 self
._items
.append(item
)
1297 class M3U_Playlist(PlaylistFile
):
1298 """ An (extended) m3u parser/writer """
1300 def __init__(self
, *args
):
1301 self
.__log
= logging
.getLogger('panucci.playlist.M3U_Playlist')
1302 PlaylistFile
.__init
__( self
, *args
)
1303 self
.extended_m3u
= False
1304 self
.current_item
= PlaylistItem()
1306 def parse_line_hook(self
, line
):
1307 if line
.startswith('#EXTM3U'):
1308 self
.extended_m3u
= True
1309 elif self
.extended_m3u
and line
.startswith('#EXTINF'):
1310 match
= re
.match('#EXTINF:([^,]+),(.*)', line
)
1311 if match
is not None:
1312 length
, title
= match
.groups()
1313 try: length
= int(length
)
1315 self
.current_item
.playlist_length
= length
1316 self
.current_item
.playlist_title
= title
1317 elif line
.startswith('#'):
1318 pass # skip comments
1320 path
= self
.get_absolute_filepath( line
)
1321 if path
is not None:
1322 if os
.path
.isfile( path
):
1323 self
.current_item
.playlist_reported_filepath
= line
1324 self
._add
_playlist
_item
(self
.current_item
)
1325 self
.current_item
= PlaylistItem()
1326 elif os
.path
.isdir( path
):
1327 files
= os
.listdir( path
)
1330 item
= PlaylistItem()
1331 item
.playlist_reported_filepath
=os
.path
.join(line
,file)
1332 self
._add
_playlist
_item
(item
)
1334 def export_hook(self
, playlist_items
):
1335 self
._file
.write('#EXTM3U\n\n')
1337 for item
in playlist_items
:
1339 if not ( item
.length
is None and item
.title
is None ):
1340 length
= -1 if item
.length
is None else int(item
.length
)
1341 title
= '' if item
.title
is None else item
.title
1342 string
+= '#EXTINF:%d,%s\n' % ( length
, title
)
1344 string
+= '%s\n' % item
.filepath
1345 self
._file
.write(string
)
1347 class PLS_Playlist(PlaylistFile
):
1348 """ A somewhat simple pls parser/writer """
1350 def __init__(self
, *args
):
1351 self
.__log
= logging
.getLogger('panucci.playlist.PLS_Playlist')
1352 PlaylistFile
.__init
__( self
, *args
)
1353 self
.current_item
= PlaylistItem()
1354 self
.in_playlist_section
= False
1355 self
.current_item_number
= None
1357 def __add_current_item(self
):
1358 self
._add
_playlist
_item
(self
.current_item
)
1360 def parse_line_hook(self
, line
):
1361 sect_regex
= '\[([^\]]+)\]'
1362 item_regex
= '[^\d]+([\d]+)=(.*)'
1364 if re
.search(item_regex
, line
) is not None:
1365 current
= re
.search(item_regex
, line
).group(1)
1366 if self
.current_item_number
is None:
1367 self
.current_item_number
= current
1368 elif self
.current_item_number
!= current
:
1369 self
.__add
_current
_item
()
1371 self
.current_item
= PlaylistItem()
1372 self
.current_item_number
= current
1374 if re
.search(sect_regex
, line
) is not None:
1375 section
= re
.match(sect_regex
, line
).group(1).lower()
1376 self
.in_playlist_section
= section
== 'playlist'
1377 elif not self
.in_playlist_section
:
1378 pass # don't do anything if we're not in [playlist]
1379 elif line
.lower().startswith('file'):
1380 self
.current_item
.playlist_reported_filepath
= re
.search(
1381 item_regex
, line
).group(2)
1382 elif line
.lower().startswith('title'):
1383 self
.current_item
.playlist_title
= re
.search(
1384 item_regex
, line
).group(2)
1385 elif line
.lower().startswith('length'):
1386 try: length
= int(re
.search(item_regex
, line
).group(2))
1388 self
.current_item
.playlist_length
= length
1390 def parse_eof_hook(self
):
1391 self
.__add
_current
_item
()
1393 def export_hook(self
, playlist_items
):
1394 self
._file
.write('[playlist]\n')
1395 self
._file
.write('NumberOfEntries=%d\n\n' % len(playlist_items
))
1397 for i
,item
in enumerate(playlist_items
):
1398 title
= '' if item
.title
is None else item
.title
1399 length
= -1 if item
.length
is None else item
.length
1400 self
._file
.write('File%d=%s\n' % (i
+1, item
.filepath
))
1401 self
._file
.write('Title%d=%s\n' % (i
+1, title
))
1402 self
._file
.write('Length%d=%s\n\n' % (i
+1, length
))
1404 self
._file
.write('Version=2\n')