Exemple #1
0
class File(QtCore.QObject, Item):

    UNDEFINED = -1
    PENDING = 0
    NORMAL = 1
    CHANGED = 2
    ERROR = 3
    REMOVED = 4

    comparison_weights = {
        "title": 13,
        "artist": 4,
        "album": 5,
        "length": 10,
        "totaltracks": 4,
        "releasetype": 20,
        "releasecountry": 2,
        "format": 2,
    }

    def __init__(self, filename):
        super(File, self).__init__()
        self.filename = filename
        self.base_filename = os.path.basename(filename)
        self._state = File.UNDEFINED
        self.state = File.PENDING
        self.error = None

        self.orig_metadata = Metadata()
        self.metadata = Metadata()

        self.similarity = 1.0
        self.parent = None

        self.lookup_task = None
        self.item = None

    def __repr__(self):
        return '<File %r>' % self.base_filename

    @property
    def new_metadata(self):
        return self.metadata

    def load(self, callback):
        thread.run_task(
            partial(self._load, self.filename),
            partial(self._loading_finished, callback),
            priority=1)

    def _loading_finished(self, callback, result=None, error=None):
        if self.state != self.PENDING:
            return
        if error is not None:
            self.error = str(error)
            self.state = self.ERROR
            from picard.formats import supported_extensions
            file_name, file_extension = os.path.splitext(self.base_filename)
            if file_extension not in supported_extensions():
                self.remove()
                log.error('Unsupported media file %r wrongly loaded. Removing ...',self)
                return
        else:
            self.error = None
            self.state = self.NORMAL
            self._copy_loaded_metadata(result)
        self.update()
        callback(self)

    def _copy_loaded_metadata(self, metadata):
        filename, _ = os.path.splitext(self.base_filename)
        metadata['~length'] = format_time(metadata.length)
        if 'title' not in metadata:
            metadata['title'] = filename
        if 'tracknumber' not in metadata:
            tracknumber = tracknum_from_filename(self.base_filename)
            if tracknumber != -1:
                tracknumber = str(tracknumber)
                metadata['tracknumber'] = tracknumber
                if metadata['title'] == filename:
                    stripped_filename = filename.lstrip('0')
                    tnlen = len(tracknumber)
                    if stripped_filename[:tnlen] == tracknumber:
                        metadata['title'] = stripped_filename[tnlen:].lstrip()
        self.orig_metadata = metadata
        self.metadata.copy(metadata)

    def copy_metadata(self, metadata):
        acoustid = self.metadata["acoustid_id"]
        preserve = config.setting["preserved_tags"].strip()
        saved_metadata = {}

        for tag in re.split(r"\s*,\s*", preserve) + PRESERVED_TAGS:
            values = self.orig_metadata.getall(tag)
            if values:
                saved_metadata[tag] = values
        deleted_tags = self.metadata.deleted_tags
        self.metadata.copy(metadata)
        self.metadata.deleted_tags = deleted_tags
        for tag, values in saved_metadata.iteritems():
            self.metadata.set(tag, values)

        if acoustid:
            self.metadata["acoustid_id"] = acoustid

    def has_error(self):
        return self.state == File.ERROR

    def _load(self, filename):
        """Load metadata from the file."""
        raise NotImplementedError

    def save(self):
        self.set_pending()
        metadata = Metadata()
        metadata.copy(self.metadata)
        thread.run_task(
            partial(self._save_and_rename, self.filename, metadata),
            self._saving_finished,
            priority=2,
            thread_pool=self.tagger.save_thread_pool)

    def _save_and_rename(self, old_filename, metadata):
        """Save the metadata."""
        # Check that file has not been removed since thread was queued
        # Also don't save if we are stopping.
        if self.state == File.REMOVED or self.tagger.stopping:
            log.debug("File not saved because %s: %r",
                "Picard is stopping" if self.tagger.stopping else "it was removed",
                self.filename)
            return None
        new_filename = old_filename
        if not config.setting["dont_write_tags"]:
            encoded_old_filename = encode_filename(old_filename)
            info = os.stat(encoded_old_filename)
            self._save(old_filename, metadata)
            if config.setting["preserve_timestamps"]:
                try:
                    os.utime(encoded_old_filename, (info.st_atime, info.st_mtime))
                except OSError:
                    log.warning("Couldn't preserve timestamp for %r", old_filename)
        # Rename files
        if config.setting["rename_files"] or config.setting["move_files"]:
            new_filename = self._rename(old_filename, metadata)
        # Move extra files (images, playlists, etc.)
        if config.setting["move_files"] and config.setting["move_additional_files"]:
            self._move_additional_files(old_filename, new_filename)
        # Delete empty directories
        if config.setting["delete_empty_dirs"]:
            dirname = encode_filename(os.path.dirname(old_filename))
            try:
                self._rmdir(dirname)
                head, tail = os.path.split(dirname)
                if not tail:
                    head, tail = os.path.split(head)
                while head and tail:
                    try:
                        self._rmdir(head)
                    except:
                        break
                    head, tail = os.path.split(head)
            except EnvironmentError:
                pass
        # Save cover art images
        if config.setting["save_images_to_files"]:
            self._save_images(os.path.dirname(new_filename), metadata)
        return new_filename

    @staticmethod
    def _rmdir(dir):
        junk_files = (".DS_Store", "desktop.ini", "Desktop.ini", "Thumbs.db")
        if not set(os.listdir(dir)) - set(junk_files):
            shutil.rmtree(dir, False)
        else:
            raise OSError

    def _saving_finished(self, result=None, error=None):
        # Handle file removed before save
        # Result is None if save was skipped
        if ((self.state == File.REMOVED or self.tagger.stopping)
            and result is None):
            return
        old_filename = new_filename = self.filename
        if error is not None:
            self.error = str(error)
            self.set_state(File.ERROR, update=True)
        else:
            self.filename = new_filename = result
            self.base_filename = os.path.basename(new_filename)
            length = self.orig_metadata.length
            temp_info = {}
            for info in ('~bitrate', '~sample_rate', '~channels',
                         '~bits_per_sample', '~format'):
                temp_info[info] = self.orig_metadata[info]
            # Data is copied from New to Original because New may be a subclass to handle id3v23
            if config.setting["clear_existing_tags"]:
                self.orig_metadata.copy(self.new_metadata)
            else:
                self.orig_metadata.update(self.new_metadata)
            self.orig_metadata.length = length
            self.orig_metadata['~length'] = format_time(length)
            for k, v in temp_info.items():
                self.orig_metadata[k] = v
            self.error = None
            # Force update to ensure file status icon changes immediately after save
            self.clear_pending(force_update=True)
            self._add_path_to_metadata(self.orig_metadata)

        if self.state != File.REMOVED:
            del self.tagger.files[old_filename]
            self.tagger.files[new_filename] = self

        if self.tagger.stopping:
            log.debug("Save of %r completed before stopping Picard", self.filename)

    def _save(self, filename, metadata):
        """Save the metadata."""
        raise NotImplementedError

    def _script_to_filename(self, format, file_metadata, settings=None):
        if settings is None:
            settings = config.setting
        metadata = Metadata()
        if config.setting["clear_existing_tags"]:
            metadata.copy(file_metadata)
        else:
            metadata.copy(self.orig_metadata)
            metadata.update(file_metadata)
        # make sure every metadata can safely be used in a path name
        for name in metadata.keys():
            if isinstance(metadata[name], basestring):
                metadata[name] = sanitize_filename(metadata[name])
        format = format.replace("\t", "").replace("\n", "")
        filename = ScriptParser().eval(format, metadata, self)
        if settings["ascii_filenames"]:
            if isinstance(filename, unicode):
                filename = unaccent(filename)
            filename = replace_non_ascii(filename)
        # replace incompatible characters
        if settings["windows_compatibility"] or sys.platform == "win32":
            filename = replace_win32_incompat(filename)
        # remove null characters
        filename = filename.replace("\x00", "")
        return filename

    def _fixed_splitext(self, filename):
        # In case the filename is blank and only has the extension
        # the real extension is in new_filename and ext is blank
        new_filename, ext = os.path.splitext(filename)
        if ext == '' and new_filename.lower() in map(unicode, self.EXTENSIONS):
            ext = new_filename
            new_filename = ''
        return new_filename, ext

    def _make_filename(self, filename, metadata, settings=None):
        """Constructs file name based on metadata and file naming formats."""
        if settings is None:
            settings = config.setting
        if settings["move_files"]:
            new_dirname = settings["move_files_to"]
            if not os.path.isabs(new_dirname):
                new_dirname = os.path.normpath(os.path.join(os.path.dirname(filename), new_dirname))
        else:
            new_dirname = os.path.dirname(filename)
        new_filename = os.path.basename(filename)

        if settings["rename_files"]:
            new_filename, ext = self._fixed_splitext(new_filename)
            ext = ext.lower()
            new_filename = new_filename + ext

            # expand the naming format
            format = settings['file_naming_format']
            if len(format) > 0:
                new_filename = self._script_to_filename(format, metadata, settings)
                # NOTE: the _script_to_filename strips the extension away
                new_filename = new_filename + ext
                if not settings['move_files']:
                    new_filename = os.path.basename(new_filename)
                new_filename = make_short_filename(new_dirname, new_filename,
                                                   config.setting['windows_compatibility'],
                                                   config.setting['windows_compatibility_drive_root'])
                # TODO: move following logic under util.filenaming
                # (and reconsider its necessity)
                # win32 compatibility fixes
                if settings['windows_compatibility'] or sys.platform == 'win32':
                    new_filename = new_filename.replace('./', '_/').replace('.\\', '_\\')
                # replace . at the beginning of file and directory names
                new_filename = new_filename.replace('/.', '/_').replace('\\.', '\\_')
                if new_filename and new_filename[0] == '.':
                    new_filename = '_' + new_filename[1:]
                # Fix for precomposed characters on OSX
                if sys.platform == "darwin":
                    new_filename = unicodedata.normalize("NFD", unicode(new_filename))

        return os.path.realpath(os.path.join(new_dirname, new_filename))

    def _rename(self, old_filename, metadata):
        new_filename, ext = os.path.splitext(
            self._make_filename(old_filename, metadata))

        if old_filename == new_filename + ext:
            return old_filename

        new_dirname = os.path.dirname(new_filename)
        if not os.path.isdir(encode_filename(new_dirname)):
            os.makedirs(new_dirname)
        tmp_filename = new_filename
        i = 1
        while (not pathcmp(old_filename, new_filename + ext) and
               os.path.exists(encode_filename(new_filename + ext))):
            new_filename = "%s (%d)" % (tmp_filename, i)
            i += 1
        new_filename = new_filename + ext
        log.debug("Moving file %r => %r", old_filename, new_filename)
        shutil.move(encode_filename(old_filename), encode_filename(new_filename))
        return new_filename

    def _save_images(self, dirname, metadata):
        """Save the cover images to disk."""
        if not metadata.images:
            return
        counters = defaultdict(lambda: 0)
        images = []
        if config.setting["caa_save_single_front_image"]:
            images = metadata.get_single_front_image()
        if not images:
            images = metadata.images
        for image in images:
            image.save(dirname, metadata, counters)

    def _move_additional_files(self, old_filename, new_filename):
        """Move extra files, like playlists..."""
        old_path = encode_filename(os.path.dirname(old_filename))
        new_path = encode_filename(os.path.dirname(new_filename))
        patterns = encode_filename(config.setting["move_additional_files_pattern"])
        patterns = filter(bool, [p.strip() for p in patterns.split()])
        try:
            names = os.listdir(old_path)
        except os.error:
            log.error("Error: {} directory not found".format(old_path))
            return
        filtered_names = filter(lambda x: x[0] != '.', names)
        for pattern in patterns:
            pattern_regex = re.compile(fnmatch.translate(pattern), re.IGNORECASE)
            file_names = names
            if pattern[0] != '.':
                file_names = filtered_names
            for old_file in file_names:
                if pattern_regex.match(old_file):
                    new_file = os.path.join(new_path, old_file)
                    old_file = os.path.join(old_path, old_file)
                    # FIXME we shouldn't do this from a thread!
                    if self.tagger.files.get(decode_filename(old_file)):
                        log.debug("File loaded in the tagger, not moving %r", old_file)
                        continue
                    log.debug("Moving %r to %r", old_file, new_file)
                    shutil.move(old_file, new_file)

    def remove(self, from_parent=True):
        if from_parent and self.parent:
            log.debug("Removing %r from %r", self, self.parent)
            self.parent.remove_file(self)
        self.tagger.acoustidmanager.remove(self)
        self.state = File.REMOVED

    def move(self, parent):
        if parent != self.parent:
            log.debug("Moving %r from %r to %r", self, self.parent, parent)
            self.clear_lookup_task()
            self.tagger._acoustid.stop_analyze(file)
            if self.parent:
                self.clear_pending()
                self.parent.remove_file(self)
            self.parent = parent
            self.parent.add_file(self)
            self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_recordingid'])

    def _move(self, parent):
        if parent != self.parent:
            log.debug("Moving %r from %r to %r", self, self.parent, parent)
            if self.parent:
                self.parent.remove_file(self)
            self.parent = parent
            self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_recordingid'])

    def supports_tag(self, name):
        """Returns whether tag ``name`` can be saved to the file."""
        return True

    def is_saved(self):
        return self.similarity == 1.0 and self.state == File.NORMAL

    def update(self, signal=True):
        new_metadata = self.new_metadata
        names = set(new_metadata.keys())
        names.update(self.orig_metadata.keys())
        clear_existing_tags = config.setting["clear_existing_tags"]
        for name in names:
            if not name.startswith('~') and self.supports_tag(name):
                new_values = new_metadata.getall(name)
                if not (new_values or clear_existing_tags):
                    continue
                orig_values = self.orig_metadata.getall(name)
                if orig_values != new_values:
                    self.similarity = self.orig_metadata.compare(new_metadata)
                    if self.state in (File.CHANGED, File.NORMAL):
                        self.state = File.CHANGED
                    break
        else:
            if self.orig_metadata.images != self.metadata.images:
                self.state = File.CHANGED
            else:
                self.similarity = 1.0
                if self.state in (File.CHANGED, File.NORMAL):
                    self.state = File.NORMAL
        if signal:
            log.debug("Updating file %r", self)
            if self.item:
                self.item.update()

    def can_save(self):
        """Return if this object can be saved."""
        return True

    def can_remove(self):
        """Return if this object can be removed."""
        return True

    def can_edit_tags(self):
        """Return if this object supports tag editing."""
        return True

    def can_analyze(self):
        """Return if this object can be fingerprinted."""
        return True

    def can_autotag(self):
        return True

    def can_refresh(self):
        return False

    def can_view_info(self):
        return True

    def _info(self, metadata, file):
        if hasattr(file.info, 'length'):
            metadata.length = int(file.info.length * 1000)
        if hasattr(file.info, 'bitrate') and file.info.bitrate:
            metadata['~bitrate'] = file.info.bitrate / 1000.0
        if hasattr(file.info, 'sample_rate') and file.info.sample_rate:
            metadata['~sample_rate'] = file.info.sample_rate
        if hasattr(file.info, 'channels') and file.info.channels:
            metadata['~channels'] = file.info.channels
        if hasattr(file.info, 'bits_per_sample') and file.info.bits_per_sample:
            metadata['~bits_per_sample'] = file.info.bits_per_sample
        metadata['~format'] = self.__class__.__name__.replace('File', '')
        self._add_path_to_metadata(metadata)

    def _add_path_to_metadata(self, metadata):
        metadata['~dirname'] = os.path.dirname(self.filename)
        filename, extension = os.path.splitext(os.path.basename(self.filename))
        metadata['~filename'] = filename
        metadata['~extension'] = extension.lower()[1:]

    def get_state(self):
        return self._state

    # in order to significantly speed up performance, the number of pending
    #  files is cached
    num_pending_files = 0

    def set_state(self, state, update=False):
        if state != self._state:
            if state == File.PENDING:
                File.num_pending_files += 1
            elif self._state == File.PENDING:
                File.num_pending_files -= 1
        self._state = state
        if update:
            self.update()
        self.tagger.tagger_stats_changed.emit()

    state = property(get_state, set_state)

    def column(self, column):
        m = self.metadata
        if column == "title" and not m["title"]:
            return self.base_filename
        return m[column]

    def _lookup_finished(self, lookuptype, document, http, error):
        self.lookup_task = None

        if self.state == File.REMOVED:
            return

        try:
            m = document.metadata[0]
            if lookuptype == "metadata":
                tracks = m.recording_list[0].recording
            elif lookuptype == "acoustid":
                tracks = m.acoustid[0].recording_list[0].recording
        except (AttributeError, IndexError):
            tracks = None

        # no matches
        if not tracks:
            self.tagger.window.set_statusbar_message(
                N_("No matching tracks for file '%(filename)s'"),
                {'filename': self.filename},
                timeout=3000
            )
            self.clear_pending()
            return

        # multiple matches -- calculate similarities to each of them
        match = sorted((self.metadata.compare_to_track(
            track, self.comparison_weights) for track in tracks),
            reverse=True, key=itemgetter(0))[0]

        if lookuptype != 'acoustid':
            threshold = config.setting['file_lookup_threshold']
            if match[0] < threshold:
                self.tagger.window.set_statusbar_message(
                    N_("No matching tracks above the threshold for file '%(filename)s'"),
                    {'filename': self.filename},
                    timeout=3000
                )
                self.clear_pending()
                return
        self.tagger.window.set_statusbar_message(
            N_("File '%(filename)s' identified!"),
            {'filename': self.filename},
            timeout=3000
        )
        self.clear_pending()

        rg, release, track = match[1:]
        if lookuptype == 'acoustid':
            self.tagger.acoustidmanager.add(self, track.id)
        if release:
            self.tagger.get_release_group_by_id(rg.id).loaded_albums.add(release.id)
            self.tagger.move_file_to_track(self, release.id, track.id)
        else:
            self.tagger.move_file_to_nat(self, track.id, node=track)

    def lookup_metadata(self):
        """Try to identify the file using the existing metadata."""
        if self.lookup_task:
            return
        self.tagger.window.set_statusbar_message(
            N_("Looking up the metadata for file %(filename)s ..."),
            {'filename': self.filename}
        )
        self.clear_lookup_task()
        metadata = self.metadata
        self.set_pending()
        self.lookup_task = self.tagger.xmlws.find_tracks(partial(self._lookup_finished, 'metadata'),
            track=metadata['title'],
            artist=metadata['artist'],
            release=metadata['album'],
            tnum=metadata['tracknumber'],
            tracks=metadata['totaltracks'],
            qdur=str(metadata.length / 2000),
            isrc=metadata['isrc'],
            limit=QUERY_LIMIT)

    def clear_lookup_task(self):
        if self.lookup_task:
            self.tagger.xmlws.remove_task(self.lookup_task)
            self.lookup_task = None

    def set_pending(self):
        if self.state != File.REMOVED:
            self.state = File.PENDING
            self.update()

    def clear_pending(self, force_update=False):
        if self.state == File.PENDING:
            self.state = File.NORMAL
            self.update()
        elif force_update:
            self.update()

    def iterfiles(self, save=False):
        yield self

    def _get_tracknumber(self):
        try:
            return int(self.metadata["tracknumber"])
        except:
            return 0
    tracknumber = property(_get_tracknumber, doc="The track number as an int.")

    def _get_discnumber(self):
        try:
            return int(self.metadata["discnumber"])
        except:
            return 0
    discnumber = property(_get_discnumber, doc="The disc number as an int.")
