1# -*- coding: utf-8 -*-
2#
3# Picard, the next-generation MusicBrainz tagger
4#
5# Copyright (C) 2004 Robert Kaye
6# Copyright (C) 2006-2009, 2011-2012, 2014 Lukáš Lalinský
7# Copyright (C) 2008 Gary van der Merwe
8# Copyright (C) 2008 Hendrik van Antwerpen
9# Copyright (C) 2008 ojnkpjg
10# Copyright (C) 2008-2011, 2014, 2018-2020 Philipp Wolfer
11# Copyright (C) 2009 Nikolai Prokoschenko
12# Copyright (C) 2011-2012 Chad Wilson
13# Copyright (C) 2011-2013, 2019 Michael Wiencek
14# Copyright (C) 2012-2013, 2016-2017 Wieland Hoffmann
15# Copyright (C) 2013, 2018 Calvin Walton
16# Copyright (C) 2013-2015, 2017 Sophist-UK
17# Copyright (C) 2013-2015, 2017-2019 Laurent Monin
18# Copyright (C) 2016 Suhas
19# Copyright (C) 2016-2018 Sambhav Kothari
20# Copyright (C) 2017 Antonio Larrosa
21# Copyright (C) 2018 Vishal Choudhary
22# Copyright (C) 2019 Joel Lintunen
23# Copyright (C) 2020 Gabriel Ferreira
24#
25# This program is free software; you can redistribute it and/or
26# modify it under the terms of the GNU General Public License
27# as published by the Free Software Foundation; either version 2
28# of the License, or (at your option) any later version.
29#
30# This program is distributed in the hope that it will be useful,
31# but WITHOUT ANY WARRANTY; without even the implied warranty of
32# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
33# GNU General Public License for more details.
34#
35# You should have received a copy of the GNU General Public License
36# along with this program; if not, write to the Free Software
37# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
38
39
40from collections import (
41    OrderedDict,
42    defaultdict,
43    namedtuple,
44)
45import traceback
46
47from PyQt5 import (
48    QtCore,
49    QtNetwork,
50)
51
52from picard import log
53from picard.cluster import Cluster
54from picard.collection import add_release_to_user_collections
55from picard.config import get_config
56from picard.const import VARIOUS_ARTISTS_ID
57from picard.dataobj import DataObject
58from picard.file import File
59from picard.mbjson import (
60    medium_to_metadata,
61    release_group_to_metadata,
62    release_to_metadata,
63    track_to_metadata,
64)
65from picard.metadata import (
66    Metadata,
67    run_album_metadata_processors,
68    run_track_metadata_processors,
69)
70from picard.plugin import (
71    PluginFunctions,
72    PluginPriority,
73)
74from picard.script import (
75    ScriptError,
76    ScriptParser,
77    enabled_tagger_scripts_texts,
78)
79from picard.track import Track
80from picard.util import (
81    find_best_match,
82    format_time,
83    mbid_validate,
84    process_events_iter,
85)
86from picard.util.imagelist import (
87    add_metadata_images,
88    remove_metadata_images,
89    update_metadata_images,
90)
91from picard.util.textencoding import asciipunct
92
93from picard.ui.item import Item
94
95
96def _create_artist_node_dict(source_node):
97    return {x['artist']['id']: x['artist'] for x in source_node['artist-credit']}
98
99
100def _copy_artist_nodes(source, target_node):
101    for credit in target_node['artist-credit']:
102        artist_node = source.get(credit['artist']['id'])
103        if artist_node:
104            credit['artist'] = artist_node
105
106
107class AlbumArtist(DataObject):
108    def __init__(self, album_artist_id):
109        super().__init__(album_artist_id)
110
111
112class Album(DataObject, Item):
113
114    metadata_images_changed = QtCore.pyqtSignal()
115    release_group_loaded = QtCore.pyqtSignal()
116
117    def __init__(self, album_id, discid=None):
118        DataObject.__init__(self, album_id)
119        self.metadata = Metadata()
120        self.orig_metadata = Metadata()
121        self.tracks = []
122        self.loaded = False
123        self.load_task = None
124        self.release_group = None
125        self._files = 0
126        self._requests = 0
127        self._tracks_loaded = False
128        self._discids = set()
129        if discid:
130            self._discids.add(discid)
131        self._after_load_callbacks = []
132        self.unmatched_files = Cluster(_("Unmatched Files"), special=True, related_album=self, hide_if_empty=True)
133        self.unmatched_files.metadata_images_changed.connect(self.update_metadata_images)
134        self.status = None
135        self._album_artists = []
136        self.update_metadata_images_enabled = True
137
138    def __repr__(self):
139        return '<Album %s %r>' % (self.id, self.metadata["album"])
140
141    def iterfiles(self, save=False):
142        for track in self.tracks:
143            yield from track.iterfiles()
144        if not save:
145            yield from self.unmatched_files.iterfiles()
146
147    def enable_update_metadata_images(self, enabled):
148        self.update_metadata_images_enabled = enabled
149
150    def append_album_artist(self, album_artist_id):
151        """Append artist id to the list of album artists
152        and return an AlbumArtist instance"""
153        album_artist = AlbumArtist(album_artist_id)
154        self._album_artists.append(album_artist)
155        return album_artist
156
157    def add_discid(self, discid):
158        if not discid:
159            return
160        self._discids.add(discid)
161        for track in self.tracks:
162            medium_discids = track.metadata.getall('~musicbrainz_discids')
163            track_discids = list(self._discids.intersection(medium_discids))
164            if track_discids:
165                track.metadata['musicbrainz_discid'] = track_discids
166                track.update()
167                for file in track.files:
168                    file.metadata['musicbrainz_discid'] = track_discids
169                    file.update()
170
171    def get_next_track(self, track):
172        try:
173            index = self.tracks.index(track)
174            return self.tracks[index + 1]
175        except (IndexError, ValueError):
176            return None
177
178    def get_album_artists(self):
179        """Returns the list of album artists (as AlbumArtist objects)"""
180        return self._album_artists
181
182    def _parse_release(self, release_node):
183        log.debug("Loading release %r ...", self.id)
184        self._tracks_loaded = False
185        release_id = release_node['id']
186        if release_id != self.id:
187            self.tagger.mbid_redirects[self.id] = release_id
188            album = self.tagger.albums.get(release_id)
189            if album:
190                log.debug("Release %r already loaded", release_id)
191                album.match_files(self.unmatched_files.files)
192                album.update()
193                self.tagger.remove_album(self)
194                return False
195            else:
196                del self.tagger.albums[self.id]
197                self.tagger.albums[release_id] = self
198                self.id = release_id
199
200        # Make the release artist nodes available, since they may
201        # contain supplementary data (aliases, tags, genres, ratings)
202        # which aren't present in the release group, track, or
203        # recording artist nodes. We can copy them into those places
204        # wherever the IDs match, so that the data is shared and
205        # available for use in mbjson.py and external plugins.
206        self._release_artist_nodes = _create_artist_node_dict(release_node)
207
208        # Get release metadata
209        m = self._new_metadata
210        m.length = 0
211
212        rg_node = release_node['release-group']
213        rg = self.release_group = self.tagger.get_release_group_by_id(rg_node['id'])
214        rg.loaded_albums.add(self.id)
215        rg.refcount += 1
216
217        _copy_artist_nodes(self._release_artist_nodes, rg_node)
218        release_group_to_metadata(rg_node, rg.metadata, rg)
219        m.copy(rg.metadata)
220        release_to_metadata(release_node, m, album=self)
221
222        config = get_config()
223
224        # Custom VA name
225        if m['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID:
226            m['albumartistsort'] = m['albumartist'] = config.setting['va_name']
227
228        # Convert Unicode punctuation
229        if config.setting['convert_punctuation']:
230            m.apply_func(asciipunct)
231
232        m['totaldiscs'] = len(release_node['media'])
233
234        # Add album to collections
235        add_release_to_user_collections(release_node)
236
237        # Run album metadata plugins
238        try:
239            run_album_metadata_processors(self, m, release_node)
240        except BaseException:
241            self.error_append(traceback.format_exc())
242
243        self._release_node = release_node
244        return True
245
246    def _release_request_finished(self, document, http, error):
247        if self.load_task is None:
248            return
249        self.load_task = None
250        parsed = False
251        try:
252            if error:
253                self.error_append(http.errorString())
254                # Fix for broken NAT releases
255                if error == QtNetwork.QNetworkReply.ContentNotFoundError:
256                    config = get_config()
257                    nats = False
258                    nat_name = config.setting["nat_name"]
259                    files = list(self.unmatched_files.files)
260                    for file in files:
261                        recordingid = file.metadata["musicbrainz_recordingid"]
262                        if mbid_validate(recordingid) and file.metadata["album"] == nat_name:
263                            nats = True
264                            self.tagger.move_file_to_nat(file, recordingid)
265                            self.tagger.nats.update()
266                    if nats and not self.get_num_unmatched_files():
267                        self.tagger.remove_album(self)
268                        error = False
269            else:
270                try:
271                    parsed = self._parse_release(document)
272                except Exception:
273                    error = True
274                    self.error_append(traceback.format_exc())
275        finally:
276            self._requests -= 1
277            if parsed or error:
278                self._finalize_loading(error)
279        # does http need to be set to None to free the memory used by the network response?
280        # http://qt-project.org/doc/qt-5/qnetworkaccessmanager.html says:
281        #     After the request has finished, it is the responsibility of the user
282        #     to delete the QNetworkReply object at an appropriate time.
283        #     Do not directly delete it inside the slot connected to finished().
284        #     You can use the deleteLater() function.
285
286    def _finalize_loading(self, error):
287        if error:
288            self.metadata.clear()
289            self.status = _("[could not load album %s]") % self.id
290            del self._new_metadata
291            del self._new_tracks
292            self.update()
293            if not self._requests:
294                self.loaded = True
295                for func, always in self._after_load_callbacks:
296                    if always:
297                        func()
298            return
299
300        if self._requests > 0:
301            return
302
303        if not self._tracks_loaded:
304            artists = set()
305            all_media = []
306            absolutetracknumber = 0
307
308            va = self._new_metadata['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID
309
310            djmix_ars = {}
311            if hasattr(self._new_metadata, "_djmix_ars"):
312                djmix_ars = self._new_metadata._djmix_ars
313
314            for medium_node in self._release_node['media']:
315                mm = Metadata()
316                mm.copy(self._new_metadata)
317                medium_to_metadata(medium_node, mm)
318                format = medium_node.get('format')
319                if format:
320                    all_media.append(format)
321
322                for dj in djmix_ars.get(mm["discnumber"], []):
323                    mm.add("djmixer", dj)
324
325                if va:
326                    mm["compilation"] = "1"
327                else:
328                    del mm["compilation"]
329
330                if 'discs' in medium_node:
331                    discids = [disc.get('id') for disc in medium_node['discs']]
332                    mm['~musicbrainz_discids'] = discids
333                    mm['musicbrainz_discid'] = list(self._discids.intersection(discids))
334
335                if "pregap" in medium_node:
336                    absolutetracknumber += 1
337                    mm['~discpregap'] = '1'
338                    extra_metadata = {
339                        '~pregap': '1',
340                        '~absolutetracknumber': absolutetracknumber,
341                    }
342                    self._finalize_loading_track(medium_node['pregap'], mm, artists, extra_metadata)
343
344                track_count = medium_node['track-count']
345                if track_count:
346                    tracklist_node = medium_node['tracks']
347                    for track_node in tracklist_node:
348                        absolutetracknumber += 1
349                        extra_metadata = {
350                            '~absolutetracknumber': absolutetracknumber,
351                        }
352                        self._finalize_loading_track(track_node, mm, artists, extra_metadata)
353
354                if "data-tracks" in medium_node:
355                    for track_node in medium_node['data-tracks']:
356                        absolutetracknumber += 1
357                        extra_metadata = {
358                            '~datatrack': '1',
359                            '~absolutetracknumber': absolutetracknumber,
360                        }
361                        self._finalize_loading_track(track_node, mm, artists, extra_metadata)
362
363            totalalbumtracks = absolutetracknumber
364            self._new_metadata['~totalalbumtracks'] = totalalbumtracks
365            # Generate a list of unique media, but keep order of first appearance
366            self._new_metadata['media'] = " / ".join(list(OrderedDict.fromkeys(all_media)))
367
368            for track in self._new_tracks:
369                track.metadata["~totalalbumtracks"] = totalalbumtracks
370                if len(artists) > 1:
371                    track.metadata["~multiartist"] = "1"
372            del self._release_node
373            del self._release_artist_nodes
374            self._tracks_loaded = True
375
376        if not self._requests:
377            self.enable_update_metadata_images(False)
378            for track in self._new_tracks:
379                track.orig_metadata.copy(track.metadata)
380                track.metadata_images_changed.connect(self.update_metadata_images)
381
382            # Prepare parser for user's script
383            for s_name, s_text in enabled_tagger_scripts_texts():
384                parser = ScriptParser()
385                for track in self._new_tracks:
386                    # Run tagger script for each track
387                    try:
388                        parser.eval(s_text, track.metadata)
389                    except ScriptError:
390                        log.exception("Failed to run tagger script %s on track", s_name)
391                    track.metadata.strip_whitespace()
392                    track.scripted_metadata.update(track.metadata)
393                # Run tagger script for the album itself
394                try:
395                    parser.eval(s_text, self._new_metadata)
396                except ScriptError:
397                    log.exception("Failed to run tagger script %s on album", s_name)
398                self._new_metadata.strip_whitespace()
399
400            unmatched_files = [file for track in self.tracks for file in track.files]
401            self.metadata = self._new_metadata
402            self.orig_metadata.copy(self.metadata)
403            self.orig_metadata.images.clear()
404            self.tracks = self._new_tracks
405            del self._new_metadata
406            del self._new_tracks
407            self.loaded = True
408            self.status = None
409            self.match_files(unmatched_files + self.unmatched_files.files)
410            self.enable_update_metadata_images(True)
411            self.update()
412            self.tagger.window.set_statusbar_message(
413                N_('Album %(id)s loaded: %(artist)s - %(album)s'),
414                {
415                    'id': self.id,
416                    'artist': self.metadata['albumartist'],
417                    'album': self.metadata['album']
418                },
419                timeout=3000
420            )
421            for func, always in self._after_load_callbacks:
422                func()
423            self._after_load_callbacks = []
424            if self.item.isSelected():
425                self.tagger.window.refresh_metadatabox()
426
427    def _finalize_loading_track(self, track_node, metadata, artists, extra_metadata=None):
428        # As noted in `_parse_release` above, the release artist nodes
429        # may contain supplementary data that isn't present in track
430        # artist nodes. Similarly, the track artists may contain
431        # information which the recording artists don't. Copy this
432        # information across to wherever the artist IDs match.
433        _copy_artist_nodes(self._release_artist_nodes, track_node)
434        _copy_artist_nodes(self._release_artist_nodes, track_node['recording'])
435        _copy_artist_nodes(_create_artist_node_dict(track_node), track_node['recording'])
436
437        track = Track(track_node['recording']['id'], self)
438        self._new_tracks.append(track)
439
440        # Get track metadata
441        tm = track.metadata
442        tm.copy(metadata)
443        track_to_metadata(track_node, track)
444        track._customize_metadata()
445
446        self._new_metadata.length += tm.length
447        artists.add(tm["artist"])
448        if extra_metadata:
449            tm.update(extra_metadata)
450
451        # Run track metadata plugins
452        try:
453            run_track_metadata_processors(self, tm, track_node, self._release_node)
454        except BaseException:
455            self.error_append(traceback.format_exc())
456
457        return track
458
459    def load(self, priority=False, refresh=False):
460        if self._requests:
461            log.info("Not reloading, some requests are still active.")
462            return
463        self.tagger.window.set_statusbar_message(
464            N_('Loading album %(id)s ...'),
465            {'id': self.id}
466        )
467        self.loaded = False
468        self.status = _("[loading album information]")
469        if self.release_group:
470            self.release_group.loaded = False
471            self.release_group.genres.clear()
472        self.metadata.clear()
473        self.genres.clear()
474        self.update(update_selection=False)
475        self._new_metadata = Metadata()
476        self._new_tracks = []
477        self._requests = 1
478        self.clear_errors()
479        config = get_config()
480        require_authentication = False
481        inc = ['release-groups', 'media', 'discids', 'recordings', 'artist-credits',
482               'artists', 'aliases', 'labels', 'isrcs', 'collections', 'annotation']
483        if self.tagger.webservice.oauth_manager.is_authorized():
484            require_authentication = True
485            inc += ['user-collections']
486        if config.setting['release_ars'] or config.setting['track_ars']:
487            inc += ['artist-rels', 'release-rels', 'url-rels', 'recording-rels', 'work-rels']
488            if config.setting['track_ars']:
489                inc += ['recording-level-rels', 'work-level-rels']
490        require_authentication = self.set_genre_inc_params(inc) or require_authentication
491        if config.setting['enable_ratings']:
492            require_authentication = True
493            inc += ['user-ratings']
494        self.load_task = self.tagger.mb_api.get_release_by_id(
495            self.id, self._release_request_finished, inc=inc,
496            mblogin=require_authentication, priority=priority, refresh=refresh)
497
498    def run_when_loaded(self, func, always=False):
499        if self.loaded:
500            func()
501        else:
502            self._after_load_callbacks.append((func, always))
503
504    def stop_loading(self):
505        if self.load_task:
506            self.tagger.webservice.remove_task(self.load_task)
507            self.load_task = None
508
509    def update(self, update_tracks=True, update_selection=True):
510        if self.item:
511            self.item.update(update_tracks, update_selection=update_selection)
512
513    def _add_file(self, track, file, new_album=True):
514        self._files += 1
515        if new_album:
516            self.update(update_tracks=False)
517            add_metadata_images(self, [file])
518
519    def _remove_file(self, track, file, new_album=True):
520        self._files -= 1
521        if new_album:
522            self.update(update_tracks=False)
523            remove_metadata_images(self, [file])
524
525    def _match_files(self, files, threshold=0):
526        """Match files to tracks on this album, based on metadata similarity or recordingid."""
527        tracks_cache = defaultdict(lambda: None)
528
529        def build_tracks_cache():
530            for track in self.tracks:
531                tm_recordingid = track.orig_metadata['musicbrainz_recordingid']
532                tm_tracknumber = track.orig_metadata['tracknumber']
533                tm_discnumber = track.orig_metadata['discnumber']
534                for tup in (
535                    (tm_recordingid, tm_tracknumber, tm_discnumber),
536                    (tm_recordingid, tm_tracknumber),
537                    (tm_recordingid, )):
538                    tracks_cache[tup] = track
539
540        SimMatchAlbum = namedtuple('SimMatchAlbum', 'similarity track')
541
542        for file in list(files):
543            if file.state == File.REMOVED:
544                continue
545            # if we have a recordingid to match against, use that in priority
546            recid = file.match_recordingid or file.metadata['musicbrainz_recordingid']
547            if recid and mbid_validate(recid):
548                if not tracks_cache:
549                    build_tracks_cache()
550                tracknumber = file.metadata['tracknumber']
551                discnumber = file.metadata['discnumber']
552                track = (tracks_cache[(recid, tracknumber, discnumber)]
553                         or tracks_cache[(recid, tracknumber)]
554                         or tracks_cache[(recid, )])
555                if track:
556                    yield (file, track)
557                    continue
558
559            # try to match by similarity
560            def candidates():
561                for track in process_events_iter(self.tracks):
562                    yield SimMatchAlbum(
563                        similarity=track.metadata.compare(file.orig_metadata),
564                        track=track
565                    )
566
567            no_match = SimMatchAlbum(similarity=-1, track=self.unmatched_files)
568            best_match = find_best_match(candidates, no_match)
569
570            if best_match.similarity < threshold:
571                yield (file, no_match.track)
572            else:
573                yield (file, best_match.result.track)
574
575    def match_files(self, files):
576        """Match and move files to tracks on this album, based on metadata similarity or recordingid."""
577        if self.loaded:
578            config = get_config()
579            moves = self._match_files(files, threshold=config.setting['track_matching_threshold'])
580            for file, target in moves:
581                file.move(target)
582        else:
583            for file in list(files):
584                file.move(self.unmatched_files)
585
586    def can_save(self):
587        return self._files > 0
588
589    def can_remove(self):
590        return True
591
592    def can_edit_tags(self):
593        return True
594
595    def can_analyze(self):
596        return False
597
598    def can_autotag(self):
599        return False
600
601    def can_refresh(self):
602        return True
603
604    def can_view_info(self):
605        return self.loaded or self.errors
606
607    def is_album_like(self):
608        return True
609
610    def get_num_matched_tracks(self):
611        num = 0
612        for track in self.tracks:
613            if track.is_linked():
614                num += 1
615        return num
616
617    def get_num_unmatched_files(self):
618        return len(self.unmatched_files.files)
619
620    def get_num_total_files(self):
621        return self._files + len(self.unmatched_files.files)
622
623    def is_complete(self):
624        if not self.tracks:
625            return False
626        for track in self.tracks:
627            if not track.is_complete():
628                return False
629        if self.get_num_unmatched_files():
630            return False
631        else:
632            return True
633
634    def is_modified(self):
635        if self.tracks:
636            for track in self.tracks:
637                for file in track.files:
638                    if not file.is_saved():
639                        return True
640        return False
641
642    def get_num_unsaved_files(self):
643        count = 0
644        for track in self.tracks:
645            for file in track.files:
646                if not file.is_saved():
647                    count += 1
648        return count
649
650    def column(self, column):
651        if column == 'title':
652            if self.status is not None:
653                title = self.status
654            else:
655                title = self.metadata['album']
656            if self.tracks:
657                linked_tracks = 0
658                for track in self.tracks:
659                    if track.is_linked():
660                        linked_tracks += 1
661
662                text = '%s\u200E (%d/%d' % (title, linked_tracks, len(self.tracks))
663                unmatched = self.get_num_unmatched_files()
664                if unmatched:
665                    text += '; %d?' % (unmatched,)
666                unsaved = self.get_num_unsaved_files()
667                if unsaved:
668                    text += '; %d*' % (unsaved,)
669                # CoverArt.set_metadata uses the orig_metadata.images if metadata.images is empty
670                # in order to show existing cover art if there's no cover art for a release. So
671                # we do the same here in order to show the number of images consistently.
672                if self.metadata.images:
673                    metadata = self.metadata
674                else:
675                    metadata = self.orig_metadata
676
677                number_of_images = len(metadata.images)
678                if getattr(metadata, 'has_common_images', True):
679                    text += ngettext("; %i image", "; %i images",
680                                     number_of_images) % number_of_images
681                else:
682                    text += ngettext("; %i image not in all tracks", "; %i different images among tracks",
683                                     number_of_images) % number_of_images
684                return text + ')'
685            else:
686                return title
687        elif column == '~length':
688            length = self.metadata.length
689            if length:
690                return format_time(length)
691            else:
692                return ''
693        elif column == 'artist':
694            return self.metadata['albumartist']
695        elif column == 'tracknumber':
696            return self.metadata['~totalalbumtracks']
697        elif column == 'discnumber':
698            return self.metadata['totaldiscs']
699        else:
700            return self.metadata[column]
701
702    def switch_release_version(self, mbid):
703        if mbid == self.id:
704            return
705        for file in list(self.iterfiles(True)):
706            file.move(self.unmatched_files)
707        album = self.tagger.albums.get(mbid)
708        if album:
709            album.match_files(self.unmatched_files.files)
710            album.update()
711            self.tagger.remove_album(self)
712        else:
713            del self.tagger.albums[self.id]
714            self.release_group.loaded_albums.discard(self.id)
715            self.id = mbid
716            self.tagger.albums[mbid] = self
717            self.load(priority=True, refresh=True)
718
719    def update_metadata_images(self):
720        if not self.update_metadata_images_enabled:
721            return
722
723        if update_metadata_images(self):
724            self.update(False)
725            self.metadata_images_changed.emit()
726
727    def keep_original_images(self):
728        self.enable_update_metadata_images(False)
729        for track in self.tracks:
730            track.keep_original_images()
731        for file in list(self.unmatched_files.files):
732            file.keep_original_images()
733        self.enable_update_metadata_images(True)
734        self.update_metadata_images()
735
736
737class NatAlbum(Album):
738
739    def __init__(self):
740        super().__init__("NATS")
741        self.loaded = True
742        self.update()
743
744    def update(self, update_tracks=True):
745        config = get_config()
746        self.enable_update_metadata_images(False)
747        old_album_title = self.metadata["album"]
748        self.metadata["album"] = config.setting["nat_name"]
749        for track in self.tracks:
750            if old_album_title == track.metadata["album"]:
751                track.metadata["album"] = self.metadata["album"]
752            for file in track.files:
753                track.update_file_metadata(file)
754        self.enable_update_metadata_images(True)
755        super().update(update_tracks)
756
757    def _finalize_loading(self, error):
758        self.update()
759
760    def can_refresh(self):
761        return False
762
763    def can_browser_lookup(self):
764        return False
765
766
767_album_post_removal_processors = PluginFunctions(label='album_post_removal_processors')
768
769
770def register_album_post_removal_processor(function, priority=PluginPriority.NORMAL):
771    """Registers an album-removed processor.
772    Args:
773        function: function to call after album removal, it will be passed the album object
774        priority: optional, PluginPriority.NORMAL by default
775    Returns:
776        None
777    """
778    _album_post_removal_processors.register(function.__module__, function, priority)
779
780
781def run_album_post_removal_processors(album_object):
782    _album_post_removal_processors.run(album_object)
783