CoverArt Browser
v2.0
Browse your cover-art albums in Rhythmbox
|
00001 # -*- Mode: python; coding: utf-8; tab-width: 4; indent-tabs-mode: nil; -*- 00002 # 00003 # Copyright (C) 2012 - fossfreedom 00004 # Copyright (C) 2012 - Agustin Carrasco 00005 # 00006 # This program is free software; you can redistribute it and/or modify 00007 # it under the terms of the GNU General Public License as published by 00008 # the Free Software Foundation; either version 2, or (at your option) 00009 # any later version. 00010 # 00011 # This program is distributed in the hope that it will be useful, 00012 # but WITHOUT ANY WARRANTY; without even the implied warranty of 00013 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 00014 # GNU General Public License for more details. 00015 # 00016 # You should have received a copy of the GNU General Public License 00017 # along with this program; if not, write to the Free Software 00018 # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. 00019 00020 ''' 00021 Structures and managers to work with albums on Rhythmbox. This module provides 00022 the base model for the plugin to work on top of. 00023 ''' 00024 00025 from datetime import datetime, date 00026 import os 00027 import cgi 00028 import tempfile 00029 import gc 00030 00031 from gi.repository import RB 00032 from gi.repository import GObject 00033 from gi.repository import Gio 00034 from gi.repository import GLib 00035 from gi.repository import Gtk 00036 from gi.repository import Gdk 00037 from gi.repository import GdkPixbuf 00038 import cairo 00039 00040 from coverart_browser_prefs import GSetting 00041 from coverart_utils import create_pixbuf_from_file_at_size 00042 from coverart_utils import SortedCollection 00043 from coverart_utils import idle_iterator 00044 from coverart_utils import NaturalString 00045 import coverart_rb3compat as rb3compat 00046 from coverart_utils import uniquify_and_sort 00047 from coverart_utils import dumpstack 00048 from coverart_utils import check_lastfm 00049 import rb 00050 00051 00052 00053 # default chunk of entries to process when loading albums 00054 ALBUM_LOAD_CHUNK = 50 00055 00056 # default chunk of albums to process when loading covers 00057 COVER_LOAD_CHUNK = 5 00058 00059 00060 class Cover(GObject.Object): 00061 ''' 00062 Cover of an Album. It may be initialized either by a file path to the image 00063 to use or by a previously allocated pixbuf. 00064 00065 :param size: `int` size in pixels of the side of the cover (asuming a 00066 square-shapped cover). 00067 :param image: `str` containing a path of an image from where to create 00068 the cover. 00069 ''' 00070 # signals 00071 __gsignals__ = { 00072 'resized': (GObject.SIGNAL_RUN_LAST, None, ()) 00073 } 00074 00075 def __init__(self, size, image): 00076 super(Cover, self).__init__() 00077 00078 assert isinstance(image, str), "image should be a string" 00079 00080 self.original = image 00081 00082 self._create_pixbuf(size) 00083 00084 def resize(self, size): 00085 ''' 00086 Resizes the cover's pixbuf. 00087 ''' 00088 if self.size != size: 00089 self._create_pixbuf(size) 00090 self.emit('resized') 00091 00092 def _create_pixbuf(self, size): 00093 self.pixbuf = create_pixbuf_from_file_at_size( 00094 self.original, size, size) 00095 00096 self.size = size 00097 00098 00099 class Shadow(Cover): 00100 SIZE = 120. 00101 WIDTH = 11 00102 00103 def __init__(self, size, image): 00104 super(Shadow, self).__init__(size, image) 00105 00106 self._calculate_sizes(size) 00107 00108 def resize(self, size): 00109 super(Shadow, self).resize(size) 00110 00111 self._calculate_sizes(size) 00112 00113 def _calculate_sizes(self, size): 00114 self.width = int(size / self.SIZE * self.WIDTH) 00115 self.cover_size = self.size - self.width * 2 00116 00117 00118 class ShadowedCover(Cover): 00119 def __init__(self, shadow, image): 00120 super(ShadowedCover, self).__init__(shadow.cover_size, image) 00121 00122 self._shadow = shadow 00123 00124 self._add_shadow() 00125 00126 def resize(self, size): 00127 if self.size != self._shadow.cover_size: 00128 self._create_pixbuf(self._shadow.cover_size) 00129 self._add_shadow() 00130 00131 self.emit('resized') 00132 00133 def _add_shadow(self): 00134 pix = self._shadow.pixbuf 00135 00136 surface = cairo.ImageSurface( 00137 cairo.FORMAT_ARGB32, pix.get_width(), pix.get_height()) 00138 context = cairo.Context(surface) 00139 00140 # draw shadow 00141 Gdk.cairo_set_source_pixbuf(context, pix, 0, 0) 00142 context.paint() 00143 00144 # draw cover 00145 Gdk.cairo_set_source_pixbuf(context, self.pixbuf, self._shadow.width, 00146 self._shadow.width) 00147 context.paint() 00148 00149 self.pixbuf = Gdk.pixbuf_get_from_surface(surface, 0, 0, 00150 self._shadow.size, self._shadow.size) 00151 00152 00153 class Track(GObject.Object): 00154 ''' 00155 A music track. Provides methods to access to most of the tracks data from 00156 Rhythmbox's database. 00157 00158 :param entry: `RB.RhythmbDBEntry` rhythmbox's database entry for the track. 00159 :param db: `RB.RhythmbDB` instance. It's needed to update the track's 00160 values. 00161 ''' 00162 # signals 00163 __gsignals__ = { 00164 'modified': (GObject.SIGNAL_RUN_LAST, None, ()), 00165 'deleted': (GObject.SIGNAL_RUN_LAST, None, ()) 00166 } 00167 00168 __hash__ = GObject.__hash__ 00169 00170 def __init__(self, entry, db=None): 00171 super(Track, self).__init__() 00172 00173 self.entry = entry 00174 self._db = db 00175 00176 def __eq__(self, other): 00177 return rb.entry_equal(self.entry, other.entry) 00178 00179 @property 00180 def title(self): 00181 return self.entry.get_string(RB.RhythmDBPropType.TITLE) 00182 00183 @property 00184 def artist(self): 00185 return self.entry.get_string(RB.RhythmDBPropType.ARTIST) 00186 00187 @property 00188 def album(self): 00189 return self.entry.get_string(RB.RhythmDBPropType.ALBUM) 00190 00191 @property 00192 def album_artist(self): 00193 return self.entry.get_string(RB.RhythmDBPropType.ALBUM_ARTIST) 00194 00195 @property 00196 def genre(self): 00197 return self.entry.get_string(RB.RhythmDBPropType.GENRE) 00198 00199 @property 00200 def year(self): 00201 return self.entry.get_ulong(RB.RhythmDBPropType.DATE) 00202 00203 @property 00204 def rating(self): 00205 return self.entry.get_double(RB.RhythmDBPropType.RATING) 00206 00207 @rating.setter 00208 def rating(self, new_rating): 00209 self._db.entry_set(self.entry, RB.RhythmDBPropType.RATING, new_rating) 00210 00211 @property 00212 def duration(self): 00213 return self.entry.get_ulong(RB.RhythmDBPropType.DURATION) 00214 00215 @property 00216 def location(self): 00217 return self.entry.get_string(RB.RhythmDBPropType.LOCATION) 00218 00219 @property 00220 def composer(self): 00221 return self.entry.get_string(RB.RhythmDBPropType.COMPOSER) 00222 00223 @property 00224 def track_number(self): 00225 return self.entry.get_ulong(RB.RhythmDBPropType.TRACK_NUMBER) 00226 00227 @property 00228 def disc_number(self): 00229 return self.entry.get_ulong(RB.RhythmDBPropType.DISC_NUMBER) 00230 00231 @property 00232 def album_artist_sort(self): 00233 sort = self.entry.get_string( 00234 RB.RhythmDBPropType.ALBUM_ARTIST_SORTNAME_FOLDED) or \ 00235 self.entry.get_string(RB.RhythmDBPropType.ALBUM_ARTIST_FOLDED) or \ 00236 self.entry.get_string(RB.RhythmDBPropType.ARTIST_FOLDED) 00237 00238 return NaturalString(sort) 00239 00240 @property 00241 def album_sort(self): 00242 sort = self.entry.get_string( 00243 RB.RhythmDBPropType.ALBUM_SORTNAME_FOLDED) or \ 00244 self.entry.get_string(RB.RhythmDBPropType.ALBUM_FOLDED) 00245 00246 return NaturalString(sort) 00247 00248 @property 00249 def is_saveable(self): 00250 return self.entry.get_entry_type().props.save_to_disk 00251 00252 def create_ext_db_key(self): 00253 ''' 00254 Returns an `RB.ExtDBKey` that can be used to acces/write some other 00255 track specific data on an `RB.ExtDB`. 00256 ''' 00257 return self.entry.create_ext_db_key(RB.RhythmDBPropType.ALBUM) 00258 00259 00260 class Album(GObject.Object): 00261 ''' 00262 An album. It's conformed from one or more tracks, and many of it's 00263 information is deduced from them. 00264 00265 :param name: `str` name of the album. 00266 :param cover: `Cover` cover for this album. 00267 ''' 00268 # signals 00269 __gsignals__ = { 00270 'modified': (GObject.SIGNAL_RUN_FIRST, None, ()), 00271 'emptied': (GObject.SIGNAL_RUN_LAST, None, ()), 00272 'cover-updated': (GObject.SIGNAL_RUN_LAST, None, ()) 00273 } 00274 00275 __hash__ = GObject.__hash__ 00276 00277 def __init__(self, name, artist, cover): 00278 super(Album, self).__init__() 00279 00280 self.name = name 00281 self.artist = artist 00282 self._album_artist_sort = None 00283 self._album_sort = None 00284 self._artists = None 00285 self._titles = None 00286 self._composers = None 00287 self._genres = None 00288 self._tracks = [] 00289 self._cover = None 00290 self.cover = cover 00291 self._year = None 00292 self._rating = None 00293 self._duration = None 00294 00295 self._signals_id = {} 00296 00297 @property 00298 def album_artist_sort(self): 00299 if not self._album_artist_sort: 00300 self._album_artist_sort = uniquify_and_sort( 00301 [track.album_artist_sort for track in self._tracks]) 00302 00303 return self._album_artist_sort 00304 00305 @property 00306 def album_sort(self): 00307 if not self._album_sort: 00308 self._album_sort = uniquify_and_sort( 00309 [track.album_sort for track in self._tracks]) 00310 00311 return self._album_sort 00312 00313 @property 00314 def artists(self): 00315 if not self._artists: 00316 self._artists = ', '.join(set( 00317 [track.artist for track in self._tracks])) 00318 00319 return self._artists 00320 00321 @property 00322 def track_titles(self): 00323 if not self._titles: 00324 self._titles = ' '.join(set( 00325 [track.title for track in self._tracks])) 00326 00327 return self._titles 00328 00329 @property 00330 def composers(self): 00331 if not self._composers: 00332 composers = [track.composer for track in self._tracks if track.composer] 00333 if composers: 00334 self._composers = ' '.join(set(composers)) 00335 00336 return self._composers 00337 00338 @property 00339 def year(self): 00340 if not self._year: 00341 real_years = [track.year for track in self._tracks if track.year != 0] 00342 00343 if len(real_years) > 0: 00344 self._year = min(real_years) 00345 else: 00346 self._year = 0 00347 00348 return self._year 00349 00350 @property 00351 def real_year(self): 00352 ''' 00353 return the calculated year e.g. 1989 00354 ''' 00355 calc_year = self.year 00356 00357 if calc_year == 0: 00358 calc_year = date.today().year 00359 else: 00360 calc_year = datetime.fromordinal(calc_year).year 00361 00362 return calc_year 00363 00364 @property 00365 def calc_year_sort(self): 00366 ''' 00367 returns a str combinationi of real_year + album name 00368 ''' 00369 00370 return str(self.real_year) + self.name 00371 00372 @property 00373 def genres(self): 00374 if not self._genres: 00375 self._genres = set([track.genre for track in self._tracks]) 00376 00377 return self._genres 00378 00379 @property 00380 def rating(self): 00381 if not self._rating: 00382 ratings = [track.rating for track in self._tracks 00383 if track.rating and track.rating != 0] 00384 00385 if len(ratings) > 0: 00386 self._rating = sum(ratings) / len(self._tracks) 00387 else: 00388 self._rating = 0 00389 return self._rating 00390 00391 @rating.setter 00392 def rating(self, new_rating): 00393 for track in self._tracks: 00394 track.rating = new_rating 00395 self._rating = None 00396 self.emit('modified') 00397 00398 @property 00399 def track_count(self): 00400 return len(self._tracks) 00401 00402 @property 00403 def duration(self): 00404 if not self._duration: 00405 self._duration = sum([track.duration for track in self._tracks]) 00406 00407 return self._duration 00408 00409 @property 00410 def cover(self): 00411 return self._cover 00412 00413 @cover.setter 00414 def cover(self, new_cover): 00415 if self._cover: 00416 self._cover.disconnect(self._cover_resized_id) 00417 00418 self._cover = new_cover 00419 self._cover_resized_id = self._cover.connect('resized', 00420 lambda *args: self.emit('cover-updated')) 00421 00422 self.emit('cover-updated') 00423 00424 def get_tracks(self, rating_threshold=0): 00425 ''' 00426 Returns the tracks on this album. If rating_threshold is provided, 00427 only those tracks over the threshold will be returned. The track list 00428 returned is ordered by track number. 00429 00430 :param rating_threshold: `float` threshold over which the rating of the 00431 track should be to be returned. 00432 ''' 00433 if not rating_threshold: 00434 # if no threshold is set, return all 00435 tracks = self._tracks 00436 else: 00437 # otherwise, only return the entries over the threshold 00438 tracks = [track for track in self._tracks 00439 if track.rating >= rating_threshold] 00440 00441 return sorted(tracks, key=lambda track: (track.disc_number, track.track_number)) 00442 00443 def add_track(self, track): 00444 ''' 00445 Adds a track to the album. 00446 00447 :param track: `Track` track to be added. 00448 ''' 00449 self._tracks.append(track) 00450 ids = (track.connect('modified', self._track_modified), 00451 track.connect('deleted', self._track_deleted)) 00452 00453 self._signals_id[track] = ids 00454 self.emit('modified') 00455 00456 def _track_modified(self, track): 00457 print("_track_modified") 00458 if track.album != self.name: 00459 self._track_deleted(track) 00460 else: 00461 self.emit('modified') 00462 00463 def _track_deleted(self, track): 00464 print("_track_deleted") 00465 self._tracks.remove(track) 00466 00467 #list(map(track.disconnect, self._signals_id[track])) 00468 for signal_id in self._signals_id[track]: 00469 track.disconnect(signal_id) 00470 00471 del self._signals_id[track] 00472 00473 if len(self._tracks) == 0: 00474 self.emit('emptied') 00475 else: 00476 self.emit('modified') 00477 00478 def create_ext_db_key(self): 00479 ''' 00480 Creates a `RB.ExtDBKey` from this album's tracks. 00481 ''' 00482 return self._tracks[0].create_ext_db_key() 00483 00484 def do_modified(self): 00485 self._album_artist = None 00486 self._album_artist_sort = None 00487 self._album_sort = None 00488 self._artists = None 00489 self._titles = None 00490 self._genres = None 00491 self._year = None 00492 self._rating = None 00493 self._duration = None 00494 self._composers = None 00495 00496 def __str__(self): 00497 return self.artist + self.name 00498 00499 def __eq__(self, other): 00500 return other and self.name == other.name and \ 00501 self.artist == other.artist 00502 00503 def __ne__(self, other): 00504 return not other or \ 00505 self.name + self.artist != other.name + other.artist 00506 00507 00508 class AlbumFilters(object): 00509 @classmethod 00510 def nay_filter(cls, *args): 00511 def filt(*args): 00512 return False 00513 00514 return filt 00515 00516 @classmethod 00517 def global_filter(cls, searchtext=None): 00518 def filt(album): 00519 # this filter is more complicated: for each word in the search 00520 # text, it tries to find at least one match on the params of 00521 # the album. If no match is given, then the album doesn't match 00522 if not searchtext: 00523 return True 00524 00525 words = RB.search_fold(searchtext).split() 00526 params = list(map(RB.search_fold, [album.name, album.artist, 00527 album.artists, album.track_titles, album.composers])) 00528 matches = [] 00529 00530 for word in words: 00531 match = False 00532 00533 for param in params: 00534 if word in param: 00535 match = True 00536 break 00537 00538 matches.append(match) 00539 00540 return False not in matches 00541 00542 return filt 00543 00544 @classmethod 00545 def album_artist_filter(cls, searchtext=None): 00546 def filt(album): 00547 if not searchtext: 00548 return True 00549 00550 return RB.search_fold(searchtext) in RB.search_fold(album.artist) 00551 00552 return filt 00553 00554 @classmethod 00555 def artist_filter(cls, searchtext=None): 00556 def filt(album): 00557 if not searchtext: 00558 return True 00559 00560 return RB.search_fold(searchtext) in RB.search_fold(album.artists) 00561 00562 return filt 00563 00564 @classmethod 00565 def similar_artist_filter(cls, searchtext=None): 00566 def filt(album): 00567 # this filter is more complicated: for each word in the search 00568 # text, it tries to find at least one match on the params of 00569 # the album. If no match is given, then the album doesn't match 00570 if not searchtext: 00571 return True 00572 00573 words = RB.search_fold(searchtext).split() 00574 params = list(map(RB.search_fold, [album.artist, 00575 album.artists])) 00576 matches = [] 00577 00578 for word in words: 00579 match = False 00580 00581 for param in params: 00582 if word in param: 00583 match = True 00584 break 00585 00586 matches.append(match) 00587 00588 return False not in matches 00589 00590 return filt 00591 00592 @classmethod 00593 def album_name_filter(cls, searchtext=None): 00594 def filt(album): 00595 if not searchtext: 00596 return True 00597 00598 return RB.search_fold(searchtext) in RB.search_fold(album.name) 00599 00600 return filt 00601 00602 @classmethod 00603 def track_title_filter(cls, searchtext=None): 00604 def filt(album): 00605 if not searchtext: 00606 return True 00607 00608 return RB.search_fold(searchtext) in RB.search_fold( 00609 album.track_titles) 00610 00611 return filt 00612 00613 @classmethod 00614 def composer_filter(cls, searchtext=None): 00615 def filt(album): 00616 if not searchtext: 00617 return True 00618 00619 return RB.search_fold(searchtext) in RB.search_fold( 00620 album.composers) 00621 00622 return filt 00623 00624 @classmethod 00625 def genre_filter(cls, searchtext=None): 00626 def filt(album): 00627 if not searchtext: 00628 return True 00629 00630 genres = RB.search_fold(' '.join(album.genres)) 00631 return RB.search_fold(searchtext) in genres 00632 00633 return filt 00634 00635 @classmethod 00636 def model_filter(cls, model=None): 00637 if not model or not len(model): 00638 return lambda x: False 00639 00640 albums = set() 00641 00642 for row in model: 00643 entry = model[row.path][0] 00644 albums.add(Track(entry).album) 00645 00646 def filt(album): 00647 return album.name in albums 00648 00649 return filt 00650 00651 @classmethod 00652 def decade_filter(cls, searchdecade=None): 00653 ''' 00654 The year is in RATA DIE format so need to extract the year 00655 00656 The searchdecade param can be None meaning all results 00657 or -1 for all albums older than our standard range which is 1930 00658 or an actual decade for 1930 to 2020 00659 ''' 00660 00661 def filt(album): 00662 if not searchdecade: 00663 return True 00664 00665 if album.year == 0: 00666 year = date.today().year 00667 else: 00668 year = datetime.fromordinal(album.year).year 00669 00670 year = int(round(year - 5, -1)) 00671 00672 if searchdecade > 0: 00673 return searchdecade == year 00674 else: 00675 return year < 1930 00676 00677 return filt 00678 00679 00680 AlbumFilters.keys = { 00681 'nay': AlbumFilters.nay_filter, 00682 'all': AlbumFilters.global_filter, 00683 'album_artist': AlbumFilters.album_artist_filter, 00684 'artist': AlbumFilters.artist_filter, 00685 'quick_artist': AlbumFilters.artist_filter, 00686 'composers': AlbumFilters.composer_filter, 00687 'similar_artist': AlbumFilters.similar_artist_filter, 00688 'album_name': AlbumFilters.album_name_filter, 00689 'track': AlbumFilters.track_title_filter, 00690 'genre': AlbumFilters.genre_filter, 00691 'model': AlbumFilters.model_filter, 00692 'decade': AlbumFilters.decade_filter 00693 } 00694 00695 sort_keys = { 00696 'name': ('album_sort', 'album_sort'), 00697 'artist': ('album_artist_sort', 'album_artist_sort'), 00698 'year': ('year', 'album_sort'), 00699 'rating': ('rating', 'album_sort'), 00700 } 00701 00702 00703 class AlbumsModel(GObject.Object): 00704 ''' 00705 Model that contains albums, keeps them sorted, filtered and provides an 00706 external `Gtk.TreeModel` interface to use as part of a Gtk interface. 00707 00708 The `Gtk.TreeModel` haves the following structure: 00709 column 0 -> string containing the album name and artist 00710 column 1 -> pixbuf of the album's cover. 00711 column 2 -> instance of the album itself. 00712 column 3 -> markup text showed under the cover. 00713 column 4 -> boolean that indicates if the row should be shown 00714 ''' 00715 # signals 00716 __gsignals__ = { 00717 'generate-tooltip': (GObject.SIGNAL_RUN_LAST, str, (object,)), 00718 'generate-markup': (GObject.SIGNAL_RUN_LAST, str, (object,)), 00719 'album-updated': ((GObject.SIGNAL_RUN_LAST, None, (object, object))), 00720 'visual-updated': ((GObject.SIGNAL_RUN_LAST, None, (object, object))), 00721 'filter-changed': ((GObject.SIGNAL_RUN_FIRST, None, ())), 00722 'album-added': ((GObject.SIGNAL_RUN_LAST, None, (object,))) 00723 } 00724 00725 # list of columns names and positions on the TreeModel 00726 columns = {'tooltip': 0, 'pixbuf': 1, 'album': 2, 'markup': 3, 'show': 4} 00727 00728 def __init__(self): 00729 super(AlbumsModel, self).__init__() 00730 00731 self._iters = {} 00732 self._albums = SortedCollection( 00733 key=lambda album: getattr(album, 'name')) 00734 self._sortkey = {'type': 'name', 'order': True} 00735 00736 self._tree_store = Gtk.ListStore(str, GdkPixbuf.Pixbuf, object, str, 00737 bool) 00738 00739 # filters 00740 self._filters = {} 00741 00742 # sorting idle call 00743 self._sort_process = None 00744 00745 # create the filtered store that's used with the view 00746 self._filtered_store = self._tree_store.filter_new() 00747 self._filtered_store.set_visible_column(AlbumsModel.columns['show']) 00748 00749 @property 00750 def store(self): 00751 return self._filtered_store 00752 00753 @idle_iterator 00754 def _recreate_text(self): 00755 def process(album, data): 00756 tree_iter = self._iters[album.name][album.artist]['iter'] 00757 markup = self.emit('generate-markup', album) 00758 00759 self._tree_store.set(tree_iter, self.columns['markup'], 00760 markup) 00761 self._emit_signal(tree_iter, 'visual-updated') 00762 00763 def error(exception): 00764 print('Error while recreating text: ' + str(exception)) 00765 00766 return ALBUM_LOAD_CHUNK, process, None, error, None 00767 00768 def _album_modified(self, album): 00769 print("_album_modified") 00770 tree_iter = self._iters[album.name][album.artist]['iter'] 00771 00772 if self._tree_store.iter_is_valid(tree_iter): 00773 # only update if the iter is valid 00774 # generate and update values 00775 tooltip, pixbuf, album, markup, hidden = \ 00776 self._generate_values(album) 00777 00778 self._tree_store.set(tree_iter, self.columns['tooltip'], tooltip, 00779 self.columns['markup'], markup, self.columns['show'], hidden) 00780 00781 # reorder the album 00782 new_pos = self._albums.reorder(album) 00783 00784 if new_pos != -1: 00785 if (new_pos + 1) >= len(self._albums): 00786 old_album = self._albums[new_pos - 1] 00787 old_iter = \ 00788 self._iters[old_album.name][old_album.artist]['iter'] 00789 self._tree_store.move_after(tree_iter, old_iter) 00790 else: 00791 old_album = self._albums[new_pos + 1] 00792 old_iter = \ 00793 self._iters[old_album.name][old_album.artist]['iter'] 00794 self._tree_store.move_before(tree_iter, old_iter) 00795 00796 # inform that the album is updated 00797 print("album modified") 00798 print(album) 00799 self._emit_signal(tree_iter, 'album-updated') 00800 00801 def _cover_updated(self, album): 00802 tree_iter = self._iters[album.name][album.artist]['iter'] 00803 00804 if self._tree_store.iter_is_valid(tree_iter): 00805 # only update if the iter is valid 00806 pixbuf = album.cover.pixbuf 00807 00808 self._tree_store.set_value(tree_iter, self.columns['pixbuf'], 00809 pixbuf) 00810 00811 self._emit_signal(tree_iter, 'visual-updated') 00812 00813 def _emit_signal(self, tree_iter, signal): 00814 # we get the filtered path and iter since that's what the outside world 00815 # interacts with 00816 tree_path = self._filtered_store.convert_child_path_to_path( 00817 self._tree_store.get_path(tree_iter)) 00818 00819 if tree_path: 00820 # if there's no path, the album doesn't show on the filtered model 00821 # so no one needs to know 00822 tree_iter = self._filtered_store.get_iter(tree_path) 00823 00824 self.emit(signal, tree_path, tree_iter) 00825 00826 def add(self, album): 00827 ''' 00828 Add an album to the model. 00829 00830 :param album: `Album` to be added to the model. 00831 ''' 00832 00833 # generate necessary values 00834 values = self._generate_values(album) 00835 # insert the values 00836 tree_iter = self._tree_store.insert(self._albums.insert(album), values) 00837 # connect signals 00838 ids = (album.connect('modified', self._album_modified), 00839 album.connect('cover-updated', self._cover_updated), 00840 album.connect('emptied', self.remove)) 00841 if not album.name in self._iters: 00842 self._iters[album.name] = {} 00843 self._iters[album.name][album.artist] = {'album': album, 00844 'iter': tree_iter, 'ids': ids} 00845 self.emit('album-added', album) 00846 return tree_iter 00847 00848 def _generate_values(self, album): 00849 tooltip = self.emit('generate-tooltip', album) 00850 markup = self.emit('generate-markup', album) 00851 pixbuf = album.cover.pixbuf 00852 hidden = self._album_filter(album) 00853 00854 return tooltip, pixbuf, album, markup, hidden 00855 00856 def remove(self, album): 00857 ''' 00858 Removes this album from the model. 00859 00860 :param album: `Album` to be removed from the model. 00861 ''' 00862 print("album model remove") 00863 print(album) 00864 self._albums.remove(album) 00865 self._tree_store.remove(self._iters[album.name][album.artist]['iter']) 00866 00867 # disconnect signals 00868 for sig_id in self._iters[album.name][album.artist]['ids']: 00869 album.disconnect(sig_id) 00870 00871 del self._iters[album.name][album.artist] 00872 00873 def contains(self, album_name, album_artist): 00874 ''' 00875 Indicates if the model contains a specific album. 00876 00877 :param album_name: `str` name of the album. 00878 ''' 00879 return album_name in self._iters \ 00880 and album_artist in self._iters[album_name] 00881 00882 def get(self, album_name, album_artist): 00883 ''' 00884 Returns the requested album. 00885 00886 :param album_name: `str` name of the album. 00887 ''' 00888 return self._iters[album_name][album_artist]['album'] 00889 00890 def get_from_dbentry(self, entry): 00891 ''' 00892 Returns the album containing the track corresponding to rhythmdbentry 00893 00894 :param entry: `RhythmDBEntry` 00895 ''' 00896 00897 album_artist = entry.get_string(RB.RhythmDBPropType.ALBUM_ARTIST) 00898 album_artist = album_artist if album_artist else entry.get_string(RB.RhythmDBPropType.ARTIST) 00899 album_name = entry.get_string(RB.RhythmDBPropType.ALBUM) 00900 00901 return self._iters[album_name][album_artist]['album'] 00902 00903 def get_all(self): 00904 ''' 00905 Returns a collection of all the albums in this model. 00906 ''' 00907 return self._albums 00908 00909 def get_from_path(self, path): 00910 ''' 00911 Returns an album referenced by a `Gtk.TreeModel` path. 00912 00913 :param path: `Gtk.TreePath` referencing the album. 00914 ''' 00915 return self._filtered_store[path][self.columns['album']] 00916 00917 def get_from_ext_db_key(self, key): 00918 ''' 00919 Returns the requested album. 00920 00921 :param key: ext_db_key 00922 ''' 00923 # get the album name and artist 00924 name = key.get_field('album') 00925 artist = key.get_field('artist') 00926 00927 # first check if there's a direct match 00928 album = self.get(name, artist) if self.contains(name, artist) else None 00929 00930 if not album: 00931 # get all the albums with the given name and look for a match 00932 albums = [artist['album'] for artist in list(self._iters[name].values())] 00933 00934 for curr_album in albums: 00935 if key.matches(curr_album.create_ext_db_key()): 00936 album = curr_album 00937 break 00938 00939 return album 00940 00941 def get_path(self, album): 00942 return self._filtered_store.convert_child_path_to_path( 00943 self._tree_store.get_path( 00944 self._iters[album.name][album.artist]['iter'])) 00945 00946 def find_first_visible(self, filter_key, filter_arg, start=None, 00947 backwards=False): 00948 album_filter = AlbumFilters.keys[filter_key](filter_arg) 00949 00950 albums = reversed(self._albums) if backwards else self._albums 00951 ini = albums.index(start) + 1 if start else 0 00952 00953 for i in range(ini, len(albums)): 00954 album = albums[i] 00955 00956 if album_filter(album) and self._album_filter(album): 00957 return album 00958 00959 return None 00960 00961 def show(self, album, show): 00962 ''' 00963 Unfilters an album, making it visible to the publicly available model's 00964 `Gtk.TreeModel` 00965 00966 :param album: `Album` to show or hide. 00967 :param show: `bool` indcating whether to show(True) or hide(False) the 00968 album. 00969 ''' 00970 album_iter = self._iters[album.name][album.artist]['iter'] 00971 00972 if self._tree_store.iter_is_valid(album_iter): 00973 self._tree_store.set_value(album_iter, self.columns['show'], show) 00974 00975 @idle_iterator 00976 def _sort(self): 00977 def process(album, data): 00978 values = self._generate_values(album) 00979 00980 tree_iter = self._tree_store.append(values) 00981 self._iters[album.name][album.artist]['iter'] = tree_iter 00982 00983 def error(exception): 00984 print('Error(1) while adding albums to the model: ' + str(exception)) 00985 00986 def finish(data): 00987 self._sort_process = None 00988 self.remove_filter('nay') 00989 00990 return ALBUM_LOAD_CHUNK, process, None, error, finish 00991 00992 def sort(self): 00993 ''' 00994 Changes the sorting strategy for the model. 00995 ''' 00996 00997 gs = GSetting() 00998 source_settings = gs.get_setting(gs.Path.PLUGIN) 00999 key = source_settings[gs.PluginKey.SORT_BY] 01000 order = source_settings[gs.PluginKey.SORT_ORDER] 01001 01002 print("current") 01003 print(self._sortkey) 01004 01005 print("registry") 01006 print(key) 01007 print(order) 01008 01009 if key == self._sortkey['type']: 01010 key = None 01011 else: 01012 self._sortkey['type'] = key 01013 01014 if order != self._sortkey['order']: 01015 reverse = True 01016 self._sortkey['order'] = order 01017 else: 01018 reverse = False 01019 01020 def key_function(album): 01021 keys = [getattr(album, prop) for prop in props] 01022 return keys 01023 01024 if not key and not reverse: 01025 print("nothing to sort") 01026 return 01027 01028 print(key) 01029 print(reverse) 01030 if key: 01031 props = sort_keys[key] 01032 self._albums.key = key_function 01033 01034 if reverse: 01035 self._albums = reversed(self._albums) 01036 01037 self._tree_store.clear() 01038 01039 # add the nay filter 01040 self.replace_filter('nay', refilter=False) 01041 01042 if self._sort_process: 01043 # stop the previous sort process if there's one 01044 self._sort_process.stop() 01045 01046 # load the albums back to the model 01047 self._sort_process = self._sort(iter(self._albums)) 01048 01049 def replace_filter(self, filter_key, filter_arg=None, refilter=True): 01050 ''' 01051 Adds or replaces a filter by it's filter_key. 01052 01053 :param filter_key: `str` key of the filter method to use. This should 01054 be one of the available keys on the `AlbumFilters` class. 01055 :param filter_arg: `object` any object that the correspondant filter 01056 method may need to perform the filtering process. 01057 :param refilter: `bool` indicating whether to force a refilter and 01058 emit the 'filter-changed' signal(True) or not(False). 01059 ''' 01060 self._filters[filter_key] = AlbumFilters.keys[filter_key](filter_arg) 01061 01062 if refilter: 01063 self.emit('filter-changed') 01064 01065 def remove_filter(self, filter_key, refilter=True): 01066 ''' 01067 Removes a filter by it's filter_key 01068 01069 :param filter_key: `str` key of the filter method to use. This should 01070 be one of the available keys on the `AlbumFilters` class. 01071 :param refilter: `bool` indicating whether to force a refilter and 01072 emit the 'filter-changed' signal(True) or not(False). 01073 ''' 01074 if filter_key in self._filters: 01075 del self._filters[filter_key] 01076 01077 if refilter: 01078 self.emit('filter-changed') 01079 01080 def clear_filters(self): 01081 ''' 01082 Clears all filters on the model. 01083 ''' 01084 if self._filters: 01085 self._filters.clear() 01086 01087 self.emit('filter-changed') 01088 01089 def do_filter_changed(self): 01090 pos = 0 01091 for show_result in list(map(self._album_filter, self._albums)): 01092 self.show(self._albums[pos], show_result) 01093 pos = pos + 1 01094 01095 def _album_filter(self, album): 01096 for f in list(self._filters.values()): 01097 if not f(album): 01098 return False 01099 01100 return True 01101 01102 def recreate_text(self): 01103 ''' 01104 Forces the recreation and update of the markup text for each album. 01105 ''' 01106 self._recreate_text(iter(self._albums)) 01107 01108 01109 class AlbumLoader(GObject.Object): 01110 ''' 01111 Loads and updates Rhythmbox's tracks and albums, updating the model 01112 accordingly. 01113 01114 :param album_manager: `AlbumManager` responsible for this loader. 01115 ''' 01116 # signals 01117 __gsignals__ = { 01118 'albums-load-finished': (GObject.SIGNAL_RUN_LAST, None, (object,)), 01119 'model-load-finished': (GObject.SIGNAL_RUN_LAST, None, ()) 01120 } 01121 01122 def __init__(self, album_manager): 01123 super(AlbumLoader, self).__init__() 01124 01125 self._album_manager = album_manager 01126 self._tracks = {} 01127 01128 self._connect_signals() 01129 01130 def _connect_signals(self): 01131 # connect signals for updating the albums 01132 self.entry_changed_id = self._album_manager.db.connect('entry-changed', 01133 self._entry_changed_callback) 01134 self.entry_added_id = self._album_manager.db.connect('entry-added', 01135 self._entry_added_callback) 01136 self.entry_deleted_id = self._album_manager.db.connect('entry-deleted', 01137 self._entry_deleted_callback) 01138 01139 @idle_iterator 01140 def _load_albums(self): 01141 def process(row, data): 01142 entry = data['model'][row.path][0] 01143 01144 # allocate the track 01145 track = Track(entry, self._album_manager.db) 01146 self._tracks[track.location] = track 01147 01148 album_name = track.album 01149 album_artist = track.album_artist 01150 album_artist = album_artist if album_artist else track.artist 01151 01152 if album_name not in data['albums']: 01153 data['albums'][album_name] = {} 01154 01155 if album_artist in data['albums'][album_name]: 01156 album = data['albums'][album_name][album_artist] 01157 else: 01158 album = Album(album_name, album_artist, 01159 self._album_manager.cover_man.unknown_cover) 01160 data['albums'][album_name][album_artist] = album 01161 01162 album.add_track(track) 01163 01164 def after(data): 01165 # update the progress 01166 data['progress'] += ALBUM_LOAD_CHUNK 01167 01168 self._album_manager.progress = data['progress'] / data['total'] 01169 01170 def error(exception): 01171 print('Error processing entries: ' + str(exception)) 01172 01173 def finish(data): 01174 self._album_manager.progress = 1 01175 self.emit('albums-load-finished', data['albums']) 01176 01177 return ALBUM_LOAD_CHUNK, process, after, error, finish 01178 01179 @idle_iterator 01180 def _load_model(self): 01181 def process(albums, data): 01182 # add the album to the model 01183 for album in list(albums.values()): 01184 self._album_manager.model.add(album) 01185 01186 def after(data): 01187 data['progress'] += ALBUM_LOAD_CHUNK 01188 01189 # update the progress 01190 self._album_manager.progress = 1 - data['progress'] / data['total'] 01191 01192 def error(exception): 01193 dumpstack("Something awful happened!") 01194 print('Error(2) while adding albums to the model: ' + str(exception)) 01195 01196 def finish(data): 01197 self._album_manager.progress = 0 01198 self.emit('model-load-finished') 01199 return False 01200 01201 return ALBUM_LOAD_CHUNK, process, after, error, finish 01202 01203 def _entry_changed_callback(self, db, entry, changes): 01204 print("CoverArtBrowser DEBUG - entry_changed_callback") 01205 # NOTE: changes are packed in array of rhythmdbentrychange 01206 01207 def analyse_change(change): 01208 print(change.prop) 01209 if change.prop is RB.RhythmDBPropType.ALBUM \ 01210 or change.prop is RB.RhythmDBPropType.ALBUM_ARTIST \ 01211 or change.prop is RB.RhythmDBPropType.ARTIST \ 01212 or change.prop is RB.RhythmDBPropType.ALBUM_SORTNAME \ 01213 or change.prop is RB.RhythmDBPropType.ALBUM_ARTIST_SORTNAME: 01214 # called when the album of a entry is modified 01215 track.emit('deleted') 01216 track.emit('modified') 01217 print("change prop album or artist") 01218 self._allocate_track(track) 01219 01220 elif change.prop is RB.RhythmDBPropType.HIDDEN: 01221 # called when an entry gets hidden (e.g.:the sound file is 01222 # removed. 01223 if changes.new: 01224 print("change prop new") 01225 track.emit('deleted') 01226 else: 01227 print("change prop dunno") 01228 self._allocate_track(track) 01229 01230 # look at all the changes and update the albums accordingly 01231 track = self._tracks[Track(entry).location] 01232 01233 #RB3 has a simple rhythmdbentrychange array to deal with so we 01234 #just need to loop each element of the array 01235 01236 for change in changes: 01237 analyse_change(change) 01238 01239 print("CoverArtBrowser DEBUG - end entry_changed_callback") 01240 01241 def _entry_added_callback(self, db, entry): 01242 print("CoverArtBrowser DEBUG - entry_added_callback") 01243 self._allocate_track(Track(entry, db)) 01244 01245 print("CoverArtBrowser DEBUG - end entry_added_callback") 01246 01247 def _entry_deleted_callback(self, db, entry): 01248 print("CoverArtBrowser DEBUG - entry_deleted_callback") 01249 prototype = Track(entry).location 01250 01251 if prototype in self._tracks: 01252 # gotta check if the track is loaded first 01253 track = self._tracks[prototype] 01254 del self._tracks[track.location] 01255 01256 track.emit('deleted') 01257 01258 print("CoverArtBrowser DEBUG - end entry_deleted_callback") 01259 01260 def _allocate_track(self, track): 01261 if track.duration > 0 and track.is_saveable: 01262 # only allocate the track if it's a valid track 01263 self._tracks[track.location] = track 01264 01265 album_name = track.album 01266 album_artist = track.album_artist 01267 album_artist = album_artist if album_artist else track.artist 01268 01269 if self._album_manager.model.contains(album_name, album_artist): 01270 print("allocate track - contains") 01271 album = self._album_manager.model.get(album_name, album_artist) 01272 print(album) 01273 album.add_track(track) 01274 else: 01275 print("allocate track - does not contain") 01276 album = Album(album_name, album_artist, 01277 self._album_manager.cover_man.unknown_cover) 01278 print(album) 01279 album.add_track(track) 01280 self._album_manager.cover_man.load_cover(album) 01281 self._album_manager.model.add(album) 01282 01283 def load_albums(self, query_model): 01284 ''' 01285 Loads and creates `Track` instances for all entries on query_model, 01286 assigning them into their correspondant `Album`. 01287 ''' 01288 print("CoverArtBrowser DEBUG - load_albums") 01289 01290 self._load_albums(iter(query_model), albums={}, model=query_model, 01291 total=len(query_model), progress=0.) 01292 01293 print("CoverArtBrowser DEBUG - load_albums finished") 01294 01295 def do_albums_load_finished(self, albums): 01296 # load the albums to the model 01297 self._album_manager.model.replace_filter('nay') 01298 self._load_model(iter(list(albums.values())), total=len(albums), progress=0.) 01299 01300 def do_model_load_finished(self): 01301 self._album_manager.model.remove_filter('nay') 01302 01303 01304 class CoverRequester(GObject.Object): 01305 def __init__(self, cover_db): 01306 super(CoverRequester, self).__init__() 01307 01308 self._cover_db = cover_db 01309 self.unknown_cover = None 01310 self._callback = None 01311 self._queue = [] 01312 self._queue_id = 0 01313 self._running = False 01314 self._stop = False 01315 01316 def add_to_queue(self, coverobjects, callback): 01317 ''' Adds coverobjects to the queue if they're not already there. ''' 01318 self._queue.extend( 01319 [coverobject for coverobject in coverobjects if coverobject not in self._queue]) 01320 01321 self._start_process(callback) 01322 01323 def replace_queue(self, coverobjects, callback): 01324 ''' Completely replace the current queue. ''' 01325 self._queue = coverobjects 01326 01327 self._start_process(callback) 01328 01329 def _start_process(self, callback): 01330 ''' Starts the queue processing if it isn't running already ''' 01331 if not self._running: 01332 self._callback = callback 01333 self._running = True 01334 self._process_queue() 01335 01336 def _process_queue(self): 01337 ''' 01338 Main method that process the queue. 01339 First, it tries to adquire a lock on the queue, and if it can, pops 01340 the next element of the queue and process it. 01341 The lock makes sure that only one request is done at a time, and 01342 successfully ignores false timeouts or strand callbacks. 01343 ''' 01344 # process the next element in the queue 01345 while self._queue: 01346 coverobject = self._queue.pop(0) 01347 01348 if coverobject.cover is self.unknown_cover: 01349 break 01350 else: 01351 coverobject = None 01352 01353 if coverobject: 01354 # inform the current coverobject being searched 01355 self._callback(coverobject) 01356 01357 # start the request 01358 self._queue_id += 1 01359 self._search_for_cover(coverobject, self._queue_id) 01360 01361 # add a timeout to the request 01362 Gdk.threads_add_timeout_seconds(GLib.PRIORITY_DEFAULT_IDLE, 40, 01363 self._next, self._queue_id) 01364 else: 01365 # if there're no more elements, clean the state of the requester 01366 self._running = False 01367 self._callback(None) 01368 01369 def _search_for_cover(self, coverobject, search_id): 01370 ''' 01371 Activelly requests a cover to the cover_db, calling 01372 the callback given once the process finishes (since it generally is 01373 asynchronous). 01374 For more information on the callback arguments, check 01375 `RB.ExtDB.request` documentation. 01376 01377 :param coverobject: covertype for which search the cover. 01378 ''' 01379 # create a key and request the cover 01380 key = coverobject.create_ext_db_key() 01381 provides = self._cover_db.request(key, self._next, search_id) 01382 01383 if not provides: 01384 # in case there is no provider, call the callback immediately 01385 self._next(search_id) 01386 01387 def _next(self, *args): 01388 ''' Advances to the next coverobject to process. ''' 01389 # get the id of the search 01390 search_id = args[-1] 01391 if search_id == self._queue_id: 01392 # only process the next element if the search_id is the same as 01393 # the current id. Otherwise, this is a invalid call 01394 self._process_queue() 01395 01396 def stop(self): 01397 ''' Clears the queue, forcing the requester to stop. ''' 01398 del self._queue[:] 01399 01400 01401 class CoverManager(GObject.Object): 01402 ''' 01403 Manager that takes care of cover loading and updating. 01404 01405 :param plugin: `Peas.PluginInfo` instance used to have access to the 01406 predefined unknown cover. 01407 :param album_manager: `AlbumManager` responsible for this manager. 01408 ''' 01409 01410 # signals 01411 __gsignals__ = { 01412 'load-finished': (GObject.SIGNAL_RUN_LAST, None, ()) 01413 } 01414 01415 # properties 01416 has_finished_loading = False 01417 force_lastfm_check = False 01418 cover_size = GObject.property(type=int, default=0) 01419 01420 def __init__(self, plugin, manager): 01421 super(CoverManager, self).__init__() 01422 #self.cover_db = None to be defined by inherited class 01423 self._manager = manager 01424 self._requester = CoverRequester(self.cover_db) 01425 01426 self.unknown_cover = None #to be defined by inherited class 01427 self.album_manager = None #to be defined by inherited class 01428 01429 # connect the signal to update cover arts when added 01430 self.req_id = self.cover_db.connect('added', 01431 self.coverart_added_callback) 01432 self.connect('load-finished', self._on_load_finished) 01433 01434 def _on_load_finished(self, *args): 01435 self.has_finished_loading = True 01436 01437 @idle_iterator 01438 def _load_covers(self): 01439 def process(coverobject, data): 01440 self.load_cover(coverobject) 01441 01442 def finish(data): 01443 self.album_manager.progress = 1 01444 gc.collect() 01445 self.emit('load-finished') 01446 01447 def error(exception): 01448 print('Error while loading covers: ' + str(exception)) 01449 01450 def after(data): 01451 data['progress'] += COVER_LOAD_CHUNK 01452 01453 # update the progress 01454 self.album_manager.progress = data['progress'] / data['total'] 01455 01456 return COVER_LOAD_CHUNK, process, after, error, finish 01457 01458 def create_unknown_cover(self, plugin): 01459 # set the unknown cover to the requester to make comparisons 01460 self._requester.unknown_cover = self.unknown_cover 01461 01462 def create_cover(self, image): 01463 return Cover(self.cover_size, image) 01464 01465 def coverart_added_callback(self, ext_db, key, path, pixbuf): 01466 # use the name to get the album and update it's cover 01467 if pixbuf: 01468 coverobject = self._manager.model.get_from_ext_db_key(key) 01469 01470 if coverobject: 01471 coverobject.cover = self.create_cover(path) 01472 01473 def load_cover(self, coverobject): 01474 ''' 01475 Tries to load an Album's cover. If no cover is found upon lookup, 01476 the unknown cover is used. 01477 This method doesn't actively tries to find a cover, for that you should 01478 use the search_cover method. 01479 01480 :param album: `Album` for which load the cover. 01481 ''' 01482 # create a key and look for the art location 01483 key = coverobject.create_ext_db_key() 01484 art_location = self.cover_db.lookup(key) 01485 01486 # try to create a cover 01487 if art_location: 01488 coverobject.cover = self.create_cover(art_location) 01489 else: 01490 coverobject.cover = self.unknown_cover 01491 01492 def load_covers(self): 01493 ''' 01494 Loads all the covers for the model's albums. 01495 ''' 01496 # get all the coverobjects 01497 coverobjects = self._manager.model.get_all() 01498 01499 self._load_covers(iter(coverobjects), total=len(coverobjects), progress=0.) 01500 01501 def search_covers(self, coverobjects=None, callback=lambda *_: None): 01502 ''' 01503 Request all the albums' covers, one by one, periodically calling a 01504 callback to inform the status of the process. 01505 The callback should accept one argument: the album which cover is 01506 being requested. When the argument passed is None, it means the 01507 process has finished. 01508 01509 :param albums: `list` of `Album` for which look for covers. 01510 :param callback: `callable` to periodically inform when an album's 01511 cover is being searched. 01512 ''' 01513 if not check_lastfm(self.force_lastfm_check): 01514 # display error message and quit 01515 dialog = Gtk.MessageDialog(None, 01516 Gtk.DialogFlags.MODAL, 01517 Gtk.MessageType.INFO, 01518 Gtk.ButtonsType.OK, 01519 _("Enable LastFM plugin and log in first")) 01520 01521 dialog.run() 01522 dialog.destroy() 01523 01524 return 01525 01526 if coverobjects is None: 01527 self._requester.replace_queue( 01528 list(self._manager.model.get_all()), callback) 01529 else: 01530 self._requester.add_to_queue(coverobjects, callback) 01531 01532 def cancel_cover_request(self): 01533 ''' 01534 Cancel the current cover request, if there is one running. 01535 ''' 01536 self._requester.stop() 01537 01538 def update_pixbuf_cover(self, coverobject, pixbuf): 01539 pass 01540 01541 def update_cover(self, coverobject, pixbuf=None, uri=None): 01542 ''' 01543 Updates the cover database, inserting the pixbuf as the cover art for 01544 all the entries on the album. 01545 In the case a uri is given instead of the pixbuf, it will first try to 01546 retrieve an image from the uri, then recall this method with the 01547 obtained pixbuf. 01548 01549 :param album: `Album` for which the cover is. 01550 :param pixbuf: `GkdPixbuf.Pixbuf` to use as a cover. 01551 :param uri: `str` from where we should try to retrieve an image. 01552 ''' 01553 if pixbuf: 01554 self.update_pixbuf_cover(coverobject, pixbuf) 01555 elif uri: 01556 parsed = rb3compat.urlparse(uri) 01557 01558 if parsed.scheme == 'file': 01559 # local file, load it on a pixbuf and assign it 01560 path = rb3compat.url2pathname(uri.strip()).replace('file://', '') 01561 01562 if os.path.exists(path): 01563 cover = GdkPixbuf.Pixbuf.new_from_file(path) 01564 self.update_cover(coverobject, cover) 01565 else: 01566 # assume is a remote uri and we have to retrieve the data 01567 def cover_update(data, coverobject): 01568 # save the cover on a temp file and open it as a pixbuf 01569 with tempfile.NamedTemporaryFile(mode='wb') as tmp: 01570 try: 01571 tmp.write(data) 01572 tmp.flush() 01573 cover = GdkPixbuf.Pixbuf.new_from_file(tmp.name) 01574 01575 # set the new cover 01576 self.update_cover(coverobject, cover) 01577 except: 01578 print("The URI doesn't point to an image or " + \ 01579 "the image couldn't be opened.") 01580 01581 async = rb.Loader() 01582 async.get_url(uri, cover_update, coverobject) 01583 01584 01585 class AlbumCoverManager(CoverManager): 01586 # properties 01587 add_shadow = GObject.property(type=bool, default=False) 01588 shadow_image = GObject.property(type=str, default="above") 01589 01590 def __init__(self, plugin, album_manager): 01591 self.cover_db = RB.ExtDB(name='album-art') 01592 super(AlbumCoverManager, self).__init__(plugin, album_manager) 01593 01594 self.album_manager = album_manager 01595 self._connect_properties() 01596 self._connect_signals(plugin) 01597 01598 # create unknown cover and shadow for covers 01599 self.create_unknown_cover(plugin) 01600 01601 def _connect_signals(self, plugin): 01602 self.connect('notify::cover-size', self._on_cover_size_changed) 01603 self.connect('notify::add-shadow', self._on_add_shadow_changed, plugin) 01604 self.connect('notify::shadow-image', self._on_add_shadow_changed, 01605 plugin) 01606 01607 def _connect_properties(self): 01608 gs = GSetting() 01609 setting = gs.get_setting(gs.Path.PLUGIN) 01610 01611 setting.bind(gs.PluginKey.COVER_SIZE, self, 'cover_size', 01612 Gio.SettingsBindFlags.GET) 01613 setting.bind(gs.PluginKey.ADD_SHADOW, self, 'add_shadow', 01614 Gio.SettingsBindFlags.GET) 01615 setting.bind(gs.PluginKey.SHADOW_IMAGE, self, 'shadow_image', 01616 Gio.SettingsBindFlags.GET) 01617 01618 def create_unknown_cover(self, plugin): 01619 # create the unknown cover 01620 self._shadow = Shadow(self.cover_size, 01621 rb.find_plugin_file(plugin, 'img/album-shadow-%s.png' % 01622 self.shadow_image)) 01623 self.unknown_cover = self.create_cover( 01624 rb.find_plugin_file(plugin, 'img/rhythmbox-missing-artwork.svg')) 01625 01626 super(AlbumCoverManager, self).create_unknown_cover(plugin) 01627 01628 def create_cover(self, image): 01629 if self.add_shadow: 01630 cover = ShadowedCover(self._shadow, image) 01631 else: 01632 cover = Cover(self.cover_size, image) 01633 01634 return cover 01635 01636 def _on_add_shadow_changed(self, obj, prop, plugin): 01637 # update the unknown_cover 01638 self.create_unknown_cover(plugin) 01639 01640 # recreate all the covers 01641 self.load_covers() 01642 01643 def _on_cover_size_changed(self, *args): 01644 ''' 01645 Updates the showing albums cover size. 01646 ''' 01647 # update the shadow 01648 self._shadow.resize(self.cover_size) 01649 01650 # update coverview item width 01651 self.update_item_width() 01652 01653 # update the album's covers 01654 albums = self.album_manager.model.get_all() 01655 01656 self._resize_covers(iter(albums), total=len(albums), progress=0.) 01657 01658 def update_item_width(self): 01659 self.album_manager.current_view.resize_icon(self.cover_size) 01660 01661 def update_pixbuf_cover(self, coverobject, pixbuf): 01662 # if it's a pixbuf, assign it to all the artist for the album 01663 key = RB.ExtDBKey.create_storage('album', coverobject.name) 01664 key.add_field('artist', coverobject.artist) 01665 01666 self.cover_db.store(key, RB.ExtDBSourceType.USER_EXPLICIT, 01667 pixbuf) 01668 01669 for artist in coverobject.artists.split(', '): 01670 key = RB.ExtDBKey.create_storage('album', coverobject.name) 01671 key.add_field('artist', artist) 01672 01673 self.cover_db.store(key, RB.ExtDBSourceType.USER_EXPLICIT, 01674 pixbuf) 01675 01676 @idle_iterator 01677 def _resize_covers(self): 01678 def process(coverobject, data): 01679 coverobject.cover.resize(self.cover_size) 01680 01681 def finish(data): 01682 self.album_manager.progress = 1 01683 self.emit('load-finished') 01684 01685 def error(exception): 01686 print("Error while resizing covers: " + str(exception)) 01687 01688 def after(data): 01689 data['progress'] += COVER_LOAD_CHUNK 01690 01691 # update the progress 01692 self.album_manager.progress = data['progress'] / data['total'] 01693 01694 return COVER_LOAD_CHUNK, process, after, error, finish 01695 01696 01697 class TextManager(GObject.Object): 01698 ''' 01699 Manager that keeps control of the text options for the model's markup text. 01700 It takes care of creating the text for the model when requested to do it. 01701 01702 :param album_manager: `AlbumManager` responsible for this manager. 01703 ''' 01704 # properties 01705 display_text_ellipsize_enabled = GObject.property(type=bool, default=False) 01706 display_text_ellipsize_length = GObject.property(type=int, default=0) 01707 display_font_size = GObject.property(type=int, default=0) 01708 01709 def __init__(self, album_manager): 01710 super(TextManager, self).__init__() 01711 01712 self._album_manager = album_manager 01713 self._current_view = self._album_manager.current_view 01714 01715 # connect properties and signals 01716 self._connect_signals() 01717 self._connect_properties() 01718 01719 def _connect_signals(self): 01720 ''' 01721 Connects the loader to all the needed signals for it to work. 01722 ''' 01723 # connect signals for the loader properties 01724 self.connect('notify::display-text-ellipsize-enabled', 01725 self._on_notify_display_text_ellipsize) 01726 self.connect('notify::display-text-ellipsize-length', 01727 self._on_notify_display_text_ellipsize) 01728 self.connect('notify::display-font-size', 01729 self._on_notify_display_text_ellipsize) 01730 01731 self._album_manager.model.connect('generate-tooltip', 01732 self._generate_tooltip) 01733 self._album_manager.model.connect('generate-markup', 01734 self._generate_markup_text) 01735 01736 def _connect_properties(self): 01737 ''' 01738 Connects the loader properties to the saved preferences. 01739 ''' 01740 gs = GSetting() 01741 setting = gs.get_setting(gs.Path.PLUGIN) 01742 01743 setting.bind(gs.PluginKey.DISPLAY_TEXT_ELLIPSIZE, self, 01744 'display_text_ellipsize_enabled', Gio.SettingsBindFlags.GET) 01745 setting.bind(gs.PluginKey.DISPLAY_TEXT_ELLIPSIZE_LENGTH, self, 01746 'display_text_ellipsize_length', 01747 Gio.SettingsBindFlags.GET) 01748 setting.bind(gs.PluginKey.DISPLAY_FONT_SIZE, self, 'display_font_size', 01749 Gio.SettingsBindFlags.GET) 01750 01751 def _on_notify_display_text_ellipsize(self, *args): 01752 ''' 01753 Callback called when one of the properties related with the ellipsize 01754 option is changed. 01755 ''' 01756 self._album_manager.model.recreate_text() 01757 01758 def _generate_tooltip(self, model, album): 01759 ''' 01760 Utility function that creates the tooltip for this album to set into 01761 the model. 01762 ''' 01763 return cgi.escape(rb3compat.unicodeencode(_('%s by %s'), 'utf-8') % (album.name, 01764 album.artists)) 01765 01766 def _generate_markup_text(self, model, album): 01767 ''' 01768 Utility function that creates the markup text for this album to set 01769 into the model. 01770 ''' 01771 # we use unicode to avoid problems with non ascii albums 01772 name = rb3compat.unicodestr(album.name, 'utf-8') 01773 artist = rb3compat.unicodestr(album.artist, 'utf-8') 01774 01775 if self.display_text_ellipsize_enabled: 01776 ellipsize = self.display_text_ellipsize_length 01777 01778 if len(name) > ellipsize: 01779 name = name[:ellipsize] + '...' 01780 01781 if len(artist) > ellipsize: 01782 artist = artist[:ellipsize] + '...' 01783 01784 name = rb3compat.unicodeencode(name, 'utf-8') 01785 artist = rb3compat.unicodeencode(artist, 'utf-8') 01786 01787 # escape odd chars 01788 artist = GLib.markup_escape_text(artist) 01789 name = GLib.markup_escape_text(name) 01790 01791 # markup format 01792 markup = "<span font='%d'><b>%s</b>\n<i>%s</i></span>" 01793 return markup % (self.display_font_size, name, artist) 01794 01795 01796 class AlbumManager(GObject.Object): 01797 ''' 01798 Main construction that glues together the different managers, the loader 01799 and the model. It takes care of initializing all the system. 01800 01801 :param plugin: `Peas.PluginInfo` instance. 01802 :param current_view: `AlbumView` where the album's cover are shown. 01803 ''' 01804 # singleton instance 01805 instance = None 01806 01807 # properties 01808 progress = GObject.property(type=float, default=0) 01809 01810 # signals 01811 __gsignals__ = { 01812 'sort': (GObject.SIGNAL_RUN_LAST, None, (object,)) 01813 } 01814 01815 01816 def __init__(self, plugin, current_view): 01817 super(AlbumManager, self).__init__() 01818 01819 self.current_view = current_view 01820 self.db = plugin.shell.props.db 01821 01822 self.model = AlbumsModel() 01823 01824 # initialize managers 01825 self.loader = AlbumLoader(self) 01826 self.cover_man = AlbumCoverManager(plugin, self) 01827 from coverart_artistview import ArtistManager 01828 01829 self.artist_man = ArtistManager(plugin, self, plugin.shell) 01830 self.text_man = TextManager(self) 01831 self._show_policy = current_view.show_policy.initialise(self) 01832 01833 # connect signals 01834 self._connect_signals() 01835 01836 def _connect_signals(self): 01837 ''' 01838 Connects the manager to all the needed signals for it to work. 01839 ''' 01840 # connect signal to the loader so it shows the albums when it finishes 01841 self._load_finished_id = self.loader.connect('model-load-finished', 01842 self._load_finished_callback) 01843 self.connect('sort', self._sort_album) 01844 01845 def _sort_album(self, widget, param): 01846 toolbar_type = param 01847 01848 if not toolbar_type or toolbar_type == "album": 01849 self.model.sort() 01850 01851 def _load_finished_callback(self, *args): 01852 self.artist_man.loader.load_artists() 01853 self.cover_man.load_covers()