1# Copyright (c) 2014-2021 Cedric Bellegarde <cedric.bellegarde@adishatz.org> 2# Copyright (c) 2015 Jean-Philippe Braun <eon@patapon.info> 3# This program is free software: you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation, either version 3 of the License, or 6# (at your option) any later version. 7# This program is distributed in the hope that it will be useful, 8# but WITHOUT ANY WARRANTY; without even the implied warranty of 9# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10# GNU General Public License for more details. 11# You should have received a copy of the GNU General Public License 12# along with this program. If not, see <http://www.gnu.org/licenses/>. 13 14from hashlib import md5 15 16from lollypop.define import App, StorageType, ScanUpdate, Type 17from lollypop.objects_track import Track 18from lollypop.objects import Base 19from lollypop.utils import emit_signal 20from lollypop.collection_item import CollectionItem 21from lollypop.logger import Logger 22 23 24class Disc: 25 """ 26 Represent an album disc 27 """ 28 29 def __init__(self, album, disc_number, storage_type, skipped): 30 self.db = App().albums 31 self.__tracks = [] 32 self.__album = album 33 self.__storage_type = storage_type 34 self.__number = disc_number 35 self.__skipped = skipped 36 37 def __del__(self): 38 """ 39 Remove ref cycles 40 """ 41 self.__album = None 42 43 # Used by pickle 44 def __getstate__(self): 45 self.db = None 46 return self.__dict__ 47 48 def __setstate__(self, d): 49 self.__dict__.update(d) 50 self.db = App().albums 51 52 def set_tracks(self, tracks): 53 """ 54 Set disc tracks 55 @param tracks as [Track] 56 """ 57 self.__tracks = tracks 58 59 @property 60 def number(self): 61 """ 62 Get disc number 63 """ 64 return self.__number 65 66 @property 67 def album(self): 68 """ 69 Get disc album 70 @return Album 71 """ 72 return self.__album 73 74 @property 75 def track_ids(self): 76 """ 77 Get disc track ids 78 @return [int] 79 """ 80 return [track.id for track in self.tracks] 81 82 @property 83 def track_uris(self): 84 """ 85 Get disc track uris 86 @return [str] 87 """ 88 return [track.uri for track in self.tracks] 89 90 @property 91 def tracks(self): 92 """ 93 Get disc tracks 94 @return [Track] 95 """ 96 if not self.__tracks and self.album.id is not None: 97 self.__tracks = [Track(track_id, self.album) 98 for track_id in self.db.get_disc_track_ids( 99 self.album.id, 100 self.album.genre_ids, 101 self.album.artist_ids, 102 self.number, 103 self.__storage_type, 104 self.__skipped)] 105 return self.__tracks 106 107 108class Album(Base): 109 """ 110 Represent an album 111 """ 112 DEFAULTS = {"artists": [], 113 "artist_ids": [], 114 "year": None, 115 "timestamp": 0, 116 "uri": "", 117 "popularity": 0, 118 "rate": 0, 119 "mtime": 1, 120 "synced": 0, 121 "loved": False, 122 "storage_type": 0, 123 "mb_album_id": None, 124 "lp_album_id": None} 125 126 def __init__(self, album_id=None, genre_ids=[], artist_ids=[], 127 skipped=True): 128 """ 129 Init album 130 @param album_id as int 131 @param genre_ids as [int] 132 @param artist_ids as [int] 133 @param skipped as bool 134 """ 135 Base.__init__(self, App().albums) 136 self.id = album_id 137 self.genre_ids = genre_ids 138 self.__tracks = [] 139 self.__discs = [] 140 self.__name = None 141 self.__skipped = skipped 142 self.__disc_number = None 143 self.__original_year = Type.NONE 144 self.__tracks_storage_type = self.storage_type 145 # Use artist ids from db else 146 if artist_ids: 147 artists = [] 148 for artist_id in set(artist_ids) | set(self.artist_ids): 149 artists.append(App().artists.get_name(artist_id)) 150 self.artists = artists 151 self.artist_ids = artist_ids 152 153 def __del__(self): 154 """ 155 Remove ref cycles 156 """ 157 self.reset_tracks() 158 159 # Used by pickle 160 def __getstate__(self): 161 self.db = None 162 return self.__dict__ 163 164 def __setstate__(self, d): 165 self.__dict__.update(d) 166 self.db = App().albums 167 168 def set_discs(self, discs): 169 """ 170 Set album discs 171 @param discs as [Disc] 172 """ 173 self.__discs = discs 174 175 def set_disc_number(self, disc_number): 176 """ 177 Set album disc 178 @param disc_number as int 179 """ 180 self.__original_year = Type.NONE 181 self.__disc_number = disc_number 182 183 def set_tracks(self, tracks, clone=True): 184 """ 185 Set album tracks, do not disable clone if you know self is already 186 used 187 @param tracks as [Track] 188 @param clone as bool 189 """ 190 if clone: 191 self.__tracks = [] 192 for track in tracks: 193 new_track = Track(track.id, self) 194 self.__tracks.append(new_track) 195 # Album tracks already belong to self 196 # Detach those tracks 197 elif self.__tracks: 198 new_album = Album(self.id, self.genre_ids, self.artist_ids) 199 new__tracks = [] 200 for track in self.__tracks: 201 if track not in tracks: 202 track.set_album(new_album) 203 new__tracks.append(track) 204 new_album.__tracks = new__tracks 205 self.__tracks = tracks 206 else: 207 self.__tracks = tracks 208 209 def append_track(self, track, clone=True): 210 """ 211 Append track to album. 212 Clone: always do this if track is used in UI/Player 213 @param track as Track 214 @param clone as bool 215 """ 216 if clone: 217 self.__tracks.append(Track(track.id, self)) 218 else: 219 self.__tracks.append(track) 220 track.set_album(self) 221 222 def append_tracks(self, tracks, clone=True): 223 """ 224 Append tracks to album 225 Clone: always do this if track is used in UI/Player 226 @param tracks as [Track] 227 @param clone as bool 228 """ 229 for track in tracks: 230 self.append_track(track, clone) 231 232 def remove_track(self, track): 233 """ 234 Remove track from album, album id is None if empty 235 @param track as Track 236 """ 237 for _track in self.tracks: 238 if track.id == _track.id: 239 self.__tracks.remove(_track) 240 empty = len(self.__tracks) == 0 241 if empty: 242 # We don't the album to load tracks anymore 243 self.id = None 244 245 def reset_tracks(self): 246 """ 247 Reset album tracks, useful for tracks loaded async 248 """ 249 self.__tracks = [] 250 self.__discs = [] 251 self.reset("artists") 252 self.reset("artist_ids") 253 self.reset("lp_album_id") 254 255 def disc_names(self, disc_number): 256 """ 257 Disc names 258 @param disc_number as int 259 @return disc names as [str] 260 """ 261 return self.db.get_disc_names(self.id, disc_number) 262 263 def set_loved(self, loved): 264 """ 265 Mark album as loved 266 @param loved as bool 267 """ 268 if self.id >= 0: 269 self.db.set_loved(self.id, loved) 270 self.loved = loved 271 272 def set_uri(self, uri): 273 """ 274 Set album uri 275 @param uri as str 276 """ 277 if self.id >= 0: 278 self.db.set_uri(self.id, uri) 279 self.uri = uri 280 281 def get_track(self, track_id): 282 """ 283 Get track 284 @param track_id as int 285 @return Track 286 """ 287 for track in self.tracks: 288 if track.id == track_id: 289 return track 290 return Track() 291 292 def save(self, save): 293 """ 294 Save album to collection. 295 @param save as bool 296 """ 297 # Save tracks 298 for track_id in self.track_ids: 299 if save: 300 App().tracks.set_storage_type(track_id, StorageType.SAVED) 301 else: 302 App().tracks.set_storage_type(track_id, StorageType.EPHEMERAL) 303 # Save album 304 self.__save(save) 305 306 def save_track(self, save, track): 307 """ 308 Save track to collection 309 @param save as bool 310 @param track as Track 311 """ 312 if save: 313 App().tracks.set_storage_type(track.id, StorageType.SAVED) 314 else: 315 App().tracks.set_storage_type(track.id, StorageType.EPHEMERAL) 316 # Save album 317 self.__save(save) 318 319 def load_tracks(self, cancellable): 320 """ 321 Load album tracks from Spotify, 322 do not call this for Storage.COLLECTION 323 @param cancellable as Gio.Cancellable 324 @return status as bool 325 """ 326 try: 327 if self.storage_type & (StorageType.COLLECTION | 328 StorageType.EXTERNAL): 329 return False 330 elif self.synced != 0 and self.synced != len(self.tracks): 331 from lollypop.search import Search 332 Search().load_tracks(self, cancellable) 333 self.reset_tracks() 334 except Exception as e: 335 Logger.warning("Album::load_tracks(): %s" % e) 336 return True 337 338 def set_synced(self, mask): 339 """ 340 Set synced mask 341 @param mask as int 342 """ 343 self.db.set_synced(self.id, mask) 344 self.synced = mask 345 346 def clone(self, skipped): 347 """ 348 Clone album 349 @param skipped as bool 350 @return album 351 """ 352 album = Album(self.id, self.genre_ids, self.artist_ids, skipped) 353 if skipped: 354 album.set_tracks(self.tracks) 355 return album 356 357 def set_storage_type(self, storage_type): 358 """ 359 Set storage type 360 @param storage_type as StorageType 361 """ 362 self.__tracks_storage_type = storage_type 363 364 def set_skipped(self): 365 """ 366 Set album as skipped, not allowing skipped tracks 367 """ 368 self.__skipped = True 369 370 def merge_discs(self): 371 """ 372 Merge album discs 373 @return Disc 374 """ 375 self.__original_year = None 376 tracks = self.tracks 377 disc = Disc(self, 0, self.__tracks_storage_type, self.__skipped) 378 disc.set_tracks(tracks) 379 self.__discs = [disc] 380 381 @property 382 def original_year(self): 383 """ 384 Get disc original year 385 @return int/None 386 """ 387 if self.__original_year == Type.NONE: 388 self.__original_year = App().tracks.get_year_for_album( 389 self.id, self.__disc_number) 390 return self.__original_year 391 392 @property 393 def collection_item(self): 394 """ 395 Get collection item related to album 396 @return CollectionItem 397 """ 398 item = CollectionItem(album_id=self.id, 399 album_name=self.name, 400 artist_ids=self.artist_ids, 401 lp_album_id=self.lp_album_id) 402 return item 403 404 @property 405 def name(self): 406 """ 407 Get album name 408 @return str 409 """ 410 if self.__name is not None: 411 return self.__name 412 if self.__disc_number is None: 413 self.__name = self.db.get_name(self.id) 414 else: 415 disc_names = self.disc_names(self.__disc_number) 416 if disc_names: 417 self.__name = ", ".join(disc_names) 418 else: 419 self.__name = self.db.get_name(self.id) 420 return self.__name 421 422 @property 423 def is_web(self): 424 """ 425 True if track is a web track 426 @return bool 427 """ 428 return not self.storage_type & (StorageType.COLLECTION | 429 StorageType.EXTERNAL) 430 431 @property 432 def tracks_count(self): 433 """ 434 Get tracks count 435 @return int 436 """ 437 if self.__tracks: 438 return len(self.__tracks) 439 else: 440 return self.db.get__tracks_count( 441 self.id, 442 self.genre_ids, 443 self.artist_ids) 444 445 @property 446 def track_ids(self): 447 """ 448 Get album track ids 449 @return [int] 450 """ 451 return [track.id for track in self.tracks] 452 453 @property 454 def track_uris(self): 455 """ 456 Get album track uris 457 @return [str] 458 """ 459 return [track.uri for track in self.tracks] 460 461 @property 462 def tracks(self): 463 """ 464 Get album tracks 465 @return [Track] 466 """ 467 if self.id is None: 468 return [] 469 if self.__tracks: 470 return self.__tracks 471 tracks = [] 472 for disc in self.discs: 473 tracks += disc.tracks 474 # Already cached by another thread 475 if not self.__tracks: 476 self.__tracks = tracks 477 return tracks 478 479 @property 480 def discs(self): 481 """ 482 Get albums discs 483 @return [Disc] 484 """ 485 if self.__discs: 486 return self.__discs 487 discs = [] 488 if self.__disc_number is None: 489 disc_numbers = self.db.get_discs(self.id) 490 else: 491 disc_numbers = [self.__disc_number] 492 for disc_number in disc_numbers: 493 disc = Disc(self, disc_number, 494 self.__tracks_storage_type, 495 self.__skipped) 496 if disc.tracks: 497 discs.append(disc) 498 # Already cached by another thread 499 if not self.__discs: 500 self.__discs = discs 501 return self.__discs 502 503 @property 504 def duration(self): 505 """ 506 Get album duration and handle caching 507 @return int 508 """ 509 if self.__tracks: 510 track_ids = [track.lp_track_id for track in self.tracks] 511 track_str = "%s" % sorted(track_ids) 512 track_hash = md5(track_str.encode("utf-8")).hexdigest() 513 album_hash = "%s-%s-%s" % ( 514 self.lp_album_id, track_hash, self.__disc_number) 515 else: 516 album_hash = "%s-%s-%s-%s" % (self.lp_album_id, 517 self.genre_ids, 518 self.artist_ids, 519 self.__disc_number) 520 duration = App().cache.get_duration(album_hash) 521 if duration is None: 522 if self.__tracks: 523 duration = 0 524 for track in self.__tracks: 525 duration += track.duration 526 else: 527 duration = self.db.get_duration(self.id, 528 self.genre_ids, 529 self.artist_ids, 530 self.__disc_number) 531 App().cache.set_duration(self.id, album_hash, duration) 532 return duration 533 534####################### 535# PRIVATE # 536####################### 537 def __save(self, save): 538 """ 539 Save album to collection. 540 @param save as bool 541 """ 542 # Save album by updating storage type 543 if save: 544 self.db.set_storage_type(self.id, StorageType.SAVED) 545 else: 546 self.db.set_storage_type(self.id, StorageType.EPHEMERAL) 547 self.reset("mtime") 548 if save: 549 item = CollectionItem(artist_ids=self.artist_ids, 550 album_id=self.id) 551 emit_signal(App().scanner, "updated", item, 552 ScanUpdate.ADDED) 553 else: 554 removed_artist_ids = [] 555 for artist_id in self.artist_ids: 556 if not App().artists.get_name(artist_id): 557 removed_artist_ids.append(artist_id) 558 item = CollectionItem(artist_ids=removed_artist_ids, 559 album_id=self.id) 560 emit_signal(App().scanner, "updated", item, 561 ScanUpdate.REMOVED) 562