Ejemplo n.º 1
0
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
Ejemplo n.º 2
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'))
Ejemplo n.º 3
0
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))
Ejemplo n.º 4
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_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)
Ejemplo n.º 5
0
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.")
Ejemplo n.º 6
0
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
Ejemplo n.º 7
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"

    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)
Ejemplo n.º 8
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"

    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)
Ejemplo n.º 9
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_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'))