moved init to playlist
[panucci.git] / src / panucci / playlist.py
blob73e4b8fece955b0dc3b1db50e97274142e9890cb
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
21 import time
22 import os
23 import re
24 import dbus
25 import logging
26 import random
27 from hashlib import md5
28 from xml.sax.saxutils import escape
30 import panucci
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)
52 self.config = config
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 )
59 self.filepath = None
60 self._id = None
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
68 Returns: Nothing
69 """
71 if filepath is None or not self.load( filepath ):
72 self.load_last_played()
73 else:
74 self.player.play()
76 def reset_playlist(self):
77 """ Sets the playlist to a default "known" state """
79 self.filepath = None
80 self._id = None
81 self.__queue.clear()
82 self.stop(None, False)
83 self.notify('reset-playlist', caller=self.reset_playlist)
85 @property
86 def id(self):
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 )
92 return self._id
94 @property
95 def current_filepath(self):
96 """ Get the current file """
97 if not self.is_empty:
98 return self.__queue.current_item.filepath
100 @property
101 def queue_modified(self):
102 return self.__queue.modified
104 @property
105 def queue_length(self):
106 return len(self.__queue)
108 @property
109 def is_empty(self):
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
121 self._id = None
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)
131 return False
133 # copy the bookmarks over to new playlist
134 db.remove_all_bookmarks(self.id)
135 self.__queue.set_new_playlist_id(self.id)
137 return True
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:
154 return True
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:
159 return True
161 def quit(self):
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
178 if bookmark is None:
179 self.__queue.current_item.seek_to = 0
180 else:
181 self.__queue.current_item.seek_to = bookmark.seek_position
183 # if we don't request a seek nothing will happen
184 if same_pos:
185 self.notify( 'seek-requested', bookmark.seek_position,
186 caller=self.__load_from_bookmark )
188 return True
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)
195 if item is not None:
196 return self.__load_from_bookmark( str(item), bookmark )
197 else:
198 self.__log.warning(
199 'item_id=%s,bookmark_id=%s not found', item_id, bookmark_id )
200 return False
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)
208 else:
209 return None, None
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.')
215 return False
216 else:
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,
222 resume_pos=False )
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)
229 if item is None:
230 self.__log.warning('No such item id (%s)', item_id)
231 return False
233 if bookmark_id is not None and bookmark is None:
234 self.__log.warning('No such bookmark id (%s)', bookmark_id)
235 return False
237 if bookmark_id is None:
238 if name and item.title != name:
239 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 )
244 else:
245 bookmark.timestamp = time.time()
247 if name is not None:
248 bookmark.bookmark_name = name
250 if seek_pos is not None:
251 bookmark.seek_position = seek_pos
253 db.update_bookmark(bookmark)
255 return True
257 def update_bookmarks(self):
258 """ Updates the database entries for items that have been modified """
259 for item in self.__queue:
260 if item.is_modified:
261 self.__log.debug(
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)
269 if item is None:
270 self.__log.info('Cannot find item with id: %s', item_id)
271 return False
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
279 else:
280 item.delete_bookmark(None)
281 self.__queue.remove(item)
282 else:
283 item.delete_bookmark(bookmark_id)
285 return True
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 ):
296 return False
297 else:
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)
313 if item is None:
314 self.__log.warning('Cannot get item for id: %s', item_id)
316 return item
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
321 just the title). """
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 )
331 if item is not None:
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
345 else:
346 return 0
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
359 else:
360 return {}
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]
371 else:
372 return 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()
383 error = False
385 if os.path.isdir(filepath):
386 self.load_directory(filepath, True)
387 else:
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 )
397 else:
398 return 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
405 if _play:
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 )
416 return not error
418 def load_last_played(self):
419 recent = self.get_recent_files(max_files=1)
420 if recent:
421 self.load(recent[0])
422 return bool(recent)
424 def __file_queued(self, filepath, successfull, notify):
425 if successfull:
426 self.notify( 'file_queued', filepath, successfull, notify,
427 caller=self.__file_queued )
429 return successfull
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 ):
438 self.__log.debug(
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)
448 return False
450 if not append:
451 if self.notify( 'playlist-to-be-overwritten',
452 caller=self.load_directory ):
453 self.reset_playlist()
454 else:
455 self.__log.info('Directory load aborted by user.')
456 return False
458 self.filepath = panucci.PLAYLIST_FILE
459 self.__queue.playlist_id = self.id
461 items = []
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)
470 items.sort()
471 for item in items:
472 self.append( item, notify=False )
474 if not append:
475 self.on_queue_current_item_changed()
477 return True
479 ##################################
480 # Playlist controls
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
487 def play(self):
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
492 return pos
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.
514 if not self.__queue:
515 return False
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:
522 skip = skip_to
523 else:
524 skip = 0
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 )
530 if not loop:
531 self.__log.warning( "Can't skip to non-existant file w/o loop."
532 " (requested=%d, total=%d)", skip,
533 self.queue_length )
534 return False
535 else:
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
544 else:
545 skip = 0
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 )
552 return True
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)
562 def prev(self):
563 """ Same as next() except moves to the previous track. """
564 return self.skip( loop=False, skip_by=-1 )
566 def last(self):
567 """ Plays last file in queue. """
568 skip_to = len(self.__queue.get_items()) - 1
569 return self.skip( False, None, skip_to )
571 def random(self):
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 = {}
592 list.__init__(self)
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 )
610 else:
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
621 tally = 0
622 for i in subset:
623 tally += int( i.filepath == item.filepath )
624 return tally
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)):
635 self.modified = True
636 return True
637 else:
638 self.__log.warning(
639 'File not found or not supported: %s', item.filepath )
641 return False
643 @property
644 def current_item(self):
645 if len(self) > 0:
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]
652 else:
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
665 def clear(self):
666 """ Reset the the queue to a known state """
668 try:
669 items = self.__mapping_dict.values()
670 for item in items:
671 list.remove(self, item)
672 except:
673 pass
674 self[:] = []
675 self.playlist_id = None
676 self.modified = True
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)
683 def get_items(self):
684 return self.__mapping_dict.values()
686 def is_empty(self):
687 if self.__mapping_dict:
688 return False
689 else:
690 return True
692 def get_bookmark(self, item_id, bookmark_id):
693 item = self.get_item(item_id)
695 if item is None:
696 self.__log.warning(
697 'Item with id "%s" not found, scanning for item...', item_id )
699 for item_ in self:
700 if item_.bookmarks.count(bookmark_id):
701 item = item_
702 break
704 if item is None: return None, None
706 if item.get_bookmark(bookmark_id):
707 return item, item.get_bookmark(bookmark_id)
708 else:
709 return item, None
711 def set_new_playlist_id(self, id):
712 self.playlist_id = id
713 for item in self:
714 item.playlist_id = id
715 for bookmark in item.bookmarks:
716 bookmark.playlist_id = id
717 bookmark.save()
719 def insert(self, position, item):
720 if not self.__prep_item(item):
721 return False
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:
728 i.is_modified = True
729 i.duplicate_id += 1
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)
745 return True
747 def append(self, item):
748 if not self.__prep_item(item):
749 return False
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)
756 return True
758 def remove(self, item):
759 if self.count(item):
760 self.modified = True
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...')
771 def pop(self, item):
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. """
781 def __init__(self):
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
788 self.seek_to = 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
798 self.bookmarks = []
800 def __set_filepath(self, fp):
801 if fp != self.__filepath:
802 self.__filepath = fp
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
807 # being used.
808 #self.__metadata.extract_metadata()
810 def __get_filepath(self):
811 return self.__filepath
813 filepath = property( __get_filepath, __set_filepath )
815 @staticmethod
816 def create_by_filepath(reported_filepath, filepath):
817 item = PlaylistItem()
818 item.playlist_reported_filepath = reported_filepath
819 item.filepath = filepath
820 return item
822 def __eq__(self, b):
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
828 else:
829 self.__log.warning('Unsupported comparison: %s', type(b))
830 return False
832 def __str__(self):
833 uid = self.filepath + str(self.duplicate_id)
834 return md5(uid).hexdigest()
836 @property
837 def metadata(self):
838 """ Returns a dict of metadata, wooo. """
840 metadata = self.__metadata.get_metadata()
841 metadata['title'] = self.title
842 return metadata
844 @property
845 def filetype(self):
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
858 else:
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))
866 @property
867 def length(self):
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
877 else:
878 return -1
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):
889 b = Bookmark()
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
897 b.save()
898 self.bookmarks.append(b)
900 def get_bookmark(self, bkmk_id):
901 for i in self.bookmarks:
902 if str(i) == bkmk_id:
903 return i
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:
912 bkmk.delete()
913 self.bookmarks = []
914 else:
915 bookmark = self.get_bookmark(bookmark_id)
916 pos = self.bookmarks.index(bookmark)
917 if pos >= 0:
918 self.bookmarks[pos].delete()
919 self.bookmarks.remove(bookmark)
920 else:
921 self.__log.info('Cannot find bookmark with id: %s',bookmark_id)
922 return False
923 return True
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. """
934 def __init__(self):
935 self.__log = logging.getLogger('panucci.playlist.Bookmark')
937 self.id = 0
938 self.playlist_id = None
939 self.bookmark_name = ''
940 self.bookmark_filepath = ''
941 self.seek_position = 0
942 self.timestamp = 0
943 self.is_resume_position = False
944 self.playlist_duplicate_id = 0
946 @staticmethod
947 def load_from_dict(bkmk_dict):
948 bkmkobj = Bookmark()
950 for key,value in bkmk_dict.iteritems():
951 if hasattr( bkmkobj, key ):
952 setattr( bkmkobj, key, value )
953 else:
954 self.__log.info('Attr: %s doesn\'t exist...', key)
956 return bkmkobj
958 def save(self):
959 self.id = db.save_bookmark(self)
960 return self.id
962 def delete(self):
963 return db.remove_bookmark(self.id)
965 def __eq__(self, b):
966 if isinstance(b, str):
967 return str(self) == b
968 elif b is None:
969 return False
970 else:
971 self.__log.warning('Unsupported comparison: %s', type(b))
972 return False
974 def __str__(self):
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:
983 return 0
984 else:
985 return -1 if self.seek_position < b.seek_position else 1
986 else:
987 self.__log.info(
988 'Can\'t compare bookmarks from different files:\n\tself: %s'
989 '\n\tb: %s', self.bookmark_filepath, b.bookmark_filepath )
990 return 0
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']
998 tag_mappings = {
999 'mp4': { '\xa9nam': 'title',
1000 '\xa9ART': 'artist',
1001 '\xa9alb': 'album',
1002 'covr': 'coverart' },
1003 'mp3': { 'TIT2': 'title',
1004 'TPE1': 'artist',
1005 'TALB': 'album',
1006 'APIC': 'coverart' },
1007 'ogg': { 'title': 'title',
1008 'artist': 'artist',
1009 'album': 'album' },
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
1018 self.title = ''
1019 self.artist = ''
1020 self.album = ''
1021 self.length = 0
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()
1032 try:
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
1041 return True
1042 except Exception, e:
1043 self.__log.debug('Cannot get metadata from gPodder: %s', str(e))
1045 return False
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()
1053 return
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
1065 else:
1066 self.__log.info(
1067 'Extracting metadata not supported for %s files.', filetype )
1068 return False
1070 try:
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
1077 return 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 ):
1086 if len(value):
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...
1090 value = value[0]
1091 else:
1092 continue
1094 if self.tag_mappings[filetype][tag] != 'coverart':
1095 try:
1096 value = escape(str(value).strip())
1097 except Exception, e:
1098 self.__log.exception(
1099 'Could not convert tag (%s) to escaped string', tag )
1100 else:
1101 # some coverart classes store the image in the data
1102 # attribute whereas others do not :S
1103 if hasattr( value, 'data' ):
1104 value = 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
1117 return None
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)
1122 try:
1123 f = open(cover,'r')
1124 except:
1125 self.__log.exception('Could not open coverart file %s', cover )
1126 continue
1128 binary_coverart = f.read()
1129 f.close()
1131 if self.__test_coverart(binary_coverart):
1132 return binary_coverart
1134 return None
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 """
1140 return True
1142 def __find_coverart_filepath(self, directory):
1143 """ finds the path of potential coverart files """
1144 dir_filelist = []
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))
1171 # yield the results
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()
1181 metadata = {
1182 'title': self.title,
1183 'artist': self.artist,
1184 'album': self.album,
1185 'image': self.coverart,
1186 'length': self.length
1189 return metadata
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
1198 self._file = None
1199 self._items = queue
1201 def __open_file(self, filepath, mode):
1202 if self._file is not None:
1203 self.close_file()
1205 try:
1206 self._file = open( filepath, mode )
1207 self._filepath = filepath
1208 except Exception, e:
1209 self._filepath = None
1210 self._file = None
1212 self.__log.exception( 'Error opening file: %s', filepath)
1213 return False
1215 return True
1217 def __close_file(self):
1218 error = False
1220 if self._file is not None:
1221 try:
1222 self._file.close()
1223 except Exception, e:
1224 self.__log.exception( 'Error closing file: %s', self.filepath )
1225 error = True
1227 self._filepath = None
1228 self._file = None
1230 return not error
1232 def get_absolute_filepath(self, item_filepath):
1233 if item_filepath is None: return
1235 if item_filepath.startswith('/'):
1236 path = item_filepath
1237 else:
1238 path = os.path.join(os.path.dirname(self._filepath), item_filepath)
1240 if os.path.exists( path ):
1241 return path
1243 def get_filelist(self):
1244 return [ item.filepath for item in self._items ]
1246 def get_filedicts(self):
1247 dict_list = []
1248 for item in self._items:
1249 d = { 'title': item.title,
1250 'length': item.length,
1251 'filepath': item.filepath }
1253 dict_list.append(d)
1254 return dict_list
1256 def get_queue(self):
1257 return self._items
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)
1265 self.__close_file()
1266 return True
1267 else:
1268 return False
1270 def export_hook(self, playlist_items):
1271 pass
1273 def parse(self, filepath):
1274 if self.__open_file( filepath, mode='r' ):
1275 current_line = self._file.readline()
1276 while current_line:
1277 self.parse_line_hook( current_line.strip() )
1278 current_line = self._file.readline()
1279 self.__close_file()
1280 self.parse_eof_hook()
1281 else:
1282 return False
1283 return True
1285 def parse_line_hook(self, line):
1286 pass
1288 def parse_eof_hook(self):
1289 pass
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)
1314 except: length = -1
1315 self.current_item.playlist_length = length
1316 self.current_item.playlist_title = title
1317 elif line.startswith('#'):
1318 pass # skip comments
1319 elif line:
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 )
1328 files.sort()
1329 for file in files:
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:
1338 string = ''
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))
1387 except: length = -1
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')