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 MetadataTest(PicardTestCase): original = None tags = [] def setUp(self): super().setUp() config.setting = settings.copy() self.metadata = Metadata() self.metadata["single1"] = "single1-value" self.metadata.add_unique("single2", "single2-value") self.metadata.add_unique("single2", "single2-value") self.multi1 = ["multi1-value", "multi1-value"] self.metadata.add("multi1", self.multi1[0]) self.metadata.add("multi1", self.multi1[1]) self.multi2 = ["multi2-value1", "multi2-value2"] self.metadata["multi2"] = self.multi2 self.multi3 = ["multi3-value1", "multi3-value2"] self.metadata.set("multi3", self.multi3) self.metadata["~hidden"] = "hidden-value" self.metadata_d1 = Metadata({ 'a': 'b', 'c': 2, 'd': ['x', 'y'], 'x': '' }) self.metadata_d2 = Metadata({ 'a': 'b', 'c': 2, 'd': ['x', 'y'], 'x': 'z' }) self.metadata_d3 = Metadata({'c': 3, 'd': ['u', 'w'], 'x': 'p'}) def tearDown(self): pass def test_metadata_setitem(self): self.assertEqual(["single1-value"], self.metadata.getraw("single1")) self.assertEqual(["single2-value"], self.metadata.getraw("single2")) self.assertEqual(self.multi1, self.metadata.getraw("multi1")) self.assertEqual(self.multi2, self.metadata.getraw("multi2")) self.assertEqual(self.multi3, self.metadata.getraw("multi3")) self.assertEqual(["hidden-value"], self.metadata.getraw("~hidden")) def test_metadata_get(self): self.assertEqual("single1-value", self.metadata["single1"]) self.assertEqual("single1-value", self.metadata.get("single1")) self.assertEqual(["single1-value"], self.metadata.getall("single1")) self.assertEqual(["single1-value"], self.metadata.getraw("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata.get("multi1")) self.assertEqual(self.multi1, self.metadata.getall("multi1")) self.assertEqual(self.multi1, self.metadata.getraw("multi1")) self.assertEqual("", self.metadata["nonexistent"]) self.assertEqual(None, self.metadata.get("nonexistent")) self.assertEqual([], self.metadata.getall("nonexistent")) self.assertRaises(KeyError, self.metadata.getraw, "nonexistent") self.assertEqual(self.metadata._store.items(), self.metadata.rawitems()) metadata_items = [(x, z) for (x, y) in self.metadata.rawitems() for z in y] self.assertEqual(metadata_items, list(self.metadata.items())) def test_metadata_delete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) def test_metadata_implicit_delete(self): self.metadata["single2"] = "" self.assertNotIn("single2", self.metadata) self.assertIn("single2", self.metadata.deleted_tags) self.metadata["unknown"] = "" self.assertNotIn("unknown", self.metadata) self.assertNotIn("unknown", self.metadata.deleted_tags) def test_metadata_set_explicit_empty(self): self.metadata.delete("single1") self.metadata.set("single1", []) self.assertIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) self.assertEqual([], self.metadata.getall("single1")) def test_metadata_undelete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) self.metadata["single1"] = "value1" self.assertIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) 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 test_metadata_copy_without_images(self): m = Metadata() m.copy(self.metadata, copy_images=False) 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(ImageList(), m.images) def test_metadata_update(self): m = Metadata() m["old"] = "old-value" self.metadata.delete("single1") m.update(self.metadata) self.assertIn("old", m) self.assertNotIn("single1", m) self.assertIn("single1", m.deleted_tags) self.assertEqual("single2-value", m["single2"]) self.assertEqual(self.metadata.deleted_tags, m.deleted_tags) self.assertEqual(self.metadata.images, m.images) self.metadata["old"] = "old-value" self.assertEqual(self.metadata._store, m._store) def test_metadata_clear(self): self.metadata.clear() self.assertEqual(0, len(self.metadata)) def test_metadata_clear_deleted(self): self.metadata.delete("single1") self.assertIn("single1", self.metadata.deleted_tags) self.metadata.clear_deleted() self.assertNotIn("single1", self.metadata.deleted_tags) def test_metadata_applyfunc(self): def func(x): return x[1:] self.metadata.apply_func(func) self.assertEqual("ingle1-value", self.metadata["single1"]) self.assertEqual("ingle1-value", self.metadata.get("single1")) self.assertEqual(["ingle1-value"], self.metadata.getall("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata.get("multi1")) self.assertEqual(list(map(func, self.multi1)), self.metadata.getall("multi1")) def test_metadata_applyfunc_preserve_tags(self): self.assertTrue(len(PRESERVED_TAGS) > 0) m = Metadata() m[PRESERVED_TAGS[0]] = 'value1' m['not_preserved'] = 'value2' def func(x): return x[1:] m.apply_func(func) self.assertEqual("value1", m[PRESERVED_TAGS[0]]) self.assertEqual("alue2", m['not_preserved']) def test_metadata_applyfunc_delete_tags(self): def func(x): return None metadata = Metadata(self.metadata) metadata.apply_func(func) self.assertEqual(0, len(metadata.rawitems())) self.assertEqual(self.metadata.keys(), metadata.deleted_tags) def test_length_score(self): results = [(20000, 0, 0.333333333333), (20000, 10000, 0.666666666667), (20000, 20000, 1.0), (20000, 30000, 0.666666666667), (20000, 40000, 0.333333333333), (20000, 50000, 0.0)] for (a, b, expected) in results: actual = Metadata.length_score(a, b) self.assertAlmostEqual(expected, actual, msg="a={a}, b={b}".format(a=a, b=b)) def test_compare_is_equal(self): m1 = Metadata() m1["title"] = "title1" m1["tracknumber"] = "2" m1.length = 360 m2 = Metadata() m2["title"] = "title1" m2["tracknumber"] = "2" m2.length = 360 self.assertEqual(m1.compare(m2), m2.compare(m1)) self.assertEqual(m1.compare(m2), 1) def test_compare_lengths(self): m1 = Metadata() m1.length = 360 m2 = Metadata() m2.length = 300 self.assertAlmostEqual(m1.compare(m2), 0.998) def test_compare_tracknumber_difference(self): m1 = Metadata() m1["tracknumber"] = "1" m2 = Metadata() m2["tracknumber"] = "2" self.assertEqual(m1.compare(m2), 0) def test_compare_deleted(self): m1 = Metadata() m1["artist"] = "TheArtist" m1["title"] = "title1" m2 = Metadata() m2["artist"] = "TheArtist" m2.delete("title") self.assertTrue(m1.compare(m2) < 1) def test_strip_whitespace(self): m1 = Metadata() m1["artist"] = " TheArtist " m1["title"] = "\t\u00A0 tit le1 \r\n" m1["genre"] = " \t" m1.strip_whitespace() self.assertEqual(m1["artist"], "TheArtist") self.assertEqual(m1["title"], "tit le1") def test_metadata_mapping_init(self): d = {'a': 'b', 'c': 2, 'd': ['x', 'y'], 'x': '', 'z': {'u', 'w'}} deleted_tags = set('c') m = Metadata(d, deleted_tags=deleted_tags, length=1234) self.assertTrue('a' in m) self.assertEqual(m.getraw('a'), ['b']) self.assertEqual(m['d'], MULTI_VALUED_JOINER.join(d['d'])) self.assertNotIn('c', m) self.assertNotIn('length', m) self.assertIn('c', m.deleted_tags) self.assertEqual(m.length, 1234) def test_metadata_mapping_init_zero(self): m = Metadata(tag1='a', tag2=0, tag3='', tag4=None) m['tag5'] = 0 m['tag1'] = '' self.assertIn('tag1', m.deleted_tags) self.assertEqual(m['tag2'], '0') self.assertNotIn('tag3', m) self.assertNotIn('tag4', m) self.assertEqual(m['tag5'], '0') def test_metadata_mapping_del(self): m = self.metadata_d1 self.assertEqual(m.getraw('a'), ['b']) self.assertNotIn('a', m.deleted_tags) self.assertNotIn('x', m.deleted_tags) self.assertRaises(KeyError, m.getraw, 'x') del m['a'] self.assertRaises(KeyError, m.getraw, 'a') self.assertIn('a', m.deleted_tags) # NOTE: historic behavior of Metadata.delete() # an attempt to delete an non-existing tag, will add it to the list # of deleted tags # so this will not raise a KeyError # as is it differs from dict or even defaultdict behavior del m['unknown'] self.assertIn('unknown', m.deleted_tags) def test_metadata_mapping_iter(self): l = set(self.metadata_d1) self.assertEqual(l, {'a', 'c', 'd'}) def test_metadata_mapping_keys(self): l = set(self.metadata_d1.keys()) self.assertEqual(l, {'a', 'c', 'd'}) def test_metadata_mapping_values(self): l = set(self.metadata_d1.values()) self.assertEqual(l, {'b', '2', 'x; y'}) def test_metadata_mapping_len(self): m = self.metadata_d1 self.assertEqual(len(m), 3) del m['x'] self.assertEqual(len(m), 3) del m['c'] self.assertEqual(len(m), 2) def _check_mapping_update(self, m): self.assertEqual(m['a'], 'b') self.assertEqual(m['c'], '3') self.assertEqual(m.getraw('d'), ['u', 'w']) self.assertEqual(m['x'], '') self.assertIn('x', m.deleted_tags) def test_metadata_mapping_update(self): # update from Metadata m = self.metadata_d2 m2 = self.metadata_d3 del m2['x'] m.update(m2) self._check_mapping_update(m) def test_metadata_mapping_update_dict(self): # update from dict m = self.metadata_d2 d2 = {'c': 3, 'd': ['u', 'w'], 'x': ''} m.update(d2) self._check_mapping_update(m) def test_metadata_mapping_update_tuple(self): # update from tuple m = self.metadata_d2 d2 = (('c', 3), ('d', ['u', 'w']), ('x', '')) m.update(d2) self._check_mapping_update(m) def test_metadata_mapping_update_dictlike(self): # update from kwargs m = self.metadata_d2 m.update(c=3, d=['u', 'w'], x='') self._check_mapping_update(m) def test_metadata_mapping_update_noparam(self): # update without parameter m = self.metadata_d2 self.assertRaises(TypeError, m.update) self.assertEqual(m['a'], 'b') def test_metadata_mapping_update_intparam(self): # update without parameter m = self.metadata_d2 self.assertRaises(TypeError, m.update, 123) def test_metadata_mapping_update_strparam(self): # update without parameter m = self.metadata_d2 self.assertRaises(ValueError, m.update, 'abc') def test_metadata_mapping_update_kw(self): m = Metadata(tag1='a', tag2='b') m.update(tag1='c') self.assertEqual(m['tag1'], 'c') self.assertEqual(m['tag2'], 'b') m.update(tag2='') self.assertIn('tag2', m.deleted_tags) def test_metadata_mapping_update_kw_del(self): m = Metadata(tag1='a', tag2='b') del m['tag1'] m2 = Metadata(tag1='c', tag2='d') del m2['tag2'] m.update(m2) self.assertEqual(m['tag1'], 'c') self.assertNotIn('tag2', m) self.assertNotIn('tag1', m.deleted_tags) self.assertIn('tag2', m.deleted_tags) def test_metadata_mapping_images(self): image1 = create_image(b'A', comment='A') image2 = create_image(b'B', comment='B') m1 = Metadata(a='b', length=1234, images=[image1]) self.assertEqual(m1.images[0], image1) self.assertEqual(len(m1), 2) # one tag, one image m1.images.append(image2) self.assertEqual(m1.images[1], image2) m1.images.pop(0) self.assertEqual(m1.images[0], image2) m2 = Metadata(a='c', length=4567, images=[image1]) m1.update(m2) self.assertEqual(m1.images[0], image1) m1.images.pop(0) self.assertEqual(len(m1), 1) # one tag, zero image self.assertFalse(m1.images) def test_metadata_mapping_iterable(self): m = Metadata(tag_tuple=('a', 0)) m['tag_set'] = {'c', 'd'} m['tag_dict'] = {'e': 1, 'f': 2} m['tag_str'] = 'gh' self.assertIn('0', m.getraw('tag_tuple')) self.assertIn('c', m.getraw('tag_set')) self.assertIn('e', m.getraw('tag_dict')) self.assertIn('gh', m.getraw('tag_str'))
class MetadataTest(unittest.TestCase): original = None tags = [] def setUp(self): config.setting = settings.copy() self.metadata = Metadata() self.metadata["single1"] = "single1-value" self.metadata.add_unique("single2", "single2-value") self.metadata.add_unique("single2", "single2-value") self.multi1 = ["multi1-value", "multi1-value"] self.metadata.add("multi1", self.multi1[0]) self.metadata.add("multi1", self.multi1[1]) self.multi2 = ["multi2-value1", "multi2-value2"] self.metadata["multi2"] = self.multi2 self.multi3 = ["multi3-value1", "multi3-value2"] self.metadata.set("multi3", self.multi3) self.metadata["~hidden"] = "hidden-value" def tearDown(self): pass def test_metadata_setitem(self): self.assertEqual(["single1-value"], dict.get(self.metadata, "single1")) self.assertEqual(["single2-value"], dict.get(self.metadata, "single2")) self.assertEqual(self.multi1, dict.get(self.metadata, "multi1")) self.assertEqual(self.multi2, dict.get(self.metadata, "multi2")) self.assertEqual(self.multi3, dict.get(self.metadata, "multi3")) self.assertEqual(["hidden-value"], dict.get(self.metadata, "~hidden")) def test_metadata_get(self): self.assertEqual("single1-value", self.metadata["single1"]) self.assertEqual("single1-value", self.metadata.get("single1")) self.assertEqual(["single1-value"], self.metadata.getall("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata.get("multi1")) self.assertEqual(self.multi1, self.metadata.getall("multi1")) self.assertEqual("", self.metadata["nonexistent"]) self.assertEqual(None, self.metadata.get("nonexistent")) self.assertEqual([], self.metadata.getall("nonexistent")) self.assertEqual(dict.items(self.metadata), self.metadata.rawitems()) metadata_items = [(x, z) for (x, y) in dict.items(self.metadata) for z in y] self.assertEqual(metadata_items, list(self.metadata.items())) def test_metadata_delete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) def test_metadata_implicit_delete(self): self.metadata["single2"] = "" self.assertNotIn("single2", self.metadata) self.assertIn("single2", self.metadata.deleted_tags) self.metadata["unknown"] = "" self.assertNotIn("unknown", self.metadata) self.assertNotIn("unknown", self.metadata.deleted_tags) def test_metadata_set_explicit_empty(self): self.metadata.delete("single1") self.metadata.set("single1", []) self.assertIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) self.assertEqual([], self.metadata.getall("single1")) def test_metadata_undelete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) self.metadata["single1"] = "value1" self.assertIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) def test_metadata_update(self): m = Metadata() m["old"] = "old-value" self.metadata.delete("single1") m.update(self.metadata) self.assertIn("old", m) self.assertNotIn("single1", m) self.assertIn("single1", m.deleted_tags) self.assertEqual("single2-value", m["single2"]) self.assertEqual(self.metadata.deleted_tags, m.deleted_tags) self.metadata["old"] = "old-value" for (key, value) in dict.items(self.metadata): self.assertIn(key, m) self.assertEqual(value, dict.get(m, key)) for (key, value) in dict.items(m): self.assertIn(key, self.metadata) self.assertEqual(value, dict.get(self.metadata, key)) def test_metadata_clear(self): self.metadata.clear() self.assertEqual(0, len(self.metadata)) def test_metadata_clear_deleted(self): self.metadata.delete("single1") self.assertIn("single1", self.metadata.deleted_tags) self.metadata.clear_deleted() self.assertNotIn("single1", self.metadata.deleted_tags) def test_metadata_applyfunc(self): func = lambda x: x[1:] self.metadata.apply_func(func) self.assertEqual("ingle1-value", self.metadata["single1"]) self.assertEqual("ingle1-value", self.metadata.get("single1")) self.assertEqual(["ingle1-value"], self.metadata.getall("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata.get("multi1")) self.assertEqual(list(map(func, self.multi1)), self.metadata.getall("multi1")) self.assertEqual("", self.metadata["nonexistent"]) self.assertEqual(None, self.metadata.get("nonexistent")) self.assertEqual([], self.metadata.getall("nonexistent")) self.assertEqual(dict.items(self.metadata), self.metadata.rawitems()) metadata_items = [(x, z) for (x, y) in dict.items(self.metadata) for z in y] self.assertEqual(metadata_items, list(self.metadata.items())) def test_length_score(self): results = [(20000, 0, 0.333333333333), (20000, 10000, 0.666666666667), (20000, 20000, 1.0), (20000, 30000, 0.666666666667), (20000, 40000, 0.333333333333), (20000, 50000, 0.0)] for (a, b, expected) in results: actual = Metadata.length_score(a, b) self.assertAlmostEqual(expected, actual, msg="a={a}, b={b}".format(a=a, b=b))
class MetadataTest(PicardTestCase): original = None tags = [] def setUp(self): super().setUp() config.setting = settings.copy() self.metadata = Metadata() self.metadata["single1"] = "single1-value" self.metadata.add_unique("single2", "single2-value") self.metadata.add_unique("single2", "single2-value") self.multi1 = ["multi1-value", "multi1-value"] self.metadata.add("multi1", self.multi1[0]) self.metadata.add("multi1", self.multi1[1]) self.multi2 = ["multi2-value1", "multi2-value2"] self.metadata["multi2"] = self.multi2 self.multi3 = ["multi3-value1", "multi3-value2"] self.metadata.set("multi3", self.multi3) self.metadata["~hidden"] = "hidden-value" self.metadata_d1 = Metadata({'a': 'b', 'c': 2, 'd': ['x', 'y'], 'x': ''}) self.metadata_d2 = Metadata({'a': 'b', 'c': 2, 'd': ['x', 'y'], 'x': 'z'}) self.metadata_d3 = Metadata({'c': 3, 'd': ['u', 'w'], 'x': 'p'}) def tearDown(self): pass def test_metadata_setitem(self): self.assertEqual(["single1-value"], self.metadata.getraw("single1")) self.assertEqual(["single2-value"], self.metadata.getraw("single2")) self.assertEqual(self.multi1, self.metadata.getraw("multi1")) self.assertEqual(self.multi2, self.metadata.getraw("multi2")) self.assertEqual(self.multi3, self.metadata.getraw("multi3")) self.assertEqual(["hidden-value"], self.metadata.getraw("~hidden")) def test_metadata_set_all_values_as_string(self): for val in (0, 2, True): str_val = str(val) self.metadata.set('val1', val) self.assertEqual([str_val], self.metadata.getraw("val1")) self.metadata['val2'] = val self.assertEqual([str_val], self.metadata.getraw("val2")) del self.metadata['val3'] self.metadata.add('val3', val) self.assertEqual([str_val], self.metadata.getraw("val3")) del self.metadata['val4'] self.metadata.add_unique('val4', val) self.assertEqual([str_val], self.metadata.getraw("val4")) def test_metadata_get(self): self.assertEqual("single1-value", self.metadata["single1"]) self.assertEqual("single1-value", self.metadata.get("single1")) self.assertEqual(["single1-value"], self.metadata.getall("single1")) self.assertEqual(["single1-value"], self.metadata.getraw("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata.get("multi1")) self.assertEqual(self.multi1, self.metadata.getall("multi1")) self.assertEqual(self.multi1, self.metadata.getraw("multi1")) self.assertEqual("", self.metadata["nonexistent"]) self.assertEqual(None, self.metadata.get("nonexistent")) self.assertEqual([], self.metadata.getall("nonexistent")) self.assertRaises(KeyError, self.metadata.getraw, "nonexistent") self.assertEqual(self.metadata._store.items(), self.metadata.rawitems()) metadata_items = [(x, z) for (x, y) in self.metadata.rawitems() for z in y] self.assertEqual(metadata_items, list(self.metadata.items())) def test_metadata_unset(self): self.metadata.unset("single1") self.assertNotIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) self.assertRaises(KeyError, self.metadata.unset, 'unknown_tag') def test_metadata_delete(self): del self.metadata["single1"] self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) def test_metadata_legacy_delete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) def test_metadata_implicit_delete(self): self.metadata["single2"] = "" self.assertNotIn("single2", self.metadata) self.assertIn("single2", self.metadata.deleted_tags) self.metadata["unknown"] = "" self.assertNotIn("unknown", self.metadata) self.assertNotIn("unknown", self.metadata.deleted_tags) def test_metadata_undelete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) self.metadata["single1"] = "value1" self.assertIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) def test_normalize_tag(self): self.assertEqual('sometag', Metadata.normalize_tag('sometag')) self.assertEqual('sometag', Metadata.normalize_tag('sometag:')) self.assertEqual('sometag', Metadata.normalize_tag('sometag::')) self.assertEqual('sometag:desc', Metadata.normalize_tag('sometag:desc')) def test_metadata_tag_trailing_colon(self): self.metadata['tag:'] = 'Foo' self.assertIn('tag', self.metadata) self.assertIn('tag:', self.metadata) self.assertEqual('Foo', self.metadata['tag']) self.assertEqual('Foo', self.metadata['tag:']) del self.metadata['tag'] self.assertNotIn('tag', self.metadata) self.assertNotIn('tag:', self.metadata) 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 test_metadata_copy_without_images(self): m = Metadata() m.copy(self.metadata, copy_images=False) 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(ImageList(), m.images) def test_metadata_update(self): m = Metadata() m["old"] = "old-value" self.metadata.delete("single1") m.update(self.metadata) self.assertIn("old", m) self.assertNotIn("single1", m) self.assertIn("single1", m.deleted_tags) self.assertEqual("single2-value", m["single2"]) self.assertEqual(self.metadata.deleted_tags, m.deleted_tags) self.assertEqual(self.metadata.images, m.images) self.metadata["old"] = "old-value" self.assertEqual(self.metadata._store, m._store) def test_metadata_clear(self): self.metadata.clear() self.assertEqual(0, len(self.metadata)) def test_metadata_clear_deleted(self): self.metadata.delete("single1") self.assertIn("single1", self.metadata.deleted_tags) self.metadata.clear_deleted() self.assertNotIn("single1", self.metadata.deleted_tags) def test_metadata_applyfunc(self): def func(x): return x[1:] self.metadata.apply_func(func) self.assertEqual("ingle1-value", self.metadata["single1"]) self.assertEqual("ingle1-value", self.metadata.get("single1")) self.assertEqual(["ingle1-value"], self.metadata.getall("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata.get("multi1")) self.assertEqual(list(map(func, self.multi1)), self.metadata.getall("multi1")) def test_metadata_applyfunc_preserve_tags(self): self.assertTrue(len(PRESERVED_TAGS) > 0) m = Metadata() m[PRESERVED_TAGS[0]] = 'value1' m['not_preserved'] = 'value2' def func(x): return x[1:] m.apply_func(func) self.assertEqual("value1", m[PRESERVED_TAGS[0]]) self.assertEqual("alue2", m['not_preserved']) def test_metadata_applyfunc_delete_tags(self): def func(x): return None metadata = Metadata(self.metadata) metadata.apply_func(func) self.assertEqual(0, len(metadata.rawitems())) self.assertEqual(self.metadata.keys(), metadata.deleted_tags) def test_length_score(self): results = [(20000, 0, 0.333333333333), (20000, 10000, 0.666666666667), (20000, 20000, 1.0), (20000, 30000, 0.666666666667), (20000, 40000, 0.333333333333), (20000, 50000, 0.0)] for (a, b, expected) in results: actual = Metadata.length_score(a, b) self.assertAlmostEqual(expected, actual, msg="a={a}, b={b}".format(a=a, b=b)) def test_compare_is_equal(self): m1 = Metadata() m1["title"] = "title1" m1["tracknumber"] = "2" m1.length = 360 m2 = Metadata() m2["title"] = "title1" m2["tracknumber"] = "2" m2.length = 360 self.assertEqual(m1.compare(m2), m2.compare(m1)) self.assertEqual(m1.compare(m2), 1) def test_compare_with_ignored(self): m1 = Metadata() m1["title"] = "title1" m1["tracknumber"] = "2" m1.length = 360 m2 = Metadata() m2["title"] = "title1" m2["tracknumber"] = "3" m2.length = 300 self.assertNotEqual(m1.compare(m2), 1) self.assertEqual(m1.compare(m2, ignored=['tracknumber', '~length']), 1) def test_compare_lengths(self): m1 = Metadata() m1.length = 360 m2 = Metadata() m2.length = 300 self.assertAlmostEqual(m1.compare(m2), 0.998) def test_compare_tracknumber_difference(self): m1 = Metadata() m1["tracknumber"] = "1" m2 = Metadata() m2["tracknumber"] = "2" m3 = Metadata() m3["tracknumber"] = "2" self.assertEqual(m1.compare(m2), 0) self.assertEqual(m2.compare(m3), 1) def test_compare_discnumber_difference(self): m1 = Metadata() m1["discnumber"] = "1" m2 = Metadata() m2["discnumber"] = "2" m3 = Metadata() m3["discnumber"] = "2" self.assertEqual(m1.compare(m2), 0) self.assertEqual(m2.compare(m3), 1) def test_compare_deleted(self): m1 = Metadata() m1["artist"] = "TheArtist" m1["title"] = "title1" m2 = Metadata() m2["artist"] = "TheArtist" m2.delete("title") self.assertTrue(m1.compare(m2) < 1) def test_strip_whitespace(self): m1 = Metadata() m1["artist"] = " TheArtist " m1["title"] = "\t\u00A0 tit le1 \r\n" m1["genre"] = " \t" m1.strip_whitespace() self.assertEqual(m1["artist"], "TheArtist") self.assertEqual(m1["title"], "tit le1") def test_metadata_mapping_init(self): d = {'a': 'b', 'c': 2, 'd': ['x', 'y'], 'x': '', 'z': {'u', 'w'}} deleted_tags = set('c') m = Metadata(d, deleted_tags=deleted_tags, length=1234) self.assertIn('a', m) self.assertEqual(m.getraw('a'), ['b']) self.assertEqual(m['d'], MULTI_VALUED_JOINER.join(d['d'])) self.assertNotIn('c', m) self.assertNotIn('length', m) self.assertIn('c', m.deleted_tags) self.assertEqual(m.length, 1234) def test_metadata_mapping_init_zero(self): m = Metadata(tag1='a', tag2=0, tag3='', tag4=None) m['tag5'] = 0 m['tag1'] = '' self.assertIn('tag1', m.deleted_tags) self.assertEqual(m['tag2'], '0') self.assertNotIn('tag3', m) self.assertNotIn('tag4', m) self.assertEqual(m['tag5'], '0') def test_metadata_mapping_del(self): m = self.metadata_d1 self.assertEqual(m.getraw('a'), ['b']) self.assertNotIn('a', m.deleted_tags) self.assertNotIn('x', m.deleted_tags) self.assertRaises(KeyError, m.getraw, 'x') del m['a'] self.assertRaises(KeyError, m.getraw, 'a') self.assertIn('a', m.deleted_tags) # NOTE: historic behavior of Metadata.delete() # an attempt to delete an non-existing tag, will add it to the list # of deleted tags # so this will not raise a KeyError # as is it differs from dict or even defaultdict behavior del m['unknown'] self.assertIn('unknown', m.deleted_tags) def test_metadata_mapping_iter(self): self.assertEqual(set(self.metadata_d1), {'a', 'c', 'd'}) def test_metadata_mapping_keys(self): self.assertEqual(set(self.metadata_d1.keys()), {'a', 'c', 'd'}) def test_metadata_mapping_values(self): self.assertEqual(set(self.metadata_d1.values()), {'b', '2', 'x; y'}) def test_metadata_mapping_len(self): m = self.metadata_d1 self.assertEqual(len(m), 3) del m['x'] self.assertEqual(len(m), 3) del m['c'] self.assertEqual(len(m), 2) def _check_mapping_update(self, m): self.assertEqual(m['a'], 'b') self.assertEqual(m['c'], '3') self.assertEqual(m.getraw('d'), ['u', 'w']) self.assertEqual(m['x'], '') self.assertIn('x', m.deleted_tags) def test_metadata_mapping_update(self): # update from Metadata m = self.metadata_d2 m2 = self.metadata_d3 del m2['x'] m.update(m2) self._check_mapping_update(m) def test_metadata_mapping_update_dict(self): # update from dict m = self.metadata_d2 d2 = {'c': 3, 'd': ['u', 'w'], 'x': ''} m.update(d2) self._check_mapping_update(m) def test_metadata_mapping_update_tuple(self): # update from tuple m = self.metadata_d2 d2 = (('c', 3), ('d', ['u', 'w']), ('x', '')) m.update(d2) self._check_mapping_update(m) def test_metadata_mapping_update_dictlike(self): # update from kwargs m = self.metadata_d2 m.update(c=3, d=['u', 'w'], x='') self._check_mapping_update(m) def test_metadata_mapping_update_noparam(self): # update without parameter m = self.metadata_d2 self.assertRaises(TypeError, m.update) self.assertEqual(m['a'], 'b') def test_metadata_mapping_update_intparam(self): # update without parameter m = self.metadata_d2 self.assertRaises(TypeError, m.update, 123) def test_metadata_mapping_update_strparam(self): # update without parameter m = self.metadata_d2 self.assertRaises(ValueError, m.update, 'abc') def test_metadata_mapping_update_kw(self): m = Metadata(tag1='a', tag2='b') m.update(tag1='c') self.assertEqual(m['tag1'], 'c') self.assertEqual(m['tag2'], 'b') m.update(tag2='') self.assertIn('tag2', m.deleted_tags) def test_metadata_mapping_update_kw_del(self): m = Metadata(tag1='a', tag2='b') del m['tag1'] m2 = Metadata(tag1='c', tag2='d') del m2['tag2'] m.update(m2) self.assertEqual(m['tag1'], 'c') self.assertNotIn('tag2', m) self.assertNotIn('tag1', m.deleted_tags) self.assertIn('tag2', m.deleted_tags) def test_metadata_mapping_images(self): image1 = create_image(b'A', comment='A') image2 = create_image(b'B', comment='B') m1 = Metadata(a='b', length=1234, images=[image1]) self.assertEqual(m1.images[0], image1) self.assertEqual(len(m1), 2) # one tag, one image m1.images.append(image2) self.assertEqual(m1.images[1], image2) m1.images.pop(0) self.assertEqual(m1.images[0], image2) m2 = Metadata(a='c', length=4567, images=[image1]) m1.update(m2) self.assertEqual(m1.images[0], image1) m1.images.pop(0) self.assertEqual(len(m1), 1) # one tag, zero image self.assertFalse(m1.images) def test_metadata_mapping_iterable(self): m = Metadata(tag_tuple=('a', 0)) m['tag_set'] = {'c', 'd'} m['tag_dict'] = {'e': 1, 'f': 2} m['tag_str'] = 'gh' self.assertIn('0', m.getraw('tag_tuple')) self.assertIn('c', m.getraw('tag_set')) self.assertIn('e', m.getraw('tag_dict')) self.assertIn('gh', m.getraw('tag_str')) def test_compare_to_release(self): release = load_test_json('release.json') metadata = Metadata() release_to_metadata(release, metadata) match = metadata.compare_to_release(release, Cluster.comparison_weights) self.assertEqual(1.0, match.similarity) self.assertEqual(release, match.release) def test_compare_to_release_with_score(self): release = load_test_json('release.json') metadata = Metadata() release_to_metadata(release, metadata) for score, sim in ((42, 0.42), ('42', 0.42), ('foo', 1.0), (None, 1.0)): release['score'] = score match = metadata.compare_to_release(release, Cluster.comparison_weights) self.assertEqual(sim, match.similarity) def test_weights_from_release_type_scores(self): release = load_test_json('release.json') parts = [] weights_from_release_type_scores(parts, release, {'Album': 0.75}, 666) self.assertEqual( parts[0], (0.75, 666) ) weights_from_release_type_scores(parts, release, {}, 666) self.assertEqual( parts[1], (0.5, 666) ) del release['release-group'] weights_from_release_type_scores(parts, release, {}, 777) self.assertEqual( parts[2], (0.0, 777) ) def test_preferred_countries(self): release = load_test_json('release.json') parts = [] weights_from_preferred_countries(parts, release, [], 666) self.assertFalse(parts) weights_from_preferred_countries(parts, release, ['FR'], 666) self.assertEqual(parts[0], (0.0, 666)) weights_from_preferred_countries(parts, release, ['GB'], 666) self.assertEqual(parts[1], (1.0, 666)) def test_preferred_formats(self): release = load_test_json('release.json') parts = [] weights_from_preferred_formats(parts, release, [], 777) self.assertFalse(parts) weights_from_preferred_formats(parts, release, ['Digital Media'], 777) self.assertEqual(parts[0], (0.0, 777)) weights_from_preferred_formats(parts, release, ['12" Vinyl'], 777) self.assertEqual(parts[1], (1.0, 777)) def test_compare_to_track(self): track_json = load_test_json('track.json') track = Track(track_json['id']) track_to_metadata(track_json, track) match = track.metadata.compare_to_track(track_json, File.comparison_weights) self.assertEqual(1.0, match.similarity) self.assertEqual(track_json, match.track) def test_compare_to_track_with_score(self): track_json = load_test_json('track.json') track = Track(track_json['id']) track_to_metadata(track_json, track) for score, sim in ((42, 0.42), ('42', 0.42), ('foo', 1.0), (None, 1.0)): track_json['score'] = score match = track.metadata.compare_to_track(track_json, File.comparison_weights) self.assertEqual(sim, match.similarity)
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.")
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 MetadataTest(PicardTestCase): original = None tags = [] def setUp(self): super().setUp() config.setting = settings.copy() self.metadata = Metadata() self.metadata["single1"] = "single1-value" self.metadata.add_unique("single2", "single2-value") self.metadata.add_unique("single2", "single2-value") self.multi1 = ["multi1-value", "multi1-value"] self.metadata.add("multi1", self.multi1[0]) self.metadata.add("multi1", self.multi1[1]) self.multi2 = ["multi2-value1", "multi2-value2"] self.metadata["multi2"] = self.multi2 self.multi3 = ["multi3-value1", "multi3-value2"] self.metadata.set("multi3", self.multi3) self.metadata["~hidden"] = "hidden-value" def tearDown(self): pass def test_metadata_setitem(self): self.assertEqual(["single1-value"], dict.get(self.metadata, "single1")) self.assertEqual(["single2-value"], dict.get(self.metadata, "single2")) self.assertEqual(self.multi1, dict.get(self.metadata, "multi1")) self.assertEqual(self.multi2, dict.get(self.metadata, "multi2")) self.assertEqual(self.multi3, dict.get(self.metadata, "multi3")) self.assertEqual(["hidden-value"], dict.get(self.metadata, "~hidden")) def test_metadata_get(self): self.assertEqual("single1-value", self.metadata["single1"]) self.assertEqual("single1-value", self.metadata.get("single1")) self.assertEqual(["single1-value"], self.metadata.getall("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata.get("multi1")) self.assertEqual(self.multi1, self.metadata.getall("multi1")) self.assertEqual("", self.metadata["nonexistent"]) self.assertEqual(None, self.metadata.get("nonexistent")) self.assertEqual([], self.metadata.getall("nonexistent")) self.assertEqual(dict.items(self.metadata), self.metadata.rawitems()) metadata_items = [(x, z) for (x, y) in dict.items(self.metadata) for z in y] self.assertEqual(metadata_items, list(self.metadata.items())) def test_metadata_delete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) def test_metadata_implicit_delete(self): self.metadata["single2"] = "" self.assertNotIn("single2", self.metadata) self.assertIn("single2", self.metadata.deleted_tags) self.metadata["unknown"] = "" self.assertNotIn("unknown", self.metadata) self.assertNotIn("unknown", self.metadata.deleted_tags) def test_metadata_set_explicit_empty(self): self.metadata.delete("single1") self.metadata.set("single1", []) self.assertIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) self.assertEqual([], self.metadata.getall("single1")) def test_metadata_undelete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) self.metadata["single1"] = "value1" self.assertIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) def test_metadata_update(self): m = Metadata() m["old"] = "old-value" self.metadata.delete("single1") m.update(self.metadata) self.assertIn("old", m) self.assertNotIn("single1", m) self.assertIn("single1", m.deleted_tags) self.assertEqual("single2-value", m["single2"]) self.assertEqual(self.metadata.deleted_tags, m.deleted_tags) self.metadata["old"] = "old-value" for (key, value) in dict.items(self.metadata): self.assertIn(key, m) self.assertEqual(value, dict.get(m, key)) for (key, value) in dict.items(m): self.assertIn(key, self.metadata) self.assertEqual(value, dict.get(self.metadata, key)) def test_metadata_clear(self): self.metadata.clear() self.assertEqual(0, len(self.metadata)) def test_metadata_clear_deleted(self): self.metadata.delete("single1") self.assertIn("single1", self.metadata.deleted_tags) self.metadata.clear_deleted() self.assertNotIn("single1", self.metadata.deleted_tags) def test_metadata_applyfunc(self): def func(x): return x[1:] self.metadata.apply_func(func) self.assertEqual("ingle1-value", self.metadata["single1"]) self.assertEqual("ingle1-value", self.metadata.get("single1")) self.assertEqual(["ingle1-value"], self.metadata.getall("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata.get("multi1")) self.assertEqual(list(map(func, self.multi1)), self.metadata.getall("multi1")) self.assertEqual("", self.metadata["nonexistent"]) self.assertEqual(None, self.metadata.get("nonexistent")) self.assertEqual([], self.metadata.getall("nonexistent")) self.assertEqual(dict.items(self.metadata), self.metadata.rawitems()) metadata_items = [(x, z) for (x, y) in dict.items(self.metadata) for z in y] self.assertEqual(metadata_items, list(self.metadata.items())) def test_length_score(self): results = [(20000, 0, 0.333333333333), (20000, 10000, 0.666666666667), (20000, 20000, 1.0), (20000, 30000, 0.666666666667), (20000, 40000, 0.333333333333), (20000, 50000, 0.0)] for (a, b, expected) in results: actual = Metadata.length_score(a, b) self.assertAlmostEqual(expected, actual, msg="a={a}, b={b}".format(a=a, b=b)) def test_compare_is_equal(self): m1 = Metadata() m1["title"] = "title1" m1["tracknumber"] = "2" m1.length = 360 m2 = Metadata() m2["title"] = "title1" m2["tracknumber"] = "2" m2.length = 360 self.assertEqual(m1.compare(m2), m2.compare(m1)) self.assertEqual(m1.compare(m2), 1) def test_compare_lengths(self): m1 = Metadata() m1.length = 360 m2 = Metadata() m2.length = 300 self.assertAlmostEqual(m1.compare(m2), 0.998) def test_compare_tracknumber_difference(self): m1 = Metadata() m1["tracknumber"] = "1" m2 = Metadata() m2["tracknumber"] = "2" self.assertEqual(m1.compare(m2), 0) def test_compare_deleted(self): m1 = Metadata() m1["artist"] = "TheArtist" m1["title"] = "title1" m2 = Metadata() m2["artist"] = "TheArtist" m2.delete("title") self.assertTrue(m1.compare(m2) < 1)
class MetadataTest(PicardTestCase): original = None tags = [] def setUp(self): super().setUp() config.setting = settings.copy() self.metadata = Metadata() self.metadata["single1"] = "single1-value" self.metadata.add_unique("single2", "single2-value") self.metadata.add_unique("single2", "single2-value") self.multi1 = ["multi1-value", "multi1-value"] self.metadata.add("multi1", self.multi1[0]) self.metadata.add("multi1", self.multi1[1]) self.multi2 = ["multi2-value1", "multi2-value2"] self.metadata["multi2"] = self.multi2 self.multi3 = ["multi3-value1", "multi3-value2"] self.metadata.set("multi3", self.multi3) self.metadata["~hidden"] = "hidden-value" self.metadata_d1 = Metadata({'a': 'b', 'c': 2, 'd': ['x', 'y'], 'x': ''}) self.metadata_d2 = Metadata({'a': 'b', 'c': 2, 'd': ['x', 'y'], 'x': 'z'}) self.metadata_d3 = Metadata({'c': 3, 'd': ['u', 'w'], 'x': 'p'}) def tearDown(self): pass def test_metadata_setitem(self): self.assertEqual(["single1-value"], self.metadata.getraw("single1")) self.assertEqual(["single2-value"], self.metadata.getraw("single2")) self.assertEqual(self.multi1, self.metadata.getraw("multi1")) self.assertEqual(self.multi2, self.metadata.getraw("multi2")) self.assertEqual(self.multi3, self.metadata.getraw("multi3")) self.assertEqual(["hidden-value"], self.metadata.getraw("~hidden")) def test_metadata_get(self): self.assertEqual("single1-value", self.metadata["single1"]) self.assertEqual("single1-value", self.metadata.get("single1")) self.assertEqual(["single1-value"], self.metadata.getall("single1")) self.assertEqual(["single1-value"], self.metadata.getraw("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(self.multi1), self.metadata.get("multi1")) self.assertEqual(self.multi1, self.metadata.getall("multi1")) self.assertEqual(self.multi1, self.metadata.getraw("multi1")) self.assertEqual("", self.metadata["nonexistent"]) self.assertEqual(None, self.metadata.get("nonexistent")) self.assertEqual([], self.metadata.getall("nonexistent")) self.assertRaises(KeyError, self.metadata.getraw, "nonexistent") self.assertEqual(self.metadata._store.items(), self.metadata.rawitems()) metadata_items = [(x, z) for (x, y) in self.metadata.rawitems() for z in y] self.assertEqual(metadata_items, list(self.metadata.items())) def test_metadata_delete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) def test_metadata_implicit_delete(self): self.metadata["single2"] = "" self.assertNotIn("single2", self.metadata) self.assertIn("single2", self.metadata.deleted_tags) self.metadata["unknown"] = "" self.assertNotIn("unknown", self.metadata) self.assertNotIn("unknown", self.metadata.deleted_tags) def test_metadata_set_explicit_empty(self): self.metadata.delete("single1") self.metadata.set("single1", []) self.assertIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) self.assertEqual([], self.metadata.getall("single1")) def test_metadata_undelete(self): self.metadata.delete("single1") self.assertNotIn("single1", self.metadata) self.assertIn("single1", self.metadata.deleted_tags) self.metadata["single1"] = "value1" self.assertIn("single1", self.metadata) self.assertNotIn("single1", self.metadata.deleted_tags) def test_metadata_update(self): m = Metadata() m["old"] = "old-value" self.metadata.delete("single1") m.update(self.metadata) self.assertIn("old", m) self.assertNotIn("single1", m) self.assertIn("single1", m.deleted_tags) self.assertEqual("single2-value", m["single2"]) self.assertEqual(self.metadata.deleted_tags, m.deleted_tags) self.metadata["old"] = "old-value" for (key, value) in self.metadata.rawitems(): self.assertIn(key, m) self.assertEqual(value, m.getraw(key)) for (key, value) in m.rawitems(): self.assertIn(key, self.metadata) self.assertEqual(value, self.metadata.getraw(key)) def test_metadata_clear(self): self.metadata.clear() self.assertEqual(0, len(self.metadata)) def test_metadata_clear_deleted(self): self.metadata.delete("single1") self.assertIn("single1", self.metadata.deleted_tags) self.metadata.clear_deleted() self.assertNotIn("single1", self.metadata.deleted_tags) def test_metadata_applyfunc(self): def func(x): return x[1:] self.metadata.apply_func(func) self.assertEqual("ingle1-value", self.metadata["single1"]) self.assertEqual("ingle1-value", self.metadata.get("single1")) self.assertEqual(["ingle1-value"], self.metadata.getall("single1")) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata["multi1"]) self.assertEqual(MULTI_VALUED_JOINER.join(map(func, self.multi1)), self.metadata.get("multi1")) self.assertEqual(list(map(func, self.multi1)), self.metadata.getall("multi1")) def test_metadata_applyfunc_preserve_tags(self): self.assertTrue(len(PRESERVED_TAGS) > 0) m = Metadata() m[PRESERVED_TAGS[0]] = 'value1' m['not_preserved'] = 'value2' def func(x): return x[1:] m.apply_func(func) self.assertEqual("value1", m[PRESERVED_TAGS[0]]) self.assertEqual("alue2", m['not_preserved']) def test_length_score(self): results = [(20000, 0, 0.333333333333), (20000, 10000, 0.666666666667), (20000, 20000, 1.0), (20000, 30000, 0.666666666667), (20000, 40000, 0.333333333333), (20000, 50000, 0.0)] for (a, b, expected) in results: actual = Metadata.length_score(a, b) self.assertAlmostEqual(expected, actual, msg="a={a}, b={b}".format(a=a, b=b)) def test_compare_is_equal(self): m1 = Metadata() m1["title"] = "title1" m1["tracknumber"] = "2" m1.length = 360 m2 = Metadata() m2["title"] = "title1" m2["tracknumber"] = "2" m2.length = 360 self.assertEqual(m1.compare(m2), m2.compare(m1)) self.assertEqual(m1.compare(m2), 1) def test_compare_lengths(self): m1 = Metadata() m1.length = 360 m2 = Metadata() m2.length = 300 self.assertAlmostEqual(m1.compare(m2), 0.998) def test_compare_tracknumber_difference(self): m1 = Metadata() m1["tracknumber"] = "1" m2 = Metadata() m2["tracknumber"] = "2" self.assertEqual(m1.compare(m2), 0) def test_compare_deleted(self): m1 = Metadata() m1["artist"] = "TheArtist" m1["title"] = "title1" m2 = Metadata() m2["artist"] = "TheArtist" m2.delete("title") self.assertTrue(m1.compare(m2) < 1) def test_strip_whitespace(self): m1 = Metadata() m1["artist"] = " TheArtist " m1["title"] = "\t\u00A0 tit le1 \r\n" m1.strip_whitespace() self.assertEqual(m1["artist"], "TheArtist") self.assertEqual(m1["title"], "tit le1") def test_metadata_mapping_init(self): d = {'a': 'b', 'c': 2, 'd': ['x', 'y'], 'x': '', 'z': {'u', 'w'}} deleted_tags = set('c') m = Metadata(d, deleted_tags=deleted_tags, length=1234) self.assertTrue('a' in m) self.assertEqual(m.getraw('a'), ['b']) self.assertEqual(m['d'], MULTI_VALUED_JOINER.join(d['d'])) self.assertNotIn('c', m) self.assertNotIn('length', m) self.assertIn('c', m.deleted_tags) self.assertEqual(m.length, 1234) def test_metadata_mapping_init_zero(self): m = Metadata(tag1='a', tag2=0, tag3='', tag4=None) m['tag5'] = 0 m['tag1'] = '' self.assertIn('tag1', m.deleted_tags) self.assertEqual(m['tag2'], '0') self.assertNotIn('tag3', m) self.assertNotIn('tag4', m) self.assertEqual(m['tag5'], '0') def test_metadata_mapping_del(self): m = self.metadata_d1 self.assertEqual(m.getraw('a'), ['b']) self.assertNotIn('a', m.deleted_tags) self.assertNotIn('x', m.deleted_tags) self.assertRaises(KeyError, m.getraw, 'x') del m['a'] self.assertRaises(KeyError, m.getraw, 'a') self.assertIn('a', m.deleted_tags) # NOTE: historic behavior of Metadata.delete() # an attempt to delete an non-existing tag, will add it to the list # of deleted tags # so this will not raise a KeyError # as is it differs from dict or even defaultdict behavior del m['unknown'] self.assertIn('unknown', m.deleted_tags) def test_metadata_mapping_iter(self): l = set(self.metadata_d1) self.assertEqual(l, {'a', 'c', 'd'}) def test_metadata_mapping_keys(self): l = set(self.metadata_d1.keys()) self.assertEqual(l, {'a', 'c', 'd'}) def test_metadata_mapping_values(self): l = set(self.metadata_d1.values()) self.assertEqual(l, {'b', '2', 'x; y'}) def test_metadata_mapping_len(self): m = self.metadata_d1 self.assertEqual(len(m), 3) del m['x'] self.assertEqual(len(m), 3) del m['c'] self.assertEqual(len(m), 2) def _check_mapping_update(self, m): self.assertEqual(m['a'], 'b') self.assertEqual(m['c'], '3') self.assertEqual(m.getraw('d'), ['u', 'w']) self.assertEqual(m['x'], '') self.assertIn('x', m.deleted_tags) def test_metadata_mapping_update(self): # update from Metadata m = self.metadata_d2 m2 = self.metadata_d3 del m2['x'] m.update(m2) self._check_mapping_update(m) def test_metadata_mapping_update_dict(self): # update from dict m = self.metadata_d2 d2 = {'c': 3, 'd': ['u', 'w'], 'x': ''} m.update(d2) self._check_mapping_update(m) def test_metadata_mapping_update_tuple(self): # update from tuple m = self.metadata_d2 d2 = (('c', 3), ('d', ['u', 'w']), ('x', '')) m.update(d2) self._check_mapping_update(m) def test_metadata_mapping_update_dictlike(self): # update from kwargs m = self.metadata_d2 m.update(c=3, d=['u', 'w'], x='') self._check_mapping_update(m) def test_metadata_mapping_update_noparam(self): # update without parameter m = self.metadata_d2 self.assertRaises(TypeError, m.update) self.assertEqual(m['a'], 'b') def test_metadata_mapping_update_intparam(self): # update without parameter m = self.metadata_d2 self.assertRaises(TypeError, m.update, 123) def test_metadata_mapping_update_strparam(self): # update without parameter m = self.metadata_d2 self.assertRaises(ValueError, m.update, 'abc') def test_metadata_mapping_update_kw(self): m = Metadata(tag1='a', tag2='b') m.update(tag1='c') self.assertEqual(m['tag1'], 'c') self.assertEqual(m['tag2'], 'b') m.update(tag2='') self.assertIn('tag2', m.deleted_tags) def test_metadata_mapping_update_kw_del(self): m = Metadata(tag1='a', tag2='b') del m['tag1'] m2 = Metadata(tag1='c', tag2='d') del m2['tag2'] m.update(m2) self.assertEqual(m['tag1'], 'c') self.assertNotIn('tag2', m) self.assertNotIn('tag1', m.deleted_tags) self.assertIn('tag2', m.deleted_tags) def test_metadata_mapping_images(self): image1 = create_image(b'A', comment='A') image2 = create_image(b'B', comment='B') m1 = Metadata(a='b', length=1234, images=[image1]) self.assertEqual(m1.images[0], image1) self.assertEqual(len(m1), 2) # one tag, one image m1.images.append(image2) self.assertEqual(m1.images[1], image2) m1.images.pop(0) self.assertEqual(m1.images[0], image2) m2 = Metadata(a='c', length=4567, images=[image1]) m1.update(m2) self.assertEqual(m1.images[0], image1) m1.images.pop(0) self.assertEqual(len(m1), 1) # one tag, zero image self.assertFalse(m1.images) def test_metadata_mapping_iterable(self): m = Metadata(tag_tuple=('a', 0)) m['tag_set'] = {'c', 'd'} m['tag_dict'] = {'e': 1, 'f': 2} m['tag_str'] = 'gh' self.assertIn('0', m.getraw('tag_tuple')) self.assertIn('c', m.getraw('tag_set')) self.assertIn('e', m.getraw('tag_dict')) self.assertIn('gh', m.getraw('tag_str'))