def new_metadata(self): if not config.setting["write_id3v23"]: return self.metadata copy = Metadata() copy.copy(self.metadata) join_with = config.setting["id3v23_join_with"] copy.multi_valued_joiner = join_with for name, values in copy.rawitems(): # ID3v23 can only save TDOR dates in YYYY format. Mutagen cannot # handle ID3v23 dates which are YYYY-MM rather than YYYY or # YYYY-MM-DD. if name == "originaldate": values = [v[:4] for v in values] elif name == "date": values = [(v[:4] if len(v) < 10 else v) for v in values] # If this is a multi-valued field, then it needs to be flattened, # unless it's TIPL or TMCL which can still be multi-valued. if (len(values) > 1 and not name in ID3File._rtipl_roles and not name.startswith("performer:")): values = [join_with.join(values)] copy[name] = values return copy
def _script_to_filename(self, format, file_metadata, settings=None): if settings is None: settings = config.setting metadata = Metadata() if config.setting["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) # make sure every metadata can safely be used in a path name for name in metadata.keys(): if isinstance(metadata[name], basestring): metadata[name] = sanitize_filename(metadata[name]) format = format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(format, metadata, self) if settings["ascii_filenames"]: if isinstance(filename, unicode): filename = unaccent(filename) filename = replace_non_ascii(filename) # replace incompatible characters if settings["windows_compatibility"] or sys.platform == "win32": filename = replace_win32_incompat(filename) # remove null characters filename = filename.replace("\x00", "") return filename
def save(pf): metadata = Metadata() metadata.copy(pf.metadata) mf = MFile(pf.filename) if mf is not None: mf.delete() return pf._save_and_rename(pf.filename, metadata)
def save(self, next, settings): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) self.tagger.save_queue.put(( partial(self._save_and_rename, self.filename, metadata, settings), partial(self._saving_finished, next), QtCore.Qt.LowEventPriority + 2))
def _script_to_filename(self, naming_format, file_metadata, settings=None): metadata = Metadata() if config.setting["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) return script_to_filename(naming_format, metadata, file=self, settings=settings)
def test_metadata_copy(self): m = Metadata() m["old"] = "old-value" self.metadata.delete("single1") m.copy(self.metadata) self.assertEqual(self.metadata._store, m._store) self.assertEqual(self.metadata.deleted_tags, m.deleted_tags) self.assertEqual(self.metadata.length, m.length) self.assertEqual(self.metadata.images, m.images)
def save(self): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) thread.run_task(partial(self._save_and_rename, self.filename, metadata), self._saving_finished, priority=2, thread_pool=self.tagger.save_thread_pool)
def save(self): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) thread.run_task( partial(self._save_and_rename, self.filename, metadata), self._saving_finished, priority=2, thread_pool=self.tagger.save_thread_pool)
def _script_to_filename(self, naming_format, file_metadata, settings=None): if settings is None: settings = config.setting metadata = Metadata() if settings["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) return script_to_filename(naming_format, metadata, file=self, settings=settings)
def _make_image_filename(self, filename, dirname, _metadata): metadata = Metadata() metadata.copy(_metadata) metadata["coverart_maintype"] = self.maintype metadata["coverart_comment"] = self.comment if self.is_front: metadata.add_unique("coverart_types", "front") for cover_type in self.types: metadata.add_unique("coverart_types", cover_type) filename = script_to_filename(filename, metadata) if not filename: filename = "cover" if not os.path.isabs(filename): filename = os.path.join(dirname, filename) return encode_filename(filename)
def _load_tracks(self, release_node, album): # this happens after the album metadata processor in picard self.tracks = [] for medium_node in release_node.medium_list[0].medium: mm = Metadata() mm.copy(album._new_metadata) medium_to_metadata(medium_node, mm) for track_node in medium_node.track_list[0].track: track = Track(track_node.recording[0].id, album) self.tracks.append(track) # Get track metadata tm = track.metadata tm.copy(mm) self._track_to_metadata(track_node, track) track._customize_metadata()
def _make_image_filename(self, filename, dirname, _metadata): metadata = Metadata() metadata.copy(_metadata) metadata["coverart_maintype"] = self.maintype metadata["coverart_comment"] = self.comment if self.is_front: metadata.add_unique("coverart_types", "front") for cover_type in self.types: metadata.add_unique("coverart_types", cover_type) filename = script_to_filename(filename, metadata) if not filename: filename = DEFAULT_COVER_IMAGE_FILENAME if not is_absolute_path(filename): filename = os.path.join(dirname, filename) return encode_filename(filename)
def _load_tracks(self, release_node, album): # this happens after the album metadata processor in picard self.tracks = [] for medium_node in release_node['media']: mm = Metadata() mm.copy(album._new_metadata) # noqa medium_to_metadata(medium_node, mm) for track_node in medium_node['tracks']: track = Track(track_node['recording']['id'], album) self.tracks.append(track) # Get track metadata tm = track.metadata tm.copy(mm) track_to_metadata(track_node, track) track._customize_metadata() # noqa
def _load(self, lines): metadata = Metadata() metadata.copy(self.metadata) metadata.add("album", self.metadata.get("title")) del metadata["title"] for line in lines: splitline = line.split() # linetokv? key = splitline[0] value = " ".join(splitline[1:]) if key == "INDEX": index, value = value.split() self.indexes[index] = value elif not key == "TRACK": self.kv_to_metadata(key, value, metadata) return metadata
def _script_to_filename(self, format, file_metadata, settings): metadata = Metadata() metadata.copy(file_metadata) # make sure every metadata can safely be used in a path name for name in metadata.keys(): if isinstance(metadata[name], basestring): metadata[name] = sanitize_filename(metadata[name]) format = format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(format, metadata, self) # replace incompatible characters if settings["windows_compatible_filenames"] or sys.platform == "win32": filename = replace_win32_incompat(filename) if settings["ascii_filenames"]: if isinstance(filename, unicode): filename = unaccent(filename) filename = replace_non_ascii(filename) return filename
def _script_to_filename(self, naming_format, file_metadata, file_extension, settings=None): if settings is None: config = get_config() settings = config.setting metadata = Metadata() if settings["clear_existing_tags"]: # script_to_filename_with_metadata guarantees this is not modified metadata = file_metadata else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) (filename, new_metadata) = script_to_filename_with_metadata( naming_format, metadata, file=self, settings=settings) if not filename: return None # NOTE: the filename generated by the naming script does not have a file extension ext = new_metadata.get('~extension', file_extension) return filename + '.' + ext.lstrip('.')
def _script_to_filename(self, naming_format, file_metadata, file_extension, settings=None): if settings is None: settings = config.setting metadata = Metadata() if settings["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) (filename, new_metadata) = script_to_filename_with_metadata(naming_format, metadata, file=self, settings=settings) # NOTE: the script_to_filename strips the extension away ext = new_metadata.get('~extension', file_extension) return filename + '.' + ext.lstrip('.')
def _make_image_filename(self, filename, dirname, _metadata): metadata = Metadata() metadata.copy(_metadata) metadata["coverart_maintype"] = self.maintype metadata["coverart_comment"] = self.comment if self.is_front: metadata.add_unique("coverart_types", "front") for cover_type in self.types: metadata.add_unique("coverart_types", cover_type) filename = ScriptParser().eval(filename, metadata) if config.setting["ascii_filenames"]: filename = replace_non_ascii(filename, pathsave=True) if not filename: filename = "cover" if not os.path.isabs(filename): filename = os.path.join(dirname, filename) # replace incompatible characters if config.setting["windows_compatibility"] or sys.platform == "win32": filename = replace_win32_incompat(filename) # remove null characters if isinstance(filename, bytes): filename = filename.replace(b"\x00", "") return encode_filename(filename)
def _script_to_filename(self, naming_format, file_metadata, settings=None): if settings is None: settings = config.setting metadata = Metadata() if config.setting["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) # make sure every metadata can safely be used in a path name for name in metadata.keys(): if isinstance(metadata[name], str): metadata[name] = sanitize_filename(metadata[name]) naming_format = naming_format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(naming_format, metadata, self) if settings["ascii_filenames"]: filename = replace_non_ascii(filename, pathsave=True) # replace incompatible characters if settings["windows_compatibility"] or sys.platform == "win32": filename = replace_win32_incompat(filename) # remove null characters if isinstance(filename, (bytes, bytearray)): filename = filename.replace(b"\x00", "") return filename
def _finalize_loading(self, error): if error: self.metadata.clear() self.status = _("[could not load album %s]") % self.id del self._new_metadata del self._new_tracks self.update() return if self._requests > 0: return if not self._tracks_loaded: artists = set() totalalbumtracks = 0 absolutetracknumber = 0 va = self._new_metadata[ 'musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID djmix_ars = {} if hasattr(self._new_metadata, "_djmix_ars"): djmix_ars = self._new_metadata._djmix_ars for medium_node in self._release_node.medium_list[0].medium: mm = Metadata() mm.copy(self._new_metadata) medium_to_metadata(medium_node, mm) discpregap = False for dj in djmix_ars.get(mm["discnumber"], []): mm.add("djmixer", dj) if "pregap" in medium_node.children: discpregap = True absolutetracknumber += 1 track = self._finalize_loading_track( medium_node.pregap[0], mm, artists, va, absolutetracknumber, discpregap) track.metadata['~pregap'] = "1" for track_node in medium_node.track_list[0].track: absolutetracknumber += 1 track = self._finalize_loading_track( track_node, mm, artists, va, absolutetracknumber, discpregap) if "data_track_list" in medium_node.children: for track_node in medium_node.data_track_list[0].track: absolutetracknumber += 1 track = self._finalize_loading_track( track_node, mm, artists, va, absolutetracknumber, discpregap) track.metadata['~datatrack'] = "1" totalalbumtracks = str(absolutetracknumber) for track in self._new_tracks: track.metadata["~totalalbumtracks"] = totalalbumtracks if len(artists) > 1: track.metadata["~multiartist"] = "1" del self._release_node self._tracks_loaded = True if not self._requests: # Prepare parser for user's script if config.setting["enable_tagger_script"]: script = config.setting["tagger_script"] if script: parser = ScriptParser() for track in self._new_tracks: # Run tagger script for each track try: parser.eval(script, track.metadata) except: self.error_append(traceback.format_exc()) # Strip leading/trailing whitespace track.metadata.strip_whitespace() # Run tagger script for the album itself try: parser.eval(script, self._new_metadata) except: self.error_append(traceback.format_exc()) self._new_metadata.strip_whitespace() for track in self.tracks: for file in list(track.linked_files): file.move(self.unmatched_files) self.metadata = self._new_metadata self.tracks = self._new_tracks del self._new_metadata del self._new_tracks self.loaded = True self.status = None self.match_files(self.unmatched_files.files) self.update() self.tagger.window.set_statusbar_message( N_('Album %(id)s loaded: %(artist)s - %(album)s'), { 'id': self.id, 'artist': self.metadata['albumartist'], 'album': self.metadata['album'] }, timeout=3000) for func in self._after_load_callbacks: func() self._after_load_callbacks = []
def _finalize_loading(self, error): if error: self.metadata.clear() self.metadata['album'] = _("[could not load album %s]") % self.id del self._new_metadata del self._new_tracks self.update() return if self._requests > 0: return if not self._tracks_loaded: artists = set() totalalbumtracks = 0 djmix_ars = {} if hasattr(self._new_metadata, "_djmix_ars"): djmix_ars = self._new_metadata._djmix_ars for medium_node in self._release_node.medium_list[0].medium: mm = Metadata() mm.copy(self._new_metadata) medium_to_metadata(medium_node, mm) totalalbumtracks += int(mm["totaltracks"]) for dj in djmix_ars.get(mm["discnumber"], []): mm.add("djmixer", dj) for track_node in medium_node.track_list[0].track: track = Track(track_node.recording[0].id, self) self._new_tracks.append(track) # Get track metadata tm = track.metadata tm.copy(mm) track_to_metadata(track_node, track, self.config) track._customize_metadata() self._new_metadata.length += tm.length artists.add(tm["musicbrainz_artistid"]) # Run track metadata plugins try: run_track_metadata_processors(self, tm, self._release_node, track_node) except: self.log.error(traceback.format_exc()) totalalbumtracks = str(totalalbumtracks) for track in self._new_tracks: track.metadata["~totalalbumtracks"] = totalalbumtracks if len(artists) > 1: track.metadata["compilation"] = "1" del self._release_node self._tracks_loaded = True if not self._requests: # Prepare parser for user's script if self.config.setting["enable_tagger_script"]: script = self.config.setting["tagger_script"] if script: parser = ScriptParser() for track in self._new_tracks: # Run tagger script for each track try: parser.eval(script, track.metadata) except: self.log.error(traceback.format_exc()) # Strip leading/trailing whitespace track.metadata.strip_whitespace() # Run tagger script for the album itself try: parser.eval(script, self._new_metadata) except: self.log.error(traceback.format_exc()) self._new_metadata.strip_whitespace() for track in self.tracks: for file in list(track.linked_files): file.move(self.unmatched_files) self.metadata = self._new_metadata self.tracks = self._new_tracks del self._new_metadata del self._new_tracks self.loaded = True self.match_files(self.unmatched_files.files) self.update() self.tagger.window.set_statusbar_message('Album %s loaded', self.id, timeout=3000) while self._after_load_callbacks.qsize() > 0: func = self._after_load_callbacks.get() func()
def _finalize_loading(self, error): if error: self.metadata.clear() self.status = _("[could not load album %s]") % self.id del self._new_metadata del self._new_tracks self.update() return if self._requests > 0: return if not self._tracks_loaded: artists = set() totalalbumtracks = 0 absolutetracknumber = 0 va = self._new_metadata['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID djmix_ars = {} if hasattr(self._new_metadata, "_djmix_ars"): djmix_ars = self._new_metadata._djmix_ars for medium_node in self._release_node['media']: mm = Metadata() mm.copy(self._new_metadata) medium_to_metadata(medium_node, mm) discpregap = False for dj in djmix_ars.get(mm["discnumber"], []): mm.add("djmixer", dj) if 'discs' in medium_node: discids = [disc.get('id') for disc in medium_node['discs']] mm['~musicbrainz_discids'] = discids mm['musicbrainz_discid'] = list(self._discids.intersection(discids)) if "pregap" in medium_node: discpregap = True absolutetracknumber += 1 track = self._finalize_loading_track(medium_node['pregap'], mm, artists, va, absolutetracknumber, discpregap) track.metadata['~pregap'] = "1" track_count = medium_node['track-count'] if track_count: tracklist_node = medium_node['tracks'] for track_node in tracklist_node: absolutetracknumber += 1 track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap) if "data-tracks" in medium_node: for track_node in medium_node['data-tracks']: absolutetracknumber += 1 track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap) track.metadata['~datatrack'] = "1" totalalbumtracks = str(absolutetracknumber) for track in self._new_tracks: track.metadata["~totalalbumtracks"] = totalalbumtracks if len(artists) > 1: track.metadata["~multiartist"] = "1" del self._release_node del self._release_artist_nodes self._tracks_loaded = True if not self._requests: self.enable_update_metadata_images(False) # Prepare parser for user's script for s_name, s_text in enabled_tagger_scripts_texts(): parser = ScriptParser() for track in self._new_tracks: # Run tagger script for each track try: parser.eval(s_text, track.metadata) except ScriptError: log.exception("Failed to run tagger script %s on track", s_name) track.metadata.strip_whitespace() # Run tagger script for the album itself try: parser.eval(s_text, self._new_metadata) except ScriptError: log.exception("Failed to run tagger script %s on album", s_name) self._new_metadata.strip_whitespace() for track in self.tracks: track.metadata_images_changed.connect(self.update_metadata_images) for file in list(track.linked_files): file.move(self.unmatched_files) self.metadata = self._new_metadata self.tracks = self._new_tracks del self._new_metadata del self._new_tracks self.loaded = True self.status = None self.match_files(self.unmatched_files.files) self.enable_update_metadata_images(True) self.update() self.tagger.window.set_statusbar_message( N_('Album %(id)s loaded: %(artist)s - %(album)s'), { 'id': self.id, 'artist': self.metadata['albumartist'], 'album': self.metadata['album'] }, timeout=3000 ) for func in self._after_load_callbacks: func() self._after_load_callbacks = []
class Track(DataObject, Item): metadata_images_changed = QtCore.pyqtSignal() def __init__(self, track_id, album=None): DataObject.__init__(self, track_id) self.album = album self.linked_files = [] self.num_linked_files = 0 self.metadata = Metadata() self.orig_metadata = Metadata() self._track_artists = [] def __repr__(self): return '<Track %s %r>' % (self.id, self.metadata["title"]) def add_file(self, file): if file not in self.linked_files: self.linked_files.append(file) self.num_linked_files += 1 self.album._add_file(self, file) self.update_file_metadata(file) file.metadata_images_changed.connect(self.update_orig_metadata_images) def update_file_metadata(self, file): if file not in self.linked_files: return file.copy_metadata(self.orig_metadata) file.metadata['~extension'] = file.orig_metadata['~extension'] self.metadata.copy(file.metadata) # Re-run tagger scripts with updated metadata for s_name, s_text in enabled_tagger_scripts_texts(): parser = ScriptParser() try: parser.eval(s_text, file.metadata) parser.eval(s_text, self.metadata) except: log.exception("Failed to run tagger script %s on file", s_name) file.metadata.strip_whitespace() self.metadata.strip_whitespace() file.metadata.changed = True file.update(signal=False) self.update() def remove_file(self, file): if file not in self.linked_files: return self.linked_files.remove(file) self.num_linked_files -= 1 file.copy_metadata(file.orig_metadata) self.album._remove_file(self, file) file.metadata_images_changed.disconnect(self.update_orig_metadata_images) if self.num_linked_files > 0: self.metadata.copy(self.linked_files[-1].orig_metadata) else: self.metadata.copy(self.orig_metadata) # Restore to non-associated state for s_name, s_text in enabled_tagger_scripts_texts(): parser = ScriptParser() try: parser.eval(s_text, self.metadata) except: log.exception("Failed to run tagger script %s on track", s_name) self.metadata.strip_whitespace() self.update() def update(self): if self.item: self.item.update() self.update_orig_metadata_images() def iterfiles(self, save=False): for file in self.linked_files: yield file def is_linked(self): return self.num_linked_files > 0 def can_save(self): """Return if this object can be saved.""" for file in self.linked_files: if file.can_save(): return True return False def can_remove(self): """Return if this object can be removed.""" for file in self.linked_files: if file.can_remove(): return True return False def can_edit_tags(self): """Return if this object supports tag editing.""" return True def can_view_info(self): return self.num_linked_files == 1 or self.metadata.images def column(self, column): m = self.metadata if column == 'title': prefix = "%s-" % m['discnumber'] if m['discnumber'] and m['totaldiscs'] != "1" else "" return "%s%s %s" % (prefix, m['tracknumber'].zfill(2), m['title']) return m[column] def is_video(self): return self.metadata['~video'] == '1' def is_pregap(self): return self.metadata['~pregap'] == '1' def is_data(self): return self.metadata['~datatrack'] == '1' def is_silence(self): return self.metadata['~silence'] == '1' def is_complete(self): return self.ignored_for_completeness() or self.num_linked_files == 1 def ignored_for_completeness(self): if (config.setting['completeness_ignore_videos'] and self.is_video()) \ or (config.setting['completeness_ignore_pregap'] and self.is_pregap()) \ or (config.setting['completeness_ignore_data'] and self.is_data()) \ or (config.setting['completeness_ignore_silence'] and self.is_silence()): return True return False def append_track_artist(self, ta_id): """Append artist id to the list of track artists and return an TrackArtist instance""" track_artist = TrackArtist(ta_id) self._track_artists.append(track_artist) return track_artist def _customize_metadata(self): tm = self.metadata # Custom VA name if tm['musicbrainz_artistid'] == VARIOUS_ARTISTS_ID: tm['artistsort'] = tm['artist'] = config.setting['va_name'] if tm['title'] == DATA_TRACK_TITLE: tm['~datatrack'] = '1' if tm['title'] == SILENCE_TRACK_TITLE: tm['~silence'] = '1' if config.setting['folksonomy_tags']: self._convert_folksonomy_tags_to_genre() # Convert Unicode punctuation if config.setting['convert_punctuation']: tm.apply_func(asciipunct) def _convert_folksonomy_tags_to_genre(self): # Combine release and track tags tags = dict(self.folksonomy_tags) self.merge_folksonomy_tags(tags, self.album.folksonomy_tags) if self.album.release_group: self.merge_folksonomy_tags(tags, self.album.release_group.folksonomy_tags) if not tags and config.setting['artists_tags']: # For compilations use each track's artists to look up tags if self.metadata['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID: for artist in self._track_artists: self.merge_folksonomy_tags(tags, artist.folksonomy_tags) else: for artist in self.album.get_album_artists(): self.merge_folksonomy_tags(tags, artist.folksonomy_tags) # Ignore tags with zero or lower score tags = dict((name, count) for name, count in tags.items() if count > 0) if not tags: return # Convert counts to values from 0 to 100 maxcount = max(tags.values()) taglist = [] for name, count in tags.items(): taglist.append((100 * count // maxcount, name)) taglist.sort(reverse=True) # And generate the genre metadata tag maxtags = config.setting['max_tags'] minusage = config.setting['min_tag_usage'] ignore_tags = self._get_ignored_folksonomy_tags() genre = [] for usage, name in taglist[:maxtags]: if name.lower() in ignore_tags: continue if usage < minusage: break name = _TRANSLATE_TAGS.get(name, name.title()) genre.append(name) join_tags = config.setting['join_tags'] if join_tags: genre = [join_tags.join(genre)] self.metadata['genre'] = genre def _get_ignored_folksonomy_tags(self): tags = [] ignore_tags = config.setting['ignore_tags'] if ignore_tags: tags = [s.strip().lower() for s in ignore_tags.split(',')] return tags def update_orig_metadata_images(self): update_metadata_images(self) def keep_original_images(self): for file in self.linked_files: file.keep_original_images() if self.linked_files: self.update_orig_metadata_images() self.metadata.images = self.orig_metadata.images[:] else: self.metadata.images = [] self.update()
def _finalize_loading(self, error): if error: self.metadata.clear() self.status = _("[could not load album %s]") % self.id del self._new_metadata del self._new_tracks self.update() return if self._requests > 0: return if not self._tracks_loaded: artists = set() all_media = [] absolutetracknumber = 0 va = self._new_metadata['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID djmix_ars = {} if hasattr(self._new_metadata, "_djmix_ars"): djmix_ars = self._new_metadata._djmix_ars for medium_node in self._release_node['media']: mm = Metadata() mm.copy(self._new_metadata) medium_to_metadata(medium_node, mm) discpregap = False format = medium_node.get('format') if format: all_media.append(format) for dj in djmix_ars.get(mm["discnumber"], []): mm.add("djmixer", dj) if 'discs' in medium_node: discids = [disc.get('id') for disc in medium_node['discs']] mm['~musicbrainz_discids'] = discids mm['musicbrainz_discid'] = list(self._discids.intersection(discids)) if "pregap" in medium_node: discpregap = True absolutetracknumber += 1 track = self._finalize_loading_track(medium_node['pregap'], mm, artists, va, absolutetracknumber, discpregap) track.metadata['~pregap'] = "1" track_count = medium_node['track-count'] if track_count: tracklist_node = medium_node['tracks'] for track_node in tracklist_node: absolutetracknumber += 1 track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap) if "data-tracks" in medium_node: for track_node in medium_node['data-tracks']: absolutetracknumber += 1 track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap) track.metadata['~datatrack'] = "1" totalalbumtracks = absolutetracknumber self._new_metadata['~totalalbumtracks'] = totalalbumtracks # Generate a list of unique media, but keep order of first appearance self._new_metadata['media'] = " / ".join(list(OrderedDict.fromkeys(all_media))) for track in self._new_tracks: track.metadata["~totalalbumtracks"] = totalalbumtracks if len(artists) > 1: track.metadata["~multiartist"] = "1" del self._release_node del self._release_artist_nodes self._tracks_loaded = True if not self._requests: self.enable_update_metadata_images(False) # Prepare parser for user's script for s_name, s_text in enabled_tagger_scripts_texts(): parser = ScriptParser() for track in self._new_tracks: # Run tagger script for each track try: parser.eval(s_text, track.metadata) except ScriptError: log.exception("Failed to run tagger script %s on track", s_name) track.metadata.strip_whitespace() # Run tagger script for the album itself try: parser.eval(s_text, self._new_metadata) except ScriptError: log.exception("Failed to run tagger script %s on album", s_name) self._new_metadata.strip_whitespace() for track in self.tracks: track.metadata_images_changed.connect(self.update_metadata_images) for file in list(track.linked_files): file.move(self.unmatched_files) self.metadata = self._new_metadata self.tracks = self._new_tracks del self._new_metadata del self._new_tracks self.loaded = True self.status = None self.match_files(self.unmatched_files.files) self.enable_update_metadata_images(True) self.update() self.tagger.window.set_statusbar_message( N_('Album %(id)s loaded: %(artist)s - %(album)s'), { 'id': self.id, 'artist': self.metadata['albumartist'], 'album': self.metadata['album'] }, timeout=3000 ) for func in self._after_load_callbacks: func() self._after_load_callbacks = []
class Album(DataObject, Item): metadata_images_changed = QtCore.pyqtSignal() def __init__(self, album_id, discid=None): DataObject.__init__(self, album_id) self.metadata = Metadata() self.orig_metadata = Metadata() self.tracks = [] self.loaded = False self.load_task = None self.release_group = None self._files = 0 self._requests = 0 self._tracks_loaded = False self._discids = set() if discid: self._discids.add(discid) self._after_load_callbacks = [] self.unmatched_files = Cluster(_("Unmatched Files"), special=True, related_album=self, hide_if_empty=True) self.unmatched_files.metadata_images_changed.connect( self.update_metadata_images) self.status = None self._album_artists = [] self.update_metadata_images_enabled = True def __repr__(self): return '<Album %s %r>' % (self.id, self.metadata["album"]) def iterfiles(self, save=False): for track in self.tracks: yield from track.iterfiles() if not save: yield from self.unmatched_files.iterfiles() def enable_update_metadata_images(self, enabled): self.update_metadata_images_enabled = enabled def append_album_artist(self, album_artist_id): """Append artist id to the list of album artists and return an AlbumArtist instance""" album_artist = AlbumArtist(album_artist_id) self._album_artists.append(album_artist) return album_artist def add_discid(self, discid): if not discid: return self._discids.add(discid) for track in self.tracks: medium_discids = track.metadata.getall('~musicbrainz_discids') track_discids = list(self._discids.intersection(medium_discids)) if track_discids: track.metadata['musicbrainz_discid'] = track_discids track.update() for file in track.files: file.metadata['musicbrainz_discid'] = track_discids file.update() def get_next_track(self, track): try: index = self.tracks.index(track) return self.tracks[index + 1] except (IndexError, ValueError): return None def get_album_artists(self): """Returns the list of album artists (as AlbumArtist objects)""" return self._album_artists def _parse_release(self, release_node): log.debug("Loading release %r ...", self.id) self._tracks_loaded = False release_id = release_node['id'] if release_id != self.id: self.tagger.mbid_redirects[self.id] = release_id album = self.tagger.albums.get(release_id) if album: log.debug("Release %r already loaded", release_id) album.match_files(self.unmatched_files.files) album.update() self.tagger.remove_album(self) return False else: del self.tagger.albums[self.id] self.tagger.albums[release_id] = self self.id = release_id # Make the release artist nodes available, since they may # contain supplementary data (aliases, tags, genres, ratings) # which aren't present in the release group, track, or # recording artist nodes. We can copy them into those places # wherever the IDs match, so that the data is shared and # available for use in mbjson.py and external plugins. self._release_artist_nodes = _create_artist_node_dict(release_node) # Get release metadata m = self._new_metadata m.length = 0 rg_node = release_node['release-group'] rg = self.release_group = self.tagger.get_release_group_by_id( rg_node['id']) rg.loaded_albums.add(self.id) rg.refcount += 1 _copy_artist_nodes(self._release_artist_nodes, rg_node) release_group_to_metadata(rg_node, rg.metadata, rg) m.copy(rg.metadata) release_to_metadata(release_node, m, album=self) config = get_config() # Custom VA name if m['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID: m['albumartistsort'] = m['albumartist'] = config.setting['va_name'] # Convert Unicode punctuation if config.setting['convert_punctuation']: m.apply_func(asciipunct) m['totaldiscs'] = len(release_node['media']) # Add album to collections add_release_to_user_collections(release_node) # Run album metadata plugins try: run_album_metadata_processors(self, m, release_node) except BaseException: self.error_append(traceback.format_exc()) self._release_node = release_node return True def _release_request_finished(self, document, http, error): if self.load_task is None: return self.load_task = None parsed = False try: if error: self.error_append(http.errorString()) # Fix for broken NAT releases if error == QtNetwork.QNetworkReply.ContentNotFoundError: config = get_config() nats = False nat_name = config.setting["nat_name"] files = list(self.unmatched_files.files) for file in files: recordingid = file.metadata["musicbrainz_recordingid"] if mbid_validate(recordingid) and file.metadata[ "album"] == nat_name: nats = True self.tagger.move_file_to_nat(file, recordingid) self.tagger.nats.update() if nats and not self.get_num_unmatched_files(): self.tagger.remove_album(self) error = False else: try: parsed = self._parse_release(document) except Exception: error = True self.error_append(traceback.format_exc()) finally: self._requests -= 1 if parsed or error: self._finalize_loading(error) # does http need to be set to None to free the memory used by the network response? # http://qt-project.org/doc/qt-5/qnetworkaccessmanager.html says: # After the request has finished, it is the responsibility of the user # to delete the QNetworkReply object at an appropriate time. # Do not directly delete it inside the slot connected to finished(). # You can use the deleteLater() function. def _finalize_loading(self, error): if error: self.metadata.clear() self.status = _("[could not load album %s]") % self.id del self._new_metadata del self._new_tracks self.update() if not self._requests: self.loaded = True for func, always in self._after_load_callbacks: if always: func() return if self._requests > 0: return if not self._tracks_loaded: artists = set() all_media = [] absolutetracknumber = 0 va = self._new_metadata[ 'musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID djmix_ars = {} if hasattr(self._new_metadata, "_djmix_ars"): djmix_ars = self._new_metadata._djmix_ars for medium_node in self._release_node['media']: mm = Metadata() mm.copy(self._new_metadata) medium_to_metadata(medium_node, mm) format = medium_node.get('format') if format: all_media.append(format) for dj in djmix_ars.get(mm["discnumber"], []): mm.add("djmixer", dj) if va: mm["compilation"] = "1" else: del mm["compilation"] if 'discs' in medium_node: discids = [disc.get('id') for disc in medium_node['discs']] mm['~musicbrainz_discids'] = discids mm['musicbrainz_discid'] = list( self._discids.intersection(discids)) if "pregap" in medium_node: absolutetracknumber += 1 mm['~discpregap'] = '1' extra_metadata = { '~pregap': '1', '~absolutetracknumber': absolutetracknumber, } self._finalize_loading_track(medium_node['pregap'], mm, artists, extra_metadata) track_count = medium_node['track-count'] if track_count: tracklist_node = medium_node['tracks'] for track_node in tracklist_node: absolutetracknumber += 1 extra_metadata = { '~absolutetracknumber': absolutetracknumber, } self._finalize_loading_track(track_node, mm, artists, extra_metadata) if "data-tracks" in medium_node: for track_node in medium_node['data-tracks']: absolutetracknumber += 1 extra_metadata = { '~datatrack': '1', '~absolutetracknumber': absolutetracknumber, } self._finalize_loading_track(track_node, mm, artists, extra_metadata) totalalbumtracks = absolutetracknumber self._new_metadata['~totalalbumtracks'] = totalalbumtracks # Generate a list of unique media, but keep order of first appearance self._new_metadata['media'] = " / ".join( list(OrderedDict.fromkeys(all_media))) for track in self._new_tracks: track.metadata["~totalalbumtracks"] = totalalbumtracks if len(artists) > 1: track.metadata["~multiartist"] = "1" del self._release_node del self._release_artist_nodes self._tracks_loaded = True if not self._requests: self.enable_update_metadata_images(False) for track in self._new_tracks: track.orig_metadata.copy(track.metadata) track.metadata_images_changed.connect( self.update_metadata_images) # Prepare parser for user's script for s_name, s_text in enabled_tagger_scripts_texts(): parser = ScriptParser() for track in self._new_tracks: # Run tagger script for each track try: parser.eval(s_text, track.metadata) except ScriptError: log.exception( "Failed to run tagger script %s on track", s_name) track.metadata.strip_whitespace() track.scripted_metadata.update(track.metadata) # Run tagger script for the album itself try: parser.eval(s_text, self._new_metadata) except ScriptError: log.exception("Failed to run tagger script %s on album", s_name) self._new_metadata.strip_whitespace() unmatched_files = [ file for track in self.tracks for file in track.files ] self.metadata = self._new_metadata self.orig_metadata.copy(self.metadata) self.orig_metadata.images.clear() self.tracks = self._new_tracks del self._new_metadata del self._new_tracks self.loaded = True self.status = None self.match_files(unmatched_files + self.unmatched_files.files) self.enable_update_metadata_images(True) self.update() self.tagger.window.set_statusbar_message( N_('Album %(id)s loaded: %(artist)s - %(album)s'), { 'id': self.id, 'artist': self.metadata['albumartist'], 'album': self.metadata['album'] }, timeout=3000) for func, always in self._after_load_callbacks: func() self._after_load_callbacks = [] if self.item.isSelected(): self.tagger.window.refresh_metadatabox() def _finalize_loading_track(self, track_node, metadata, artists, extra_metadata=None): # As noted in `_parse_release` above, the release artist nodes # may contain supplementary data that isn't present in track # artist nodes. Similarly, the track artists may contain # information which the recording artists don't. Copy this # information across to wherever the artist IDs match. _copy_artist_nodes(self._release_artist_nodes, track_node) _copy_artist_nodes(self._release_artist_nodes, track_node['recording']) _copy_artist_nodes(_create_artist_node_dict(track_node), track_node['recording']) track = Track(track_node['recording']['id'], self) self._new_tracks.append(track) # Get track metadata tm = track.metadata tm.copy(metadata) track_to_metadata(track_node, track) track._customize_metadata() self._new_metadata.length += tm.length artists.add(tm["artist"]) if extra_metadata: tm.update(extra_metadata) # Run track metadata plugins try: run_track_metadata_processors(self, tm, track_node, self._release_node) except BaseException: self.error_append(traceback.format_exc()) return track def load(self, priority=False, refresh=False): if self._requests: log.info("Not reloading, some requests are still active.") return self.tagger.window.set_statusbar_message( N_('Loading album %(id)s ...'), {'id': self.id}) self.loaded = False self.status = _("[loading album information]") if self.release_group: self.release_group.loaded = False self.release_group.genres.clear() self.metadata.clear() self.genres.clear() self.update(update_selection=False) self._new_metadata = Metadata() self._new_tracks = [] self._requests = 1 self.clear_errors() config = get_config() require_authentication = False inc = [ 'release-groups', 'media', 'discids', 'recordings', 'artist-credits', 'artists', 'aliases', 'labels', 'isrcs', 'collections', 'annotation' ] if self.tagger.webservice.oauth_manager.is_authorized(): require_authentication = True inc += ['user-collections'] if config.setting['release_ars'] or config.setting['track_ars']: inc += [ 'artist-rels', 'release-rels', 'url-rels', 'recording-rels', 'work-rels' ] if config.setting['track_ars']: inc += ['recording-level-rels', 'work-level-rels'] require_authentication = self.set_genre_inc_params( inc) or require_authentication if config.setting['enable_ratings']: require_authentication = True inc += ['user-ratings'] self.load_task = self.tagger.mb_api.get_release_by_id( self.id, self._release_request_finished, inc=inc, mblogin=require_authentication, priority=priority, refresh=refresh) def run_when_loaded(self, func, always=False): if self.loaded: func() else: self._after_load_callbacks.append((func, always)) def stop_loading(self): if self.load_task: self.tagger.webservice.remove_task(self.load_task) self.load_task = None def update(self, update_tracks=True, update_selection=True): if self.item: self.item.update(update_tracks, update_selection=update_selection) def _add_file(self, track, file, new_album=True): self._files += 1 if new_album: self.update(update_tracks=False) add_metadata_images(self, [file]) def _remove_file(self, track, file, new_album=True): self._files -= 1 if new_album: self.update(update_tracks=False) remove_metadata_images(self, [file]) def _match_files(self, files, threshold=0): """Match files to tracks on this album, based on metadata similarity or recordingid.""" tracks_cache = defaultdict(lambda: None) def build_tracks_cache(): for track in self.tracks: tm_recordingid = track.orig_metadata['musicbrainz_recordingid'] tm_tracknumber = track.orig_metadata['tracknumber'] tm_discnumber = track.orig_metadata['discnumber'] for tup in ((tm_recordingid, tm_tracknumber, tm_discnumber), (tm_recordingid, tm_tracknumber), (tm_recordingid, )): tracks_cache[tup] = track SimMatchAlbum = namedtuple('SimMatchAlbum', 'similarity track') for file in list(files): if file.state == File.REMOVED: continue # if we have a recordingid to match against, use that in priority recid = file.match_recordingid or file.metadata[ 'musicbrainz_recordingid'] if recid and mbid_validate(recid): if not tracks_cache: build_tracks_cache() tracknumber = file.metadata['tracknumber'] discnumber = file.metadata['discnumber'] track = (tracks_cache[(recid, tracknumber, discnumber)] or tracks_cache[(recid, tracknumber)] or tracks_cache[(recid, )]) if track: yield (file, track) continue # try to match by similarity def candidates(): for track in process_events_iter(self.tracks): yield SimMatchAlbum(similarity=track.metadata.compare( file.orig_metadata), track=track) no_match = SimMatchAlbum(similarity=-1, track=self.unmatched_files) best_match = find_best_match(candidates, no_match) if best_match.similarity < threshold: yield (file, no_match.track) else: yield (file, best_match.result.track) def match_files(self, files): """Match and move files to tracks on this album, based on metadata similarity or recordingid.""" if self.loaded: config = get_config() moves = self._match_files( files, threshold=config.setting['track_matching_threshold']) for file, target in moves: file.move(target) else: for file in list(files): file.move(self.unmatched_files) def can_save(self): return self._files > 0 def can_remove(self): return True def can_edit_tags(self): return True def can_analyze(self): return False def can_autotag(self): return False def can_refresh(self): return True def can_view_info(self): return self.loaded or self.errors def is_album_like(self): return True def get_num_matched_tracks(self): num = 0 for track in self.tracks: if track.is_linked(): num += 1 return num def get_num_unmatched_files(self): return len(self.unmatched_files.files) def get_num_total_files(self): return self._files + len(self.unmatched_files.files) def is_complete(self): if not self.tracks: return False for track in self.tracks: if not track.is_complete(): return False if self.get_num_unmatched_files(): return False else: return True def is_modified(self): if self.tracks: for track in self.tracks: for file in track.files: if not file.is_saved(): return True return False def get_num_unsaved_files(self): count = 0 for track in self.tracks: for file in track.files: if not file.is_saved(): count += 1 return count def column(self, column): if column == 'title': if self.status is not None: title = self.status else: title = self.metadata['album'] if self.tracks: linked_tracks = 0 for track in self.tracks: if track.is_linked(): linked_tracks += 1 elems = ['%d/%d' % (linked_tracks, len(self.tracks))] unmatched = self.get_num_unmatched_files() if unmatched: elems.append('%d?' % (unmatched, )) unsaved = self.get_num_unsaved_files() if unsaved: elems.append('%d*' % (unsaved, )) ca_detailed = self.cover_art_description_detailed() if ca_detailed: elems.append(ca_detailed) return '%s\u200E (%s)' % (title, '; '.join(elems)) else: return title elif column == '~length': length = self.metadata.length if length: return format_time(length) else: return '' elif column == 'artist': return self.metadata['albumartist'] elif column == 'tracknumber': return self.metadata['~totalalbumtracks'] elif column == 'discnumber': return self.metadata['totaldiscs'] elif column == 'covercount': return self.cover_art_description() else: return self.metadata[column] def switch_release_version(self, mbid): if mbid == self.id: return for file in list(self.iterfiles(True)): file.move(self.unmatched_files) album = self.tagger.albums.get(mbid) if album: album.match_files(self.unmatched_files.files) album.update() self.tagger.remove_album(self) else: del self.tagger.albums[self.id] self.release_group.loaded_albums.discard(self.id) self.id = mbid self.tagger.albums[mbid] = self self.load(priority=True, refresh=True) def update_metadata_images(self): if not self.update_metadata_images_enabled: return if update_metadata_images(self): self.update(False) self.metadata_images_changed.emit() def keep_original_images(self): self.enable_update_metadata_images(False) for track in self.tracks: track.keep_original_images() for file in list(self.unmatched_files.files): file.keep_original_images() self.enable_update_metadata_images(True) self.update_metadata_images()
def _finalize_loading(self, error): if error: self.metadata.clear() self.status = _("[could not load album %s]") % self.id del self._new_metadata del self._new_tracks self.update() return if self._requests > 0: return if not self._tracks_loaded: artists = set() totalalbumtracks = 0 absolutetracknumber = 0 va = self._new_metadata['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID djmix_ars = {} if hasattr(self._new_metadata, "_djmix_ars"): djmix_ars = self._new_metadata._djmix_ars for medium_node in self._release_node.medium_list[0].medium: mm = Metadata() mm.copy(self._new_metadata) medium_to_metadata(medium_node, mm) discpregap = False for dj in djmix_ars.get(mm["discnumber"], []): mm.add("djmixer", dj) if "pregap" in medium_node.children: discpregap = True absolutetracknumber += 1 track = self._finalize_loading_track(medium_node.pregap[0], mm, artists, va, absolutetracknumber, discpregap) track.metadata['~pregap'] = "1" for track_node in medium_node.track_list[0].track: absolutetracknumber += 1 track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap) if "data_track_list" in medium_node.children: for track_node in medium_node.data_track_list[0].track: absolutetracknumber += 1 track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap) track.metadata['~datatrack'] = "1" totalalbumtracks = str(absolutetracknumber) for track in self._new_tracks: track.metadata["~totalalbumtracks"] = totalalbumtracks if len(artists) > 1: track.metadata["~multiartist"] = "1" del self._release_node self._tracks_loaded = True if not self._requests: # Prepare parser for user's script if config.setting["enable_tagger_script"]: script = config.setting["tagger_script"] if script: parser = ScriptParser() for track in self._new_tracks: # Run tagger script for each track try: parser.eval(script, track.metadata) except: self.error_append(traceback.format_exc()) # Strip leading/trailing whitespace track.metadata.strip_whitespace() # Run tagger script for the album itself try: parser.eval(script, self._new_metadata) except: self.error_append(traceback.format_exc()) self._new_metadata.strip_whitespace() for track in self.tracks: for file in list(track.linked_files): file.move(self.unmatched_files) self.metadata = self._new_metadata self.tracks = self._new_tracks del self._new_metadata del self._new_tracks self.loaded = True self.status = None self.match_files(self.unmatched_files.files) self.update() self.tagger.window.set_statusbar_message( N_('Album %(id)s loaded: %(artist)s - %(album)s'), { 'id': self.id, 'artist': self.metadata['albumartist'], 'album': self.metadata['album'] }, timeout=3000 ) for func in self._after_load_callbacks: func() self._after_load_callbacks = []
def _finalize_loading(self, error): if error: self.metadata.clear() self.metadata['album'] = _("[could not load album %s]") % self.id del self._new_metadata del self._new_tracks self.update() return if self._requests > 0: return if not self._tracks_loaded: artists = set() totalalbumtracks = 0 djmix_ars = {} if hasattr(self._new_metadata, "_djmix_ars"): djmix_ars = self._new_metadata._djmix_ars for medium_node in self._release_node.medium_list[0].medium: mm = Metadata() mm.copy(self._new_metadata) medium_to_metadata(medium_node, mm) totalalbumtracks += int(mm["totaltracks"]) for dj in djmix_ars.get(mm["discnumber"], []): mm.add("djmixer", dj) for track_node in medium_node.track_list[0].track: track = Track(track_node.recording[0].id, self) self._new_tracks.append(track) # Get track metadata tm = track.metadata tm.copy(mm) track_to_metadata(track_node, track, self.config) track._customize_metadata() self._new_metadata.length += tm.length artists.add(tm["musicbrainz_artistid"]) # Run track metadata plugins try: run_track_metadata_processors(self, tm, self._release_node, track_node) except: self.log.error(traceback.format_exc()) totalalbumtracks = str(totalalbumtracks) for track in self._new_tracks: track.metadata["~totalalbumtracks"] = totalalbumtracks if len(artists) > 1: track.metadata["compilation"] = "1" del self._release_node self._tracks_loaded = True if not self._requests: # Prepare parser for user's script if self.config.setting["enable_tagger_script"]: script = self.config.setting["tagger_script"] if script: parser = ScriptParser() for track in self._new_tracks: # Run tagger script for each track try: parser.eval(script, track.metadata) except: self.log.error(traceback.format_exc()) # Strip leading/trailing whitespace track.metadata.strip_whitespace() # Run tagger script for the album itself try: parser.eval(script, self._new_metadata) except: self.log.error(traceback.format_exc()) self._new_metadata.strip_whitespace() for track in self.tracks: for file in list(track.linked_files): file.move(self.unmatched_files) self.metadata = self._new_metadata self.tracks = self._new_tracks del self._new_metadata del self._new_tracks self.loaded = True self.match_files(self.unmatched_files.files) self.update() self.tagger.window.set_statusbar_message(_('Album %s loaded'), self.id, timeout=3000) while self._after_load_callbacks.qsize() > 0: func = self._after_load_callbacks.get() func()
class File(QtCore.QObject, Item): UNDEFINED = -1 PENDING = 0 NORMAL = 1 CHANGED = 2 ERROR = 3 REMOVED = 4 comparison_weights = { "title": 13, "artist": 4, "album": 5, "length": 10, "totaltracks": 4, "releasetype": 20, "releasecountry": 2, "format": 2, } def __init__(self, filename): super(File, self).__init__() self.filename = filename self.base_filename = os.path.basename(filename) self._state = File.UNDEFINED self.state = File.PENDING self.error = None self.orig_metadata = Metadata() self.metadata = Metadata() self.similarity = 1.0 self.parent = None self.lookup_task = None self.item = None def __repr__(self): return '<File %r>' % self.base_filename def load(self, callback): thread.run_task( partial(self._load, self.filename), partial(self._loading_finished, callback), priority=1) def _loading_finished(self, callback, result=None, error=None): if self.state != self.PENDING: return if error is not None: self.error = str(error) self.state = self.ERROR else: self.error = None self.state = self.NORMAL self._copy_loaded_metadata(result) self.update() callback(self) def _copy_loaded_metadata(self, metadata): filename, _ = os.path.splitext(self.base_filename) metadata['~length'] = format_time(metadata.length) if 'title' not in metadata: metadata['title'] = filename if 'tracknumber' not in metadata: tracknumber = tracknum_from_filename(self.base_filename) if tracknumber != -1: metadata['tracknumber'] = str(tracknumber) self.orig_metadata = metadata self.metadata.copy(metadata) _default_preserved_tags = [ "~bitrate", "~bits_per_sample", "~format", "~channels", "~filename", "~dirname", "~extension" ] def copy_metadata(self, metadata): acoustid = self.metadata["acoustid_id"] preserve = config.setting["preserved_tags"].strip() saved_metadata = {} for tag in re.split(r"\s+", preserve) + File._default_preserved_tags: values = self.orig_metadata.getall(tag) if values: saved_metadata[tag] = values self.metadata.copy(metadata) for tag, values in saved_metadata.iteritems(): self.metadata.set(tag, values) self.metadata["acoustid_id"] = acoustid def has_error(self): return self.state == File.ERROR def _load(self): """Load metadata from the file.""" raise NotImplementedError def save(self): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) thread.run_task( partial(self._save_and_rename, self.filename, metadata), self._saving_finished, priority=2, thread_pool=self.tagger.save_thread_pool) def _save_and_rename(self, old_filename, metadata): """Save the metadata.""" new_filename = old_filename if not config.setting["dont_write_tags"]: encoded_old_filename = encode_filename(old_filename) info = os.stat(encoded_old_filename) self._save(old_filename, metadata) if config.setting["preserve_timestamps"]: try: os.utime(encoded_old_filename, (info.st_atime, info.st_mtime)) except OSError: log.warning("Couldn't preserve timestamp for %r", old_filename) # Rename files if config.setting["rename_files"] or config.setting["move_files"]: new_filename = self._rename(old_filename, metadata) # Move extra files (images, playlists, etc.) if config.setting["move_files"] and config.setting["move_additional_files"]: self._move_additional_files(old_filename, new_filename) # Delete empty directories if config.setting["delete_empty_dirs"]: dirname = encode_filename(os.path.dirname(old_filename)) try: self._rmdir(dirname) head, tail = os.path.split(dirname) if not tail: head, tail = os.path.split(head) while head and tail: try: self._rmdir(head) except: break head, tail = os.path.split(head) except EnvironmentError: pass # Save cover art images if config.setting["save_images_to_files"]: self._save_images(os.path.dirname(new_filename), metadata) return new_filename @staticmethod def _rmdir(dir): junk_files = (".DS_Store", "desktop.ini", "Desktop.ini", "Thumbs.db") if not set(os.listdir(dir)) - set(junk_files): shutil.rmtree(dir, False) else: raise OSError def _saving_finished(self, result=None, error=None): old_filename = new_filename = self.filename if error is not None: self.error = str(error) self.set_state(File.ERROR, update=True) else: self.filename = new_filename = result self.base_filename = os.path.basename(new_filename) length = self.orig_metadata.length temp_info = {} for info in ('~bitrate', '~sample_rate', '~channels', '~bits_per_sample', '~format'): temp_info[info] = self.orig_metadata[info] # Data is copied from New to Original because New may be a subclass to handle id3v23 if config.setting["clear_existing_tags"]: self.orig_metadata.copy(self.metadata) else: self.orig_metadata.update(self.metadata) self.orig_metadata.length = length self.orig_metadata['~length'] = format_time(length) for k, v in temp_info.items(): self.orig_metadata[k] = v self.error = None self.clear_pending() self._add_path_to_metadata(self.orig_metadata) del self.tagger.files[old_filename] self.tagger.files[new_filename] = self def _save(self, filename, metadata): """Save the metadata.""" raise NotImplementedError def _script_to_filename(self, format, file_metadata, settings=config.setting): metadata = Metadata() if config.setting["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) # make sure every metadata can safely be used in a path name for name in metadata.keys(): if isinstance(metadata[name], basestring): metadata[name] = sanitize_filename(metadata[name]) format = format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(format, metadata, self) if settings["ascii_filenames"]: if isinstance(filename, unicode): filename = unaccent(filename) filename = replace_non_ascii(filename) # replace incompatible characters if settings["windows_compatible_filenames"] or sys.platform == "win32": filename = replace_win32_incompat(filename) # remove null characters filename = filename.replace("\x00", "") return filename def _make_filename(self, filename, metadata, settings=config.setting): """Constructs file name based on metadata and file naming formats.""" if settings["move_files"]: new_dirname = settings["move_files_to"] if not os.path.isabs(new_dirname): new_dirname = os.path.normpath(os.path.join(os.path.dirname(filename), new_dirname)) else: new_dirname = os.path.dirname(filename) new_filename, ext = os.path.splitext(os.path.basename(filename)) if settings["rename_files"]: # expand the naming format format = settings['file_naming_format'] if len(format) > 0: new_filename = self._script_to_filename(format, metadata, settings) if not settings['move_files']: new_filename = os.path.basename(new_filename) new_filename = make_short_filename(new_dirname, new_filename) # win32 compatibility fixes if settings['windows_compatible_filenames'] or sys.platform == 'win32': new_filename = new_filename.replace('./', '_/').replace('.\\', '_\\') # replace . at the beginning of file and directory names new_filename = new_filename.replace('/.', '/_').replace('\\.', '\\_') if new_filename and new_filename[0] == '.': new_filename = '_' + new_filename[1:] # Fix for precomposed characters on OSX if sys.platform == "darwin": new_filename = unicodedata.normalize("NFD", unicode(new_filename)) return os.path.realpath(os.path.join(new_dirname, new_filename + ext.lower())) def _rename(self, old_filename, metadata): new_filename, ext = os.path.splitext( self._make_filename(old_filename, metadata)) if old_filename == new_filename + ext: return old_filename new_dirname = os.path.dirname(new_filename) if not os.path.isdir(encode_filename(new_dirname)): os.makedirs(new_dirname) tmp_filename = new_filename i = 1 while (not pathcmp(old_filename, new_filename + ext) and os.path.exists(encode_filename(new_filename + ext))): new_filename = "%s (%d)" % (tmp_filename, i) i += 1 new_filename = new_filename + ext log.debug("Moving file %r => %r", old_filename, new_filename) shutil.move(encode_filename(old_filename), encode_filename(new_filename)) return new_filename def _make_image_filename(self, image_filename, dirname, metadata): image_filename = self._script_to_filename(image_filename, metadata) if not image_filename: image_filename = "cover" if os.path.isabs(image_filename): filename = image_filename else: filename = os.path.join(dirname, image_filename) if config.setting['windows_compatible_filenames'] or sys.platform == 'win32': filename = filename.replace('./', '_/').replace('.\\', '_\\') return encode_filename(filename) def _save_images(self, dirname, metadata): """Save the cover images to disk.""" if not metadata.images: return default_filename = self._make_image_filename( config.setting["cover_image_filename"], dirname, metadata) overwrite = config.setting["save_images_overwrite"] counters = defaultdict(lambda: 0) for image in metadata.images: filename = image["filename"] data = image["data"] mime = image["mime"] if filename is None: filename = default_filename else: filename = self._make_image_filename(filename, dirname, metadata) image_filename = filename ext = mimetype.get_extension(mime, ".jpg") if counters[filename] > 0: image_filename = "%s (%d)" % (filename, counters[filename]) counters[filename] = counters[filename] + 1 while os.path.exists(image_filename + ext) and not overwrite: if os.path.getsize(image_filename + ext) == len(data): log.debug("Identical file size, not saving %r", image_filename) break image_filename = "%s (%d)" % (filename, counters[filename]) counters[filename] = counters[filename] + 1 else: new_filename = image_filename + ext # Even if overwrite is enabled we don't need to write the same # image multiple times if (os.path.exists(new_filename) and os.path.getsize(new_filename) == len(data)): log.debug("Identical file size, not saving %r", image_filename) continue log.debug("Saving cover images to %r", image_filename) new_dirname = os.path.dirname(image_filename) if not os.path.isdir(new_dirname): os.makedirs(new_dirname) f = open(image_filename + ext, "wb") f.write(data) f.close() def _move_additional_files(self, old_filename, new_filename): """Move extra files, like playlists...""" old_path = encode_filename(os.path.dirname(old_filename)) new_path = encode_filename(os.path.dirname(new_filename)) patterns = encode_filename(config.setting["move_additional_files_pattern"]) patterns = filter(bool, [p.strip() for p in patterns.split()]) for pattern in patterns: # FIXME glob1 is not documented, maybe we need our own implemention? for old_file in glob.glob1(old_path, pattern): new_file = os.path.join(new_path, old_file) old_file = os.path.join(old_path, old_file) # FIXME we shouldn't do this from a thread! if self.tagger.files.get(decode_filename(old_file)): log.debug("File loaded in the tagger, not moving %r", old_file) continue log.debug("Moving %r to %r", old_file, new_file) shutil.move(old_file, new_file) def remove(self, from_parent=True): if from_parent and self.parent: log.debug("Removing %r from %r", self, self.parent) self.parent.remove_file(self) self.tagger.acoustidmanager.remove(self) self.state = File.REMOVED def move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) self.clear_lookup_task() self.tagger._acoustid.stop_analyze(file) if self.parent: self.clear_pending() self.parent.remove_file(self) self.parent = parent self.parent.add_file(self) self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_recordingid']) def _move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) if self.parent: self.parent.remove_file(self) self.parent = parent self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_recordingid']) def supports_tag(self, name): """Returns whether tag ``name`` can be saved to the file.""" return True def is_saved(self): return self.similarity == 1.0 and self.state == File.NORMAL def update(self, signal=True): names = set(self.metadata.keys()) names.update(self.orig_metadata.keys()) clear_existing_tags = config.setting["clear_existing_tags"] for name in names: if not name.startswith('~') and self.supports_tag(name): new_values = self.metadata.getall(name) if not (new_values or clear_existing_tags): continue orig_values = self.orig_metadata.getall(name) if orig_values != new_values: self.similarity = self.orig_metadata.compare(self.metadata) if self.state in (File.CHANGED, File.NORMAL): self.state = File.CHANGED break else: self.similarity = 1.0 if self.state in (File.CHANGED, File.NORMAL): self.state = File.NORMAL if signal: log.debug("Updating file %r", self) if self.item: self.item.update() def can_save(self): """Return if this object can be saved.""" return True def can_remove(self): """Return if this object can be removed.""" return True def can_edit_tags(self): """Return if this object supports tag editing.""" return True def can_analyze(self): """Return if this object can be fingerprinted.""" return True def can_autotag(self): return True def can_refresh(self): return False def can_view_info(self): return True def _info(self, metadata, file): if hasattr(file.info, 'length'): metadata.length = int(file.info.length * 1000) if hasattr(file.info, 'bitrate') and file.info.bitrate: metadata['~bitrate'] = file.info.bitrate / 1000.0 if hasattr(file.info, 'sample_rate') and file.info.sample_rate: metadata['~sample_rate'] = file.info.sample_rate if hasattr(file.info, 'channels') and file.info.channels: metadata['~channels'] = file.info.channels if hasattr(file.info, 'bits_per_sample') and file.info.bits_per_sample: metadata['~bits_per_sample'] = file.info.bits_per_sample metadata['~format'] = self.__class__.__name__.replace('File', '') self._add_path_to_metadata(metadata) def _add_path_to_metadata(self, metadata): metadata['~dirname'] = os.path.dirname(self.filename) filename, extension = os.path.splitext(os.path.basename(self.filename)) metadata['~filename'] = filename metadata['~extension'] = extension.lower()[1:] def get_state(self): return self._state # in order to significantly speed up performance, the number of pending # files is cached num_pending_files = 0 def set_state(self, state, update=False): if state != self._state: if state == File.PENDING: File.num_pending_files += 1 elif self._state == File.PENDING: File.num_pending_files -= 1 self._state = state if update: self.update() self.tagger.tagger_stats_changed.emit() state = property(get_state, set_state) def column(self, column): m = self.metadata if column == "title" and not m["title"]: return self.base_filename return m[column] def _lookup_finished(self, lookuptype, document, http, error): self.lookup_task = None if self.state == File.REMOVED: return try: m = document.metadata[0] if lookuptype == "metadata": tracks = m.recording_list[0].recording elif lookuptype == "acoustid": tracks = m.acoustid[0].recording_list[0].recording except (AttributeError, IndexError): tracks = None # no matches if not tracks: self.tagger.window.set_statusbar_message(N_("No matching tracks for file %s"), self.filename, timeout=3000) self.clear_pending() return # multiple matches -- calculate similarities to each of them match = sorted((self.metadata.compare_to_track( track, self.comparison_weights) for track in tracks), reverse=True, key=itemgetter(0))[0] if lookuptype != 'acoustid': threshold = config.setting['file_lookup_threshold'] if match[0] < threshold: self.tagger.window.set_statusbar_message(N_("No matching tracks above the threshold for file %s"), self.filename, timeout=3000) self.clear_pending() return self.tagger.window.set_statusbar_message(N_("File %s identified!"), self.filename, timeout=3000) self.clear_pending() rg, release, track = match[1:] if lookuptype == 'acoustid': self.tagger.acoustidmanager.add(self, track.id) if release: self.tagger.get_release_group_by_id(rg.id).loaded_albums.add(release.id) self.tagger.move_file_to_track(self, release.id, track.id) else: self.tagger.move_file_to_nat(self, track.id, node=track) def lookup_metadata(self): """Try to identify the file using the existing metadata.""" if self.lookup_task: return self.tagger.window.set_statusbar_message(N_("Looking up the metadata for file %s..."), self.filename) self.clear_lookup_task() metadata = self.metadata self.lookup_task = self.tagger.xmlws.find_tracks(partial(self._lookup_finished, 'metadata'), track=metadata['title'], artist=metadata['artist'], release=metadata['album'], tnum=metadata['tracknumber'], tracks=metadata['totaltracks'], qdur=str(metadata.length / 2000), isrc=metadata['isrc'], limit=25) def clear_lookup_task(self): if self.lookup_task: self.tagger.xmlws.remove_task(self.lookup_task) self.lookup_task = None def set_pending(self): if self.state != File.REMOVED: self.state = File.PENDING self.update() def clear_pending(self): if self.state == File.PENDING: self.state = File.NORMAL self.update() def iterfiles(self, save=False): yield self def _get_tracknumber(self): try: return int(self.metadata["tracknumber"]) except: return 0 tracknumber = property(_get_tracknumber, doc="The track number as an int.") def _get_discnumber(self): try: return int(self.metadata["discnumber"]) except: return 0 discnumber = property(_get_discnumber, doc="The disc number as an int.")
class File(LockableObject, Item): __id_counter = 0 @staticmethod def new_id(): File.__id_counter += 1 return File.__id_counter UNDEFINED = -1 PENDING = 0 NORMAL = 1 CHANGED = 2 ERROR = 3 REMOVED = 4 comparison_weights = { "title": 13, "artist": 4, "album": 5, "length": 10, "totaltracks": 4, "releasetype": 20, "releasecountry": 2, "format": 2, } def __init__(self, filename): super(File, self).__init__() self.id = self.new_id() self.filename = filename self.base_filename = os.path.basename(filename) self._state = File.UNDEFINED self.state = File.PENDING self.error = None self.orig_metadata = Metadata() self.user_metadata = Metadata() self.saved_metadata = Metadata() self.metadata = self.user_metadata self.similarity = 1.0 self.parent = None self.lookup_task = None def __repr__(self): return '<File #%d %r>' % (self.id, self.base_filename) def load(self, next): self.tagger.load_queue.put( (partial(self._load, self.filename), partial(self._loading_finished, next), QtCore.Qt.LowEventPriority + 1)) @call_next def _loading_finished(self, next, result=None, error=None): if self.state != self.PENDING: return if error is not None: self.error = str(error) self.state = self.ERROR else: self.error = None self.state = self.NORMAL self._copy_loaded_metadata(result) self.update() return self def _copy_loaded_metadata(self, metadata): filename, extension = os.path.splitext(self.base_filename) self.metadata.copy(metadata) self.metadata['~extension'] = extension[1:].lower() self.metadata['~length'] = format_time(self.metadata.length) if 'title' not in self.metadata: self.metadata['title'] = filename if 'tracknumber' not in self.metadata: match = re.match("(?:track)?\s*(?:no|nr)?\s*(\d+)", filename, re.I) if match: try: tracknumber = int(match.group(1)) except ValueError: pass else: self.metadata['tracknumber'] = str(tracknumber) self.orig_metadata.copy(self.metadata) def copy_metadata(self, metadata): exceptions = ['musicip_puid', 'acoustid_id'] if self.config.setting['preserved_tags']: exceptions.extend( re.split(r'\s+', self.config.setting['preserved_tags'].strip())) for tag in exceptions: self.saved_metadata[tag] = self.metadata[tag] self.metadata.copy(metadata) for tag in exceptions: self.metadata[tag] = self.saved_metadata.pop(tag) def has_error(self): return self.state == File.ERROR def _load(self): """Load metadata from the file.""" raise NotImplementedError def save(self, next, settings): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) self.tagger.save_queue.put( (partial(self._save_and_rename, self.filename, metadata, settings), partial(self._saving_finished, next), QtCore.Qt.LowEventPriority + 2)) def _save_and_rename(self, old_filename, metadata, settings): """Save the metadata.""" new_filename = old_filename if not settings["dont_write_tags"]: encoded_old_filename = encode_filename(old_filename) info = os.stat(encoded_old_filename) self._save(old_filename, metadata, settings) if settings["preserve_timestamps"]: try: os.utime(encoded_old_filename, (info.st_atime, info.st_mtime)) except OSError: self.log.warning("Couldn't preserve timestamp for %r", old_filename) # Rename files if settings["rename_files"] or settings["move_files"]: new_filename = self._rename(old_filename, metadata, settings) # Move extra files (images, playlists, etc.) if settings["move_files"] and settings["move_additional_files"]: self._move_additional_files(old_filename, new_filename, settings) # Delete empty directories if settings["delete_empty_dirs"]: dirname = encode_filename(os.path.dirname(old_filename)) try: self._rmdir(dirname) head, tail = os.path.split(dirname) if not tail: head, tail = os.path.split(head) while head and tail: try: self._rmdir(head) except: break head, tail = os.path.split(head) except EnvironmentError: pass # Save cover art images if settings["save_images_to_files"]: self._save_images(os.path.dirname(new_filename), metadata, settings) return new_filename @staticmethod def _rmdir(dir): junk_files = (".DS_Store", "desktop.ini", "Desktop.ini", "Thumbs.db") if not set(os.listdir(dir)) - set(junk_files): shutil.rmtree(dir, False) else: raise OSError @call_next def _saving_finished(self, next, result=None, error=None): old_filename = new_filename = self.filename if error is not None: self.error = str(error) self.set_state(File.ERROR, update=True) else: self.filename = new_filename = result self.base_filename = os.path.basename(new_filename) length = self.orig_metadata.length temp_info = {} for info in ('~#bitrate', '~#sample_rate', '~#channels', '~#bits_per_sample', '~format', '~extension'): temp_info[info] = self.orig_metadata[info] if self.config.setting["clear_existing_tags"]: self.orig_metadata.copy(self.metadata) else: self.orig_metadata.update(self.metadata) self.orig_metadata.length = length self.orig_metadata['~length'] = format_time(length) for k, v in temp_info.items(): self.orig_metadata[k] = v self.error = None self.clear_pending() return self, old_filename, new_filename def _save(self, filename, metadata, settings): """Save the metadata.""" raise NotImplementedError def _script_to_filename(self, format, file_metadata, settings): metadata = Metadata() if self.config.setting["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) # make sure every metadata can safely be used in a path name for name in metadata.keys(): if isinstance(metadata[name], basestring): metadata[name] = sanitize_filename(metadata[name]) format = format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(format, metadata, self) # replace incompatible characters if settings["windows_compatible_filenames"] or sys.platform == "win32": filename = replace_win32_incompat(filename) if settings["ascii_filenames"]: if isinstance(filename, unicode): filename = unaccent(filename) filename = replace_non_ascii(filename) # remove null characters filename = filename.replace("\x00", "") return filename def _make_filename(self, filename, metadata, settings): """Constructs file name based on metadata and file naming formats.""" if settings["move_files"]: new_dirname = settings["move_files_to"] if not os.path.isabs(new_dirname): new_dirname = os.path.normpath( os.path.join(os.path.dirname(filename), new_dirname)) else: new_dirname = os.path.dirname(filename) new_filename, ext = os.path.splitext(os.path.basename(filename)) if settings["rename_files"]: # expand the naming format format = settings['file_naming_format'] if len(format) > 0: new_filename = self._script_to_filename( format, metadata, settings) if not settings['move_files']: new_filename = os.path.basename(new_filename) new_filename = make_short_filename(new_dirname, new_filename) # win32 compatibility fixes if settings[ 'windows_compatible_filenames'] or sys.platform == 'win32': new_filename = new_filename.replace('./', '_/').replace( '.\\', '_\\') # replace . at the beginning of file and directory names new_filename = new_filename.replace('/.', '/_').replace( '\\.', '\\_') if new_filename[0] == '.': new_filename = '_' + new_filename[1:] # Fix for precomposed characters on OSX if sys.platform == "darwin": new_filename = unicodedata.normalize("NFD", new_filename) return os.path.realpath( os.path.join(new_dirname, new_filename + ext.lower())) def _rename(self, old_filename, metadata, settings): new_filename, ext = os.path.splitext( self._make_filename(old_filename, metadata, settings)) if old_filename != new_filename + ext: new_dirname = os.path.dirname(new_filename) if not os.path.isdir(encode_filename(new_dirname)): os.makedirs(new_dirname) tmp_filename = new_filename i = 1 while (not pathcmp(old_filename, new_filename + ext) and os.path.exists(encode_filename(new_filename + ext))): new_filename = "%s (%d)" % (tmp_filename, i) i += 1 new_filename = new_filename + ext self.log.debug("Moving file %r => %r", old_filename, new_filename) shutil.move(encode_filename(old_filename), encode_filename(new_filename)) return new_filename else: return old_filename def _make_image_filename(self, image_filename, dirname, metadata, settings): image_filename = self._script_to_filename(image_filename, metadata, settings) if not image_filename: image_filename = "cover" if os.path.isabs(image_filename): filename = image_filename else: filename = os.path.join(dirname, image_filename) if settings['windows_compatible_filenames'] or sys.platform == 'win32': filename = filename.replace('./', '_/').replace('.\\', '_\\') return encode_filename(filename) def _save_images(self, dirname, metadata, settings): """Save the cover images to disk.""" if not metadata.images: return default_filename = self._make_image_filename( settings["cover_image_filename"], dirname, metadata, settings) overwrite = settings["save_images_overwrite"] i = 0 for mime, data, filename in metadata.images: if filename is None: filename = default_filename else: filename = self._make_image_filename(filename, dirname, metadata, settings) image_filename = filename ext = mimetype.get_extension(mime, ".jpg") i += 1 while os.path.exists(image_filename + ext) and not overwrite: if os.path.getsize(image_filename + ext) == len(data): self.log.debug("Identical file size, not saving %r", image_filename) break image_filename = "%s (%d)" % (filename, i) i += 1 else: self.log.debug("Saving cover images to %r", image_filename) new_dirname = os.path.dirname(image_filename) if not os.path.isdir(new_dirname): os.makedirs(new_dirname) f = open(image_filename + ext, "wb") f.write(data) f.close() def _move_additional_files(self, old_filename, new_filename, settings): """Move extra files, like playlists...""" old_path = encode_filename(os.path.dirname(old_filename)) new_path = encode_filename(os.path.dirname(new_filename)) patterns = encode_filename(settings["move_additional_files_pattern"]) patterns = filter(bool, [p.strip() for p in patterns.split()]) files = [] for pattern in patterns: # FIXME glob1 is not documented, maybe we need our own implemention? for old_file in glob.glob1(old_path, pattern): new_file = os.path.join(new_path, old_file) old_file = os.path.join(old_path, old_file) # FIXME we shouldn't do this from a thread! if self.tagger.get_file_by_filename(decode_filename(old_file)): self.log.debug("File loaded in the tagger, not moving %r", old_file) continue self.log.debug("Moving %r to %r", old_file, new_file) shutil.move(old_file, new_file) def remove(self, from_parent=True): if from_parent and self.parent: self.log.debug("Removing %r from %r", self, self.parent) self.parent.remove_file(self) self.tagger.puidmanager.remove(self.metadata['musicip_puid']) self.tagger.acoustidmanager.remove(self) self.state = File.REMOVED def move(self, parent): if parent != self.parent: self.log.debug("Moving %r from %r to %r", self, self.parent, parent) self.clear_lookup_task() self.tagger._ofa.stop_analyze(self) if self.parent: self.clear_pending() self.parent.remove_file(self) self.parent = parent self.parent.add_file(self) self.tagger.puidmanager.update( self.metadata['musicip_puid'], self.metadata['musicbrainz_trackid']) self.tagger.acoustidmanager.update( self, self.metadata['musicbrainz_trackid']) def _move(self, parent): if parent != self.parent: self.log.debug("Moving %r from %r to %r", self, self.parent, parent) if self.parent: self.parent.remove_file(self) self.parent = parent self.tagger.puidmanager.update( self.metadata['musicip_puid'], self.metadata['musicbrainz_trackid']) self.tagger.acoustidmanager.update( self, self.metadata['musicbrainz_trackid']) def supports_tag(self, name): """Returns whether tag ``name`` can be saved to the file.""" return True def is_saved(self): return self.similarity == 1.0 and self.state == File.NORMAL def update(self, signal=True): names = set(self.metadata._items.keys()) names.update(self.orig_metadata._items.keys()) clear_existing_tags = self.config.setting["clear_existing_tags"] for name in names: if not name.startswith('~') and self.supports_tag(name): new_values = self.metadata.getall(name) if not (new_values or clear_existing_tags): continue orig_values = self.orig_metadata.getall(name) if orig_values != new_values: self.similarity = self.orig_metadata.compare(self.metadata) if self.state in (File.CHANGED, File.NORMAL): self.state = File.CHANGED break else: self.similarity = 1.0 if self.state in (File.CHANGED, File.NORMAL): self.state = File.NORMAL if signal: self.log.debug("Updating file %r", self) if self.item: self.item.update() if isinstance(self.parent, Track): self.parent.update() def can_save(self): """Return if this object can be saved.""" return True def can_remove(self): """Return if this object can be removed.""" return True def can_edit_tags(self): """Return if this object supports tag editing.""" return True def can_analyze(self): """Return if this object can be fingerprinted.""" return True def can_autotag(self): return True def can_refresh(self): return False def can_view_info(self): return True def _info(self, metadata, file): if hasattr(file.info, 'length'): metadata.length = int(file.info.length * 1000) if hasattr(file.info, 'bitrate') and file.info.bitrate: metadata['~#bitrate'] = file.info.bitrate / 1000.0 if hasattr(file.info, 'sample_rate') and file.info.sample_rate: metadata['~#sample_rate'] = file.info.sample_rate if hasattr(file.info, 'channels') and file.info.channels: metadata['~#channels'] = file.info.channels if hasattr(file.info, 'bits_per_sample') and file.info.bits_per_sample: metadata['~#bits_per_sample'] = file.info.bits_per_sample metadata['~format'] = self.__class__.__name__.replace('File', '') def get_state(self): return self._state # in order to significantly speed up performance, the number of pending # files is cached num_pending_files = 0 def set_state(self, state, update=False): if state != self._state: if state == File.PENDING: File.num_pending_files += 1 elif self._state == File.PENDING: File.num_pending_files -= 1 self._state = state if update: self.update() self.tagger.emit(QtCore.SIGNAL("file_state_changed"), File.num_pending_files) state = property(get_state, set_state) def column(self, column): m = self.metadata if column == "title" and not m["title"]: return self.base_filename return m[column] def _compare_to_track(self, track): """ Compare file metadata to a MusicBrainz track. Weigths: * title = 13 * artist name = 4 * release name = 5 * length = 10 * number of tracks = 4 * album type = 20 * release country = 2 * format = 2 """ total = 0.0 parts = [] w = self.comparison_weights if 'title' in self.metadata: a = self.metadata['title'] b = track.title[0].text parts.append((similarity2(a, b), w["title"])) total += w["title"] if 'artist' in self.metadata: a = self.metadata['artist'] b = artist_credit_from_node(track.artist_credit[0], self.config)[0] parts.append((similarity2(a, b), w["artist"])) total += w["artist"] a = self.metadata.length if a > 0 and 'length' in track.children: b = int(track.length[0].text) score = 1.0 - min(abs(a - b), 30000) / 30000.0 parts.append((score, w["length"])) total += w["length"] releases = [] if "release_list" in track.children and "release" in track.release_list[ 0].children: releases = track.release_list[0].release if not releases: return (total, None) scores = [] for release in releases: t, p = self.metadata.compare_to_release(release, w, self.config) total_ = total + t parts_ = list(parts) + p scores.append((reduce(lambda x, y: x + y[0] * y[1] / total_, parts_, 0.0), release.id)) return max(scores, key=lambda x: x[0]) def _lookup_finished(self, lookuptype, document, http, error): self.lookup_task = None if self.state == File.REMOVED: return try: m = document.metadata[0] if lookuptype == "metadata": tracks = m.recording_list[0].recording elif lookuptype == "puid": tracks = m.puid[0].recording_list[0].recording elif lookuptype == "acoustid": tracks = m.puid[0].recording_list[0].recording elif lookuptype == "trackid": tracks = m.recording except (AttributeError, IndexError): tracks = None # no matches if not tracks: self.tagger.window.set_statusbar_message( N_("No matching tracks for file %s"), self.filename, timeout=3000) self.clear_pending() return # multiple matches -- calculate similarities to each of them matches = [] for track in tracks: score, release = self._compare_to_track(track) matches.append((score, track, release)) matches.sort(reverse=True) #self.log.debug("Track matches: %r", matches) if lookuptype != 'puid' and lookuptype != 'acoustid': threshold = self.config.setting['file_lookup_threshold'] if matches[0][0] < threshold: self.tagger.window.set_statusbar_message( N_("No matching tracks above the threshold for file %s"), self.filename, timeout=3000) self.clear_pending() return self.tagger.window.set_statusbar_message(N_("File %s identified!"), self.filename, timeout=3000) self.clear_pending() albumid = matches[0][2] track = matches[0][1] if lookuptype == 'puid': self.tagger.puidmanager.add(self.metadata['musicip_puid'], track.id) elif lookuptype == 'acoustid': self.tagger.acoustidmanager.add(self, track.id) if albumid: self.tagger.move_file_to_track(self, albumid, track.id) else: self.tagger.move_file_to_nat(self, track.id, node=track) def lookup_puid(self, puid): """ Try to identify the file using the PUID. """ self.tagger.window.set_statusbar_message( N_("Looking up the PUID for file %s..."), self.filename) self.clear_lookup_task() self.lookup_task = self.tagger.xmlws.lookup_puid( puid, partial(self._lookup_finished, 'puid')) def lookup_metadata(self): """ Try to identify the file using the existing metadata. """ self.tagger.window.set_statusbar_message( N_("Looking up the metadata for file %s..."), self.filename) self.clear_lookup_task() self.lookup_task = self.tagger.xmlws.find_tracks( partial(self._lookup_finished, 'metadata'), track=self.metadata.get('title', ''), artist=self.metadata.get('artist', ''), release=self.metadata.get('album', ''), tnum=self.metadata.get('tracknumber', ''), tracks=self.metadata.get('totaltracks', ''), qdur=str(self.metadata.length / 2000), limit=25) def clear_lookup_task(self): if self.lookup_task: self.tagger.xmlws.remove_task(self.lookup_task) self.lookup_task = None def set_pending(self): if self.state == File.REMOVED: return self.state = File.PENDING self.update() def clear_pending(self): if self.state == File.PENDING: self.state = File.NORMAL self.update() def iterfiles(self, save=False): yield self def _get_tracknumber(self): try: return int(self.metadata["tracknumber"]) except: return 0 tracknumber = property(_get_tracknumber, doc="The track number as an int.") def _get_discnumber(self): try: return int(self.metadata["discnumber"]) except: return 0 discnumber = property(_get_discnumber, doc="The disc number as an int.")
class File(QtCore.QObject, Item): UNDEFINED = -1 PENDING = 0 NORMAL = 1 CHANGED = 2 ERROR = 3 REMOVED = 4 comparison_weights = { "title": 13, "artist": 4, "album": 5, "length": 10, "totaltracks": 4, "releasetype": 20, "releasecountry": 2, "format": 2, } def __init__(self, filename): super(File, self).__init__() self.filename = filename self.base_filename = os.path.basename(filename) self._state = File.UNDEFINED self.state = File.PENDING self.error = None self.orig_metadata = Metadata() self.metadata = Metadata() self.similarity = 1.0 self.parent = None self.lookup_task = None self.item = None def __repr__(self): return "<File %r>" % self.base_filename def load(self, next): self.tagger.load_queue.put( (partial(self._load, self.filename), partial(self._loading_finished, next), QtCore.Qt.LowEventPriority + 1) ) @call_next def _loading_finished(self, next, result=None, error=None): if self.state != self.PENDING: return if error is not None: self.error = str(error) self.state = self.ERROR else: self.error = None self.state = self.NORMAL self._copy_loaded_metadata(result) self.update() return self def _copy_loaded_metadata(self, metadata): filename, _ = os.path.splitext(self.base_filename) metadata["~length"] = format_time(metadata.length) if "title" not in metadata: metadata["title"] = filename if "tracknumber" not in metadata: match = re.match("(?:track)?\s*(?:no|nr)?\s*(\d+)", filename, re.I) if match: try: tracknumber = int(match.group(1)) except ValueError: pass else: metadata["tracknumber"] = str(tracknumber) self.orig_metadata.copy(metadata) self.metadata = metadata _default_preserved_tags = [ "~bitrate", "~bits_per_sample", "~format", "~channels", "~filename", "~dirname", "~extension", ] def copy_metadata(self, metadata): acoustid = self.metadata["acoustid_id"] preserve = config.setting["preserved_tags"].strip() saved_metadata = {} for tag in re.split(r"\s+", preserve) + File._default_preserved_tags: values = self.orig_metadata.getall(tag) if values: saved_metadata[tag] = values self.metadata.copy(metadata) for tag, values in saved_metadata.iteritems(): self.metadata.set(tag, values) self.metadata["acoustid_id"] = acoustid def has_error(self): return self.state == File.ERROR def _load(self): """Load metadata from the file.""" raise NotImplementedError def save(self, next): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) self.tagger.save_queue.put( ( partial(self._save_and_rename, self.filename, metadata), partial(self._saving_finished, next), QtCore.Qt.LowEventPriority + 2, ) ) def _save_and_rename(self, old_filename, metadata): """Save the metadata.""" new_filename = old_filename if not config.setting["dont_write_tags"]: encoded_old_filename = encode_filename(old_filename) info = os.stat(encoded_old_filename) self._save(old_filename, metadata) if config.setting["preserve_timestamps"]: try: os.utime(encoded_old_filename, (info.st_atime, info.st_mtime)) except OSError: log.warning("Couldn't preserve timestamp for %r", old_filename) # Rename files if config.setting["rename_files"] or config.setting["move_files"]: new_filename = self._rename(old_filename, metadata) # Move extra files (images, playlists, etc.) if config.setting["move_files"] and config.setting["move_additional_files"]: self._move_additional_files(old_filename, new_filename) # Delete empty directories if config.setting["delete_empty_dirs"]: dirname = encode_filename(os.path.dirname(old_filename)) try: self._rmdir(dirname) head, tail = os.path.split(dirname) if not tail: head, tail = os.path.split(head) while head and tail: try: self._rmdir(head) except: break head, tail = os.path.split(head) except EnvironmentError: pass # Save cover art images if config.setting["save_images_to_files"]: self._save_images(os.path.dirname(new_filename), metadata) return new_filename @staticmethod def _rmdir(dir): junk_files = (".DS_Store", "desktop.ini", "Desktop.ini", "Thumbs.db") if not set(os.listdir(dir)) - set(junk_files): shutil.rmtree(dir, False) else: raise OSError @call_next def _saving_finished(self, next, result=None, error=None): old_filename = new_filename = self.filename if error is not None: self.error = str(error) self.set_state(File.ERROR, update=True) else: self.filename = new_filename = result self.base_filename = os.path.basename(new_filename) length = self.orig_metadata.length temp_info = {} for info in ("~bitrate", "~sample_rate", "~channels", "~bits_per_sample", "~format"): temp_info[info] = self.orig_metadata[info] if config.setting["clear_existing_tags"]: self.orig_metadata.copy(self.metadata) else: self.orig_metadata.update(self.metadata) self.orig_metadata.length = length self.orig_metadata["~length"] = format_time(length) for k, v in temp_info.items(): self.orig_metadata[k] = v self.error = None self.clear_pending() self._add_path_to_metadata(self.orig_metadata) return self, old_filename, new_filename def _save(self, filename, metadata): """Save the metadata.""" raise NotImplementedError def _script_to_filename(self, format, file_metadata): metadata = Metadata() if config.setting["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) # make sure every metadata can safely be used in a path name for name in metadata.keys(): if isinstance(metadata[name], basestring): metadata[name] = sanitize_filename(metadata[name]) format = format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(format, metadata, self) if config.setting["ascii_filenames"]: if isinstance(filename, unicode): filename = unaccent(filename) filename = replace_non_ascii(filename) # replace incompatible characters if config.setting["windows_compatible_filenames"] or sys.platform == "win32": filename = replace_win32_incompat(filename) # remove null characters filename = filename.replace("\x00", "") return filename def _make_filename(self, filename, metadata): """Constructs file name based on metadata and file naming formats.""" if config.setting["move_files"]: new_dirname = config.setting["move_files_to"] if not os.path.isabs(new_dirname): new_dirname = os.path.normpath(os.path.join(os.path.dirname(filename), new_dirname)) else: new_dirname = os.path.dirname(filename) new_filename, ext = os.path.splitext(os.path.basename(filename)) if config.setting["rename_files"]: # expand the naming format format = config.setting["file_naming_format"] if len(format) > 0: new_filename = self._script_to_filename(format, metadata) if not config.setting["move_files"]: new_filename = os.path.basename(new_filename) new_filename = make_short_filename(new_dirname, new_filename) # win32 compatibility fixes if config.setting["windows_compatible_filenames"] or sys.platform == "win32": new_filename = new_filename.replace("./", "_/").replace(".\\", "_\\") # replace . at the beginning of file and directory names new_filename = new_filename.replace("/.", "/_").replace("\\.", "\\_") if new_filename and new_filename[0] == ".": new_filename = "_" + new_filename[1:] # Fix for precomposed characters on OSX if sys.platform == "darwin": new_filename = unicodedata.normalize("NFD", unicode(new_filename)) return os.path.realpath(os.path.join(new_dirname, new_filename + ext.lower())) def _rename(self, old_filename, metadata): new_filename, ext = os.path.splitext(self._make_filename(old_filename, metadata)) if old_filename != new_filename + ext: new_dirname = os.path.dirname(new_filename) if not os.path.isdir(encode_filename(new_dirname)): os.makedirs(new_dirname) tmp_filename = new_filename i = 1 while not pathcmp(old_filename, new_filename + ext) and os.path.exists(encode_filename(new_filename + ext)): new_filename = "%s (%d)" % (tmp_filename, i) i += 1 new_filename = new_filename + ext log.debug("Moving file %r => %r", old_filename, new_filename) shutil.move(encode_filename(old_filename), encode_filename(new_filename)) return new_filename else: return old_filename def _make_image_filename(self, image_filename, dirname, metadata): image_filename = self._script_to_filename(image_filename, metadata) if not image_filename: image_filename = "cover" if os.path.isabs(image_filename): filename = image_filename else: filename = os.path.join(dirname, image_filename) if config.setting["windows_compatible_filenames"] or sys.platform == "win32": filename = filename.replace("./", "_/").replace(".\\", "_\\") return encode_filename(filename) def _save_images(self, dirname, metadata): """Save the cover images to disk.""" if not metadata.images: return default_filename = self._make_image_filename(config.setting["cover_image_filename"], dirname, metadata) overwrite = config.setting["save_images_overwrite"] counters = defaultdict(lambda: 0) for image in metadata.images: filename = image["filename"] data = image["data"] mime = image["mime"] if filename is None: filename = default_filename else: filename = self._make_image_filename(filename, dirname, metadata) image_filename = filename ext = mimetype.get_extension(mime, ".jpg") if counters[filename] > 0: image_filename = "%s (%d)" % (filename, counters[filename]) counters[filename] = counters[filename] + 1 while os.path.exists(image_filename + ext) and not overwrite: if os.path.getsize(image_filename + ext) == len(data): log.debug("Identical file size, not saving %r", image_filename) break image_filename = "%s (%d)" % (filename, counters[filename]) counters[filename] = counters[filename] + 1 else: new_filename = image_filename + ext # Even if overwrite is enabled we don't need to write the same # image multiple times if os.path.exists(new_filename) and os.path.getsize(new_filename) == len(data): log.debug("Identical file size, not saving %r", image_filename) return log.debug("Saving cover images to %r", image_filename) new_dirname = os.path.dirname(image_filename) if not os.path.isdir(new_dirname): os.makedirs(new_dirname) f = open(image_filename + ext, "wb") f.write(data) f.close() def _move_additional_files(self, old_filename, new_filename): """Move extra files, like playlists...""" old_path = encode_filename(os.path.dirname(old_filename)) new_path = encode_filename(os.path.dirname(new_filename)) patterns = encode_filename(config.setting["move_additional_files_pattern"]) patterns = filter(bool, [p.strip() for p in patterns.split()]) for pattern in patterns: # FIXME glob1 is not documented, maybe we need our own implemention? for old_file in glob.glob1(old_path, pattern): new_file = os.path.join(new_path, old_file) old_file = os.path.join(old_path, old_file) # FIXME we shouldn't do this from a thread! if self.tagger.files.get(decode_filename(old_file)): log.debug("File loaded in the tagger, not moving %r", old_file) continue log.debug("Moving %r to %r", old_file, new_file) shutil.move(old_file, new_file) def remove(self, from_parent=True): if from_parent and self.parent: log.debug("Removing %r from %r", self, self.parent) self.parent.remove_file(self) self.tagger.acoustidmanager.remove(self) self.state = File.REMOVED def move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) self.clear_lookup_task() self.tagger._acoustid.stop_analyze(file) if self.parent: self.clear_pending() self.parent.remove_file(self) self.parent = parent self.parent.add_file(self) self.tagger.acoustidmanager.update(self, self.metadata["musicbrainz_trackid"]) def _move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) if self.parent: self.parent.remove_file(self) self.parent = parent self.tagger.acoustidmanager.update(self, self.metadata["musicbrainz_trackid"]) def supports_tag(self, name): """Returns whether tag ``name`` can be saved to the file.""" return True def is_saved(self): return self.similarity == 1.0 and self.state == File.NORMAL def update(self, signal=True): names = set(self.metadata.keys()) names.update(self.orig_metadata.keys()) clear_existing_tags = config.setting["clear_existing_tags"] for name in names: if not name.startswith("~") and self.supports_tag(name): new_values = self.metadata.getall(name) if not (new_values or clear_existing_tags): continue orig_values = self.orig_metadata.getall(name) if orig_values != new_values: self.similarity = self.orig_metadata.compare(self.metadata) if self.state in (File.CHANGED, File.NORMAL): self.state = File.CHANGED break else: self.similarity = 1.0 if self.state in (File.CHANGED, File.NORMAL): self.state = File.NORMAL if signal: log.debug("Updating file %r", self) if self.item: self.item.update() def can_save(self): """Return if this object can be saved.""" return True def can_remove(self): """Return if this object can be removed.""" return True def can_edit_tags(self): """Return if this object supports tag editing.""" return True def can_analyze(self): """Return if this object can be fingerprinted.""" return True def can_autotag(self): return True def can_refresh(self): return False def can_view_info(self): return True def _info(self, metadata, file): if hasattr(file.info, "length"): metadata.length = int(file.info.length * 1000) if hasattr(file.info, "bitrate") and file.info.bitrate: metadata["~bitrate"] = file.info.bitrate / 1000.0 if hasattr(file.info, "sample_rate") and file.info.sample_rate: metadata["~sample_rate"] = file.info.sample_rate if hasattr(file.info, "channels") and file.info.channels: metadata["~channels"] = file.info.channels if hasattr(file.info, "bits_per_sample") and file.info.bits_per_sample: metadata["~bits_per_sample"] = file.info.bits_per_sample metadata["~format"] = self.__class__.__name__.replace("File", "") self._add_path_to_metadata(metadata) def _add_path_to_metadata(self, metadata): metadata["~dirname"] = os.path.dirname(self.filename) filename, extension = os.path.splitext(os.path.basename(self.filename)) metadata["~filename"] = filename metadata["~extension"] = extension.lower()[1:] def get_state(self): return self._state # in order to significantly speed up performance, the number of pending # files is cached num_pending_files = 0 def set_state(self, state, update=False): if state != self._state: if state == File.PENDING: File.num_pending_files += 1 elif self._state == File.PENDING: File.num_pending_files -= 1 self._state = state if update: self.update() self.tagger.tagger_stats_changed.emit() state = property(get_state, set_state) def column(self, column): m = self.metadata if column == "title" and not m["title"]: return self.base_filename return m[column] def _lookup_finished(self, lookuptype, document, http, error): self.lookup_task = None if self.state == File.REMOVED: return try: m = document.metadata[0] if lookuptype == "metadata": tracks = m.recording_list[0].recording elif lookuptype == "acoustid": tracks = m.acoustid[0].recording_list[0].recording except (AttributeError, IndexError): tracks = None # no matches if not tracks: self.tagger.window.set_statusbar_message(N_("No matching tracks for file %s"), self.filename, timeout=3000) self.clear_pending() return # multiple matches -- calculate similarities to each of them match = sorted( (self.metadata.compare_to_track(track, self.comparison_weights) for track in tracks), reverse=True, key=itemgetter(0), )[0] if lookuptype != "acoustid": threshold = config.setting["file_lookup_threshold"] if match[0] < threshold: self.tagger.window.set_statusbar_message( N_("No matching tracks above the threshold for file %s"), self.filename, timeout=3000 ) self.clear_pending() return self.tagger.window.set_statusbar_message(N_("File %s identified!"), self.filename, timeout=3000) self.clear_pending() rg, release, track = match[1:] if lookuptype == "acoustid": self.tagger.acoustidmanager.add(self, track.id) if release: self.tagger.get_release_group_by_id(rg.id).loaded_albums.add(release.id) self.tagger.move_file_to_track(self, release.id, track.id) else: self.tagger.move_file_to_nat(self, track.id, node=track) def lookup_metadata(self): """Try to identify the file using the existing metadata.""" if self.lookup_task: return self.tagger.window.set_statusbar_message(N_("Looking up the metadata for file %s..."), self.filename) self.clear_lookup_task() metadata = self.metadata self.lookup_task = self.tagger.xmlws.find_tracks( partial(self._lookup_finished, "metadata"), track=metadata["title"], artist=metadata["artist"], release=metadata["album"], tnum=metadata["tracknumber"], tracks=metadata["totaltracks"], qdur=str(metadata.length / 2000), isrc=metadata["isrc"], limit=25, ) def clear_lookup_task(self): if self.lookup_task: self.tagger.xmlws.remove_task(self.lookup_task) self.lookup_task = None def set_pending(self): if self.state != File.REMOVED: self.state = File.PENDING self.update() def clear_pending(self): if self.state == File.PENDING: self.state = File.NORMAL self.update() def iterfiles(self, save=False): yield self def _get_tracknumber(self): try: return int(self.metadata["tracknumber"]) except: return 0 tracknumber = property(_get_tracknumber, doc="The track number as an int.") def _get_discnumber(self): try: return int(self.metadata["discnumber"]) except: return 0 discnumber = property(_get_discnumber, doc="The disc number as an int.")
class File(QtCore.QObject, Item): metadata_images_changed = QtCore.pyqtSignal() NAME = None UNDEFINED = -1 PENDING = 0 NORMAL = 1 CHANGED = 2 ERROR = 3 REMOVED = 4 LOOKUP_METADATA = 1 LOOKUP_ACOUSTID = 2 EXTENSIONS = [] comparison_weights = { "title": 13, "artist": 4, "album": 5, "length": 10, "totaltracks": 4, "releasetype": 14, "releasecountry": 2, "format": 2, "isvideo": 2, "date": 4, } class PreserveTimesStatError(Exception): pass class PreserveTimesUtimeError(Exception): pass # in order to significantly speed up performance, the number of pending # files is cached, set @state.setter num_pending_files = 0 def __init__(self, filename): super().__init__() self.filename = filename self.base_filename = os.path.basename(filename) self._state = File.UNDEFINED self.state = File.PENDING self.orig_metadata = Metadata() self.metadata = Metadata() self.similarity = 1.0 self.parent = None self.lookup_task = None self.item = None self.acoustid_fingerprint = None self.acoustid_length = 0 self.match_recordingid = None self.acousticbrainz_is_duplicate = False self.acousticbrainz_features_file = None self.acousticbrainz_error = False def __repr__(self): return '<%s %r>' % (type(self).__name__, self.base_filename) # pylint: disable=no-self-use def format_specific_metadata(self, metadata, tag, settings=None): """Can be overridden to customize how a tag is displayed in the UI. This is useful if a tag saved to the underlying format will differ from the internal representation in a way that would cause data loss. This is e.g. the case for some ID3v2.3 tags. Args: metadata: The metadata object to read the tag from tag: Name of the tag settings: Dictionary of settings. If not set, config.setting should be used Returns: An array of values for the tag """ return metadata.getall(tag) def _format_specific_copy(self, metadata, settings=None): """Creates a copy of metadata, but applies format_specific_metadata() to the values. """ copy = Metadata(deleted_tags=metadata.deleted_tags, images=metadata.images, length=metadata.length) for name in metadata: copy[name] = self.format_specific_metadata(metadata, name, settings) return copy def load(self, callback): thread.run_task(partial(self._load_check, self.filename), partial(self._loading_finished, callback), priority=1) def _load_check(self, filename): # Check that file has not been removed since thread was queued # Don't load if we are stopping. if self.state != File.PENDING: log.debug("File not loaded because it was removed: %r", self.filename) return None if self.tagger.stopping: log.debug("File not loaded because %s is stopping: %r", PICARD_APP_NAME, self.filename) return None return self._load(filename) def _load(self, filename): """Load metadata from the file.""" raise NotImplementedError def _loading_finished(self, callback, result=None, error=None): if self.state != File.PENDING or self.tagger.stopping: return config = get_config() if error is not None: self.state = self.ERROR self.error_append(str(error)) # If loading failed, force format guessing and try loading again from picard.formats.util import guess_format try: alternative_file = guess_format(self.filename) except (FileNotFoundError, OSError): log.error("Guessing format of %s failed", self.filename, exc_info=True) alternative_file = None if alternative_file: # Do not retry reloading exactly the same file format if type(alternative_file) != type(self): # pylint: disable=unidiomatic-typecheck log.debug('Loading %r failed, retrying as %r' % (self, alternative_file)) self.remove() alternative_file.load(callback) return else: alternative_file.remove() # cleanup unused File object from picard.formats import supported_extensions file_name, file_extension = os.path.splitext(self.base_filename) if file_extension not in supported_extensions(): log.error( 'Unsupported media file %r wrongly loaded. Removing ...', self) callback(self, remove_file=True) return else: self.clear_errors() self.state = self.NORMAL postprocessors = [] if config.setting["guess_tracknumber_and_title"]: postprocessors.append(self._guess_tracknumber_and_title) self._copy_loaded_metadata(result, postprocessors) # use cached fingerprint from file metadata if not config.setting["ignore_existing_acoustid_fingerprints"]: fingerprints = self.metadata.getall('acoustid_fingerprint') if fingerprints: self.set_acoustid_fingerprint(fingerprints[0]) run_file_post_load_processors(self) self.update() callback(self) def _copy_loaded_metadata(self, metadata, postprocessors=None): metadata['~length'] = format_time(metadata.length) if postprocessors: for processor in postprocessors: processor(metadata) self.orig_metadata = metadata self.metadata.copy(metadata) def _guess_tracknumber_and_title(self, metadata): missing = {'tracknumber', 'title'} - set(metadata) if missing: guessed = tracknum_and_title_from_filename(self.base_filename) for m in missing: metadata[m] = getattr(guessed, m) def copy_metadata(self, metadata, preserve_deleted=True): acoustid = self.metadata["acoustid_id"] saved_metadata = {} preserved_tags = PreservedTags() for tag, values in self.orig_metadata.rawitems(): if tag in preserved_tags or tag in PRESERVED_TAGS: saved_metadata[tag] = values deleted_tags = self.metadata.deleted_tags images_changed = self.metadata.images != metadata.images self.metadata.copy(metadata) for info in FILE_INFO_TAGS: metadata[info] = self.orig_metadata[info] if preserve_deleted: for tag in deleted_tags: del self.metadata[tag] self.metadata.update(saved_metadata) if acoustid and "acoustid_id" not in metadata.deleted_tags: self.metadata["acoustid_id"] = acoustid if images_changed: self.metadata_images_changed.emit() def keep_original_images(self): if self.metadata.images != self.orig_metadata.images: self.metadata.images = self.orig_metadata.images.copy() self.update(signal=False) self.metadata_images_changed.emit() def has_error(self): return self.state == File.ERROR def save(self): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) thread.run_task(partial(self._save_and_rename, self.filename, metadata), self._saving_finished, thread_pool=self.tagger.save_thread_pool) def _preserve_times(self, filename, func): """Save filename times before calling func, and set them again""" try: # https://docs.python.org/3/library/os.html#os.utime # Since Python 3.3, ns parameter is available # The best way to preserve exact times is to use the st_atime_ns and st_mtime_ns # fields from the os.stat() result object with the ns parameter to utime. st = os.stat(filename) except OSError as why: errmsg = "Couldn't read timestamps from %r: %s" % (filename, why) raise self.PreserveTimesStatError(errmsg) from None # if we can't read original times, don't call func and let caller handle this func() try: os.utime(filename, ns=(st.st_atime_ns, st.st_mtime_ns)) except OSError as why: errmsg = "Couldn't preserve timestamps for %r: %s" % (filename, why) raise self.PreserveTimesUtimeError(errmsg) from None return (st.st_atime_ns, st.st_mtime_ns) def _save_and_rename(self, old_filename, metadata): """Save the metadata.""" config = get_config() # Check that file has not been removed since thread was queued # Also don't save if we are stopping. if self.state == File.REMOVED: log.debug("File not saved because it was removed: %r", self.filename) return None if self.tagger.stopping: log.debug("File not saved because %s is stopping: %r", PICARD_APP_NAME, self.filename) return None new_filename = old_filename if not config.setting["dont_write_tags"]: save = partial(self._save, old_filename, metadata) if config.setting["preserve_timestamps"]: try: self._preserve_times(old_filename, save) except self.PreserveTimesUtimeError as why: log.warning(why) else: save() # Rename files if config.setting["rename_files"] or config.setting["move_files"]: new_filename = self._rename(old_filename, metadata, config.setting) # Move extra files (images, playlists, etc.) self._move_additional_files(old_filename, new_filename, config) # Delete empty directories if config.setting["delete_empty_dirs"]: dirname = os.path.dirname(old_filename) try: emptydir.rm_empty_dir(dirname) head, tail = os.path.split(dirname) if not tail: head, tail = os.path.split(head) while head and tail: emptydir.rm_empty_dir(head) head, tail = os.path.split(head) except OSError as why: log.warning("Error removing directory: %s", why) except emptydir.SkipRemoveDir as why: log.debug("Not removing empty directory: %s", why) # Save cover art images if config.setting["save_images_to_files"]: self._save_images(os.path.dirname(new_filename), metadata) return new_filename def _saving_finished(self, result=None, error=None): # Handle file removed before save # Result is None if save was skipped if ((self.state == File.REMOVED or self.tagger.stopping) and result is None): return old_filename = new_filename = self.filename if error is not None: self.state = File.ERROR self.error_append(str(error)) else: self.filename = new_filename = result self.base_filename = os.path.basename(new_filename) length = self.orig_metadata.length temp_info = {} for info in FILE_INFO_TAGS: temp_info[info] = self.orig_metadata[info] images_changed = self.orig_metadata.images != self.metadata.images # Copy new metadata to original metadata, applying format specific # conversions (e.g. for ID3v2.3) config = get_config() new_metadata = self._format_specific_copy(self.metadata, config.setting) if config.setting["clear_existing_tags"]: self.orig_metadata = new_metadata else: self.orig_metadata.update(new_metadata) # After saving deleted tags should no longer be marked deleted self.metadata.clear_deleted() self.orig_metadata.clear_deleted() self.orig_metadata.length = length self.orig_metadata['~length'] = format_time(length) self.orig_metadata.update(temp_info) self.clear_errors() self.clear_pending(signal=False) self._add_path_to_metadata(self.orig_metadata) if images_changed: self.metadata_images_changed.emit() # run post save hook run_file_post_save_processors(self) # Force update to ensure file status icon changes immediately after save self.update() if self.state != File.REMOVED: del self.tagger.files[old_filename] self.tagger.files[new_filename] = self if self.tagger.stopping: log.debug("Save of %r completed before stopping Picard", self.filename) def _save(self, filename, metadata): """Save the metadata.""" raise NotImplementedError def _script_to_filename(self, naming_format, file_metadata, file_extension, settings=None): if settings is None: config = get_config() settings = config.setting metadata = Metadata() if settings["clear_existing_tags"]: # script_to_filename_with_metadata guarantees this is not modified metadata = file_metadata else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) (filename, new_metadata) = script_to_filename_with_metadata(naming_format, metadata, file=self, settings=settings) if not filename: return None # NOTE: the filename generated by the naming script does not have a file extension ext = new_metadata.get('~extension', file_extension) return filename + '.' + ext.lstrip('.') def _fixed_splitext(self, filename): # In case the filename is blank and only has the extension # the real extension is in new_filename and ext is blank new_filename, ext = os.path.splitext(filename) if ext == '' and new_filename.lower() in self.EXTENSIONS: ext = new_filename new_filename = '' return new_filename, ext def _clean_file_extension(self, filename): """Takes a filename and converts the extension to lowercase. If the file has no extension a default extension for the format is used. Args: filename: The filename Returns: A tuple containing the filename with fixed extension and the extension itself. """ filename, ext = self._fixed_splitext(filename) if not ext and self.EXTENSIONS: ext = self.EXTENSIONS[0] ext = ext.lower() return (filename + ext, ext) def _format_filename(self, new_dirname, new_filename, metadata, settings, naming_format): old_filename = new_filename new_filename, ext = self._clean_file_extension(new_filename) if naming_format: new_filename = self._script_to_filename(naming_format, metadata, ext, settings) if not new_filename: new_filename = old_filename if not settings['rename_files']: new_filename = os.path.join(os.path.dirname(new_filename), old_filename) if not settings['move_files']: new_filename = os.path.basename(new_filename) win_compat = IS_WIN or settings['windows_compatibility'] new_filename = make_short_filename(new_dirname, new_filename, win_compat) new_filename = make_save_path(new_filename, win_compat=win_compat, mac_compat=IS_MACOS) return new_filename def make_filename(self, filename, metadata, settings=None, naming_format=None): """Constructs file name based on metadata and file naming formats.""" if settings is None: config = get_config() settings = config.setting if naming_format is None: naming_format = get_file_naming_script(settings) if settings["move_files"]: new_dirname = settings["move_files_to"] if not is_absolute_path(new_dirname): new_dirname = os.path.normpath( os.path.join(os.path.dirname(filename), new_dirname)) else: new_dirname = os.path.dirname(filename) try: new_dirname = os.path.realpath(new_dirname) except FileNotFoundError: # os.path.realpath can fail if cwd does not exist and path is relative pass new_filename = os.path.basename(filename) if settings["rename_files"] or settings["move_files"]: new_filename = self._format_filename(new_dirname, new_filename, metadata, settings, naming_format) new_path = os.path.join(new_dirname, new_filename) return new_path def _rename(self, old_filename, metadata, settings=None): new_filename = self.make_filename(old_filename, metadata, settings) if old_filename == new_filename: return old_filename new_dirname = os.path.dirname(new_filename) if not os.path.isdir(new_dirname): os.makedirs(new_dirname) new_filename = get_available_filename(new_filename, old_filename) log.debug("Moving file %r => %r", old_filename, new_filename) move_ensure_casing(old_filename, new_filename) return new_filename def _save_images(self, dirname, metadata): """Save the cover images to disk.""" if not metadata.images: return counters = Counter() images = [] config = get_config() if config.setting["save_only_one_front_image"]: front = metadata.images.get_front_image() if front: images.append(front) if not images: images = metadata.images for image in images: image.save(dirname, metadata, counters) def _move_additional_files(self, old_filename, new_filename, config): """Move extra files, like images, playlists...""" if config.setting["move_files"] and config.setting[ "move_additional_files"]: new_path = os.path.dirname(new_filename) old_path = os.path.dirname(old_filename) if new_path != old_path: patterns_string = config.setting[ "move_additional_files_pattern"] patterns = self._compile_move_additional_files_pattern( patterns_string) try: moves = self._get_additional_files_moves( old_path, new_path, patterns) self._apply_additional_files_moves(moves) except OSError as why: log.error("Failed to scan %r: %s", old_path, why) @staticmethod def _compile_move_additional_files_pattern(patterns_string): return {(re.compile(fnmatch.translate(pattern), re.IGNORECASE), pattern.startswith('.')) for pattern in set(patterns_string.lower().split())} def _get_additional_files_moves(self, old_path, new_path, patterns): if patterns: with os.scandir(old_path) as scan: for entry in scan: is_hidden = entry.name.startswith('.') for pattern_regex, match_hidden in patterns: if is_hidden and not match_hidden: continue if pattern_regex.match(entry.name): new_file_path = os.path.join(new_path, entry.name) yield (entry.path, new_file_path) break # we are done with this file def _apply_additional_files_moves(self, moves): for old_file_path, new_file_path in moves: # FIXME we shouldn't do this from a thread! if self.tagger.files.get(decode_filename(old_file_path)): log.debug("File loaded in the tagger, not moving %r", old_file_path) continue log.debug("Moving %r to %r", old_file_path, new_file_path) try: shutil.move(old_file_path, new_file_path) except OSError as why: log.error("Failed to move %r to %r: %s", old_file_path, new_file_path, why) def remove(self, from_parent=True): if from_parent and self.parent: log.debug("Removing %r from %r", self, self.parent) self.parent.remove_file(self) self.tagger.acoustidmanager.remove(self) self.state = File.REMOVED def move(self, parent): # To be able to move a file the target must implement add_file(file) if hasattr(parent, 'add_file') and parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) self.clear_lookup_task() self.tagger._acoustid.stop_analyze(self) new_album = True if self.parent: new_album = self.parent.album != parent.album self.clear_pending() self.parent.remove_file(self, new_album=new_album) self.parent = parent self.parent.add_file(self, new_album=new_album) self.acoustid_update() return True else: return False def _move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) if self.parent: self.parent.remove_file(self) self.parent = parent self.acoustid_update() def set_acoustid_fingerprint(self, fingerprint, length=None): if not fingerprint: self.acoustid_fingerprint = None self.acoustid_length = 0 self.tagger.acoustidmanager.remove(self) elif fingerprint != self.acoustid_fingerprint: self.acoustid_fingerprint = fingerprint self.acoustid_length = length or self.metadata.length // 1000 self.tagger.acoustidmanager.add(self, None) self.acoustid_update() config = get_config() if config.setting['save_acoustid_fingerprints']: self.metadata['acoustid_fingerprint'] = fingerprint def acoustid_update(self): recording_id = None if self.parent and hasattr(self.parent, 'orig_metadata'): recording_id = self.parent.orig_metadata['musicbrainz_recordingid'] if not recording_id: recording_id = self.metadata['musicbrainz_recordingid'] self.tagger.acoustidmanager.update(self, recording_id) self.update_item() @classmethod def supports_tag(cls, name): """Returns whether tag ``name`` can be saved to the file.""" return True def is_saved(self): return self.similarity == 1.0 and self.state == File.NORMAL def _tags_to_update(self, ignored_tags): for name in set(self.metadata) | set(self.orig_metadata): if name.startswith('~'): continue if not self.supports_tag(name): continue if name in ignored_tags: continue yield name def update(self, signal=True): config = get_config() clear_existing_tags = config.setting["clear_existing_tags"] ignored_tags = set(config.setting["compare_ignore_tags"]) for name in self._tags_to_update(ignored_tags): new_values = self.format_specific_metadata(self.metadata, name, config.setting) if not (new_values or clear_existing_tags or name in self.metadata.deleted_tags): continue orig_values = self.orig_metadata.getall(name) if orig_values != new_values: self.similarity = self.orig_metadata.compare( self.metadata, ignored_tags) if self.state == File.NORMAL: self.state = File.CHANGED break else: if (self.metadata.images and self.orig_metadata.images != self.metadata.images): self.state = File.CHANGED else: self.similarity = 1.0 if self.state == File.CHANGED: self.state = File.NORMAL if signal: log.debug("Updating file %r", self) self.update_item() def can_save(self): """Return if this object can be saved.""" return True def can_remove(self): """Return if this object can be removed.""" return True def can_edit_tags(self): """Return if this object supports tag editing.""" return True def can_analyze(self): """Return if this object can be fingerprinted.""" return True def can_autotag(self): return True def can_refresh(self): return False def can_view_info(self): return True def can_extract(self): from picard.track import Track return (isinstance(self.parent, Track) and self.is_saved() and bool(self.metadata["musicbrainz_recordingid"])) def _info(self, metadata, file): if hasattr(file.info, 'length'): metadata.length = int(file.info.length * 1000) if hasattr(file.info, 'bitrate') and file.info.bitrate: metadata['~bitrate'] = file.info.bitrate / 1000.0 if hasattr(file.info, 'sample_rate') and file.info.sample_rate: metadata['~sample_rate'] = file.info.sample_rate if hasattr(file.info, 'channels') and file.info.channels: metadata['~channels'] = file.info.channels if hasattr(file.info, 'bits_per_sample') and file.info.bits_per_sample: metadata['~bits_per_sample'] = file.info.bits_per_sample if self.NAME: metadata['~format'] = self.NAME else: metadata['~format'] = self.__class__.__name__.replace('File', '') self._add_path_to_metadata(metadata) def _add_path_to_metadata(self, metadata): metadata['~dirname'] = os.path.dirname(self.filename) filename, extension = os.path.splitext(os.path.basename(self.filename)) metadata['~filename'] = filename metadata['~extension'] = extension.lower()[1:] @property def state(self): """Current state of the File object""" return self._state @state.setter def state(self, state): if state == self._state: return if state == File.PENDING: File.num_pending_files += 1 self.tagger.tagger_stats_changed.emit() elif self._state == File.PENDING: File.num_pending_files -= 1 self.tagger.tagger_stats_changed.emit() self._state = state def column(self, column): m = self.metadata if column == "title" and not m["title"]: return self.base_filename elif column == "covercount": return self.cover_art_description() return m[column] def _lookup_finished(self, lookuptype, document, http, error): self.lookup_task = None if self.state == File.REMOVED: return if error: log.error( "Network error encountered during the lookup for %s. Error code: %s", self.filename, error) try: tracks = document['recordings'] except (KeyError, TypeError): tracks = None def statusbar(message): self.tagger.window.set_statusbar_message( message, {'filename': self.filename}, timeout=3000) if tracks: if lookuptype == File.LOOKUP_ACOUSTID: threshold = 0 else: config = get_config() threshold = config.setting['file_lookup_threshold'] trackmatch = self._match_to_track(tracks, threshold=threshold) if trackmatch is None: statusbar( N_("No matching tracks above the threshold for file '%(filename)s'" )) else: statusbar(N_("File '%(filename)s' identified!")) (recording_id, release_group_id, release_id, acoustid, node) = trackmatch if lookuptype == File.LOOKUP_ACOUSTID: self.metadata['acoustid_id'] = acoustid self.tagger.acoustidmanager.add(self, recording_id) if release_group_id is not None: releasegroup = self.tagger.get_release_group_by_id( release_group_id) releasegroup.loaded_albums.add(release_id) self.tagger.move_file_to_track(self, release_id, recording_id) else: self.tagger.move_file_to_nat(self, recording_id) else: statusbar(N_("No matching tracks for file '%(filename)s'")) self.clear_pending() def _match_to_track(self, tracks, threshold=0): # multiple matches -- calculate similarities to each of them def candidates(): for track in tracks: yield self.metadata.compare_to_track(track, self.comparison_weights) no_match = SimMatchTrack(similarity=-1, releasegroup=None, release=None, track=None) best_match = find_best_match(candidates, no_match) if best_match.similarity < threshold: return None else: track_id = best_match.result.track['id'] release_group_id, release_id, node = None, None, None acoustid = best_match.result.track.get('acoustid', None) if best_match.result.release: release_group_id = best_match.result.releasegroup['id'] release_id = best_match.result.release['id'] elif 'title' in best_match.result.track: node = best_match.result.track return (track_id, release_group_id, release_id, acoustid, node) def lookup_metadata(self): """Try to identify the file using the existing metadata.""" if self.lookup_task: return self.tagger.window.set_statusbar_message( N_("Looking up the metadata for file %(filename)s ..."), {'filename': self.filename}) self.clear_lookup_task() metadata = self.metadata self.set_pending() self.lookup_task = self.tagger.mb_api.find_tracks( partial(self._lookup_finished, File.LOOKUP_METADATA), track=metadata['title'], artist=metadata['artist'], release=metadata['album'], tnum=metadata['tracknumber'], tracks=metadata['totaltracks'], qdur=str(metadata.length // 2000), isrc=metadata['isrc'], limit=QUERY_LIMIT) def clear_lookup_task(self): if self.lookup_task: self.tagger.webservice.remove_task(self.lookup_task) self.lookup_task = None def set_pending(self): if self.state != File.REMOVED: self.state = File.PENDING self.update_item(update_selection=False) def clear_pending(self, signal=True): if self.state == File.PENDING: self.state = File.NORMAL # Update file to recalculate changed state self.update(signal=False) if signal: self.update_item(update_selection=False) def update_item(self, update_selection=True): if self.item: self.item.update(update_selection=update_selection) def iterfiles(self, save=False): yield self
class File(QtCore.QObject, Item): metadata_images_changed = QtCore.pyqtSignal() NAME = None UNDEFINED = -1 PENDING = 0 NORMAL = 1 CHANGED = 2 ERROR = 3 REMOVED = 4 LOOKUP_METADATA = 1 LOOKUP_ACOUSTID = 2 comparison_weights = { "title": 13, "artist": 4, "album": 5, "length": 10, "totaltracks": 4, "releasetype": 20, "releasecountry": 2, "format": 2, } class PreserveTimesStatError(Exception): pass class PreserveTimesUtimeError(Exception): pass # in order to significantly speed up performance, the number of pending # files is cached, set @state.setter num_pending_files = 0 def __init__(self, filename): super().__init__() self.filename = filename self.base_filename = os.path.basename(filename) self._state = File.UNDEFINED self.state = File.PENDING self.error = None self.orig_metadata = Metadata() self.metadata = Metadata() self.similarity = 1.0 self.parent = None self.lookup_task = None self.item = None def __repr__(self): return '<File %r>' % self.base_filename @property def new_metadata(self): return self.metadata def load(self, callback): thread.run_task( partial(self._load_check, self.filename), partial(self._loading_finished, callback), priority=1) def _load_check(self, filename): # Check that file has not been removed since thread was queued # Don't load if we are stopping. if self.state != File.PENDING: log.debug("File not loaded because it was removed: %r", self.filename) return None if self.tagger.stopping: log.debug("File not loaded because %s is stopping: %r", PICARD_APP_NAME, self.filename) return None return self._load(filename) def _load(self, filename): """Load metadata from the file.""" raise NotImplementedError def _loading_finished(self, callback, result=None, error=None): if self.state != File.PENDING or self.tagger.stopping: return if error is not None: self.error = str(error) self.state = self.ERROR from picard.formats import supported_extensions file_name, file_extension = os.path.splitext(self.base_filename) if file_extension not in supported_extensions(): self.remove() log.error('Unsupported media file %r wrongly loaded. Removing ...', self) return else: self.error = None self.state = self.NORMAL self._copy_loaded_metadata(result) self.update() callback(self) def _copy_loaded_metadata(self, metadata): filename, _ = os.path.splitext(self.base_filename) metadata['~length'] = format_time(metadata.length) if 'title' not in metadata: metadata['title'] = filename if 'tracknumber' not in metadata: tracknumber = tracknum_from_filename(self.base_filename) if tracknumber != -1: tracknumber = str(tracknumber) metadata['tracknumber'] = tracknumber if metadata['title'] == filename: stripped_filename = filename.lstrip('0') tnlen = len(tracknumber) if stripped_filename[:tnlen] == tracknumber: metadata['title'] = stripped_filename[tnlen:].lstrip() self.orig_metadata = metadata self.metadata.copy(metadata) def copy_metadata(self, metadata, preserve_deleted=True): acoustid = self.metadata["acoustid_id"] preserve = config.setting["preserved_tags"].strip() saved_metadata = {} for tag in re.split(r"\s*,\s*", preserve) + PRESERVED_TAGS: values = self.orig_metadata.getall(tag) if values: saved_metadata[tag] = values deleted_tags = self.metadata.deleted_tags self.metadata.copy(metadata) if preserve_deleted: for tag in deleted_tags: del self.metadata[tag] for tag, values in saved_metadata.items(): self.metadata.set(tag, values) if acoustid and "acoustid_id" not in metadata.deleted_tags: self.metadata["acoustid_id"] = acoustid self.metadata_images_changed.emit() def keep_original_images(self): self.metadata.images = self.orig_metadata.images[:] self.update() self.metadata_images_changed.emit() def has_error(self): return self.state == File.ERROR def save(self): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) thread.run_task( partial(self._save_and_rename, self.filename, metadata), self._saving_finished, priority=2, thread_pool=self.tagger.save_thread_pool) def _preserve_times(self, filename, func): """Save filename times before calling func, and set them again""" try: # https://docs.python.org/3/library/os.html#os.utime # Since Python 3.3, ns parameter is available # The best way to preserve exact times is to use the st_atime_ns and st_mtime_ns # fields from the os.stat() result object with the ns parameter to utime. st = os.stat(filename) except OSError as why: errmsg = "Couldn't read timestamps from %r: %s" % (filename, why) raise self.PreserveTimesStatError(errmsg) from None # if we can't read original times, don't call func and let caller handle this func() try: os.utime(filename, ns=(st.st_atime_ns, st.st_mtime_ns)) except OSError as why: errmsg = "Couldn't preserve timestamps for %r: %s" % (filename, why) raise self.PreserveTimesUtimeError(errmsg) from None return (st.st_atime_ns, st.st_mtime_ns) def _save_and_rename(self, old_filename, metadata): """Save the metadata.""" # Check that file has not been removed since thread was queued # Also don't save if we are stopping. if self.state == File.REMOVED: log.debug("File not saved because it was removed: %r", self.filename) return None if self.tagger.stopping: log.debug("File not saved because %s is stopping: %r", PICARD_APP_NAME, self.filename) return None new_filename = old_filename if not config.setting["dont_write_tags"]: save = partial(self._save, old_filename, metadata) if config.setting["preserve_timestamps"]: try: self._preserve_times(old_filename, save) except self.PreserveTimesStatError as why: log.warning(why) # we didn't save the file yet, bail out return None except self.FilePreserveTimesUtimeError as why: log.warning(why) else: save() # Rename files if config.setting["rename_files"] or config.setting["move_files"]: new_filename = self._rename(old_filename, metadata) # Move extra files (images, playlists, etc.) if config.setting["move_files"] and config.setting["move_additional_files"]: self._move_additional_files(old_filename, new_filename) # Delete empty directories if config.setting["delete_empty_dirs"]: dirname = os.path.dirname(old_filename) try: self._rmdir(dirname) head, tail = os.path.split(dirname) if not tail: head, tail = os.path.split(head) while head and tail: try: self._rmdir(head) except BaseException: break head, tail = os.path.split(head) except EnvironmentError: pass # Save cover art images if config.setting["save_images_to_files"]: self._save_images(os.path.dirname(new_filename), metadata) return new_filename @staticmethod def _rmdir(path): junk_files = (".DS_Store", "desktop.ini", "Desktop.ini", "Thumbs.db") if not set(os.listdir(path)) - set(junk_files): shutil.rmtree(path, False) else: raise OSError def _saving_finished(self, result=None, error=None): # Handle file removed before save # Result is None if save was skipped if ((self.state == File.REMOVED or self.tagger.stopping) and result is None): return old_filename = new_filename = self.filename if error is not None: self.error = str(error) self.state = File.ERROR else: self.filename = new_filename = result self.base_filename = os.path.basename(new_filename) length = self.orig_metadata.length temp_info = {} for info in ('~bitrate', '~sample_rate', '~channels', '~bits_per_sample', '~format'): temp_info[info] = self.orig_metadata[info] # Data is copied from New to Original because New may be # a subclass to handle id3v23 if config.setting["clear_existing_tags"]: self.orig_metadata.copy(self.new_metadata) else: self.orig_metadata.update(self.new_metadata) # After saving deleted tags should no longer be marked deleted self.new_metadata.clear_deleted() self.orig_metadata.clear_deleted() self.orig_metadata.length = length self.orig_metadata['~length'] = format_time(length) for k, v in temp_info.items(): self.orig_metadata[k] = v self.error = None self.clear_pending() self._add_path_to_metadata(self.orig_metadata) self.metadata_images_changed.emit() # run post save hook run_file_post_save_processors(self) # Force update to ensure file status icon changes immediately after save self.update() if self.state != File.REMOVED: del self.tagger.files[old_filename] self.tagger.files[new_filename] = self if self.tagger.stopping: log.debug("Save of %r completed before stopping Picard", self.filename) def _save(self, filename, metadata): """Save the metadata.""" raise NotImplementedError def _script_to_filename(self, naming_format, file_metadata, settings=None): if settings is None: settings = config.setting metadata = Metadata() if settings["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) return script_to_filename(naming_format, metadata, file=self, settings=settings) def _fixed_splitext(self, filename): # In case the filename is blank and only has the extension # the real extension is in new_filename and ext is blank new_filename, ext = os.path.splitext(filename) if ext == '' and new_filename.lower() in self.EXTENSIONS: ext = new_filename new_filename = '' return new_filename, ext def _format_filename(self, new_dirname, new_filename, metadata, settings): # TODO: tests !! new_filename, ext = self._fixed_splitext(new_filename) ext = ext.lower() new_filename = new_filename + ext # expand the naming format naming_format = settings['file_naming_format'] if naming_format: new_filename = self._script_to_filename(naming_format, metadata, settings) # NOTE: the _script_to_filename strips the extension away new_filename = new_filename + ext if not settings['move_files']: new_filename = os.path.basename(new_filename) win_compat = IS_WIN or settings['windows_compatibility'] new_filename = make_short_filename(new_dirname, new_filename, win_compat) # TODO: move following logic under util.filenaming # (and reconsider its necessity) # win32 compatibility fixes if win_compat: new_filename = new_filename.replace('./', '_/').replace('.\\', '_\\') # replace . at the beginning of file and directory names # FIXME: even on non-win platforms ??? new_filename = new_filename.replace('/.', '/_').replace('\\.', '\\_') if new_filename.startswith('.'): new_filename = '_' + new_filename[1:] # Fix for precomposed characters on OSX if IS_MACOS: new_filename = unicodedata.normalize("NFD", new_filename) return new_filename def _make_filename(self, filename, metadata, settings=None): """Constructs file name based on metadata and file naming formats.""" if settings is None: settings = config.setting if settings["move_files"]: new_dirname = settings["move_files_to"] if not os.path.isabs(new_dirname): new_dirname = os.path.normpath(os.path.join(os.path.dirname(filename), new_dirname)) else: new_dirname = os.path.dirname(filename) new_filename = os.path.basename(filename) if settings["rename_files"]: new_filename = self._format_filename(new_dirname, new_filename, metadata, settings) new_path = os.path.join(new_dirname, new_filename) try: return os.path.realpath(new_path) except FileNotFoundError: # os.path.realpath can fail if cwd doesn't exist return new_path def _rename(self, old_filename, metadata): new_filename, ext = os.path.splitext( self._make_filename(old_filename, metadata)) if old_filename == new_filename + ext: return old_filename new_dirname = os.path.dirname(new_filename) if not os.path.isdir(new_dirname): os.makedirs(new_dirname) tmp_filename = new_filename i = 1 while (not pathcmp(old_filename, new_filename + ext) and os.path.exists(new_filename + ext)): new_filename = "%s (%d)" % (tmp_filename, i) i += 1 new_filename = new_filename + ext log.debug("Moving file %r => %r", old_filename, new_filename) shutil.move(old_filename, new_filename) return new_filename def _save_images(self, dirname, metadata): """Save the cover images to disk.""" if not metadata.images: return counters = defaultdict(lambda: 0) images = [] if config.setting["caa_save_single_front_image"]: images = [metadata.images.get_front_image()] if not images: images = metadata.images for image in images: image.save(dirname, metadata, counters) def _move_additional_files(self, old_filename, new_filename): """Move extra files, like images, playlists...""" new_path = os.path.dirname(new_filename) old_path = os.path.dirname(old_filename) if new_path == old_path: # skip, same directory, nothing to move return patterns = config.setting["move_additional_files_pattern"] pattern_regexes = set() for pattern in patterns.split(): pattern = pattern.strip() if not pattern: continue pattern_regex = re.compile(fnmatch.translate(pattern), re.IGNORECASE) match_hidden = pattern.startswith('.') pattern_regexes.add((pattern_regex, match_hidden)) if not pattern_regexes: return moves = set() try: # TODO: use with statement with python 3.6+ for entry in os.scandir(old_path): is_hidden = entry.name.startswith('.') for pattern_regex, match_hidden in pattern_regexes: if is_hidden and not match_hidden: continue if pattern_regex.match(entry.name): new_file_path = os.path.join(new_path, entry.name) moves.add((entry.path, new_file_path)) break # we are done with this file except OSError as why: log.error("Failed to scan %r: %s", old_path, why) return for old_file_path, new_file_path in moves: # FIXME we shouldn't do this from a thread! if self.tagger.files.get(decode_filename(old_file_path)): log.debug("File loaded in the tagger, not moving %r", old_file_path) continue log.debug("Moving %r to %r", old_file_path, new_file_path) try: shutil.move(old_file_path, new_file_path) except OSError as why: log.error("Failed to move %r to %r: %s", old_file_path, new_file_path, why) def remove(self, from_parent=True): if from_parent and self.parent: log.debug("Removing %r from %r", self, self.parent) self.parent.remove_file(self) self.tagger.acoustidmanager.remove(self) self.state = File.REMOVED def move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) self.clear_lookup_task() self.tagger._acoustid.stop_analyze(self) if self.parent: self.clear_pending() self.parent.remove_file(self) self.parent = parent self.parent.add_file(self) self._acoustid_update() def _move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) if self.parent: self.parent.remove_file(self) self.parent = parent self._acoustid_update() def _acoustid_update(self): recording_id = None if self.parent and hasattr(self.parent, 'orig_metadata'): recording_id = self.parent.orig_metadata['musicbrainz_recordingid'] if not recording_id: recording_id = self.metadata['musicbrainz_recordingid'] self.tagger.acoustidmanager.update(self, recording_id) @classmethod def supports_tag(cls, name): """Returns whether tag ``name`` can be saved to the file.""" return True def is_saved(self): return self.similarity == 1.0 and self.state == File.NORMAL def update(self, signal=True): new_metadata = self.new_metadata names = set(new_metadata.keys()) names.update(self.orig_metadata.keys()) clear_existing_tags = config.setting["clear_existing_tags"] for name in names: if not name.startswith('~') and self.supports_tag(name): new_values = new_metadata.getall(name) if not (new_values or clear_existing_tags or name in new_metadata.deleted_tags): continue orig_values = self.orig_metadata.getall(name) if orig_values != new_values: self.similarity = self.orig_metadata.compare(new_metadata) if self.state == File.NORMAL: self.state = File.CHANGED break else: if (self.metadata.images and self.orig_metadata.images != self.metadata.images): self.state = File.CHANGED else: self.similarity = 1.0 if self.state == File.CHANGED: self.state = File.NORMAL if signal: log.debug("Updating file %r", self) self.update_item() def can_save(self): """Return if this object can be saved.""" return True def can_remove(self): """Return if this object can be removed.""" return True def can_edit_tags(self): """Return if this object supports tag editing.""" return True def can_analyze(self): """Return if this object can be fingerprinted.""" return True def can_autotag(self): return True def can_refresh(self): return False def can_view_info(self): return True def _info(self, metadata, file): if hasattr(file.info, 'length'): metadata.length = int(file.info.length * 1000) if hasattr(file.info, 'bitrate') and file.info.bitrate: metadata['~bitrate'] = file.info.bitrate / 1000.0 if hasattr(file.info, 'sample_rate') and file.info.sample_rate: metadata['~sample_rate'] = file.info.sample_rate if hasattr(file.info, 'channels') and file.info.channels: metadata['~channels'] = file.info.channels if hasattr(file.info, 'bits_per_sample') and file.info.bits_per_sample: metadata['~bits_per_sample'] = file.info.bits_per_sample if self.NAME: metadata['~format'] = self.NAME else: metadata['~format'] = self.__class__.__name__.replace('File', '') self._add_path_to_metadata(metadata) def _add_path_to_metadata(self, metadata): metadata['~dirname'] = os.path.dirname(self.filename) filename, extension = os.path.splitext(os.path.basename(self.filename)) metadata['~filename'] = filename metadata['~extension'] = extension.lower()[1:] @property def state(self): """Current state of the File object""" return self._state @state.setter def state(self, state): if state == self._state: return if state == File.PENDING: File.num_pending_files += 1 self.tagger.tagger_stats_changed.emit() elif self._state == File.PENDING: File.num_pending_files -= 1 self.tagger.tagger_stats_changed.emit() self._state = state def column(self, column): m = self.metadata if column == "title" and not m["title"]: return self.base_filename return m[column] def _lookup_finished(self, lookuptype, document, http, error): self.lookup_task = None if self.state == File.REMOVED: return if error: log.error("Network error encountered during the lookup for %s. Error code: %s", self.filename, error) try: tracks = document['recordings'] except (KeyError, TypeError): tracks = None def statusbar(message): self.tagger.window.set_statusbar_message( message, {'filename': self.filename}, timeout=3000 ) if tracks: if lookuptype == File.LOOKUP_ACOUSTID: threshold = 0 else: threshold = config.setting['file_lookup_threshold'] trackmatch = self._match_to_track(tracks, threshold=threshold) if trackmatch is None: statusbar(N_("No matching tracks above the threshold for file '%(filename)s'")) else: statusbar(N_("File '%(filename)s' identified!")) (track_id, release_group_id, release_id, node) = trackmatch if lookuptype == File.LOOKUP_ACOUSTID: self.tagger.acoustidmanager.add(self, track_id) if release_group_id is not None: releasegroup = self.tagger.get_release_group_by_id(release_group_id) releasegroup.loaded_albums.add(release_id) self.tagger.move_file_to_track(self, release_id, track_id) else: self.tagger.move_file_to_nat(self, track_id, node=node) else: statusbar(N_("No matching tracks for file '%(filename)s'")) self.clear_pending() def _match_to_track(self, tracks, threshold=0): # multiple matches -- calculate similarities to each of them def candidates(): for track in tracks: yield self.metadata.compare_to_track(track, self.comparison_weights) no_match = SimMatchTrack(similarity=-1, releasegroup=None, release=None, track=None) best_match = find_best_match(candidates, no_match) if best_match.similarity < threshold: return None else: track_id = best_match.result.track['id'] release_group_id, release_id, node = None, None, None if best_match.result.release: release_group_id = best_match.result.releasegroup['id'] release_id = best_match.result.release['id'] elif 'title' in best_match.result.track: node = best_match.result.track return (track_id, release_group_id, release_id, node) def lookup_metadata(self): """Try to identify the file using the existing metadata.""" if self.lookup_task: return self.tagger.window.set_statusbar_message( N_("Looking up the metadata for file %(filename)s ..."), {'filename': self.filename} ) self.clear_lookup_task() metadata = self.metadata self.set_pending() self.lookup_task = self.tagger.mb_api.find_tracks( partial(self._lookup_finished, File.LOOKUP_METADATA), track=metadata['title'], artist=metadata['artist'], release=metadata['album'], tnum=metadata['tracknumber'], tracks=metadata['totaltracks'], qdur=str(metadata.length // 2000), isrc=metadata['isrc'], limit=QUERY_LIMIT) def clear_lookup_task(self): if self.lookup_task: self.tagger.webservice.remove_task(self.lookup_task) self.lookup_task = None def set_pending(self): if self.state != File.REMOVED: self.state = File.PENDING self.update_item() def clear_pending(self): if self.state == File.PENDING: self.state = File.NORMAL self.update_item() def update_item(self): if self.item: self.item.update() def iterfiles(self, save=False): yield self @property def tracknumber(self): """The track number as an int.""" try: return int(self.metadata["tracknumber"]) except BaseException: return 0 @property def discnumber(self): """The disc number as an int.""" try: return int(self.metadata["discnumber"]) except BaseException: return 0
class File(LockableObject, Item): __id_counter = 0 @staticmethod def new_id(): File.__id_counter += 1 return File.__id_counter UNDEFINED = -1 PENDING = 0 NORMAL = 1 CHANGED = 2 ERROR = 3 REMOVED = 4 comparison_weights = { "title": 13, "artist": 4, "album": 5, "length": 10, "totaltracks": 4, "releasetype": 20, "releasecountry": 2, "format": 2, } def __init__(self, filename): super(File, self).__init__() self.id = self.new_id() self.filename = filename self.base_filename = os.path.basename(filename) self._state = File.UNDEFINED self.state = File.PENDING self.error = None self.orig_metadata = Metadata() self.user_metadata = Metadata() self.saved_metadata = Metadata() self.metadata = self.user_metadata self.similarity = 1.0 self.parent = None self.lookup_task = None def __repr__(self): return '<File #%d %r>' % (self.id, self.base_filename) def load(self, next): self.tagger.load_queue.put(( partial(self._load, self.filename), partial(self._loading_finished, next), QtCore.Qt.LowEventPriority + 1)) @call_next def _loading_finished(self, next, result=None, error=None): if self.state != self.PENDING: return if error is not None: self.error = str(error) self.state = self.ERROR else: self.error = None self.state = self.NORMAL self._copy_loaded_metadata(result) self.update() return self def _copy_loaded_metadata(self, metadata): filename, extension = os.path.splitext(self.base_filename) self.metadata.copy(metadata) self.metadata['~extension'] = extension[1:].lower() self.metadata['~length'] = format_time(self.metadata.length) if 'title' not in self.metadata: self.metadata['title'] = filename if 'tracknumber' not in self.metadata: match = re.match("(?:track)?\s*(?:no|nr)?\s*(\d+)", filename, re.I) if match: try: tracknumber = int(match.group(1)) except ValueError: pass else: self.metadata['tracknumber'] = str(tracknumber) self.orig_metadata.copy(self.metadata) def copy_metadata(self, metadata): exceptions = ['musicip_puid', 'acoustid_id'] if self.config.setting['preserved_tags']: exceptions.extend(re.split(r'\s+', self.config.setting['preserved_tags'].strip())) for tag in exceptions: self.saved_metadata[tag] = self.metadata[tag] self.metadata.copy(metadata) for tag in exceptions: self.metadata[tag] = self.saved_metadata.pop(tag) def has_error(self): return self.state == File.ERROR def _load(self): """Load metadata from the file.""" raise NotImplementedError def save(self, next, settings): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) self.tagger.save_queue.put(( partial(self._save_and_rename, self.filename, metadata, settings), partial(self._saving_finished, next), QtCore.Qt.LowEventPriority + 2)) def _save_and_rename(self, old_filename, metadata, settings): """Save the metadata.""" new_filename = old_filename if not settings["dont_write_tags"]: encoded_old_filename = encode_filename(old_filename) info = os.stat(encoded_old_filename) self._save(old_filename, metadata, settings) if settings["preserve_timestamps"]: try: os.utime(encoded_old_filename, (info.st_atime, info.st_mtime)) except OSError: self.log.warning("Couldn't preserve timestamp for %r", old_filename) # Rename files if settings["rename_files"] or settings["move_files"]: new_filename = self._rename(old_filename, metadata, settings) # Move extra files (images, playlists, etc.) if settings["move_files"] and settings["move_additional_files"]: self._move_additional_files(old_filename, new_filename, settings) # Delete empty directories if settings["delete_empty_dirs"]: dirname = encode_filename(os.path.dirname(old_filename)) try: self._rmdir(dirname) head, tail = os.path.split(dirname) if not tail: head, tail = os.path.split(head) while head and tail: try: self._rmdir(head) except: break head, tail = os.path.split(head) except EnvironmentError: pass # Save cover art images if settings["save_images_to_files"]: self._save_images(new_filename, metadata, settings) return new_filename @staticmethod def _rmdir(dir): junk_files = (".DS_Store", "desktop.ini", "Desktop.ini", "Thumbs.db") if not set(os.listdir(dir)) - set(junk_files): shutil.rmtree(dir, False) else: raise OSError @call_next def _saving_finished(self, next, result=None, error=None): old_filename = new_filename = self.filename if error is not None: self.error = str(error) self.set_state(File.ERROR, update=True) else: self.filename = new_filename = result self.base_filename = os.path.basename(new_filename) length = self.orig_metadata.length temp_info = {} for info in ('~#bitrate', '~#sample_rate', '~#channels', '~#bits_per_sample', '~format', '~extension'): temp_info[info] = self.orig_metadata[info] if self.config.setting["clear_existing_tags"]: self.orig_metadata.copy(self.metadata) else: self.orig_metadata.update(self.metadata) self.orig_metadata.length = length self.orig_metadata['~length'] = format_time(length) for k, v in temp_info.items(): self.orig_metadata[k] = v self.error = None self.clear_pending() return self, old_filename, new_filename def _save(self, filename, metadata, settings): """Save the metadata.""" raise NotImplementedError def _script_to_filename(self, format, file_metadata, settings): metadata = Metadata() if self.config.setting["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) # make sure every metadata can safely be used in a path name for name in metadata.keys(): if isinstance(metadata[name], basestring): metadata[name] = sanitize_filename(metadata[name]) format = format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(format, metadata, self) # replace incompatible characters if settings["windows_compatible_filenames"] or sys.platform == "win32": filename = replace_win32_incompat(filename) if settings["ascii_filenames"]: if isinstance(filename, unicode): filename = unaccent(filename) filename = replace_non_ascii(filename) # remove null characters filename = filename.replace("\x00", "") return filename def _make_filename(self, filename, metadata, settings): """Constructs file name based on metadata and file naming formats.""" if settings["move_files"]: new_dirname = settings["move_files_to"] if not os.path.isabs(new_dirname): new_dirname = os.path.normpath(os.path.join(os.path.dirname(filename), new_dirname)) else: new_dirname = os.path.dirname(filename) new_filename, ext = os.path.splitext(os.path.basename(filename)) if settings["rename_files"]: # expand the naming format format = settings['file_naming_format'] if len(format) > 0: new_filename = self._script_to_filename(format, metadata, settings) if not settings['move_files']: new_filename = os.path.basename(new_filename) new_filename = make_short_filename(new_dirname, new_filename) # win32 compatibility fixes if settings['windows_compatible_filenames'] or sys.platform == 'win32': new_filename = new_filename.replace('./', '_/').replace('.\\', '_\\') # replace . at the beginning of file and directory names new_filename = new_filename.replace('/.', '/_').replace('\\.', '\\_') if new_filename[0] == '.': new_filename = '_' + new_filename[1:] # Fix for precomposed characters on OSX if sys.platform == "darwin": new_filename = unicodedata.normalize("NFD", new_filename) return os.path.realpath(os.path.join(new_dirname, new_filename + ext.lower())) def _rename(self, old_filename, metadata, settings): new_filename, ext = os.path.splitext( self._make_filename(old_filename, metadata, settings)) if old_filename != new_filename + ext: new_dirname = os.path.dirname(new_filename) if not os.path.isdir(encode_filename(new_dirname)): os.makedirs(new_dirname) tmp_filename = new_filename i = 1 while (not pathcmp(old_filename, new_filename + ext) and os.path.exists(encode_filename(new_filename + ext))): new_filename = "%s (%d)" % (tmp_filename, i) i += 1 new_filename = new_filename + ext self.log.debug("Moving file %r => %r", old_filename, new_filename) shutil.move(encode_filename(old_filename), encode_filename(new_filename)) return new_filename else: return old_filename def _save_images(self, filename, metadata, settings): """Save the cover images to disk.""" if not metadata.images: return overwrite = settings["save_images_overwrite"] image_filename = self._script_to_filename( settings["cover_image_filename"], metadata, settings) if not image_filename: image_filename = "cover" if os.path.isabs(image_filename): filename = image_filename else: filename = os.path.join(os.path.dirname(filename), image_filename) if settings['windows_compatible_filenames'] or sys.platform == 'win32': filename = filename.replace('./', '_/').replace('.\\', '_\\') filename = encode_filename(filename) i = 0 for mime, data in metadata.images: image_filename = filename ext = mimetype.get_extension(mime, ".jpg") if i > 0: image_filename = "%s (%d)" % (filename, i) i += 1 while os.path.exists(image_filename + ext) and not overwrite: if os.path.getsize(image_filename + ext) == len(data): self.log.debug("Identical file size, not saving %r", image_filename) break image_filename = "%s (%d)" % (filename, i) i += 1 else: self.log.debug("Saving cover images to %r", image_filename) f = open(image_filename + ext, "wb") f.write(data) f.close() def _move_additional_files(self, old_filename, new_filename, settings): """Move extra files, like playlists...""" old_path = encode_filename(os.path.dirname(old_filename)) new_path = encode_filename(os.path.dirname(new_filename)) patterns = encode_filename(settings["move_additional_files_pattern"]) patterns = filter(bool, [p.strip() for p in patterns.split()]) files = [] for pattern in patterns: # FIXME glob1 is not documented, maybe we need our own implemention? for old_file in glob.glob1(old_path, pattern): new_file = os.path.join(new_path, old_file) old_file = os.path.join(old_path, old_file) # FIXME we shouldn't do this from a thread! if self.tagger.get_file_by_filename(decode_filename(old_file)): self.log.debug("File loaded in the tagger, not moving %r", old_file) continue self.log.debug("Moving %r to %r", old_file, new_file) shutil.move(old_file, new_file) def remove(self, from_parent=True): if from_parent and self.parent: self.log.debug("Removing %r from %r", self, self.parent) self.parent.remove_file(self) self.tagger.puidmanager.remove(self.metadata['musicip_puid']) self.tagger.acoustidmanager.remove(self) self.state = File.REMOVED def move(self, parent): if parent != self.parent: self.log.debug("Moving %r from %r to %r", self, self.parent, parent) self.clear_lookup_task() self.tagger._ofa.stop_analyze(self) if self.parent: self.clear_pending() self.parent.remove_file(self) self.parent = parent self.parent.add_file(self) self.tagger.puidmanager.update(self.metadata['musicip_puid'], self.metadata['musicbrainz_trackid']) self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_trackid']) def _move(self, parent): if parent != self.parent: self.log.debug("Moving %r from %r to %r", self, self.parent, parent) if self.parent: self.parent.remove_file(self) self.parent = parent self.tagger.puidmanager.update(self.metadata['musicip_puid'], self.metadata['musicbrainz_trackid']) self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_trackid']) def supports_tag(self, name): """Returns whether tag ``name`` can be saved to the file.""" return True def is_saved(self): return self.similarity == 1.0 and self.state == File.NORMAL def update(self, signal=True): names = set(self.metadata._items.keys()) names.update(self.orig_metadata._items.keys()) clear_existing_tags = self.config.setting["clear_existing_tags"] for name in names: if not name.startswith('~') and self.supports_tag(name): new_values = self.metadata.getall(name) if not (new_values or clear_existing_tags): continue orig_values = self.orig_metadata.getall(name) if orig_values != new_values: self.similarity = self.orig_metadata.compare(self.metadata) if self.state in (File.CHANGED, File.NORMAL): self.state = File.CHANGED break else: self.similarity = 1.0 if self.state in (File.CHANGED, File.NORMAL): self.state = File.NORMAL if signal: self.log.debug("Updating file %r", self) if self.item: self.item.update() if isinstance(self.parent, Track): self.parent.update() def can_save(self): """Return if this object can be saved.""" return True def can_remove(self): """Return if this object can be removed.""" return True def can_edit_tags(self): """Return if this object supports tag editing.""" return True def can_analyze(self): """Return if this object can be fingerprinted.""" return True def can_autotag(self): return True def can_refresh(self): return False def can_view_info(self): return True def _info(self, metadata, file): if hasattr(file.info, 'length'): metadata.length = int(file.info.length * 1000) if hasattr(file.info, 'bitrate') and file.info.bitrate: metadata['~#bitrate'] = file.info.bitrate / 1000.0 if hasattr(file.info, 'sample_rate') and file.info.sample_rate: metadata['~#sample_rate'] = file.info.sample_rate if hasattr(file.info, 'channels') and file.info.channels: metadata['~#channels'] = file.info.channels if hasattr(file.info, 'bits_per_sample') and file.info.bits_per_sample: metadata['~#bits_per_sample'] = file.info.bits_per_sample metadata['~format'] = self.__class__.__name__.replace('File', '') def get_state(self): return self._state # in order to significantly speed up performance, the number of pending # files is cached num_pending_files = 0 def set_state(self, state, update=False): if state != self._state: if state == File.PENDING: File.num_pending_files += 1 elif self._state == File.PENDING: File.num_pending_files -= 1 self._state = state if update: self.update() self.tagger.emit(QtCore.SIGNAL("file_state_changed"), File.num_pending_files) state = property(get_state, set_state) def column(self, column): m = self.metadata if column == "title" and not m["title"]: return self.base_filename return m[column] def _compare_to_track(self, track): """ Compare file metadata to a MusicBrainz track. Weigths: * title = 13 * artist name = 4 * release name = 5 * length = 10 * number of tracks = 4 * album type = 20 * release country = 2 * format = 2 """ total = 0.0 parts = [] w = self.comparison_weights if 'title' in self.metadata: a = self.metadata['title'] b = track.title[0].text parts.append((similarity2(a, b), w["title"])) total += w["title"] if 'artist' in self.metadata: a = self.metadata['artist'] b = artist_credit_from_node(track.artist_credit[0], self.config)[0] parts.append((similarity2(a, b), w["artist"])) total += w["artist"] a = self.metadata.length if a > 0 and 'length' in track.children: b = int(track.length[0].text) score = 1.0 - min(abs(a - b), 30000) / 30000.0 parts.append((score, w["length"])) total += w["length"] releases = [] if "release_list" in track.children and "release" in track.release_list[0].children: releases = track.release_list[0].release if not releases: return (total, None) scores = [] for release in releases: t, p = self.metadata.compare_to_release(release, w, self.config) total_ = total + t parts_ = list(parts) + p scores.append((reduce(lambda x, y: x + y[0] * y[1] / total_, parts_, 0.0), release.id)) return max(scores, key=lambda x: x[0]) def _lookup_finished(self, lookuptype, document, http, error): self.lookup_task = None if self.state == File.REMOVED: return try: m = document.metadata[0] if lookuptype == "metadata": tracks = m.recording_list[0].recording elif lookuptype == "puid": tracks = m.puid[0].recording_list[0].recording elif lookuptype == "acoustid": tracks = m.puid[0].recording_list[0].recording elif lookuptype == "trackid": tracks = m.recording except (AttributeError, IndexError): tracks = None # no matches if not tracks: self.tagger.window.set_statusbar_message(N_("No matching tracks for file %s"), self.filename, timeout=3000) self.clear_pending() return # multiple matches -- calculate similarities to each of them matches = [] for track in tracks: score, release = self._compare_to_track(track) matches.append((score, track, release)) matches.sort(reverse=True) #self.log.debug("Track matches: %r", matches) if lookuptype != 'puid' and lookuptype != 'acoustid': threshold = self.config.setting['file_lookup_threshold'] if matches[0][0] < threshold: self.tagger.window.set_statusbar_message(N_("No matching tracks above the threshold for file %s"), self.filename, timeout=3000) self.clear_pending() return self.tagger.window.set_statusbar_message(N_("File %s identified!"), self.filename, timeout=3000) self.clear_pending() albumid = matches[0][2] track = matches[0][1] if lookuptype == 'puid': self.tagger.puidmanager.add(self.metadata['musicip_puid'], track.id) elif lookuptype == 'acoustid': self.tagger.acoustidmanager.add(self, track.id) if albumid: self.tagger.move_file_to_track(self, albumid, track.id) else: self.tagger.move_file_to_nat(self, track.id, node=track) def lookup_puid(self, puid): """ Try to identify the file using the PUID. """ self.tagger.window.set_statusbar_message(N_("Looking up the PUID for file %s..."), self.filename) self.clear_lookup_task() self.lookup_task = self.tagger.xmlws.lookup_puid(puid, partial(self._lookup_finished, 'puid')) def lookup_metadata(self): """ Try to identify the file using the existing metadata. """ self.tagger.window.set_statusbar_message(N_("Looking up the metadata for file %s..."), self.filename) self.clear_lookup_task() self.lookup_task = self.tagger.xmlws.find_tracks(partial(self._lookup_finished, 'metadata'), track=self.metadata.get('title', ''), artist=self.metadata.get('artist', ''), release=self.metadata.get('album', ''), tnum=self.metadata.get('tracknumber', ''), tracks=self.metadata.get('totaltracks', ''), qdur=str(self.metadata.length / 2000), limit=25) def clear_lookup_task(self): if self.lookup_task: self.tagger.xmlws.remove_task(self.lookup_task) self.lookup_task = None def set_pending(self): if self.state == File.REMOVED: return self.state = File.PENDING self.update() def clear_pending(self): if self.state == File.PENDING: self.state = File.NORMAL self.update() def iterfiles(self, save=False): yield self def _get_tracknumber(self): try: return int(self.metadata["tracknumber"]) except: return 0 tracknumber = property(_get_tracknumber, doc="The track number as an int.") def _get_discnumber(self): try: return int(self.metadata["discnumber"]) except: return 0 discnumber = property(_get_discnumber, doc="The disc number as an int.")
class File(QtCore.QObject, Item): metadata_images_changed = QtCore.pyqtSignal() UNDEFINED = -1 PENDING = 0 NORMAL = 1 CHANGED = 2 ERROR = 3 REMOVED = 4 comparison_weights = { "title": 13, "artist": 4, "album": 5, "length": 10, "totaltracks": 4, "releasetype": 20, "releasecountry": 2, "format": 2, } def __init__(self, filename): super().__init__() self.filename = filename self.base_filename = os.path.basename(filename) self._state = File.UNDEFINED self.state = File.PENDING self.error = None self.orig_metadata = Metadata() self.metadata = Metadata() self.similarity = 1.0 self.parent = None self.lookup_task = None self.item = None def __repr__(self): return '<File %r>' % self.base_filename @property def new_metadata(self): return self.metadata def load(self, callback): thread.run_task(partial(self._load_check, self.filename), partial(self._loading_finished, callback), priority=1) def _load_check(self, filename): # Check that file has not been removed since thread was queued # Don't load if we are stopping. if self.state != File.PENDING: log.debug("File not loaded because it was removed: %r", self.filename) return None if self.tagger.stopping: log.debug("File not loaded because %s is stopping: %r", PICARD_APP_NAME, self.filename) return None return self._load(filename) def _load(self, filename): """Load metadata from the file.""" raise NotImplementedError def _loading_finished(self, callback, result=None, error=None): if self.state != File.PENDING or self.tagger.stopping: return if error is not None: self.error = str(error) self.state = self.ERROR from picard.formats import supported_extensions file_name, file_extension = os.path.splitext(self.base_filename) if file_extension not in supported_extensions(): self.remove() log.error( 'Unsupported media file %r wrongly loaded. Removing ...', self) return else: self.error = None self.state = self.NORMAL self._copy_loaded_metadata(result) self.update() callback(self) def _copy_loaded_metadata(self, metadata): filename, _ = os.path.splitext(self.base_filename) metadata['~length'] = format_time(metadata.length) if 'title' not in metadata: metadata['title'] = filename if 'tracknumber' not in metadata: tracknumber = tracknum_from_filename(self.base_filename) if tracknumber != -1: tracknumber = str(tracknumber) metadata['tracknumber'] = tracknumber if metadata['title'] == filename: stripped_filename = filename.lstrip('0') tnlen = len(tracknumber) if stripped_filename[:tnlen] == tracknumber: metadata['title'] = stripped_filename[tnlen:].lstrip() self.orig_metadata = metadata self.metadata.copy(metadata) def copy_metadata(self, metadata, preserve_deleted=True): acoustid = self.metadata["acoustid_id"] preserve = config.setting["preserved_tags"].strip() saved_metadata = {} for tag in re.split(r"\s*,\s*", preserve) + PRESERVED_TAGS: values = self.orig_metadata.getall(tag) if values: saved_metadata[tag] = values deleted_tags = self.metadata.deleted_tags self.metadata.copy(metadata) if preserve_deleted: for tag in deleted_tags: self.metadata.delete(tag) for tag, values in saved_metadata.items(): self.metadata.set(tag, values) if acoustid: self.metadata["acoustid_id"] = acoustid self.metadata_images_changed.emit() def keep_original_images(self): self.metadata.images = self.orig_metadata.images[:] self.update() self.metadata_images_changed.emit() def has_error(self): return self.state == File.ERROR def save(self): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) thread.run_task(partial(self._save_and_rename, self.filename, metadata), self._saving_finished, priority=2, thread_pool=self.tagger.save_thread_pool) def _save_and_rename(self, old_filename, metadata): """Save the metadata.""" # Check that file has not been removed since thread was queued # Also don't save if we are stopping. if self.state == File.REMOVED: log.debug("File not saved because it was removed: %r", self.filename) return None if self.tagger.stopping: log.debug("File not saved because %s is stopping: %r", PICARD_APP_NAME, self.filename) return None new_filename = old_filename if not config.setting["dont_write_tags"]: info = os.stat(old_filename) self._save(old_filename, metadata) if config.setting["preserve_timestamps"]: try: os.utime(old_filename, (info.st_atime, info.st_mtime)) except OSError: log.warning("Couldn't preserve timestamp for %r", old_filename) # Rename files if config.setting["rename_files"] or config.setting["move_files"]: new_filename = self._rename(old_filename, metadata) # Move extra files (images, playlists, etc.) if config.setting["move_files"] and config.setting[ "move_additional_files"]: self._move_additional_files(old_filename, new_filename) # Delete empty directories if config.setting["delete_empty_dirs"]: dirname = os.path.dirname(old_filename) try: self._rmdir(dirname) head, tail = os.path.split(dirname) if not tail: head, tail = os.path.split(head) while head and tail: try: self._rmdir(head) except: break head, tail = os.path.split(head) except EnvironmentError: pass # Save cover art images if config.setting["save_images_to_files"]: self._save_images(os.path.dirname(new_filename), metadata) return new_filename @staticmethod def _rmdir(path): junk_files = (".DS_Store", "desktop.ini", "Desktop.ini", "Thumbs.db") if not set(os.listdir(path)) - set(junk_files): shutil.rmtree(path, False) else: raise OSError def _saving_finished(self, result=None, error=None): # Handle file removed before save # Result is None if save was skipped if ((self.state == File.REMOVED or self.tagger.stopping) and result is None): return old_filename = new_filename = self.filename if error is not None: self.error = str(error) self.set_state(File.ERROR, update=True) else: self.filename = new_filename = result self.base_filename = os.path.basename(new_filename) length = self.orig_metadata.length temp_info = {} for info in ('~bitrate', '~sample_rate', '~channels', '~bits_per_sample', '~format'): temp_info[info] = self.orig_metadata[info] # Data is copied from New to Original because New may be # a subclass to handle id3v23 if config.setting["clear_existing_tags"]: self.orig_metadata.copy(self.new_metadata) else: self.orig_metadata.update(self.new_metadata) # After saving deleted tags should no longer be marked deleted self.new_metadata.clear_deleted() self.orig_metadata.clear_deleted() self.orig_metadata.length = length self.orig_metadata['~length'] = format_time(length) for k, v in temp_info.items(): self.orig_metadata[k] = v self.error = None # Force update to ensure file status icon changes immediately after save self.clear_pending(force_update=True) self._add_path_to_metadata(self.orig_metadata) self.metadata_images_changed.emit() if self.state != File.REMOVED: del self.tagger.files[old_filename] self.tagger.files[new_filename] = self if self.tagger.stopping: log.debug("Save of %r completed before stopping Picard", self.filename) def _save(self, filename, metadata): """Save the metadata.""" raise NotImplementedError def _script_to_filename(self, naming_format, file_metadata, settings=None): if settings is None: settings = config.setting metadata = Metadata() if config.setting["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) # make sure every metadata can safely be used in a path name for name in metadata.keys(): if isinstance(metadata[name], str): metadata[name] = sanitize_filename(metadata[name]) naming_format = naming_format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(naming_format, metadata, self) if settings["ascii_filenames"]: filename = replace_non_ascii(filename, pathsave=True) # replace incompatible characters if settings["windows_compatibility"] or sys.platform == "win32": filename = replace_win32_incompat(filename) # remove null characters if isinstance(filename, (bytes, bytearray)): filename = filename.replace(b"\x00", "") return filename def _fixed_splitext(self, filename): # In case the filename is blank and only has the extension # the real extension is in new_filename and ext is blank new_filename, ext = os.path.splitext(filename) if ext == '' and new_filename.lower() in self.EXTENSIONS: ext = new_filename new_filename = '' return new_filename, ext def _make_filename(self, filename, metadata, settings=None): """Constructs file name based on metadata and file naming formats.""" if settings is None: settings = config.setting if settings["move_files"]: new_dirname = settings["move_files_to"] if not os.path.isabs(new_dirname): new_dirname = os.path.normpath( os.path.join(os.path.dirname(filename), new_dirname)) else: new_dirname = os.path.dirname(filename) new_filename = os.path.basename(filename) if settings["rename_files"]: new_filename, ext = self._fixed_splitext(new_filename) ext = ext.lower() new_filename = new_filename + ext # expand the naming format naming_format = settings['file_naming_format'] if len(naming_format) > 0: new_filename = self._script_to_filename( naming_format, metadata, settings) # NOTE: the _script_to_filename strips the extension away new_filename = new_filename + ext if not settings['move_files']: new_filename = os.path.basename(new_filename) new_filename = make_short_filename( new_dirname, new_filename, config.setting['windows_compatibility'], config.setting['windows_compatibility_drive_root']) # TODO: move following logic under util.filenaming # (and reconsider its necessity) # win32 compatibility fixes if settings['windows_compatibility'] or sys.platform == 'win32': new_filename = new_filename.replace('./', '_/').replace( '.\\', '_\\') # replace . at the beginning of file and directory names new_filename = new_filename.replace('/.', '/_').replace( '\\.', '\\_') if new_filename and new_filename[0] == '.': new_filename = '_' + new_filename[1:] # Fix for precomposed characters on OSX if sys.platform == "darwin": new_filename = unicodedata.normalize("NFD", new_filename) new_path = os.path.join(new_dirname, new_filename) try: return os.path.realpath(new_path) except FileNotFoundError: # os.path.realpath can fail if cwd doesn't exist return new_path def _rename(self, old_filename, metadata): new_filename, ext = os.path.splitext( self._make_filename(old_filename, metadata)) if old_filename == new_filename + ext: return old_filename new_dirname = os.path.dirname(new_filename) if not os.path.isdir(new_dirname): os.makedirs(new_dirname) tmp_filename = new_filename i = 1 while (not pathcmp(old_filename, new_filename + ext) and os.path.exists(new_filename + ext)): new_filename = "%s (%d)" % (tmp_filename, i) i += 1 new_filename = new_filename + ext log.debug("Moving file %r => %r", old_filename, new_filename) shutil.move(old_filename, new_filename) return new_filename def _save_images(self, dirname, metadata): """Save the cover images to disk.""" if not metadata.images: return counters = defaultdict(lambda: 0) images = [] if config.setting["caa_save_single_front_image"]: images = metadata.get_single_front_image() if not images: images = metadata.images for image in images: image.save(dirname, metadata, counters) def _move_additional_files(self, old_filename, new_filename): """Move extra files, like playlists...""" old_path = os.path.dirname(old_filename) new_path = os.path.dirname(new_filename) try: names = os.listdir(old_path) except os.error: log.error("Error: {} directory not found".naming_format(old_path)) return filtered_names = [name for name in names if name[0] != "."] for pattern in config.setting["move_additional_files_pattern"].split(): pattern = pattern.strip() if not pattern: continue pattern_regex = re.compile(fnmatch.translate(pattern), re.IGNORECASE) file_names = names if pattern[0] != '.': file_names = filtered_names for old_file in file_names: if pattern_regex.match(old_file): new_file = os.path.join(new_path, old_file) old_file = os.path.join(old_path, old_file) # FIXME we shouldn't do this from a thread! if self.tagger.files.get(decode_filename(old_file)): log.debug("File loaded in the tagger, not moving %r", old_file) continue log.debug("Moving %r to %r", old_file, new_file) shutil.move(old_file, new_file) def remove(self, from_parent=True): if from_parent and self.parent: log.debug("Removing %r from %r", self, self.parent) self.parent.remove_file(self) self.tagger.acoustidmanager.remove(self) self.state = File.REMOVED def move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) self.clear_lookup_task() self.tagger._acoustid.stop_analyze(self) if self.parent: self.clear_pending() self.parent.remove_file(self) self.parent = parent self.parent.add_file(self) self.tagger.acoustidmanager.update( self, self.metadata['musicbrainz_recordingid']) def _move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) if self.parent: self.parent.remove_file(self) self.parent = parent self.tagger.acoustidmanager.update( self, self.metadata['musicbrainz_recordingid']) @classmethod def supports_tag(cls, name): """Returns whether tag ``name`` can be saved to the file.""" return True def is_saved(self): return self.similarity == 1.0 and self.state == File.NORMAL def update(self, signal=True): new_metadata = self.new_metadata names = set(new_metadata.keys()) names.update(self.orig_metadata.keys()) clear_existing_tags = config.setting["clear_existing_tags"] for name in names: if not name.startswith('~') and self.supports_tag(name): new_values = new_metadata.getall(name) if not (new_values or clear_existing_tags): continue orig_values = self.orig_metadata.getall(name) if orig_values != new_values: self.similarity = self.orig_metadata.compare(new_metadata) if self.state in (File.CHANGED, File.NORMAL): self.state = File.CHANGED break else: if (self.metadata.images and self.orig_metadata.images != self.metadata.images): self.state = File.CHANGED else: self.similarity = 1.0 if self.state in (File.CHANGED, File.NORMAL): self.state = File.NORMAL if signal: log.debug("Updating file %r", self) if self.item: self.item.update() def can_save(self): """Return if this object can be saved.""" return True def can_remove(self): """Return if this object can be removed.""" return True def can_edit_tags(self): """Return if this object supports tag editing.""" return True def can_analyze(self): """Return if this object can be fingerprinted.""" return True def can_autotag(self): return True def can_refresh(self): return False def can_view_info(self): return True def _info(self, metadata, file): if hasattr(file.info, 'length'): metadata.length = int(file.info.length * 1000) if hasattr(file.info, 'bitrate') and file.info.bitrate: metadata['~bitrate'] = file.info.bitrate / 1000.0 if hasattr(file.info, 'sample_rate') and file.info.sample_rate: metadata['~sample_rate'] = file.info.sample_rate if hasattr(file.info, 'channels') and file.info.channels: metadata['~channels'] = file.info.channels if hasattr(file.info, 'bits_per_sample') and file.info.bits_per_sample: metadata['~bits_per_sample'] = file.info.bits_per_sample metadata['~format'] = self.__class__.__name__.replace('File', '') self._add_path_to_metadata(metadata) def _add_path_to_metadata(self, metadata): metadata['~dirname'] = os.path.dirname(self.filename) filename, extension = os.path.splitext(os.path.basename(self.filename)) metadata['~filename'] = filename metadata['~extension'] = extension.lower()[1:] def get_state(self): return self._state # in order to significantly speed up performance, the number of pending # files is cached num_pending_files = 0 def set_state(self, state, update=False): if state != self._state: if state == File.PENDING: File.num_pending_files += 1 elif self._state == File.PENDING: File.num_pending_files -= 1 self._state = state if update: self.update() self.tagger.tagger_stats_changed.emit() state = property(get_state, set_state) def column(self, column): m = self.metadata if column == "title" and not m["title"]: return self.base_filename return m[column] def _lookup_finished(self, lookuptype, document, http, error): self.lookup_task = None if self.state == File.REMOVED: return if error: log.error( "Network error encountered during the lookup for %s. Error code: %s", self.filename, error) try: if lookuptype == "metadata": tracks = document['recordings'] elif lookuptype == "acoustid": tracks = document['recordings'] except (KeyError, TypeError): tracks = None # no matches if not tracks: self.tagger.window.set_statusbar_message( N_("No matching tracks for file '%(filename)s'"), {'filename': self.filename}, timeout=3000) self.clear_pending() return # multiple matches -- calculate similarities to each of them match = sorted( (self.metadata.compare_to_track(track, self.comparison_weights) for track in tracks), reverse=True, key=itemgetter(0))[0] if lookuptype != 'acoustid' and match[0] < config.setting[ 'file_lookup_threshold']: self.tagger.window.set_statusbar_message(N_( "No matching tracks above the threshold for file '%(filename)s'" ), {'filename': self.filename}, timeout=3000) self.clear_pending() return self.tagger.window.set_statusbar_message( N_("File '%(filename)s' identified!"), {'filename': self.filename}, timeout=3000) self.clear_pending() rg, release, track = match[1:] if lookuptype == 'acoustid': self.tagger.acoustidmanager.add(self, track['id']) if release: self.tagger.get_release_group_by_id(rg['id']).loaded_albums.add( release['id']) self.tagger.move_file_to_track(self, release['id'], track['id']) else: node = track if 'title' in track else None self.tagger.move_file_to_nat(self, track['id'], node=node) def lookup_metadata(self): """Try to identify the file using the existing metadata.""" if self.lookup_task: return self.tagger.window.set_statusbar_message( N_("Looking up the metadata for file %(filename)s ..."), {'filename': self.filename}) self.clear_lookup_task() metadata = self.metadata self.set_pending() self.lookup_task = self.tagger.mb_api.find_tracks( partial(self._lookup_finished, 'metadata'), track=metadata['title'], artist=metadata['artist'], release=metadata['album'], tnum=metadata['tracknumber'], tracks=metadata['totaltracks'], qdur=str(metadata.length // 2000), isrc=metadata['isrc'], limit=QUERY_LIMIT) def clear_lookup_task(self): if self.lookup_task: self.tagger.webservice.remove_task(self.lookup_task) self.lookup_task = None def set_pending(self): if self.state != File.REMOVED: self.state = File.PENDING self.update() def clear_pending(self, force_update=False): if self.state == File.PENDING: self.state = File.NORMAL self.update() elif force_update: self.update() def iterfiles(self, save=False): yield self def _get_tracknumber(self): try: return self.metadata["tracknumber"] except: return 0 tracknumber = property(_get_tracknumber, doc="The track number as an int.") def _get_discnumber(self): try: return self.metadata["discnumber"] except: return 0 discnumber = property(_get_discnumber, doc="The disc number as an int.")
def _parse_release(self, document): self.log.debug("Loading release %r", self.id) release_node = document.metadata[0].release[0] if release_node.id != self.id: album = self.tagger.get_album_by_id(release_node.id) self.tagger.albumids[self.id] = release_node.id self.id = release_node.id if album: album.match_files(self.unmatched_files.files) album.update() self.tagger.remove_album(self) self.log.debug("Release %r already loaded", self.id) return False # Get release metadata m = self._new_metadata m.length = 0 release_to_metadata(release_node, m, config=self.config, album=self) self.format_str = media_formats_from_node(release_node.medium_list[0]) self.rgid = release_node.release_group[0].id if self._discid: m['musicbrainz_discid'] = self._discid # 'Translate' artist name if self.config.setting['translate_artist_names']: m['albumartist'] = translate_artist(m['albumartist'], m['albumartistsort']) # Custom VA name if m['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID: m['albumartistsort'] = m['albumartist'] = self.config.setting['va_name'] ignore_tags = [s.strip() for s in self.config.setting['ignore_tags'].split(',')] first_artist = None compilation = False track_counts = [] m['totaldiscs'] = release_node.medium_list[0].count self._metadata_processors = [partial(run_album_metadata_processors, self, m, release_node)] for medium_node in release_node.medium_list[0].medium: mm = Metadata() mm.copy(m) medium_to_metadata(medium_node, mm) track_counts.append(mm['totaltracks']) for track_node in medium_node.track_list[0].track: t = Track(track_node.recording[0].id, self) self._new_tracks.append(t) # Get track metadata tm = t.metadata tm.copy(mm) track_to_metadata(track_node, t, self.config) m.length += tm.length artist_id = tm['musicbrainz_artistid'] if compilation is False: if first_artist is None: first_artist = artist_id if first_artist != artist_id: compilation = True for track in self._new_tracks: track.metadata['compilation'] = '1' else: tm['compilation'] = '1' t._customize_metadata(ignore_tags) plugins = partial(run_track_metadata_processors, self, tm, release_node, track_node) self._metadata_processors.append(plugins) m["~totalalbumtracks"] = str(sum(map(int, track_counts))) self.tracks_str = " + ".join(track_counts) return True
class File(QtCore.QObject, Item): UNDEFINED = -1 PENDING = 0 NORMAL = 1 CHANGED = 2 ERROR = 3 REMOVED = 4 comparison_weights = { "title": 13, "artist": 4, "album": 5, "length": 10, "totaltracks": 4, "releasetype": 20, "releasecountry": 2, "format": 2, } def __init__(self, filename): super(File, self).__init__() self.filename = filename self.base_filename = os.path.basename(filename) self._state = File.UNDEFINED self.state = File.PENDING self.error = None self.orig_metadata = Metadata() self.metadata = Metadata() self.similarity = 1.0 self.parent = None self.lookup_task = None self.item = None def __repr__(self): return '<File %r>' % self.base_filename @property def new_metadata(self): return self.metadata def load(self, callback): thread.run_task( partial(self._load, self.filename), partial(self._loading_finished, callback), priority=1) def _loading_finished(self, callback, result=None, error=None): if self.state != self.PENDING: return if error is not None: self.error = str(error) self.state = self.ERROR from picard.formats import supported_extensions file_name, file_extension = os.path.splitext(self.base_filename) if file_extension not in supported_extensions(): self.remove() log.error('Unsupported media file %r wrongly loaded. Removing ...',self) return else: self.error = None self.state = self.NORMAL self._copy_loaded_metadata(result) self.update() callback(self) def _copy_loaded_metadata(self, metadata): filename, _ = os.path.splitext(self.base_filename) metadata['~length'] = format_time(metadata.length) if 'title' not in metadata: metadata['title'] = filename if 'tracknumber' not in metadata: tracknumber = tracknum_from_filename(self.base_filename) if tracknumber != -1: tracknumber = str(tracknumber) metadata['tracknumber'] = tracknumber if metadata['title'] == filename: stripped_filename = filename.lstrip('0') tnlen = len(tracknumber) if stripped_filename[:tnlen] == tracknumber: metadata['title'] = stripped_filename[tnlen:].lstrip() self.orig_metadata = metadata self.metadata.copy(metadata) def copy_metadata(self, metadata): acoustid = self.metadata["acoustid_id"] preserve = config.setting["preserved_tags"].strip() saved_metadata = {} for tag in re.split(r"\s*,\s*", preserve) + PRESERVED_TAGS: values = self.orig_metadata.getall(tag) if values: saved_metadata[tag] = values deleted_tags = self.metadata.deleted_tags self.metadata.copy(metadata) self.metadata.deleted_tags = deleted_tags for tag, values in saved_metadata.iteritems(): self.metadata.set(tag, values) if acoustid: self.metadata["acoustid_id"] = acoustid def has_error(self): return self.state == File.ERROR def _load(self, filename): """Load metadata from the file.""" raise NotImplementedError def save(self): self.set_pending() metadata = Metadata() metadata.copy(self.metadata) thread.run_task( partial(self._save_and_rename, self.filename, metadata), self._saving_finished, priority=2, thread_pool=self.tagger.save_thread_pool) def _save_and_rename(self, old_filename, metadata): """Save the metadata.""" # Check that file has not been removed since thread was queued # Also don't save if we are stopping. if self.state == File.REMOVED or self.tagger.stopping: log.debug("File not saved because %s: %r", "Picard is stopping" if self.tagger.stopping else "it was removed", self.filename) return None new_filename = old_filename if not config.setting["dont_write_tags"]: encoded_old_filename = encode_filename(old_filename) info = os.stat(encoded_old_filename) self._save(old_filename, metadata) if config.setting["preserve_timestamps"]: try: os.utime(encoded_old_filename, (info.st_atime, info.st_mtime)) except OSError: log.warning("Couldn't preserve timestamp for %r", old_filename) # Rename files if config.setting["rename_files"] or config.setting["move_files"]: new_filename = self._rename(old_filename, metadata) # Move extra files (images, playlists, etc.) if config.setting["move_files"] and config.setting["move_additional_files"]: self._move_additional_files(old_filename, new_filename) # Delete empty directories if config.setting["delete_empty_dirs"]: dirname = encode_filename(os.path.dirname(old_filename)) try: self._rmdir(dirname) head, tail = os.path.split(dirname) if not tail: head, tail = os.path.split(head) while head and tail: try: self._rmdir(head) except: break head, tail = os.path.split(head) except EnvironmentError: pass # Save cover art images if config.setting["save_images_to_files"]: self._save_images(os.path.dirname(new_filename), metadata) return new_filename @staticmethod def _rmdir(dir): junk_files = (".DS_Store", "desktop.ini", "Desktop.ini", "Thumbs.db") if not set(os.listdir(dir)) - set(junk_files): shutil.rmtree(dir, False) else: raise OSError def _saving_finished(self, result=None, error=None): # Handle file removed before save # Result is None if save was skipped if ((self.state == File.REMOVED or self.tagger.stopping) and result is None): return old_filename = new_filename = self.filename if error is not None: self.error = str(error) self.set_state(File.ERROR, update=True) else: self.filename = new_filename = result self.base_filename = os.path.basename(new_filename) length = self.orig_metadata.length temp_info = {} for info in ('~bitrate', '~sample_rate', '~channels', '~bits_per_sample', '~format'): temp_info[info] = self.orig_metadata[info] # Data is copied from New to Original because New may be a subclass to handle id3v23 if config.setting["clear_existing_tags"]: self.orig_metadata.copy(self.new_metadata) else: self.orig_metadata.update(self.new_metadata) self.orig_metadata.length = length self.orig_metadata['~length'] = format_time(length) for k, v in temp_info.items(): self.orig_metadata[k] = v self.error = None # Force update to ensure file status icon changes immediately after save self.clear_pending(force_update=True) self._add_path_to_metadata(self.orig_metadata) if self.state != File.REMOVED: del self.tagger.files[old_filename] self.tagger.files[new_filename] = self if self.tagger.stopping: log.debug("Save of %r completed before stopping Picard", self.filename) def _save(self, filename, metadata): """Save the metadata.""" raise NotImplementedError def _script_to_filename(self, format, file_metadata, settings=None): if settings is None: settings = config.setting metadata = Metadata() if config.setting["clear_existing_tags"]: metadata.copy(file_metadata) else: metadata.copy(self.orig_metadata) metadata.update(file_metadata) # make sure every metadata can safely be used in a path name for name in metadata.keys(): if isinstance(metadata[name], basestring): metadata[name] = sanitize_filename(metadata[name]) format = format.replace("\t", "").replace("\n", "") filename = ScriptParser().eval(format, metadata, self) if settings["ascii_filenames"]: if isinstance(filename, unicode): filename = unaccent(filename) filename = replace_non_ascii(filename) # replace incompatible characters if settings["windows_compatibility"] or sys.platform == "win32": filename = replace_win32_incompat(filename) # remove null characters filename = filename.replace("\x00", "") return filename def _fixed_splitext(self, filename): # In case the filename is blank and only has the extension # the real extension is in new_filename and ext is blank new_filename, ext = os.path.splitext(filename) if ext == '' and new_filename.lower() in map(unicode, self.EXTENSIONS): ext = new_filename new_filename = '' return new_filename, ext def _make_filename(self, filename, metadata, settings=None): """Constructs file name based on metadata and file naming formats.""" if settings is None: settings = config.setting if settings["move_files"]: new_dirname = settings["move_files_to"] if not os.path.isabs(new_dirname): new_dirname = os.path.normpath(os.path.join(os.path.dirname(filename), new_dirname)) else: new_dirname = os.path.dirname(filename) new_filename = os.path.basename(filename) if settings["rename_files"]: new_filename, ext = self._fixed_splitext(new_filename) ext = ext.lower() new_filename = new_filename + ext # expand the naming format format = settings['file_naming_format'] if len(format) > 0: new_filename = self._script_to_filename(format, metadata, settings) # NOTE: the _script_to_filename strips the extension away new_filename = new_filename + ext if not settings['move_files']: new_filename = os.path.basename(new_filename) new_filename = make_short_filename(new_dirname, new_filename, config.setting['windows_compatibility'], config.setting['windows_compatibility_drive_root']) # TODO: move following logic under util.filenaming # (and reconsider its necessity) # win32 compatibility fixes if settings['windows_compatibility'] or sys.platform == 'win32': new_filename = new_filename.replace('./', '_/').replace('.\\', '_\\') # replace . at the beginning of file and directory names new_filename = new_filename.replace('/.', '/_').replace('\\.', '\\_') if new_filename and new_filename[0] == '.': new_filename = '_' + new_filename[1:] # Fix for precomposed characters on OSX if sys.platform == "darwin": new_filename = unicodedata.normalize("NFD", unicode(new_filename)) return os.path.realpath(os.path.join(new_dirname, new_filename)) def _rename(self, old_filename, metadata): new_filename, ext = os.path.splitext( self._make_filename(old_filename, metadata)) if old_filename == new_filename + ext: return old_filename new_dirname = os.path.dirname(new_filename) if not os.path.isdir(encode_filename(new_dirname)): os.makedirs(new_dirname) tmp_filename = new_filename i = 1 while (not pathcmp(old_filename, new_filename + ext) and os.path.exists(encode_filename(new_filename + ext))): new_filename = "%s (%d)" % (tmp_filename, i) i += 1 new_filename = new_filename + ext log.debug("Moving file %r => %r", old_filename, new_filename) shutil.move(encode_filename(old_filename), encode_filename(new_filename)) return new_filename def _save_images(self, dirname, metadata): """Save the cover images to disk.""" if not metadata.images: return counters = defaultdict(lambda: 0) images = [] if config.setting["caa_save_single_front_image"]: images = metadata.get_single_front_image() if not images: images = metadata.images for image in images: image.save(dirname, metadata, counters) def _move_additional_files(self, old_filename, new_filename): """Move extra files, like playlists...""" old_path = encode_filename(os.path.dirname(old_filename)) new_path = encode_filename(os.path.dirname(new_filename)) patterns = encode_filename(config.setting["move_additional_files_pattern"]) patterns = filter(bool, [p.strip() for p in patterns.split()]) try: names = os.listdir(old_path) except os.error: log.error("Error: {} directory not found".format(old_path)) return filtered_names = filter(lambda x: x[0] != '.', names) for pattern in patterns: pattern_regex = re.compile(fnmatch.translate(pattern), re.IGNORECASE) file_names = names if pattern[0] != '.': file_names = filtered_names for old_file in file_names: if pattern_regex.match(old_file): new_file = os.path.join(new_path, old_file) old_file = os.path.join(old_path, old_file) # FIXME we shouldn't do this from a thread! if self.tagger.files.get(decode_filename(old_file)): log.debug("File loaded in the tagger, not moving %r", old_file) continue log.debug("Moving %r to %r", old_file, new_file) shutil.move(old_file, new_file) def remove(self, from_parent=True): if from_parent and self.parent: log.debug("Removing %r from %r", self, self.parent) self.parent.remove_file(self) self.tagger.acoustidmanager.remove(self) self.state = File.REMOVED def move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) self.clear_lookup_task() self.tagger._acoustid.stop_analyze(file) if self.parent: self.clear_pending() self.parent.remove_file(self) self.parent = parent self.parent.add_file(self) self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_recordingid']) def _move(self, parent): if parent != self.parent: log.debug("Moving %r from %r to %r", self, self.parent, parent) if self.parent: self.parent.remove_file(self) self.parent = parent self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_recordingid']) def supports_tag(self, name): """Returns whether tag ``name`` can be saved to the file.""" return True def is_saved(self): return self.similarity == 1.0 and self.state == File.NORMAL def update(self, signal=True): new_metadata = self.new_metadata names = set(new_metadata.keys()) names.update(self.orig_metadata.keys()) clear_existing_tags = config.setting["clear_existing_tags"] for name in names: if not name.startswith('~') and self.supports_tag(name): new_values = new_metadata.getall(name) if not (new_values or clear_existing_tags): continue orig_values = self.orig_metadata.getall(name) if orig_values != new_values: self.similarity = self.orig_metadata.compare(new_metadata) if self.state in (File.CHANGED, File.NORMAL): self.state = File.CHANGED break else: if self.orig_metadata.images != self.metadata.images: self.state = File.CHANGED else: self.similarity = 1.0 if self.state in (File.CHANGED, File.NORMAL): self.state = File.NORMAL if signal: log.debug("Updating file %r", self) if self.item: self.item.update() def can_save(self): """Return if this object can be saved.""" return True def can_remove(self): """Return if this object can be removed.""" return True def can_edit_tags(self): """Return if this object supports tag editing.""" return True def can_analyze(self): """Return if this object can be fingerprinted.""" return True def can_autotag(self): return True def can_refresh(self): return False def can_view_info(self): return True def _info(self, metadata, file): if hasattr(file.info, 'length'): metadata.length = int(file.info.length * 1000) if hasattr(file.info, 'bitrate') and file.info.bitrate: metadata['~bitrate'] = file.info.bitrate / 1000.0 if hasattr(file.info, 'sample_rate') and file.info.sample_rate: metadata['~sample_rate'] = file.info.sample_rate if hasattr(file.info, 'channels') and file.info.channels: metadata['~channels'] = file.info.channels if hasattr(file.info, 'bits_per_sample') and file.info.bits_per_sample: metadata['~bits_per_sample'] = file.info.bits_per_sample metadata['~format'] = self.__class__.__name__.replace('File', '') self._add_path_to_metadata(metadata) def _add_path_to_metadata(self, metadata): metadata['~dirname'] = os.path.dirname(self.filename) filename, extension = os.path.splitext(os.path.basename(self.filename)) metadata['~filename'] = filename metadata['~extension'] = extension.lower()[1:] def get_state(self): return self._state # in order to significantly speed up performance, the number of pending # files is cached num_pending_files = 0 def set_state(self, state, update=False): if state != self._state: if state == File.PENDING: File.num_pending_files += 1 elif self._state == File.PENDING: File.num_pending_files -= 1 self._state = state if update: self.update() self.tagger.tagger_stats_changed.emit() state = property(get_state, set_state) def column(self, column): m = self.metadata if column == "title" and not m["title"]: return self.base_filename return m[column] def _lookup_finished(self, lookuptype, document, http, error): self.lookup_task = None if self.state == File.REMOVED: return try: m = document.metadata[0] if lookuptype == "metadata": tracks = m.recording_list[0].recording elif lookuptype == "acoustid": tracks = m.acoustid[0].recording_list[0].recording except (AttributeError, IndexError): tracks = None # no matches if not tracks: self.tagger.window.set_statusbar_message( N_("No matching tracks for file '%(filename)s'"), {'filename': self.filename}, timeout=3000 ) self.clear_pending() return # multiple matches -- calculate similarities to each of them match = sorted((self.metadata.compare_to_track( track, self.comparison_weights) for track in tracks), reverse=True, key=itemgetter(0))[0] if lookuptype != 'acoustid': threshold = config.setting['file_lookup_threshold'] if match[0] < threshold: self.tagger.window.set_statusbar_message( N_("No matching tracks above the threshold for file '%(filename)s'"), {'filename': self.filename}, timeout=3000 ) self.clear_pending() return self.tagger.window.set_statusbar_message( N_("File '%(filename)s' identified!"), {'filename': self.filename}, timeout=3000 ) self.clear_pending() rg, release, track = match[1:] if lookuptype == 'acoustid': self.tagger.acoustidmanager.add(self, track.id) if release: self.tagger.get_release_group_by_id(rg.id).loaded_albums.add(release.id) self.tagger.move_file_to_track(self, release.id, track.id) else: self.tagger.move_file_to_nat(self, track.id, node=track) def lookup_metadata(self): """Try to identify the file using the existing metadata.""" if self.lookup_task: return self.tagger.window.set_statusbar_message( N_("Looking up the metadata for file %(filename)s ..."), {'filename': self.filename} ) self.clear_lookup_task() metadata = self.metadata self.set_pending() self.lookup_task = self.tagger.xmlws.find_tracks(partial(self._lookup_finished, 'metadata'), track=metadata['title'], artist=metadata['artist'], release=metadata['album'], tnum=metadata['tracknumber'], tracks=metadata['totaltracks'], qdur=str(metadata.length / 2000), isrc=metadata['isrc'], limit=QUERY_LIMIT) def clear_lookup_task(self): if self.lookup_task: self.tagger.xmlws.remove_task(self.lookup_task) self.lookup_task = None def set_pending(self): if self.state != File.REMOVED: self.state = File.PENDING self.update() def clear_pending(self, force_update=False): if self.state == File.PENDING: self.state = File.NORMAL self.update() elif force_update: self.update() def iterfiles(self, save=False): yield self def _get_tracknumber(self): try: return int(self.metadata["tracknumber"]) except: return 0 tracknumber = property(_get_tracknumber, doc="The track number as an int.") def _get_discnumber(self): try: return int(self.metadata["discnumber"]) except: return 0 discnumber = property(_get_discnumber, doc="The disc number as an int.")
def _load_tracks(self): artists = set() all_media = [] absolutetracknumber = 0 va = self._new_metadata[ 'musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID djmix_ars = {} if hasattr(self._new_metadata, "_djmix_ars"): djmix_ars = self._new_metadata._djmix_ars for medium_node in self._release_node['media']: mm = Metadata() mm.copy(self._new_metadata) medium_to_metadata(medium_node, mm) format = medium_node.get('format') if format: all_media.append(format) for dj in djmix_ars.get(mm["discnumber"], []): mm.add("djmixer", dj) if va: mm["compilation"] = "1" else: del mm["compilation"] if 'discs' in medium_node: discids = [disc.get('id') for disc in medium_node['discs']] mm['~musicbrainz_discids'] = discids mm['musicbrainz_discid'] = list( self._discids.intersection(discids)) if "pregap" in medium_node: absolutetracknumber += 1 mm['~discpregap'] = '1' extra_metadata = { '~pregap': '1', '~absolutetracknumber': absolutetracknumber, } self._finalize_loading_track(medium_node['pregap'], mm, artists, extra_metadata) track_count = medium_node['track-count'] if track_count: tracklist_node = medium_node['tracks'] for track_node in tracklist_node: absolutetracknumber += 1 extra_metadata = { '~absolutetracknumber': absolutetracknumber, } self._finalize_loading_track(track_node, mm, artists, extra_metadata) if "data-tracks" in medium_node: for track_node in medium_node['data-tracks']: absolutetracknumber += 1 extra_metadata = { '~datatrack': '1', '~absolutetracknumber': absolutetracknumber, } self._finalize_loading_track(track_node, mm, artists, extra_metadata) totalalbumtracks = absolutetracknumber self._new_metadata['~totalalbumtracks'] = totalalbumtracks # Generate a list of unique media, but keep order of first appearance self._new_metadata['media'] = " / ".join( list(OrderedDict.fromkeys(all_media))) for track in self._new_tracks: track.metadata["~totalalbumtracks"] = totalalbumtracks if len(artists) > 1: track.metadata["~multiartist"] = "1" del self._release_node del self._release_artist_nodes self._tracks_loaded = True