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