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