Exemple #2
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
Exemple #3
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

    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 = set(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 set(file_names):
                if pattern_regex.match(old_file):
                    names.discard(old_file)
                    filtered_names.discard(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
        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:]

    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.")
Exemple #4
0
class File(QtCore.QObject, Item):

    UNDEFINED = -1
    PENDING = 0
    NORMAL = 1
    CHANGED = 2
    ERROR = 3
    REMOVED = 4

    comparison_weights = {
        "title": 13,
        "artist": 4,
        "album": 5,
        "length": 10,
        "totaltracks": 4,
        "releasetype": 20,
        "releasecountry": 2,
        "format": 2,
    }

    def __init__(self, filename):
        super(File, self).__init__()
        self.filename = filename
        self.base_filename = os.path.basename(filename)
        self._state = File.UNDEFINED
        self.state = File.PENDING
        self.error = None

        self.orig_metadata = Metadata()
        self.metadata = Metadata()

        self.similarity = 1.0
        self.parent = None

        self.lookup_task = None
        self.item = None

    def __repr__(self):
        return '<File %r>' % self.base_filename

    def load(self, next):
        self.tagger.load_queue.put((
            partial(self._load, self.filename),
            partial(self._loading_finished, next),
            QtCore.Qt.LowEventPriority + 1))

    @call_next
    def _loading_finished(self, next, result=None, error=None):
        if self.state != self.PENDING:
            return
        if error is not None:
            self.error = str(error)
            self.state = self.ERROR
        else:
            self.error = None
            self.state = self.NORMAL
            self._copy_loaded_metadata(result)
        self.update()
        return self

    def _copy_loaded_metadata(self, metadata):
        filename, extension = os.path.splitext(self.base_filename)
        metadata['~extension'] = extension[1:].lower()
        metadata['~length'] = format_time(metadata.length)
        if 'title' not in metadata:
            metadata['title'] = filename
        if 'tracknumber' not in metadata:
            match = re.match("(?:track)?\s*(?:no|nr)?\s*(\d+)", filename, re.I)
            if match:
                try:
                    tracknumber = int(match.group(1))
                except ValueError:
                    pass
                else:
                    metadata['tracknumber'] = str(tracknumber)
        self.orig_metadata.copy(metadata)
        self.metadata = metadata

    def copy_metadata(self, metadata):
        acoustid = self.metadata["acoustid_id"]
        preserve = self.config.setting["preserved_tags"].strip()
        if preserve:
            saved_metadata = {}
            for tag in re.split(r"\s+", preserve):
                values = self.orig_metadata.getall(tag)
                if values:
                    saved_metadata[tag] = values
            self.metadata.copy(metadata)
            for tag, values in saved_metadata.iteritems():
                self.metadata.set(tag, values)
        else:
            self.metadata.copy(metadata)
        self.metadata["acoustid_id"] = acoustid

    def has_error(self):
        return self.state == File.ERROR

    def _load(self):
        """Load metadata from the file."""
        raise NotImplementedError

    def save(self, next, settings):
        self.set_pending()
        metadata = Metadata()
        metadata.copy(self.metadata)
        self.tagger.save_queue.put((
            partial(self._save_and_rename, self.filename, metadata, settings),
            partial(self._saving_finished, next),
            QtCore.Qt.LowEventPriority + 2))

    def _save_and_rename(self, old_filename, metadata, settings):
        """Save the metadata."""
        new_filename = old_filename
        if not settings["dont_write_tags"]:
            encoded_old_filename = encode_filename(old_filename)
            info = os.stat(encoded_old_filename)
            self._save(old_filename, metadata, settings)
            if settings["preserve_timestamps"]:
                try:
                    os.utime(encoded_old_filename, (info.st_atime, info.st_mtime))
                except OSError:
                    self.log.warning("Couldn't preserve timestamp for %r", old_filename)
        # Rename files
        if settings["rename_files"] or settings["move_files"]:
            new_filename = self._rename(old_filename, metadata, settings)
        # Move extra files (images, playlists, etc.)
        if settings["move_files"] and settings["move_additional_files"]:
            self._move_additional_files(old_filename, new_filename, settings)
        # Delete empty directories
        if settings["delete_empty_dirs"]:
            dirname = encode_filename(os.path.dirname(old_filename))
            try:
                self._rmdir(dirname)
                head, tail = os.path.split(dirname)
                if not tail:
                    head, tail = os.path.split(head)
                while head and tail:
                    try:
                        self._rmdir(head)
                    except:
                        break
                    head, tail = os.path.split(head)
            except EnvironmentError:
                pass
        # Save cover art images
        if settings["save_images_to_files"]:
            self._save_images(os.path.dirname(new_filename), metadata, settings)
        return new_filename

    @staticmethod
    def _rmdir(dir):
        junk_files = (".DS_Store", "desktop.ini", "Desktop.ini", "Thumbs.db")
        if not set(os.listdir(dir)) - set(junk_files):
            shutil.rmtree(dir, False)
        else:
            raise OSError

    @call_next
    def _saving_finished(self, next, result=None, error=None):
        old_filename = new_filename = self.filename
        if error is not None:
            self.error = str(error)
            self.set_state(File.ERROR, update=True)
        else:
            self.filename = new_filename = result
            self.base_filename = os.path.basename(new_filename)
            length = self.orig_metadata.length
            temp_info = {}
            for info in ('~#bitrate', '~#sample_rate', '~#channels',
                         '~#bits_per_sample', '~format', '~extension'):
                temp_info[info] = self.orig_metadata[info]
            if self.config.setting["clear_existing_tags"]:
                self.orig_metadata.copy(self.metadata)
            else:
                self.orig_metadata.update(self.metadata)
            self.orig_metadata.length = length
            self.orig_metadata['~length'] = format_time(length)
            for k, v in temp_info.items():
                self.orig_metadata[k] = v
            self.error = None
            self.clear_pending()
        return self, old_filename, new_filename

    def _save(self, filename, metadata, settings):
        """Save the metadata."""
        raise NotImplementedError

    def _script_to_filename(self, format, file_metadata, settings):
        metadata = Metadata()
        if self.config.setting["clear_existing_tags"]:
            metadata.copy(file_metadata)
        else:
            metadata.copy(self.orig_metadata)
            metadata.update(file_metadata)
        # make sure every metadata can safely be used in a path name
        for name in metadata.keys():
            if isinstance(metadata[name], basestring):
                metadata[name] = sanitize_filename(metadata[name])
        format = format.replace("\t", "").replace("\n", "")
        filename = ScriptParser().eval(format, metadata, self)
        # replace incompatible characters
        if settings["windows_compatible_filenames"] or sys.platform == "win32":
            filename = replace_win32_incompat(filename)
        if settings["ascii_filenames"]:
            if isinstance(filename, unicode):
                filename = unaccent(filename)
            filename = replace_non_ascii(filename)
        # remove null characters
        filename = filename.replace("\x00", "")
        return filename

    def _make_filename(self, filename, metadata, settings):
        """Constructs file name based on metadata and file naming formats."""
        if settings["move_files"]:
            new_dirname = settings["move_files_to"]
            if not os.path.isabs(new_dirname):
                new_dirname = os.path.normpath(os.path.join(os.path.dirname(filename), new_dirname))
        else:
            new_dirname = os.path.dirname(filename)
        new_filename, ext = os.path.splitext(os.path.basename(filename))

        if settings["rename_files"]:
            # expand the naming format
            format = settings['file_naming_format']
            if len(format) > 0:
                new_filename = self._script_to_filename(format, metadata, settings)
                if not settings['move_files']:
                    new_filename = os.path.basename(new_filename)
                new_filename = make_short_filename(new_dirname, new_filename)
                # win32 compatibility fixes
                if settings['windows_compatible_filenames'] or sys.platform == 'win32':
                    new_filename = new_filename.replace('./', '_/').replace('.\\', '_\\')
                # replace . at the beginning of file and directory names
                new_filename = new_filename.replace('/.', '/_').replace('\\.', '\\_')
                if new_filename and new_filename[0] == '.':
                    new_filename = '_' + new_filename[1:]
                # Fix for precomposed characters on OSX
                if sys.platform == "darwin":
                    new_filename = unicodedata.normalize("NFD", unicode(new_filename))
        return os.path.realpath(os.path.join(new_dirname, new_filename + ext.lower()))

    def _rename(self, old_filename, metadata, settings):
        new_filename, ext = os.path.splitext(
            self._make_filename(old_filename, metadata, settings))
        if old_filename != new_filename + ext:
            new_dirname = os.path.dirname(new_filename)
            if not os.path.isdir(encode_filename(new_dirname)):
                os.makedirs(new_dirname)
            tmp_filename = new_filename
            i = 1
            while (not pathcmp(old_filename, new_filename + ext) and
                   os.path.exists(encode_filename(new_filename + ext))):
                new_filename = "%s (%d)" % (tmp_filename, i)
                i += 1
            new_filename = new_filename + ext
            self.log.debug("Moving file %r => %r", old_filename, new_filename)
            shutil.move(encode_filename(old_filename), encode_filename(new_filename))
            return new_filename
        else:
            return old_filename

    def _make_image_filename(self, image_filename, dirname, metadata, settings):
        image_filename = self._script_to_filename(image_filename, metadata, settings)
        if not image_filename:
            image_filename = "cover"
        if os.path.isabs(image_filename):
            filename = image_filename
        else:
            filename = os.path.join(dirname, image_filename)
        if settings['windows_compatible_filenames'] or sys.platform == 'win32':
            filename = filename.replace('./', '_/').replace('.\\', '_\\')
        return encode_filename(filename)

    def _save_images(self, dirname, metadata, settings):
        """Save the cover images to disk."""
        if not metadata.images:
            return
        default_filename = self._make_image_filename(
            settings["cover_image_filename"], dirname, metadata, settings)
        overwrite = settings["save_images_overwrite"]
        counters = defaultdict(lambda: 0)
        for image in metadata.images:
            filename = image["filename"]
            data = image["data"]
            mime = image["mime"]
            if filename is None:
                filename = default_filename
            else:
                filename = self._make_image_filename(filename, dirname, metadata, settings)
            image_filename = filename
            ext = mimetype.get_extension(mime, ".jpg")
            if counters[filename] > 0:
                image_filename = "%s (%d)" % (filename, counters[filename])
            counters[filename] = counters[filename] + 1
            while os.path.exists(image_filename + ext) and not overwrite:
                if os.path.getsize(image_filename + ext) == len(data):
                    self.log.debug("Identical file size, not saving %r", image_filename)
                    break
                image_filename = "%s (%d)" % (filename, counters[filename])
                counters[filename] = counters[filename] + 1
            else:
                self.log.debug("Saving cover images to %r", image_filename)
                new_dirname = os.path.dirname(image_filename)
                if not os.path.isdir(new_dirname):
                    os.makedirs(new_dirname)
                f = open(image_filename + ext, "wb")
                f.write(data)
                f.close()

    def _move_additional_files(self, old_filename, new_filename, settings):
        """Move extra files, like playlists..."""
        old_path = encode_filename(os.path.dirname(old_filename))
        new_path = encode_filename(os.path.dirname(new_filename))
        patterns = encode_filename(settings["move_additional_files_pattern"])
        patterns = filter(bool, [p.strip() for p in patterns.split()])
        for pattern in patterns:
            # FIXME glob1 is not documented, maybe we need our own implemention?
            for old_file in glob.glob1(old_path, pattern):
                new_file = os.path.join(new_path, old_file)
                old_file = os.path.join(old_path, old_file)
                # FIXME we shouldn't do this from a thread!
                if self.tagger.files.get(decode_filename(old_file)):
                    self.log.debug("File loaded in the tagger, not moving %r", old_file)
                    continue
                self.log.debug("Moving %r to %r", old_file, new_file)
                shutil.move(old_file, new_file)

    def remove(self, from_parent=True):
        if from_parent and self.parent:
            self.log.debug("Removing %r from %r", self, self.parent)
            self.parent.remove_file(self)
        self.tagger.acoustidmanager.remove(self)
        self.state = File.REMOVED

    def move(self, parent):
        if parent != self.parent:
            self.log.debug("Moving %r from %r to %r", self, self.parent, parent)
            self.clear_lookup_task()
            self.tagger._acoustid.stop_analyze(file)
            if self.parent:
                self.clear_pending()
                self.parent.remove_file(self)
            self.parent = parent
            self.parent.add_file(self)
            self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_trackid'])

    def _move(self, parent):
        if parent != self.parent:
            self.log.debug("Moving %r from %r to %r", self, self.parent, parent)
            if self.parent:
                self.parent.remove_file(self)
            self.parent = parent
            self.tagger.acoustidmanager.update(self, self.metadata['musicbrainz_trackid'])

    def supports_tag(self, name):
        """Returns whether tag ``name`` can be saved to the file."""
        return True

    def is_saved(self):
        return self.similarity == 1.0 and self.state == File.NORMAL

    def update(self, signal=True):
        names = set(self.metadata.keys())
        names.update(self.orig_metadata.keys())
        clear_existing_tags = self.config.setting["clear_existing_tags"]
        for name in names:
            if not name.startswith('~') and self.supports_tag(name):
                new_values = self.metadata.getall(name)
                if not (new_values or clear_existing_tags):
                    continue
                orig_values = self.orig_metadata.getall(name)
                if orig_values != new_values:
                    self.similarity = self.orig_metadata.compare(self.metadata)
                    if self.state in (File.CHANGED, File.NORMAL):
                        self.state = File.CHANGED
                    break
        else:
            self.similarity = 1.0
            if self.state in (File.CHANGED, File.NORMAL):
                self.state = File.NORMAL
        if signal:
            self.log.debug("Updating file %r", self)
            if self.item:
                self.item.update()

    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)
        metadata['~filename'], metadata['~extension'] = os.path.splitext(
                os.path.basename(self.filename))

    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.file_state_changed.emit(File.num_pending_files)

    state = property(get_state, set_state)

    def column(self, column):
        m = self.metadata
        if column == "title" and not m["title"]:
            return self.base_filename
        return m[column]

    def _lookup_finished(self, lookuptype, document, http, error):
        self.lookup_task = None

        if self.state == File.REMOVED:
            return

        try:
            m = document.metadata[0]
            if lookuptype == "metadata":
                tracks = m.recording_list[0].recording
            elif lookuptype == "acoustid":
                tracks = m.acoustid[0].recording_list[0].recording
        except (AttributeError, IndexError):
            tracks = None

        # no matches
        if not tracks:
            self.tagger.window.set_statusbar_message(N_("No matching tracks for file %s"), self.filename, timeout=3000)
            self.clear_pending()
            return

        # multiple matches -- calculate similarities to each of them
        match = sorted((self.metadata.compare_to_track(
            track, self.comparison_weights) for track in tracks),
            reverse=True, key=itemgetter(0))[0]

        if lookuptype != 'acoustid':
            threshold = self.config.setting['file_lookup_threshold']
            if match[0] < threshold:
                self.tagger.window.set_statusbar_message(N_("No matching tracks above the threshold for file %s"), self.filename, timeout=3000)
                self.clear_pending()
                return
        self.tagger.window.set_statusbar_message(N_("File %s identified!"), self.filename, timeout=3000)
        self.clear_pending()

        rg, release, track = match[1:]
        if lookuptype == 'acoustid':
            self.tagger.acoustidmanager.add(self, track.id)
        if release:
            self.tagger.get_release_group_by_id(rg.id).loaded_albums.add(release.id)
            self.tagger.move_file_to_track(self, release.id, track.id)
        else:
            self.tagger.move_file_to_nat(self, track.id, node=track)

    def lookup_metadata(self):
        """ Try to identify the file using the existing metadata. """
        self.tagger.window.set_statusbar_message(N_("Looking up the metadata for file %s..."), self.filename)
        self.clear_lookup_task()
        metadata = self.metadata
        self.lookup_task = self.tagger.xmlws.find_tracks(partial(self._lookup_finished, 'metadata'),
            track=metadata['title'],
            artist=metadata['artist'],
            release=metadata['album'],
            tnum=metadata['tracknumber'],
            tracks=metadata['totaltracks'],
            qdur=str(metadata.length / 2000),
            isrc=metadata['isrc'],
            limit=25)

    def clear_lookup_task(self):
        if self.lookup_task:
            self.tagger.xmlws.remove_task(self.lookup_task)
            self.lookup_task = None

    def set_pending(self):
        if self.state != File.REMOVED:
            self.state = File.PENDING
            self.update()

    def clear_pending(self):
        if self.state == File.PENDING:
            self.state = File.NORMAL
            self.update()

    def iterfiles(self, save=False):
        yield self

    def _get_tracknumber(self):
        try:
            return int(self.metadata["tracknumber"])
        except:
            return 0
    tracknumber = property(_get_tracknumber, doc="The track number as an int.")

    def _get_discnumber(self):
        try:
            return int(self.metadata["discnumber"])
        except:
            return 0
    discnumber = property(_get_discnumber, doc="The disc number as an int.")
Exemple #5
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": 14,
        "releasecountry": 2,
        "format": 2,
        "isvideo": 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 '<%s %r>' % (type(self).__name__, 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)
        run_file_post_load_processors(self)
        self.update()
        callback(self)

    def _copy_loaded_metadata(self, metadata):
        filename, _ = os.path.splitext(self.base_filename)
        metadata['~length'] = format_time(metadata.length)
        if 'tracknumber' not in metadata:
            tracknumber = tracknum_from_filename(self.base_filename)
            if tracknumber != -1:
                tracknumber = str(tracknumber)
                metadata['tracknumber'] = tracknumber
                if 'title' not in metadata:
                    stripped_filename = filename.lstrip('0')
                    tnlen = len(tracknumber)
                    if stripped_filename[:tnlen] == tracknumber:
                        metadata['title'] = stripped_filename[tnlen:].lstrip()
        if 'title' not in metadata:
            metadata['title'] = filename
        self.orig_metadata = metadata
        self.metadata.copy(metadata)

    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
        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[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.copy()
        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.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)
        # 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:
                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.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):
        old_filename = new_filename
        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['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)
            # 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
            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"] or settings["move_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"]
        ignored_tags = config.setting["compare_ignore_tags"]
        for name in names:
            if (not name.startswith('~') and self.supports_tag(name)
                    and name not in ignored_tags):
                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, 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 _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.metadata['acoustid_id'] = node.get('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 = None, None

            if best_match.result.release:
                release_group_id = best_match.result.releasegroup['id']
                release_id = best_match.result.release['id']
            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