Пример #1
0
    def new_metadata(self):
        if not config.setting["write_id3v23"]:
            return self.metadata

        copy = Metadata()
        copy.copy(self.metadata)

        join_with = config.setting["id3v23_join_with"]
        copy.multi_valued_joiner = join_with

        for name, values in copy.rawitems():
            # ID3v23 can only save TDOR dates in YYYY format. Mutagen cannot
            # handle ID3v23 dates which are YYYY-MM rather than YYYY or
            # YYYY-MM-DD.

            if name == "originaldate":
                values = [v[:4] for v in values]
            elif name == "date":
                values = [(v[:4] if len(v) < 10 else v) for v in values]

            # If this is a multi-valued field, then it needs to be flattened,
            # unless it's TIPL or TMCL which can still be multi-valued.

            if (len(values) > 1 and not name in ID3File._rtipl_roles
                    and not name.startswith("performer:")):
                values = [join_with.join(values)]

            copy[name] = values

        return copy
Пример #2
0
 def _script_to_filename(self, format, file_metadata, settings=None):
     if settings is None:
         settings = config.setting
     metadata = Metadata()
     if config.setting["clear_existing_tags"]:
         metadata.copy(file_metadata)
     else:
         metadata.copy(self.orig_metadata)
         metadata.update(file_metadata)
     # make sure every metadata can safely be used in a path name
     for name in metadata.keys():
         if isinstance(metadata[name], basestring):
             metadata[name] = sanitize_filename(metadata[name])
     format = format.replace("\t", "").replace("\n", "")
     filename = ScriptParser().eval(format, metadata, self)
     if settings["ascii_filenames"]:
         if isinstance(filename, unicode):
             filename = unaccent(filename)
         filename = replace_non_ascii(filename)
     # replace incompatible characters
     if settings["windows_compatibility"] or sys.platform == "win32":
         filename = replace_win32_incompat(filename)
     # remove null characters
     filename = filename.replace("\x00", "")
     return filename
 def save(pf):
     metadata = Metadata()
     metadata.copy(pf.metadata)
     mf = MFile(pf.filename)
     if mf is not None:
         mf.delete()
     return pf._save_and_rename(pf.filename, metadata)
Пример #4
0
 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))
Пример #5
0
 def _script_to_filename(self, naming_format, file_metadata, settings=None):
     metadata = Metadata()
     if config.setting["clear_existing_tags"]:
         metadata.copy(file_metadata)
     else:
         metadata.copy(self.orig_metadata)
         metadata.update(file_metadata)
     return script_to_filename(naming_format, metadata, file=self, settings=settings)
Пример #6
0
 def test_metadata_copy(self):
     m = Metadata()
     m["old"] = "old-value"
     self.metadata.delete("single1")
     m.copy(self.metadata)
     self.assertEqual(self.metadata._store, m._store)
     self.assertEqual(self.metadata.deleted_tags, m.deleted_tags)
     self.assertEqual(self.metadata.length, m.length)
     self.assertEqual(self.metadata.images, m.images)
Пример #7
0
 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)
Пример #8
0
 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)
Пример #9
0
 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)
Пример #10
0
 def _make_image_filename(self, filename, dirname, _metadata):
     metadata = Metadata()
     metadata.copy(_metadata)
     metadata["coverart_maintype"] = self.maintype
     metadata["coverart_comment"] = self.comment
     if self.is_front:
         metadata.add_unique("coverart_types", "front")
     for cover_type in self.types:
         metadata.add_unique("coverart_types", cover_type)
     filename = script_to_filename(filename, metadata)
     if not filename:
         filename = "cover"
     if not os.path.isabs(filename):
         filename = os.path.join(dirname, filename)
     return encode_filename(filename)
Пример #11
0
 def _make_image_filename(self, filename, dirname, _metadata):
     metadata = Metadata()
     metadata.copy(_metadata)
     metadata["coverart_maintype"] = self.maintype
     metadata["coverart_comment"] = self.comment
     if self.is_front:
         metadata.add_unique("coverart_types", "front")
     for cover_type in self.types:
         metadata.add_unique("coverart_types", cover_type)
     filename = script_to_filename(filename, metadata)
     if not filename:
         filename = "cover"
     if not os.path.isabs(filename):
         filename = os.path.join(dirname, filename)
     return encode_filename(filename)
Пример #12
0
 def _load_tracks(self, release_node, album):
     # this happens after the album metadata processor in picard
     self.tracks = []
     for medium_node in release_node.medium_list[0].medium:
         mm = Metadata()
         mm.copy(album._new_metadata)
         medium_to_metadata(medium_node, mm)
         for track_node in medium_node.track_list[0].track:
             track = Track(track_node.recording[0].id, album)
             self.tracks.append(track)
             # Get track metadata
             tm = track.metadata
             tm.copy(mm)
             self._track_to_metadata(track_node, track)
             track._customize_metadata()
Пример #13
0
 def _make_image_filename(self, filename, dirname, _metadata):
     metadata = Metadata()
     metadata.copy(_metadata)
     metadata["coverart_maintype"] = self.maintype
     metadata["coverart_comment"] = self.comment
     if self.is_front:
         metadata.add_unique("coverart_types", "front")
     for cover_type in self.types:
         metadata.add_unique("coverart_types", cover_type)
     filename = script_to_filename(filename, metadata)
     if not filename:
         filename = DEFAULT_COVER_IMAGE_FILENAME
     if not is_absolute_path(filename):
         filename = os.path.join(dirname, filename)
     return encode_filename(filename)
Пример #14
0
 def _load_tracks(self, release_node, album):
     # this happens after the album metadata processor in picard
     self.tracks = []
     for medium_node in release_node['media']:
         mm = Metadata()
         mm.copy(album._new_metadata)  # noqa
         medium_to_metadata(medium_node, mm)
         for track_node in medium_node['tracks']:
             track = Track(track_node['recording']['id'], album)
             self.tracks.append(track)
             # Get track metadata
             tm = track.metadata
             tm.copy(mm)
             track_to_metadata(track_node, track)
             track._customize_metadata()  # noqa
Пример #15
0
 def _load_tracks(self, release_node, album):
     # this happens after the album metadata processor in picard
     self.tracks = []
     for medium_node in release_node.medium_list[0].medium:
         mm = Metadata()
         mm.copy(album._new_metadata)
         medium_to_metadata(medium_node, mm)
         for track_node in medium_node.track_list[0].track:
             track = Track(track_node.recording[0].id, album)
             self.tracks.append(track)
             # Get track metadata
             tm = track.metadata
             tm.copy(mm)
             self._track_to_metadata(track_node, track)
             track._customize_metadata()
Пример #16
0
 def _load(self, lines):
     metadata = Metadata()
     metadata.copy(self.metadata)
     metadata.add("album", self.metadata.get("title"))
     del metadata["title"]
     for line in lines:
         splitline = line.split()
         # linetokv?
         key = splitline[0]
         value = " ".join(splitline[1:])
         if key == "INDEX":
             index, value = value.split()
             self.indexes[index] = value
         elif not key == "TRACK":
             self.kv_to_metadata(key, value, metadata)
     return metadata
Пример #17
0
 def _script_to_filename(self, format, file_metadata, settings):
     metadata = Metadata()
     metadata.copy(file_metadata)
     # make sure every metadata can safely be used in a path name
     for name in metadata.keys():
         if isinstance(metadata[name], basestring):
             metadata[name] = sanitize_filename(metadata[name])
     format = format.replace("\t", "").replace("\n", "")
     filename = ScriptParser().eval(format, metadata, self)
     # replace incompatible characters
     if settings["windows_compatible_filenames"] or sys.platform == "win32":
         filename = replace_win32_incompat(filename)
     if settings["ascii_filenames"]:
         if isinstance(filename, unicode):
             filename = unaccent(filename)
         filename = replace_non_ascii(filename)
     return filename
Пример #18
0
 def _script_to_filename(self, format, file_metadata, settings):
     metadata = Metadata()
     metadata.copy(file_metadata)
     # make sure every metadata can safely be used in a path name
     for name in metadata.keys():
         if isinstance(metadata[name], basestring):
             metadata[name] = sanitize_filename(metadata[name])
     format = format.replace("\t", "").replace("\n", "")
     filename = ScriptParser().eval(format, metadata, self)
     # replace incompatible characters
     if settings["windows_compatible_filenames"] or sys.platform == "win32":
         filename = replace_win32_incompat(filename)
     if settings["ascii_filenames"]:
         if isinstance(filename, unicode):
             filename = unaccent(filename)
         filename = replace_non_ascii(filename)
     return filename
Пример #19
0
 def _script_to_filename(self, naming_format, file_metadata, file_extension, settings=None):
     if settings is None:
         config = get_config()
         settings = config.setting
     metadata = Metadata()
     if settings["clear_existing_tags"]:
         # script_to_filename_with_metadata guarantees this is not modified
         metadata = file_metadata
     else:
         metadata.copy(self.orig_metadata)
         metadata.update(file_metadata)
     (filename, new_metadata) = script_to_filename_with_metadata(
         naming_format, metadata, file=self, settings=settings)
     if not filename:
         return None
     # NOTE: the filename generated by the naming script does not have a file extension
     ext = new_metadata.get('~extension', file_extension)
     return filename + '.' + ext.lstrip('.')
Пример #20
0
 def _script_to_filename(self,
                         naming_format,
                         file_metadata,
                         file_extension,
                         settings=None):
     if settings is None:
         settings = config.setting
     metadata = Metadata()
     if settings["clear_existing_tags"]:
         metadata.copy(file_metadata)
     else:
         metadata.copy(self.orig_metadata)
         metadata.update(file_metadata)
     (filename,
      new_metadata) = script_to_filename_with_metadata(naming_format,
                                                       metadata,
                                                       file=self,
                                                       settings=settings)
     # NOTE: the script_to_filename strips the extension away
     ext = new_metadata.get('~extension', file_extension)
     return filename + '.' + ext.lstrip('.')
Пример #21
0
 def _make_image_filename(self, filename, dirname, _metadata):
     metadata = Metadata()
     metadata.copy(_metadata)
     metadata["coverart_maintype"] = self.maintype
     metadata["coverart_comment"] = self.comment
     if self.is_front:
         metadata.add_unique("coverart_types", "front")
     for cover_type in self.types:
         metadata.add_unique("coverart_types", cover_type)
     filename = ScriptParser().eval(filename, metadata)
     if config.setting["ascii_filenames"]:
         filename = replace_non_ascii(filename, pathsave=True)
     if not filename:
         filename = "cover"
     if not os.path.isabs(filename):
         filename = os.path.join(dirname, filename)
     # replace incompatible characters
     if config.setting["windows_compatibility"] or sys.platform == "win32":
         filename = replace_win32_incompat(filename)
     # remove null characters
     if isinstance(filename, bytes):
         filename = filename.replace(b"\x00", "")
     return encode_filename(filename)
Пример #22
0
 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
Пример #23
0
    def _finalize_loading(self, error):
        if error:
            self.metadata.clear()
            self.status = _("[could not load album %s]") % self.id
            del self._new_metadata
            del self._new_tracks
            self.update()
            return

        if self._requests > 0:
            return

        if not self._tracks_loaded:
            artists = set()
            totalalbumtracks = 0
            absolutetracknumber = 0
            va = self._new_metadata[
                'musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID

            djmix_ars = {}
            if hasattr(self._new_metadata, "_djmix_ars"):
                djmix_ars = self._new_metadata._djmix_ars

            for medium_node in self._release_node.medium_list[0].medium:
                mm = Metadata()
                mm.copy(self._new_metadata)
                medium_to_metadata(medium_node, mm)
                discpregap = False

                for dj in djmix_ars.get(mm["discnumber"], []):
                    mm.add("djmixer", dj)

                if "pregap" in medium_node.children:
                    discpregap = True
                    absolutetracknumber += 1
                    track = self._finalize_loading_track(
                        medium_node.pregap[0], mm, artists, va,
                        absolutetracknumber, discpregap)
                    track.metadata['~pregap'] = "1"

                for track_node in medium_node.track_list[0].track:
                    absolutetracknumber += 1
                    track = self._finalize_loading_track(
                        track_node, mm, artists, va, absolutetracknumber,
                        discpregap)

                if "data_track_list" in medium_node.children:
                    for track_node in medium_node.data_track_list[0].track:
                        absolutetracknumber += 1
                        track = self._finalize_loading_track(
                            track_node, mm, artists, va, absolutetracknumber,
                            discpregap)
                        track.metadata['~datatrack'] = "1"

            totalalbumtracks = str(absolutetracknumber)

            for track in self._new_tracks:
                track.metadata["~totalalbumtracks"] = totalalbumtracks
                if len(artists) > 1:
                    track.metadata["~multiartist"] = "1"
            del self._release_node
            self._tracks_loaded = True

        if not self._requests:
            # Prepare parser for user's script
            if config.setting["enable_tagger_script"]:
                script = config.setting["tagger_script"]
                if script:
                    parser = ScriptParser()
                    for track in self._new_tracks:
                        # Run tagger script for each track
                        try:
                            parser.eval(script, track.metadata)
                        except:
                            self.error_append(traceback.format_exc())
                        # Strip leading/trailing whitespace
                        track.metadata.strip_whitespace()
                    # Run tagger script for the album itself
                    try:
                        parser.eval(script, self._new_metadata)
                    except:
                        self.error_append(traceback.format_exc())
                    self._new_metadata.strip_whitespace()

            for track in self.tracks:
                for file in list(track.linked_files):
                    file.move(self.unmatched_files)
            self.metadata = self._new_metadata
            self.tracks = self._new_tracks
            del self._new_metadata
            del self._new_tracks
            self.loaded = True
            self.status = None
            self.match_files(self.unmatched_files.files)
            self.update()
            self.tagger.window.set_statusbar_message(
                N_('Album %(id)s loaded: %(artist)s - %(album)s'), {
                    'id': self.id,
                    'artist': self.metadata['albumartist'],
                    'album': self.metadata['album']
                },
                timeout=3000)
            for func in self._after_load_callbacks:
                func()
            self._after_load_callbacks = []
Пример #24
0
    def _finalize_loading(self, error):
        if error:
            self.metadata.clear()
            self.metadata['album'] = _("[could not load album %s]") % self.id
            del self._new_metadata
            del self._new_tracks
            self.update()
            return

        if self._requests > 0:
            return

        if not self._tracks_loaded:
            artists = set()
            totalalbumtracks = 0

            djmix_ars = {}
            if hasattr(self._new_metadata, "_djmix_ars"):
                djmix_ars = self._new_metadata._djmix_ars

            for medium_node in self._release_node.medium_list[0].medium:
                mm = Metadata()
                mm.copy(self._new_metadata)
                medium_to_metadata(medium_node, mm)
                totalalbumtracks += int(mm["totaltracks"])

                for dj in djmix_ars.get(mm["discnumber"], []):
                    mm.add("djmixer", dj)

                for track_node in medium_node.track_list[0].track:
                    track = Track(track_node.recording[0].id, self)
                    self._new_tracks.append(track)

                    # Get track metadata
                    tm = track.metadata
                    tm.copy(mm)
                    track_to_metadata(track_node, track, self.config)
                    track._customize_metadata()

                    self._new_metadata.length += tm.length
                    artists.add(tm["musicbrainz_artistid"])

                    # Run track metadata plugins
                    try:
                        run_track_metadata_processors(self, tm,
                                                      self._release_node,
                                                      track_node)
                    except:
                        self.log.error(traceback.format_exc())

            totalalbumtracks = str(totalalbumtracks)

            for track in self._new_tracks:
                track.metadata["~totalalbumtracks"] = totalalbumtracks
                if len(artists) > 1:
                    track.metadata["compilation"] = "1"

            del self._release_node
            self._tracks_loaded = True

        if not self._requests:
            # Prepare parser for user's script
            if self.config.setting["enable_tagger_script"]:
                script = self.config.setting["tagger_script"]
                if script:
                    parser = ScriptParser()
                    for track in self._new_tracks:
                        # Run tagger script for each track
                        try:
                            parser.eval(script, track.metadata)
                        except:
                            self.log.error(traceback.format_exc())
                        # Strip leading/trailing whitespace
                        track.metadata.strip_whitespace()
                    # Run tagger script for the album itself
                    try:
                        parser.eval(script, self._new_metadata)
                    except:
                        self.log.error(traceback.format_exc())
                    self._new_metadata.strip_whitespace()

            for track in self.tracks:
                for file in list(track.linked_files):
                    file.move(self.unmatched_files)
            self.metadata = self._new_metadata
            self.tracks = self._new_tracks
            del self._new_metadata
            del self._new_tracks
            self.loaded = True
            self.match_files(self.unmatched_files.files)
            self.update()
            self.tagger.window.set_statusbar_message('Album %s loaded',
                                                     self.id,
                                                     timeout=3000)
            while self._after_load_callbacks.qsize() > 0:
                func = self._after_load_callbacks.get()
                func()
Пример #25
0
    def _finalize_loading(self, error):
        if error:
            self.metadata.clear()
            self.status = _("[could not load album %s]") % self.id
            del self._new_metadata
            del self._new_tracks
            self.update()
            return

        if self._requests > 0:
            return

        if not self._tracks_loaded:
            artists = set()
            totalalbumtracks = 0
            absolutetracknumber = 0
            va = self._new_metadata['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID

            djmix_ars = {}
            if hasattr(self._new_metadata, "_djmix_ars"):
                djmix_ars = self._new_metadata._djmix_ars

            for medium_node in self._release_node['media']:
                mm = Metadata()
                mm.copy(self._new_metadata)
                medium_to_metadata(medium_node, mm)
                discpregap = False

                for dj in djmix_ars.get(mm["discnumber"], []):
                    mm.add("djmixer", dj)

                if 'discs' in medium_node:
                    discids = [disc.get('id') for disc in medium_node['discs']]
                    mm['~musicbrainz_discids'] = discids
                    mm['musicbrainz_discid'] = list(self._discids.intersection(discids))

                if "pregap" in medium_node:
                    discpregap = True
                    absolutetracknumber += 1
                    track = self._finalize_loading_track(medium_node['pregap'], mm, artists, va, absolutetracknumber, discpregap)
                    track.metadata['~pregap'] = "1"

                track_count = medium_node['track-count']
                if track_count:
                    tracklist_node = medium_node['tracks']
                    for track_node in tracklist_node:
                        absolutetracknumber += 1
                        track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap)

                if "data-tracks" in medium_node:
                    for track_node in medium_node['data-tracks']:
                        absolutetracknumber += 1
                        track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap)
                        track.metadata['~datatrack'] = "1"

            totalalbumtracks = str(absolutetracknumber)

            for track in self._new_tracks:
                track.metadata["~totalalbumtracks"] = totalalbumtracks
                if len(artists) > 1:
                    track.metadata["~multiartist"] = "1"
            del self._release_node
            del self._release_artist_nodes
            self._tracks_loaded = True

        if not self._requests:
            self.enable_update_metadata_images(False)
            # Prepare parser for user's script
            for s_name, s_text in enabled_tagger_scripts_texts():
                parser = ScriptParser()
                for track in self._new_tracks:
                    # Run tagger script for each track
                    try:
                        parser.eval(s_text, track.metadata)
                    except ScriptError:
                        log.exception("Failed to run tagger script %s on track", s_name)
                    track.metadata.strip_whitespace()
                # Run tagger script for the album itself
                try:
                    parser.eval(s_text, self._new_metadata)
                except ScriptError:
                    log.exception("Failed to run tagger script %s on album", s_name)
                self._new_metadata.strip_whitespace()

            for track in self.tracks:
                track.metadata_images_changed.connect(self.update_metadata_images)
                for file in list(track.linked_files):
                    file.move(self.unmatched_files)
            self.metadata = self._new_metadata
            self.tracks = self._new_tracks
            del self._new_metadata
            del self._new_tracks
            self.loaded = True
            self.status = None
            self.match_files(self.unmatched_files.files)
            self.enable_update_metadata_images(True)
            self.update()
            self.tagger.window.set_statusbar_message(
                N_('Album %(id)s loaded: %(artist)s - %(album)s'),
                {
                    'id': self.id,
                    'artist': self.metadata['albumartist'],
                    'album': self.metadata['album']
                },
                timeout=3000
            )
            for func in self._after_load_callbacks:
                func()
            self._after_load_callbacks = []
Пример #26
0
class Track(DataObject, Item):

    metadata_images_changed = QtCore.pyqtSignal()

    def __init__(self, track_id, album=None):
        DataObject.__init__(self, track_id)
        self.album = album
        self.linked_files = []
        self.num_linked_files = 0
        self.metadata = Metadata()
        self.orig_metadata = Metadata()
        self._track_artists = []

    def __repr__(self):
        return '<Track %s %r>' % (self.id, self.metadata["title"])

    def add_file(self, file):
        if file not in self.linked_files:
            self.linked_files.append(file)
            self.num_linked_files += 1
        self.album._add_file(self, file)
        self.update_file_metadata(file)
        file.metadata_images_changed.connect(self.update_orig_metadata_images)

    def update_file_metadata(self, file):
        if file not in self.linked_files:
            return
        file.copy_metadata(self.orig_metadata)
        file.metadata['~extension'] = file.orig_metadata['~extension']
        self.metadata.copy(file.metadata)

        # Re-run tagger scripts with updated metadata
        for s_name, s_text in enabled_tagger_scripts_texts():
            parser = ScriptParser()
            try:
                parser.eval(s_text, file.metadata)
                parser.eval(s_text, self.metadata)
            except:
                log.exception("Failed to run tagger script %s on file", s_name)
            file.metadata.strip_whitespace()
            self.metadata.strip_whitespace()

        file.metadata.changed = True
        file.update(signal=False)
        self.update()

    def remove_file(self, file):
        if file not in self.linked_files:
            return
        self.linked_files.remove(file)
        self.num_linked_files -= 1
        file.copy_metadata(file.orig_metadata)
        self.album._remove_file(self, file)
        file.metadata_images_changed.disconnect(self.update_orig_metadata_images)

        if self.num_linked_files > 0:
            self.metadata.copy(self.linked_files[-1].orig_metadata)
        else:
            self.metadata.copy(self.orig_metadata)

        # Restore to non-associated state
        for s_name, s_text in enabled_tagger_scripts_texts():
            parser = ScriptParser()
            try:
                parser.eval(s_text, self.metadata)
            except:
                log.exception("Failed to run tagger script %s on track", s_name)
            self.metadata.strip_whitespace()

        self.update()

    def update(self):
        if self.item:
            self.item.update()
        self.update_orig_metadata_images()

    def iterfiles(self, save=False):
        for file in self.linked_files:
            yield file

    def is_linked(self):
        return self.num_linked_files > 0

    def can_save(self):
        """Return if this object can be saved."""
        for file in self.linked_files:
            if file.can_save():
                return True
        return False

    def can_remove(self):
        """Return if this object can be removed."""
        for file in self.linked_files:
            if file.can_remove():
                return True
        return False

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

    def can_view_info(self):
        return self.num_linked_files == 1 or self.metadata.images

    def column(self, column):
        m = self.metadata
        if column == 'title':
            prefix = "%s-" % m['discnumber'] if m['discnumber'] and m['totaldiscs'] != "1" else ""
            return "%s%s  %s" % (prefix, m['tracknumber'].zfill(2), m['title'])
        return m[column]

    def is_video(self):
        return self.metadata['~video'] == '1'

    def is_pregap(self):
        return self.metadata['~pregap'] == '1'

    def is_data(self):
        return self.metadata['~datatrack'] == '1'

    def is_silence(self):
        return self.metadata['~silence'] == '1'

    def is_complete(self):
        return self.ignored_for_completeness() or self.num_linked_files == 1

    def ignored_for_completeness(self):
        if (config.setting['completeness_ignore_videos'] and self.is_video()) \
            or (config.setting['completeness_ignore_pregap'] and self.is_pregap()) \
            or (config.setting['completeness_ignore_data'] and self.is_data()) \
            or (config.setting['completeness_ignore_silence'] and self.is_silence()):
            return True
        return False

    def append_track_artist(self, ta_id):
        """Append artist id to the list of track artists
        and return an TrackArtist instance"""
        track_artist = TrackArtist(ta_id)
        self._track_artists.append(track_artist)
        return track_artist

    def _customize_metadata(self):
        tm = self.metadata

        # Custom VA name
        if tm['musicbrainz_artistid'] == VARIOUS_ARTISTS_ID:
            tm['artistsort'] = tm['artist'] = config.setting['va_name']

        if tm['title'] == DATA_TRACK_TITLE:
            tm['~datatrack'] = '1'

        if tm['title'] == SILENCE_TRACK_TITLE:
            tm['~silence'] = '1'

        if config.setting['folksonomy_tags']:
            self._convert_folksonomy_tags_to_genre()

        # Convert Unicode punctuation
        if config.setting['convert_punctuation']:
            tm.apply_func(asciipunct)

    def _convert_folksonomy_tags_to_genre(self):
        # Combine release and track tags
        tags = dict(self.folksonomy_tags)
        self.merge_folksonomy_tags(tags, self.album.folksonomy_tags)
        if self.album.release_group:
            self.merge_folksonomy_tags(tags, self.album.release_group.folksonomy_tags)
        if not tags and config.setting['artists_tags']:
            # For compilations use each track's artists to look up tags
            if self.metadata['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID:
                for artist in self._track_artists:
                    self.merge_folksonomy_tags(tags, artist.folksonomy_tags)
            else:
                for artist in self.album.get_album_artists():
                    self.merge_folksonomy_tags(tags, artist.folksonomy_tags)
        # Ignore tags with zero or lower score
        tags = dict((name, count) for name, count in tags.items() if count > 0)
        if not tags:
            return
        # Convert counts to values from 0 to 100
        maxcount = max(tags.values())
        taglist = []
        for name, count in tags.items():
            taglist.append((100 * count // maxcount, name))
        taglist.sort(reverse=True)
        # And generate the genre metadata tag
        maxtags = config.setting['max_tags']
        minusage = config.setting['min_tag_usage']
        ignore_tags = self._get_ignored_folksonomy_tags()
        genre = []
        for usage, name in taglist[:maxtags]:
            if name.lower() in ignore_tags:
                continue
            if usage < minusage:
                break
            name = _TRANSLATE_TAGS.get(name, name.title())
            genre.append(name)
        join_tags = config.setting['join_tags']
        if join_tags:
            genre = [join_tags.join(genre)]
        self.metadata['genre'] = genre

    def _get_ignored_folksonomy_tags(self):
        tags = []
        ignore_tags = config.setting['ignore_tags']
        if ignore_tags:
            tags = [s.strip().lower() for s in ignore_tags.split(',')]
        return tags

    def update_orig_metadata_images(self):
        update_metadata_images(self)

    def keep_original_images(self):
        for file in self.linked_files:
            file.keep_original_images()
        if self.linked_files:
            self.update_orig_metadata_images()
            self.metadata.images = self.orig_metadata.images[:]
        else:
            self.metadata.images = []
        self.update()
Пример #27
0
    def _finalize_loading(self, error):
        if error:
            self.metadata.clear()
            self.status = _("[could not load album %s]") % self.id
            del self._new_metadata
            del self._new_tracks
            self.update()
            return

        if self._requests > 0:
            return

        if not self._tracks_loaded:
            artists = set()
            all_media = []
            absolutetracknumber = 0

            va = self._new_metadata['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID

            djmix_ars = {}
            if hasattr(self._new_metadata, "_djmix_ars"):
                djmix_ars = self._new_metadata._djmix_ars

            for medium_node in self._release_node['media']:
                mm = Metadata()
                mm.copy(self._new_metadata)
                medium_to_metadata(medium_node, mm)
                discpregap = False
                format = medium_node.get('format')
                if format:
                    all_media.append(format)

                for dj in djmix_ars.get(mm["discnumber"], []):
                    mm.add("djmixer", dj)

                if 'discs' in medium_node:
                    discids = [disc.get('id') for disc in medium_node['discs']]
                    mm['~musicbrainz_discids'] = discids
                    mm['musicbrainz_discid'] = list(self._discids.intersection(discids))

                if "pregap" in medium_node:
                    discpregap = True
                    absolutetracknumber += 1
                    track = self._finalize_loading_track(medium_node['pregap'], mm, artists, va, absolutetracknumber, discpregap)
                    track.metadata['~pregap'] = "1"

                track_count = medium_node['track-count']
                if track_count:
                    tracklist_node = medium_node['tracks']
                    for track_node in tracklist_node:
                        absolutetracknumber += 1
                        track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap)

                if "data-tracks" in medium_node:
                    for track_node in medium_node['data-tracks']:
                        absolutetracknumber += 1
                        track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap)
                        track.metadata['~datatrack'] = "1"

            totalalbumtracks = absolutetracknumber
            self._new_metadata['~totalalbumtracks'] = totalalbumtracks
            # Generate a list of unique media, but keep order of first appearance
            self._new_metadata['media'] = " / ".join(list(OrderedDict.fromkeys(all_media)))

            for track in self._new_tracks:
                track.metadata["~totalalbumtracks"] = totalalbumtracks
                if len(artists) > 1:
                    track.metadata["~multiartist"] = "1"
            del self._release_node
            del self._release_artist_nodes
            self._tracks_loaded = True

        if not self._requests:
            self.enable_update_metadata_images(False)
            # Prepare parser for user's script
            for s_name, s_text in enabled_tagger_scripts_texts():
                parser = ScriptParser()
                for track in self._new_tracks:
                    # Run tagger script for each track
                    try:
                        parser.eval(s_text, track.metadata)
                    except ScriptError:
                        log.exception("Failed to run tagger script %s on track", s_name)
                    track.metadata.strip_whitespace()
                # Run tagger script for the album itself
                try:
                    parser.eval(s_text, self._new_metadata)
                except ScriptError:
                    log.exception("Failed to run tagger script %s on album", s_name)
                self._new_metadata.strip_whitespace()

            for track in self.tracks:
                track.metadata_images_changed.connect(self.update_metadata_images)
                for file in list(track.linked_files):
                    file.move(self.unmatched_files)
            self.metadata = self._new_metadata
            self.tracks = self._new_tracks
            del self._new_metadata
            del self._new_tracks
            self.loaded = True
            self.status = None
            self.match_files(self.unmatched_files.files)
            self.enable_update_metadata_images(True)
            self.update()
            self.tagger.window.set_statusbar_message(
                N_('Album %(id)s loaded: %(artist)s - %(album)s'),
                {
                    'id': self.id,
                    'artist': self.metadata['albumartist'],
                    'album': self.metadata['album']
                },
                timeout=3000
            )
            for func in self._after_load_callbacks:
                func()
            self._after_load_callbacks = []
Пример #28
0
class Album(DataObject, Item):

    metadata_images_changed = QtCore.pyqtSignal()

    def __init__(self, album_id, discid=None):
        DataObject.__init__(self, album_id)
        self.metadata = Metadata()
        self.orig_metadata = Metadata()
        self.tracks = []
        self.loaded = False
        self.load_task = None
        self.release_group = None
        self._files = 0
        self._requests = 0
        self._tracks_loaded = False
        self._discids = set()
        if discid:
            self._discids.add(discid)
        self._after_load_callbacks = []
        self.unmatched_files = Cluster(_("Unmatched Files"),
                                       special=True,
                                       related_album=self,
                                       hide_if_empty=True)
        self.unmatched_files.metadata_images_changed.connect(
            self.update_metadata_images)
        self.status = None
        self._album_artists = []
        self.update_metadata_images_enabled = True

    def __repr__(self):
        return '<Album %s %r>' % (self.id, self.metadata["album"])

    def iterfiles(self, save=False):
        for track in self.tracks:
            yield from track.iterfiles()
        if not save:
            yield from self.unmatched_files.iterfiles()

    def enable_update_metadata_images(self, enabled):
        self.update_metadata_images_enabled = enabled

    def append_album_artist(self, album_artist_id):
        """Append artist id to the list of album artists
        and return an AlbumArtist instance"""
        album_artist = AlbumArtist(album_artist_id)
        self._album_artists.append(album_artist)
        return album_artist

    def add_discid(self, discid):
        if not discid:
            return
        self._discids.add(discid)
        for track in self.tracks:
            medium_discids = track.metadata.getall('~musicbrainz_discids')
            track_discids = list(self._discids.intersection(medium_discids))
            if track_discids:
                track.metadata['musicbrainz_discid'] = track_discids
                track.update()
                for file in track.files:
                    file.metadata['musicbrainz_discid'] = track_discids
                    file.update()

    def get_next_track(self, track):
        try:
            index = self.tracks.index(track)
            return self.tracks[index + 1]
        except (IndexError, ValueError):
            return None

    def get_album_artists(self):
        """Returns the list of album artists (as AlbumArtist objects)"""
        return self._album_artists

    def _parse_release(self, release_node):
        log.debug("Loading release %r ...", self.id)
        self._tracks_loaded = False
        release_id = release_node['id']
        if release_id != self.id:
            self.tagger.mbid_redirects[self.id] = release_id
            album = self.tagger.albums.get(release_id)
            if album:
                log.debug("Release %r already loaded", release_id)
                album.match_files(self.unmatched_files.files)
                album.update()
                self.tagger.remove_album(self)
                return False
            else:
                del self.tagger.albums[self.id]
                self.tagger.albums[release_id] = self
                self.id = release_id

        # Make the release artist nodes available, since they may
        # contain supplementary data (aliases, tags, genres, ratings)
        # which aren't present in the release group, track, or
        # recording artist nodes. We can copy them into those places
        # wherever the IDs match, so that the data is shared and
        # available for use in mbjson.py and external plugins.
        self._release_artist_nodes = _create_artist_node_dict(release_node)

        # Get release metadata
        m = self._new_metadata
        m.length = 0

        rg_node = release_node['release-group']
        rg = self.release_group = self.tagger.get_release_group_by_id(
            rg_node['id'])
        rg.loaded_albums.add(self.id)
        rg.refcount += 1

        _copy_artist_nodes(self._release_artist_nodes, rg_node)
        release_group_to_metadata(rg_node, rg.metadata, rg)
        m.copy(rg.metadata)
        release_to_metadata(release_node, m, album=self)

        config = get_config()

        # Custom VA name
        if m['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID:
            m['albumartistsort'] = m['albumartist'] = config.setting['va_name']

        # Convert Unicode punctuation
        if config.setting['convert_punctuation']:
            m.apply_func(asciipunct)

        m['totaldiscs'] = len(release_node['media'])

        # Add album to collections
        add_release_to_user_collections(release_node)

        # Run album metadata plugins
        try:
            run_album_metadata_processors(self, m, release_node)
        except BaseException:
            self.error_append(traceback.format_exc())

        self._release_node = release_node
        return True

    def _release_request_finished(self, document, http, error):
        if self.load_task is None:
            return
        self.load_task = None
        parsed = False
        try:
            if error:
                self.error_append(http.errorString())
                # Fix for broken NAT releases
                if error == QtNetwork.QNetworkReply.ContentNotFoundError:
                    config = get_config()
                    nats = False
                    nat_name = config.setting["nat_name"]
                    files = list(self.unmatched_files.files)
                    for file in files:
                        recordingid = file.metadata["musicbrainz_recordingid"]
                        if mbid_validate(recordingid) and file.metadata[
                                "album"] == nat_name:
                            nats = True
                            self.tagger.move_file_to_nat(file, recordingid)
                            self.tagger.nats.update()
                    if nats and not self.get_num_unmatched_files():
                        self.tagger.remove_album(self)
                        error = False
            else:
                try:
                    parsed = self._parse_release(document)
                except Exception:
                    error = True
                    self.error_append(traceback.format_exc())
        finally:
            self._requests -= 1
            if parsed or error:
                self._finalize_loading(error)
        # does http need to be set to None to free the memory used by the network response?
        # http://qt-project.org/doc/qt-5/qnetworkaccessmanager.html says:
        #     After the request has finished, it is the responsibility of the user
        #     to delete the QNetworkReply object at an appropriate time.
        #     Do not directly delete it inside the slot connected to finished().
        #     You can use the deleteLater() function.

    def _finalize_loading(self, error):
        if error:
            self.metadata.clear()
            self.status = _("[could not load album %s]") % self.id
            del self._new_metadata
            del self._new_tracks
            self.update()
            if not self._requests:
                self.loaded = True
                for func, always in self._after_load_callbacks:
                    if always:
                        func()
            return

        if self._requests > 0:
            return

        if not self._tracks_loaded:
            artists = set()
            all_media = []
            absolutetracknumber = 0

            va = self._new_metadata[
                'musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID

            djmix_ars = {}
            if hasattr(self._new_metadata, "_djmix_ars"):
                djmix_ars = self._new_metadata._djmix_ars

            for medium_node in self._release_node['media']:
                mm = Metadata()
                mm.copy(self._new_metadata)
                medium_to_metadata(medium_node, mm)
                format = medium_node.get('format')
                if format:
                    all_media.append(format)

                for dj in djmix_ars.get(mm["discnumber"], []):
                    mm.add("djmixer", dj)

                if va:
                    mm["compilation"] = "1"
                else:
                    del mm["compilation"]

                if 'discs' in medium_node:
                    discids = [disc.get('id') for disc in medium_node['discs']]
                    mm['~musicbrainz_discids'] = discids
                    mm['musicbrainz_discid'] = list(
                        self._discids.intersection(discids))

                if "pregap" in medium_node:
                    absolutetracknumber += 1
                    mm['~discpregap'] = '1'
                    extra_metadata = {
                        '~pregap': '1',
                        '~absolutetracknumber': absolutetracknumber,
                    }
                    self._finalize_loading_track(medium_node['pregap'], mm,
                                                 artists, extra_metadata)

                track_count = medium_node['track-count']
                if track_count:
                    tracklist_node = medium_node['tracks']
                    for track_node in tracklist_node:
                        absolutetracknumber += 1
                        extra_metadata = {
                            '~absolutetracknumber': absolutetracknumber,
                        }
                        self._finalize_loading_track(track_node, mm, artists,
                                                     extra_metadata)

                if "data-tracks" in medium_node:
                    for track_node in medium_node['data-tracks']:
                        absolutetracknumber += 1
                        extra_metadata = {
                            '~datatrack': '1',
                            '~absolutetracknumber': absolutetracknumber,
                        }
                        self._finalize_loading_track(track_node, mm, artists,
                                                     extra_metadata)

            totalalbumtracks = absolutetracknumber
            self._new_metadata['~totalalbumtracks'] = totalalbumtracks
            # Generate a list of unique media, but keep order of first appearance
            self._new_metadata['media'] = " / ".join(
                list(OrderedDict.fromkeys(all_media)))

            for track in self._new_tracks:
                track.metadata["~totalalbumtracks"] = totalalbumtracks
                if len(artists) > 1:
                    track.metadata["~multiartist"] = "1"
            del self._release_node
            del self._release_artist_nodes
            self._tracks_loaded = True

        if not self._requests:
            self.enable_update_metadata_images(False)
            for track in self._new_tracks:
                track.orig_metadata.copy(track.metadata)
                track.metadata_images_changed.connect(
                    self.update_metadata_images)

            # Prepare parser for user's script
            for s_name, s_text in enabled_tagger_scripts_texts():
                parser = ScriptParser()
                for track in self._new_tracks:
                    # Run tagger script for each track
                    try:
                        parser.eval(s_text, track.metadata)
                    except ScriptError:
                        log.exception(
                            "Failed to run tagger script %s on track", s_name)
                    track.metadata.strip_whitespace()
                    track.scripted_metadata.update(track.metadata)
                # Run tagger script for the album itself
                try:
                    parser.eval(s_text, self._new_metadata)
                except ScriptError:
                    log.exception("Failed to run tagger script %s on album",
                                  s_name)
                self._new_metadata.strip_whitespace()

            unmatched_files = [
                file for track in self.tracks for file in track.files
            ]
            self.metadata = self._new_metadata
            self.orig_metadata.copy(self.metadata)
            self.orig_metadata.images.clear()
            self.tracks = self._new_tracks
            del self._new_metadata
            del self._new_tracks
            self.loaded = True
            self.status = None
            self.match_files(unmatched_files + self.unmatched_files.files)
            self.enable_update_metadata_images(True)
            self.update()
            self.tagger.window.set_statusbar_message(
                N_('Album %(id)s loaded: %(artist)s - %(album)s'), {
                    'id': self.id,
                    'artist': self.metadata['albumartist'],
                    'album': self.metadata['album']
                },
                timeout=3000)
            for func, always in self._after_load_callbacks:
                func()
            self._after_load_callbacks = []
            if self.item.isSelected():
                self.tagger.window.refresh_metadatabox()

    def _finalize_loading_track(self,
                                track_node,
                                metadata,
                                artists,
                                extra_metadata=None):
        # As noted in `_parse_release` above, the release artist nodes
        # may contain supplementary data that isn't present in track
        # artist nodes. Similarly, the track artists may contain
        # information which the recording artists don't. Copy this
        # information across to wherever the artist IDs match.
        _copy_artist_nodes(self._release_artist_nodes, track_node)
        _copy_artist_nodes(self._release_artist_nodes, track_node['recording'])
        _copy_artist_nodes(_create_artist_node_dict(track_node),
                           track_node['recording'])

        track = Track(track_node['recording']['id'], self)
        self._new_tracks.append(track)

        # Get track metadata
        tm = track.metadata
        tm.copy(metadata)
        track_to_metadata(track_node, track)
        track._customize_metadata()

        self._new_metadata.length += tm.length
        artists.add(tm["artist"])
        if extra_metadata:
            tm.update(extra_metadata)

        # Run track metadata plugins
        try:
            run_track_metadata_processors(self, tm, track_node,
                                          self._release_node)
        except BaseException:
            self.error_append(traceback.format_exc())

        return track

    def load(self, priority=False, refresh=False):
        if self._requests:
            log.info("Not reloading, some requests are still active.")
            return
        self.tagger.window.set_statusbar_message(
            N_('Loading album %(id)s ...'), {'id': self.id})
        self.loaded = False
        self.status = _("[loading album information]")
        if self.release_group:
            self.release_group.loaded = False
            self.release_group.genres.clear()
        self.metadata.clear()
        self.genres.clear()
        self.update(update_selection=False)
        self._new_metadata = Metadata()
        self._new_tracks = []
        self._requests = 1
        self.clear_errors()
        config = get_config()
        require_authentication = False
        inc = [
            'release-groups', 'media', 'discids', 'recordings',
            'artist-credits', 'artists', 'aliases', 'labels', 'isrcs',
            'collections', 'annotation'
        ]
        if self.tagger.webservice.oauth_manager.is_authorized():
            require_authentication = True
            inc += ['user-collections']
        if config.setting['release_ars'] or config.setting['track_ars']:
            inc += [
                'artist-rels', 'release-rels', 'url-rels', 'recording-rels',
                'work-rels'
            ]
            if config.setting['track_ars']:
                inc += ['recording-level-rels', 'work-level-rels']
        require_authentication = self.set_genre_inc_params(
            inc) or require_authentication
        if config.setting['enable_ratings']:
            require_authentication = True
            inc += ['user-ratings']
        self.load_task = self.tagger.mb_api.get_release_by_id(
            self.id,
            self._release_request_finished,
            inc=inc,
            mblogin=require_authentication,
            priority=priority,
            refresh=refresh)

    def run_when_loaded(self, func, always=False):
        if self.loaded:
            func()
        else:
            self._after_load_callbacks.append((func, always))

    def stop_loading(self):
        if self.load_task:
            self.tagger.webservice.remove_task(self.load_task)
            self.load_task = None

    def update(self, update_tracks=True, update_selection=True):
        if self.item:
            self.item.update(update_tracks, update_selection=update_selection)

    def _add_file(self, track, file, new_album=True):
        self._files += 1
        if new_album:
            self.update(update_tracks=False)
            add_metadata_images(self, [file])

    def _remove_file(self, track, file, new_album=True):
        self._files -= 1
        if new_album:
            self.update(update_tracks=False)
            remove_metadata_images(self, [file])

    def _match_files(self, files, threshold=0):
        """Match files to tracks on this album, based on metadata similarity or recordingid."""
        tracks_cache = defaultdict(lambda: None)

        def build_tracks_cache():
            for track in self.tracks:
                tm_recordingid = track.orig_metadata['musicbrainz_recordingid']
                tm_tracknumber = track.orig_metadata['tracknumber']
                tm_discnumber = track.orig_metadata['discnumber']
                for tup in ((tm_recordingid, tm_tracknumber,
                             tm_discnumber), (tm_recordingid, tm_tracknumber),
                            (tm_recordingid, )):
                    tracks_cache[tup] = track

        SimMatchAlbum = namedtuple('SimMatchAlbum', 'similarity track')

        for file in list(files):
            if file.state == File.REMOVED:
                continue
            # if we have a recordingid to match against, use that in priority
            recid = file.match_recordingid or file.metadata[
                'musicbrainz_recordingid']
            if recid and mbid_validate(recid):
                if not tracks_cache:
                    build_tracks_cache()
                tracknumber = file.metadata['tracknumber']
                discnumber = file.metadata['discnumber']
                track = (tracks_cache[(recid, tracknumber, discnumber)]
                         or tracks_cache[(recid, tracknumber)]
                         or tracks_cache[(recid, )])
                if track:
                    yield (file, track)
                    continue

            # try to match by similarity
            def candidates():
                for track in process_events_iter(self.tracks):
                    yield SimMatchAlbum(similarity=track.metadata.compare(
                        file.orig_metadata),
                                        track=track)

            no_match = SimMatchAlbum(similarity=-1, track=self.unmatched_files)
            best_match = find_best_match(candidates, no_match)

            if best_match.similarity < threshold:
                yield (file, no_match.track)
            else:
                yield (file, best_match.result.track)

    def match_files(self, files):
        """Match and move files to tracks on this album, based on metadata similarity or recordingid."""
        if self.loaded:
            config = get_config()
            moves = self._match_files(
                files, threshold=config.setting['track_matching_threshold'])
            for file, target in moves:
                file.move(target)
        else:
            for file in list(files):
                file.move(self.unmatched_files)

    def can_save(self):
        return self._files > 0

    def can_remove(self):
        return True

    def can_edit_tags(self):
        return True

    def can_analyze(self):
        return False

    def can_autotag(self):
        return False

    def can_refresh(self):
        return True

    def can_view_info(self):
        return self.loaded or self.errors

    def is_album_like(self):
        return True

    def get_num_matched_tracks(self):
        num = 0
        for track in self.tracks:
            if track.is_linked():
                num += 1
        return num

    def get_num_unmatched_files(self):
        return len(self.unmatched_files.files)

    def get_num_total_files(self):
        return self._files + len(self.unmatched_files.files)

    def is_complete(self):
        if not self.tracks:
            return False
        for track in self.tracks:
            if not track.is_complete():
                return False
        if self.get_num_unmatched_files():
            return False
        else:
            return True

    def is_modified(self):
        if self.tracks:
            for track in self.tracks:
                for file in track.files:
                    if not file.is_saved():
                        return True
        return False

    def get_num_unsaved_files(self):
        count = 0
        for track in self.tracks:
            for file in track.files:
                if not file.is_saved():
                    count += 1
        return count

    def column(self, column):
        if column == 'title':
            if self.status is not None:
                title = self.status
            else:
                title = self.metadata['album']
            if self.tracks:
                linked_tracks = 0
                for track in self.tracks:
                    if track.is_linked():
                        linked_tracks += 1

                elems = ['%d/%d' % (linked_tracks, len(self.tracks))]
                unmatched = self.get_num_unmatched_files()
                if unmatched:
                    elems.append('%d?' % (unmatched, ))
                unsaved = self.get_num_unsaved_files()
                if unsaved:
                    elems.append('%d*' % (unsaved, ))
                ca_detailed = self.cover_art_description_detailed()
                if ca_detailed:
                    elems.append(ca_detailed)

                return '%s\u200E (%s)' % (title, '; '.join(elems))
            else:
                return title
        elif column == '~length':
            length = self.metadata.length
            if length:
                return format_time(length)
            else:
                return ''
        elif column == 'artist':
            return self.metadata['albumartist']
        elif column == 'tracknumber':
            return self.metadata['~totalalbumtracks']
        elif column == 'discnumber':
            return self.metadata['totaldiscs']
        elif column == 'covercount':
            return self.cover_art_description()
        else:
            return self.metadata[column]

    def switch_release_version(self, mbid):
        if mbid == self.id:
            return
        for file in list(self.iterfiles(True)):
            file.move(self.unmatched_files)
        album = self.tagger.albums.get(mbid)
        if album:
            album.match_files(self.unmatched_files.files)
            album.update()
            self.tagger.remove_album(self)
        else:
            del self.tagger.albums[self.id]
            self.release_group.loaded_albums.discard(self.id)
            self.id = mbid
            self.tagger.albums[mbid] = self
            self.load(priority=True, refresh=True)

    def update_metadata_images(self):
        if not self.update_metadata_images_enabled:
            return

        if update_metadata_images(self):
            self.update(False)
            self.metadata_images_changed.emit()

    def keep_original_images(self):
        self.enable_update_metadata_images(False)
        for track in self.tracks:
            track.keep_original_images()
        for file in list(self.unmatched_files.files):
            file.keep_original_images()
        self.enable_update_metadata_images(True)
        self.update_metadata_images()
Пример #29
0
    def _finalize_loading(self, error):
        if error:
            self.metadata.clear()
            self.status = _("[could not load album %s]") % self.id
            del self._new_metadata
            del self._new_tracks
            self.update()
            return

        if self._requests > 0:
            return

        if not self._tracks_loaded:
            artists = set()
            totalalbumtracks = 0
            absolutetracknumber = 0
            va = self._new_metadata['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID

            djmix_ars = {}
            if hasattr(self._new_metadata, "_djmix_ars"):
                djmix_ars = self._new_metadata._djmix_ars

            for medium_node in self._release_node.medium_list[0].medium:
                mm = Metadata()
                mm.copy(self._new_metadata)
                medium_to_metadata(medium_node, mm)
                discpregap = False

                for dj in djmix_ars.get(mm["discnumber"], []):
                    mm.add("djmixer", dj)

                if "pregap" in medium_node.children:
                    discpregap = True
                    absolutetracknumber += 1
                    track = self._finalize_loading_track(medium_node.pregap[0], mm, artists, va, absolutetracknumber, discpregap)
                    track.metadata['~pregap'] = "1"

                for track_node in medium_node.track_list[0].track:
                    absolutetracknumber += 1
                    track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap)

                if "data_track_list" in medium_node.children:
                    for track_node in medium_node.data_track_list[0].track:
                        absolutetracknumber += 1
                        track = self._finalize_loading_track(track_node, mm, artists, va, absolutetracknumber, discpregap)
                        track.metadata['~datatrack'] = "1"

            totalalbumtracks = str(absolutetracknumber)

            for track in self._new_tracks:
                track.metadata["~totalalbumtracks"] = totalalbumtracks
                if len(artists) > 1:
                    track.metadata["~multiartist"] = "1"
            del self._release_node
            self._tracks_loaded = True

        if not self._requests:
            # Prepare parser for user's script
            if config.setting["enable_tagger_script"]:
                script = config.setting["tagger_script"]
                if script:
                    parser = ScriptParser()
                    for track in self._new_tracks:
                        # Run tagger script for each track
                        try:
                            parser.eval(script, track.metadata)
                        except:
                            self.error_append(traceback.format_exc())
                        # Strip leading/trailing whitespace
                        track.metadata.strip_whitespace()
                    # Run tagger script for the album itself
                    try:
                        parser.eval(script, self._new_metadata)
                    except:
                        self.error_append(traceback.format_exc())
                    self._new_metadata.strip_whitespace()

            for track in self.tracks:
                for file in list(track.linked_files):
                    file.move(self.unmatched_files)
            self.metadata = self._new_metadata
            self.tracks = self._new_tracks
            del self._new_metadata
            del self._new_tracks
            self.loaded = True
            self.status = None
            self.match_files(self.unmatched_files.files)
            self.update()
            self.tagger.window.set_statusbar_message(
                N_('Album %(id)s loaded: %(artist)s - %(album)s'),
                {
                    'id': self.id,
                    'artist': self.metadata['albumartist'],
                    'album': self.metadata['album']
                },
                timeout=3000
            )
            for func in self._after_load_callbacks:
                func()
            self._after_load_callbacks = []
Пример #30
0
    def _finalize_loading(self, error):
        if error:
            self.metadata.clear()
            self.metadata['album'] = _("[could not load album %s]") % self.id
            del self._new_metadata
            del self._new_tracks
            self.update()
            return

        if self._requests > 0:
            return

        if not self._tracks_loaded:
            artists = set()
            totalalbumtracks = 0

            djmix_ars = {}
            if hasattr(self._new_metadata, "_djmix_ars"):
                djmix_ars = self._new_metadata._djmix_ars

            for medium_node in self._release_node.medium_list[0].medium:
                mm = Metadata()
                mm.copy(self._new_metadata)
                medium_to_metadata(medium_node, mm)
                totalalbumtracks += int(mm["totaltracks"])

                for dj in djmix_ars.get(mm["discnumber"], []):
                    mm.add("djmixer", dj)

                for track_node in medium_node.track_list[0].track:
                    track = Track(track_node.recording[0].id, self)
                    self._new_tracks.append(track)

                    # Get track metadata
                    tm = track.metadata
                    tm.copy(mm)
                    track_to_metadata(track_node, track, self.config)
                    track._customize_metadata()

                    self._new_metadata.length += tm.length
                    artists.add(tm["musicbrainz_artistid"])

                    # Run track metadata plugins
                    try:
                        run_track_metadata_processors(self, tm, self._release_node, track_node)
                    except:
                        self.log.error(traceback.format_exc())

            totalalbumtracks = str(totalalbumtracks)

            for track in self._new_tracks:
                track.metadata["~totalalbumtracks"] = totalalbumtracks
                if len(artists) > 1:
                    track.metadata["compilation"] = "1"

            del self._release_node
            self._tracks_loaded = True

        if not self._requests:
            # Prepare parser for user's script
            if self.config.setting["enable_tagger_script"]:
                script = self.config.setting["tagger_script"]
                if script:
                    parser = ScriptParser()
                    for track in self._new_tracks:
                        # Run tagger script for each track
                        try:
                            parser.eval(script, track.metadata)
                        except:
                            self.log.error(traceback.format_exc())
                        # Strip leading/trailing whitespace
                        track.metadata.strip_whitespace()
                    # Run tagger script for the album itself
                    try:
                        parser.eval(script, self._new_metadata)
                    except:
                        self.log.error(traceback.format_exc())
                    self._new_metadata.strip_whitespace()

            for track in self.tracks:
                for file in list(track.linked_files):
                    file.move(self.unmatched_files)
            self.metadata = self._new_metadata
            self.tracks = self._new_tracks
            del self._new_metadata
            del self._new_tracks
            self.loaded = True
            self.match_files(self.unmatched_files.files)
            self.update()
            self.tagger.window.set_statusbar_message(_('Album %s loaded'), self.id, timeout=3000)
            while self._after_load_callbacks.qsize() > 0:
                func = self._after_load_callbacks.get()
                func()
Пример #31
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, callback):
        thread.run_task(
            partial(self._load, self.filename),
            partial(self._loading_finished, callback),
            priority=1)

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

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

    _default_preserved_tags = [
        "~bitrate", "~bits_per_sample", "~format", "~channels", "~filename",
        "~dirname", "~extension"
    ]

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

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

        self.metadata["acoustid_id"] = acoustid

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

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

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

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

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

    def _saving_finished(self, result=None, error=None):
        old_filename = new_filename = self.filename
        if error is not None:
            self.error = str(error)
            self.set_state(File.ERROR, update=True)
        else:
            self.filename = new_filename = result
            self.base_filename = os.path.basename(new_filename)
            length = self.orig_metadata.length
            temp_info = {}
            for info in ('~bitrate', '~sample_rate', '~channels',
                         '~bits_per_sample', '~format'):
                temp_info[info] = self.orig_metadata[info]
            # Data is copied from New to Original because New may be a subclass to handle id3v23
            if config.setting["clear_existing_tags"]:
                self.orig_metadata.copy(self.metadata)
            else:
                self.orig_metadata.update(self.metadata)
            self.orig_metadata.length = length
            self.orig_metadata['~length'] = format_time(length)
            for k, v in temp_info.items():
                self.orig_metadata[k] = v
            self.error = None
            self.clear_pending()
            self._add_path_to_metadata(self.orig_metadata)

        del self.tagger.files[old_filename]
        self.tagger.files[new_filename] = self

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

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

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

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

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

        if old_filename == new_filename + ext:
            return old_filename

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    def can_autotag(self):
        return True

    def can_refresh(self):
        return False

    def can_view_info(self):
        return True

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

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

    def get_state(self):
        return self._state

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

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

    state = property(get_state, set_state)

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

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

        if self.state == File.REMOVED:
            return

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

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

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

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

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

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

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

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

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

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

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

    def _get_discnumber(self):
        try:
            return int(self.metadata["discnumber"])
        except:
            return 0
    discnumber = property(_get_discnumber, doc="The disc number as an int.")
Пример #32
0
class File(LockableObject, Item):

    __id_counter = 0

    @staticmethod
    def new_id():
        File.__id_counter += 1
        return File.__id_counter

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

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

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

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

        self.similarity = 1.0
        self.parent = None

        self.lookup_task = None

    def __repr__(self):
        return '<File #%d %r>' % (self.id, self.base_filename)

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

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

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

    def copy_metadata(self, metadata):
        exceptions = ['musicip_puid', 'acoustid_id']
        if self.config.setting['preserved_tags']:
            exceptions.extend(
                re.split(r'\s+',
                         self.config.setting['preserved_tags'].strip()))
        for tag in exceptions:
            self.saved_metadata[tag] = self.metadata[tag]
        self.metadata.copy(metadata)
        for tag in exceptions:
            self.metadata[tag] = self.saved_metadata.pop(tag)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    def can_autotag(self):
        return True

    def can_refresh(self):
        return False

    def can_view_info(self):
        return True

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

    def get_state(self):
        return self._state

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

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

    state = property(get_state, set_state)

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

    def _compare_to_track(self, track):
        """
        Compare file metadata to a MusicBrainz track.

        Weigths:
          * title                = 13
          * artist name          = 4
          * release name         = 5
          * length               = 10
          * number of tracks     = 4
          * album type           = 20
          * release country      = 2
          * format               = 2

        """
        total = 0.0
        parts = []
        w = self.comparison_weights

        if 'title' in self.metadata:
            a = self.metadata['title']
            b = track.title[0].text
            parts.append((similarity2(a, b), w["title"]))
            total += w["title"]

        if 'artist' in self.metadata:
            a = self.metadata['artist']
            b = artist_credit_from_node(track.artist_credit[0], self.config)[0]
            parts.append((similarity2(a, b), w["artist"]))
            total += w["artist"]

        a = self.metadata.length
        if a > 0 and 'length' in track.children:
            b = int(track.length[0].text)
            score = 1.0 - min(abs(a - b), 30000) / 30000.0
            parts.append((score, w["length"]))
            total += w["length"]

        releases = []
        if "release_list" in track.children and "release" in track.release_list[
                0].children:
            releases = track.release_list[0].release

        if not releases:
            return (total, None)

        scores = []
        for release in releases:
            t, p = self.metadata.compare_to_release(release, w, self.config)
            total_ = total + t
            parts_ = list(parts) + p
            scores.append((reduce(lambda x, y: x + y[0] * y[1] / total_,
                                  parts_, 0.0), release.id))

        return max(scores, key=lambda x: x[0])

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

        if self.state == File.REMOVED:
            return

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

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

        # multiple matches -- calculate similarities to each of them
        matches = []
        for track in tracks:
            score, release = self._compare_to_track(track)
            matches.append((score, track, release))
        matches.sort(reverse=True)
        #self.log.debug("Track matches: %r", matches)

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

        albumid = matches[0][2]
        track = matches[0][1]
        if lookuptype == 'puid':
            self.tagger.puidmanager.add(self.metadata['musicip_puid'],
                                        track.id)
        elif lookuptype == 'acoustid':
            self.tagger.acoustidmanager.add(self, track.id)
        if albumid:
            self.tagger.move_file_to_track(self, albumid, track.id)
        else:
            self.tagger.move_file_to_nat(self, track.id, node=track)

    def lookup_puid(self, puid):
        """ Try to identify the file using the PUID. """
        self.tagger.window.set_statusbar_message(
            N_("Looking up the PUID for file %s..."), self.filename)
        self.clear_lookup_task()
        self.lookup_task = self.tagger.xmlws.lookup_puid(
            puid, partial(self._lookup_finished, 'puid'))

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

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

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

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

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

    def _get_tracknumber(self):
        try:
            return int(self.metadata["tracknumber"])
        except:
            return 0

    tracknumber = property(_get_tracknumber, doc="The track number as an int.")

    def _get_discnumber(self):
        try:
            return int(self.metadata["discnumber"])
        except:
            return 0

    discnumber = property(_get_discnumber, doc="The disc number as an int.")
Пример #33
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, _ = os.path.splitext(self.base_filename)
        metadata["~length"] = format_time(metadata.length)
        if "title" not in metadata:
            metadata["title"] = filename
        if "tracknumber" not in metadata:
            match = re.match("(?:track)?\s*(?:no|nr)?\s*(\d+)", filename, re.I)
            if match:
                try:
                    tracknumber = int(match.group(1))
                except ValueError:
                    pass
                else:
                    metadata["tracknumber"] = str(tracknumber)
        self.orig_metadata.copy(metadata)
        self.metadata = metadata

    _default_preserved_tags = [
        "~bitrate",
        "~bits_per_sample",
        "~format",
        "~channels",
        "~filename",
        "~dirname",
        "~extension",
    ]

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

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

        self.metadata["acoustid_id"] = acoustid

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    def can_autotag(self):
        return True

    def can_refresh(self):
        return False

    def can_view_info(self):
        return True

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

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

    def get_state(self):
        return self._state

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

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

    state = property(get_state, set_state)

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

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

        if self.state == File.REMOVED:
            return

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

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

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

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

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

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

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

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

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

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

    def _get_tracknumber(self):
        try:
            return int(self.metadata["tracknumber"])
        except:
            return 0

    tracknumber = property(_get_tracknumber, doc="The track number as an int.")

    def _get_discnumber(self):
        try:
            return int(self.metadata["discnumber"])
        except:
            return 0

    discnumber = property(_get_discnumber, doc="The disc number as an int.")
Пример #34
0
class File(QtCore.QObject, Item):

    metadata_images_changed = QtCore.pyqtSignal()

    NAME = None

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

    LOOKUP_METADATA = 1
    LOOKUP_ACOUSTID = 2

    EXTENSIONS = []

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

    class PreserveTimesStatError(Exception):
        pass

    class PreserveTimesUtimeError(Exception):
        pass

    # in order to significantly speed up performance, the number of pending
    # files is cached, set @state.setter
    num_pending_files = 0

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

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

        self.similarity = 1.0
        self.parent = None

        self.lookup_task = None
        self.item = None

        self.acoustid_fingerprint = None
        self.acoustid_length = 0
        self.match_recordingid = None

        self.acousticbrainz_is_duplicate = False
        self.acousticbrainz_features_file = None
        self.acousticbrainz_error = False

    def __repr__(self):
        return '<%s %r>' % (type(self).__name__, self.base_filename)

    # pylint: disable=no-self-use
    def format_specific_metadata(self, metadata, tag, settings=None):
        """Can be overridden to customize how a tag is displayed in the UI.
        This is useful if a tag saved to the underlying format will differ from
        the internal representation in a way that would cause data loss. This is e.g.
        the case for some ID3v2.3 tags.

        Args:
            metadata: The metadata object to read the tag from
            tag: Name of the tag
            settings: Dictionary of settings. If not set, config.setting should be used

        Returns:
            An array of values for the tag
        """
        return metadata.getall(tag)

    def _format_specific_copy(self, metadata, settings=None):
        """Creates a copy of metadata, but applies format_specific_metadata() to the values.
        """
        copy = Metadata(deleted_tags=metadata.deleted_tags,
                        images=metadata.images,
                        length=metadata.length)
        for name in metadata:
            copy[name] = self.format_specific_metadata(metadata, name,
                                                       settings)
        return copy

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

    def _load_check(self, filename):
        # Check that file has not been removed since thread was queued
        # Don't load if we are stopping.
        if self.state != File.PENDING:
            log.debug("File not loaded because it was removed: %r",
                      self.filename)
            return None
        if self.tagger.stopping:
            log.debug("File not loaded because %s is stopping: %r",
                      PICARD_APP_NAME, self.filename)
            return None
        return self._load(filename)

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

    def _loading_finished(self, callback, result=None, error=None):
        if self.state != File.PENDING or self.tagger.stopping:
            return
        config = get_config()
        if error is not None:
            self.state = self.ERROR
            self.error_append(str(error))

            # If loading failed, force format guessing and try loading again
            from picard.formats.util import guess_format
            try:
                alternative_file = guess_format(self.filename)
            except (FileNotFoundError, OSError):
                log.error("Guessing format of %s failed",
                          self.filename,
                          exc_info=True)
                alternative_file = None

            if alternative_file:
                # Do not retry reloading exactly the same file format
                if type(alternative_file) != type(self):  # pylint: disable=unidiomatic-typecheck
                    log.debug('Loading %r failed, retrying as %r' %
                              (self, alternative_file))
                    self.remove()
                    alternative_file.load(callback)
                    return
                else:
                    alternative_file.remove()  # cleanup unused File object
            from picard.formats import supported_extensions
            file_name, file_extension = os.path.splitext(self.base_filename)
            if file_extension not in supported_extensions():
                log.error(
                    'Unsupported media file %r wrongly loaded. Removing ...',
                    self)
                callback(self, remove_file=True)
                return
        else:
            self.clear_errors()
            self.state = self.NORMAL
            postprocessors = []
            if config.setting["guess_tracknumber_and_title"]:
                postprocessors.append(self._guess_tracknumber_and_title)
            self._copy_loaded_metadata(result, postprocessors)
        # use cached fingerprint from file metadata
        if not config.setting["ignore_existing_acoustid_fingerprints"]:
            fingerprints = self.metadata.getall('acoustid_fingerprint')
            if fingerprints:
                self.set_acoustid_fingerprint(fingerprints[0])
        run_file_post_load_processors(self)
        self.update()
        callback(self)

    def _copy_loaded_metadata(self, metadata, postprocessors=None):
        metadata['~length'] = format_time(metadata.length)
        if postprocessors:
            for processor in postprocessors:
                processor(metadata)
        self.orig_metadata = metadata
        self.metadata.copy(metadata)

    def _guess_tracknumber_and_title(self, metadata):
        missing = {'tracknumber', 'title'} - set(metadata)
        if missing:
            guessed = tracknum_and_title_from_filename(self.base_filename)
            for m in missing:
                metadata[m] = getattr(guessed, m)

    def copy_metadata(self, metadata, preserve_deleted=True):
        acoustid = self.metadata["acoustid_id"]
        saved_metadata = {}

        preserved_tags = PreservedTags()
        for tag, values in self.orig_metadata.rawitems():
            if tag in preserved_tags or tag in PRESERVED_TAGS:
                saved_metadata[tag] = values
        deleted_tags = self.metadata.deleted_tags
        images_changed = self.metadata.images != metadata.images
        self.metadata.copy(metadata)
        for info in FILE_INFO_TAGS:
            metadata[info] = self.orig_metadata[info]
        if preserve_deleted:
            for tag in deleted_tags:
                del self.metadata[tag]
        self.metadata.update(saved_metadata)

        if acoustid and "acoustid_id" not in metadata.deleted_tags:
            self.metadata["acoustid_id"] = acoustid
        if images_changed:
            self.metadata_images_changed.emit()

    def keep_original_images(self):
        if self.metadata.images != self.orig_metadata.images:
            self.metadata.images = self.orig_metadata.images.copy()
            self.update(signal=False)
            self.metadata_images_changed.emit()

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

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

    def _preserve_times(self, filename, func):
        """Save filename times before calling func, and set them again"""
        try:
            # https://docs.python.org/3/library/os.html#os.utime
            # Since Python 3.3, ns parameter is available
            # The best way to preserve exact times is to use the st_atime_ns and st_mtime_ns
            # fields from the os.stat() result object with the ns parameter to utime.
            st = os.stat(filename)
        except OSError as why:
            errmsg = "Couldn't read timestamps from %r: %s" % (filename, why)
            raise self.PreserveTimesStatError(errmsg) from None
            # if we can't read original times, don't call func and let caller handle this
        func()
        try:
            os.utime(filename, ns=(st.st_atime_ns, st.st_mtime_ns))
        except OSError as why:
            errmsg = "Couldn't preserve timestamps for %r: %s" % (filename,
                                                                  why)
            raise self.PreserveTimesUtimeError(errmsg) from None
        return (st.st_atime_ns, st.st_mtime_ns)

    def _save_and_rename(self, old_filename, metadata):
        """Save the metadata."""
        config = get_config()
        # Check that file has not been removed since thread was queued
        # Also don't save if we are stopping.
        if self.state == File.REMOVED:
            log.debug("File not saved because it was removed: %r",
                      self.filename)
            return None
        if self.tagger.stopping:
            log.debug("File not saved because %s is stopping: %r",
                      PICARD_APP_NAME, self.filename)
            return None
        new_filename = old_filename
        if not config.setting["dont_write_tags"]:
            save = partial(self._save, old_filename, metadata)
            if config.setting["preserve_timestamps"]:
                try:
                    self._preserve_times(old_filename, save)
                except self.PreserveTimesUtimeError as why:
                    log.warning(why)
            else:
                save()
        # Rename files
        if config.setting["rename_files"] or config.setting["move_files"]:
            new_filename = self._rename(old_filename, metadata, config.setting)
        # Move extra files (images, playlists, etc.)
        self._move_additional_files(old_filename, new_filename, config)
        # Delete empty directories
        if config.setting["delete_empty_dirs"]:
            dirname = os.path.dirname(old_filename)
            try:
                emptydir.rm_empty_dir(dirname)
                head, tail = os.path.split(dirname)
                if not tail:
                    head, tail = os.path.split(head)
                while head and tail:
                    emptydir.rm_empty_dir(head)
                    head, tail = os.path.split(head)
            except OSError as why:
                log.warning("Error removing directory: %s", why)
            except emptydir.SkipRemoveDir as why:
                log.debug("Not removing empty directory: %s", why)
        # Save cover art images
        if config.setting["save_images_to_files"]:
            self._save_images(os.path.dirname(new_filename), metadata)
        return new_filename

    def _saving_finished(self, result=None, error=None):
        # Handle file removed before save
        # Result is None if save was skipped
        if ((self.state == File.REMOVED or self.tagger.stopping)
                and result is None):
            return
        old_filename = new_filename = self.filename
        if error is not None:
            self.state = File.ERROR
            self.error_append(str(error))
        else:
            self.filename = new_filename = result
            self.base_filename = os.path.basename(new_filename)
            length = self.orig_metadata.length
            temp_info = {}
            for info in FILE_INFO_TAGS:
                temp_info[info] = self.orig_metadata[info]
            images_changed = self.orig_metadata.images != self.metadata.images
            # Copy new metadata to original metadata, applying format specific
            # conversions (e.g. for ID3v2.3)
            config = get_config()
            new_metadata = self._format_specific_copy(self.metadata,
                                                      config.setting)
            if config.setting["clear_existing_tags"]:
                self.orig_metadata = new_metadata
            else:
                self.orig_metadata.update(new_metadata)
            # After saving deleted tags should no longer be marked deleted
            self.metadata.clear_deleted()
            self.orig_metadata.clear_deleted()
            self.orig_metadata.length = length
            self.orig_metadata['~length'] = format_time(length)
            self.orig_metadata.update(temp_info)
            self.clear_errors()
            self.clear_pending(signal=False)
            self._add_path_to_metadata(self.orig_metadata)
            if images_changed:
                self.metadata_images_changed.emit()

            # run post save hook
            run_file_post_save_processors(self)

        # Force update to ensure file status icon changes immediately after save
        self.update()

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

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

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

    def _script_to_filename(self,
                            naming_format,
                            file_metadata,
                            file_extension,
                            settings=None):
        if settings is None:
            config = get_config()
            settings = config.setting
        metadata = Metadata()
        if settings["clear_existing_tags"]:
            # script_to_filename_with_metadata guarantees this is not modified
            metadata = file_metadata
        else:
            metadata.copy(self.orig_metadata)
            metadata.update(file_metadata)
        (filename,
         new_metadata) = script_to_filename_with_metadata(naming_format,
                                                          metadata,
                                                          file=self,
                                                          settings=settings)
        if not filename:
            return None
        # NOTE: the filename generated by the naming script does not have a file extension
        ext = new_metadata.get('~extension', file_extension)
        return filename + '.' + ext.lstrip('.')

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

    def _clean_file_extension(self, filename):
        """Takes a filename and converts the extension to lowercase.

        If the file has no extension a default extension for the format is used.

        Args:
            filename: The filename

        Returns: A tuple containing the filename with fixed extension and the extension itself.
        """
        filename, ext = self._fixed_splitext(filename)
        if not ext and self.EXTENSIONS:
            ext = self.EXTENSIONS[0]
        ext = ext.lower()
        return (filename + ext, ext)

    def _format_filename(self, new_dirname, new_filename, metadata, settings,
                         naming_format):
        old_filename = new_filename
        new_filename, ext = self._clean_file_extension(new_filename)

        if naming_format:
            new_filename = self._script_to_filename(naming_format, metadata,
                                                    ext, settings)
            if not new_filename:
                new_filename = old_filename
            if not settings['rename_files']:
                new_filename = os.path.join(os.path.dirname(new_filename),
                                            old_filename)
            if not settings['move_files']:
                new_filename = os.path.basename(new_filename)
            win_compat = IS_WIN or settings['windows_compatibility']
            new_filename = make_short_filename(new_dirname, new_filename,
                                               win_compat)
            new_filename = make_save_path(new_filename,
                                          win_compat=win_compat,
                                          mac_compat=IS_MACOS)
        return new_filename

    def make_filename(self,
                      filename,
                      metadata,
                      settings=None,
                      naming_format=None):
        """Constructs file name based on metadata and file naming formats."""
        if settings is None:
            config = get_config()
            settings = config.setting
        if naming_format is None:
            naming_format = get_file_naming_script(settings)
        if settings["move_files"]:
            new_dirname = settings["move_files_to"]
            if not is_absolute_path(new_dirname):
                new_dirname = os.path.normpath(
                    os.path.join(os.path.dirname(filename), new_dirname))
        else:
            new_dirname = os.path.dirname(filename)
        try:
            new_dirname = os.path.realpath(new_dirname)
        except FileNotFoundError:
            # os.path.realpath can fail if cwd does not exist and path is relative
            pass
        new_filename = os.path.basename(filename)

        if settings["rename_files"] or settings["move_files"]:
            new_filename = self._format_filename(new_dirname, new_filename,
                                                 metadata, settings,
                                                 naming_format)

        new_path = os.path.join(new_dirname, new_filename)
        return new_path

    def _rename(self, old_filename, metadata, settings=None):
        new_filename = self.make_filename(old_filename, metadata, settings)
        if old_filename == new_filename:
            return old_filename

        new_dirname = os.path.dirname(new_filename)
        if not os.path.isdir(new_dirname):
            os.makedirs(new_dirname)
        new_filename = get_available_filename(new_filename, old_filename)
        log.debug("Moving file %r => %r", old_filename, new_filename)
        move_ensure_casing(old_filename, new_filename)
        return new_filename

    def _save_images(self, dirname, metadata):
        """Save the cover images to disk."""
        if not metadata.images:
            return
        counters = Counter()
        images = []
        config = get_config()
        if config.setting["save_only_one_front_image"]:
            front = metadata.images.get_front_image()
            if front:
                images.append(front)
        if not images:
            images = metadata.images
        for image in images:
            image.save(dirname, metadata, counters)

    def _move_additional_files(self, old_filename, new_filename, config):
        """Move extra files, like images, playlists..."""
        if config.setting["move_files"] and config.setting[
                "move_additional_files"]:
            new_path = os.path.dirname(new_filename)
            old_path = os.path.dirname(old_filename)
            if new_path != old_path:
                patterns_string = config.setting[
                    "move_additional_files_pattern"]
                patterns = self._compile_move_additional_files_pattern(
                    patterns_string)
                try:
                    moves = self._get_additional_files_moves(
                        old_path, new_path, patterns)
                    self._apply_additional_files_moves(moves)
                except OSError as why:
                    log.error("Failed to scan %r: %s", old_path, why)

    @staticmethod
    def _compile_move_additional_files_pattern(patterns_string):
        return {(re.compile(fnmatch.translate(pattern),
                            re.IGNORECASE), pattern.startswith('.'))
                for pattern in set(patterns_string.lower().split())}

    def _get_additional_files_moves(self, old_path, new_path, patterns):
        if patterns:
            with os.scandir(old_path) as scan:
                for entry in scan:
                    is_hidden = entry.name.startswith('.')
                    for pattern_regex, match_hidden in patterns:
                        if is_hidden and not match_hidden:
                            continue
                        if pattern_regex.match(entry.name):
                            new_file_path = os.path.join(new_path, entry.name)
                            yield (entry.path, new_file_path)
                            break  # we are done with this file

    def _apply_additional_files_moves(self, moves):
        for old_file_path, new_file_path in moves:
            # FIXME we shouldn't do this from a thread!
            if self.tagger.files.get(decode_filename(old_file_path)):
                log.debug("File loaded in the tagger, not moving %r",
                          old_file_path)
                continue
            log.debug("Moving %r to %r", old_file_path, new_file_path)
            try:
                shutil.move(old_file_path, new_file_path)
            except OSError as why:
                log.error("Failed to move %r to %r: %s", old_file_path,
                          new_file_path, why)

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

    def move(self, parent):
        # To be able to move a file the target must implement add_file(file)
        if hasattr(parent, 'add_file') and parent != self.parent:
            log.debug("Moving %r from %r to %r", self, self.parent, parent)
            self.clear_lookup_task()
            self.tagger._acoustid.stop_analyze(self)
            new_album = True
            if self.parent:
                new_album = self.parent.album != parent.album
                self.clear_pending()
                self.parent.remove_file(self, new_album=new_album)
            self.parent = parent
            self.parent.add_file(self, new_album=new_album)
            self.acoustid_update()
            return True
        else:
            return False

    def _move(self, parent):
        if parent != self.parent:
            log.debug("Moving %r from %r to %r", self, self.parent, parent)
            if self.parent:
                self.parent.remove_file(self)
            self.parent = parent
            self.acoustid_update()

    def set_acoustid_fingerprint(self, fingerprint, length=None):
        if not fingerprint:
            self.acoustid_fingerprint = None
            self.acoustid_length = 0
            self.tagger.acoustidmanager.remove(self)
        elif fingerprint != self.acoustid_fingerprint:
            self.acoustid_fingerprint = fingerprint
            self.acoustid_length = length or self.metadata.length // 1000
            self.tagger.acoustidmanager.add(self, None)
            self.acoustid_update()
        config = get_config()
        if config.setting['save_acoustid_fingerprints']:
            self.metadata['acoustid_fingerprint'] = fingerprint

    def acoustid_update(self):
        recording_id = None
        if self.parent and hasattr(self.parent, 'orig_metadata'):
            recording_id = self.parent.orig_metadata['musicbrainz_recordingid']
            if not recording_id:
                recording_id = self.metadata['musicbrainz_recordingid']
        self.tagger.acoustidmanager.update(self, recording_id)
        self.update_item()

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

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

    def _tags_to_update(self, ignored_tags):
        for name in set(self.metadata) | set(self.orig_metadata):
            if name.startswith('~'):
                continue
            if not self.supports_tag(name):
                continue
            if name in ignored_tags:
                continue
            yield name

    def update(self, signal=True):
        config = get_config()
        clear_existing_tags = config.setting["clear_existing_tags"]
        ignored_tags = set(config.setting["compare_ignore_tags"])

        for name in self._tags_to_update(ignored_tags):
            new_values = self.format_specific_metadata(self.metadata, name,
                                                       config.setting)
            if not (new_values or clear_existing_tags
                    or name in self.metadata.deleted_tags):
                continue
            orig_values = self.orig_metadata.getall(name)
            if orig_values != new_values:
                self.similarity = self.orig_metadata.compare(
                    self.metadata, ignored_tags)
                if self.state == File.NORMAL:
                    self.state = File.CHANGED
                break
        else:
            if (self.metadata.images
                    and self.orig_metadata.images != self.metadata.images):
                self.state = File.CHANGED
            else:
                self.similarity = 1.0
                if self.state == File.CHANGED:
                    self.state = File.NORMAL
        if signal:
            log.debug("Updating file %r", self)
            self.update_item()

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

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

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

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

    def can_autotag(self):
        return True

    def can_refresh(self):
        return False

    def can_view_info(self):
        return True

    def can_extract(self):
        from picard.track import Track
        return (isinstance(self.parent, Track) and self.is_saved()
                and bool(self.metadata["musicbrainz_recordingid"]))

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

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

    @property
    def state(self):
        """Current state of the File object"""
        return self._state

    @state.setter
    def state(self, state):
        if state == self._state:
            return
        if state == File.PENDING:
            File.num_pending_files += 1
            self.tagger.tagger_stats_changed.emit()
        elif self._state == File.PENDING:
            File.num_pending_files -= 1
            self.tagger.tagger_stats_changed.emit()
        self._state = state

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

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

        if self.state == File.REMOVED:
            return
        if error:
            log.error(
                "Network error encountered during the lookup for %s. Error code: %s",
                self.filename, error)
        try:
            tracks = document['recordings']
        except (KeyError, TypeError):
            tracks = None

        def statusbar(message):
            self.tagger.window.set_statusbar_message(
                message, {'filename': self.filename}, timeout=3000)

        if tracks:
            if lookuptype == File.LOOKUP_ACOUSTID:
                threshold = 0
            else:
                config = get_config()
                threshold = config.setting['file_lookup_threshold']

            trackmatch = self._match_to_track(tracks, threshold=threshold)
            if trackmatch is None:
                statusbar(
                    N_("No matching tracks above the threshold for file '%(filename)s'"
                       ))
            else:
                statusbar(N_("File '%(filename)s' identified!"))
                (recording_id, release_group_id, release_id, acoustid,
                 node) = trackmatch
                if lookuptype == File.LOOKUP_ACOUSTID:
                    self.metadata['acoustid_id'] = acoustid
                    self.tagger.acoustidmanager.add(self, recording_id)
                if release_group_id is not None:
                    releasegroup = self.tagger.get_release_group_by_id(
                        release_group_id)
                    releasegroup.loaded_albums.add(release_id)
                    self.tagger.move_file_to_track(self, release_id,
                                                   recording_id)
                else:
                    self.tagger.move_file_to_nat(self, recording_id)
        else:
            statusbar(N_("No matching tracks for file '%(filename)s'"))

        self.clear_pending()

    def _match_to_track(self, tracks, threshold=0):
        # multiple matches -- calculate similarities to each of them
        def candidates():
            for track in tracks:
                yield self.metadata.compare_to_track(track,
                                                     self.comparison_weights)

        no_match = SimMatchTrack(similarity=-1,
                                 releasegroup=None,
                                 release=None,
                                 track=None)
        best_match = find_best_match(candidates, no_match)

        if best_match.similarity < threshold:
            return None
        else:
            track_id = best_match.result.track['id']
            release_group_id, release_id, node = None, None, None
            acoustid = best_match.result.track.get('acoustid', None)

            if best_match.result.release:
                release_group_id = best_match.result.releasegroup['id']
                release_id = best_match.result.release['id']
            elif 'title' in best_match.result.track:
                node = best_match.result.track
            return (track_id, release_group_id, release_id, acoustid, node)

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

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

    def set_pending(self):
        if self.state != File.REMOVED:
            self.state = File.PENDING
            self.update_item(update_selection=False)

    def clear_pending(self, signal=True):
        if self.state == File.PENDING:
            self.state = File.NORMAL
            # Update file to recalculate changed state
            self.update(signal=False)
            if signal:
                self.update_item(update_selection=False)

    def update_item(self, update_selection=True):
        if self.item:
            self.item.update(update_selection=update_selection)

    def iterfiles(self, save=False):
        yield self
Пример #35
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
Пример #36
0
class File(LockableObject, Item):

    __id_counter = 0
    @staticmethod
    def new_id():
        File.__id_counter += 1
        return File.__id_counter

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

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

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

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

        self.similarity = 1.0
        self.parent = None

        self.lookup_task = None

    def __repr__(self):
        return '<File #%d %r>' % (self.id, self.base_filename)

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

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

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

    def copy_metadata(self, metadata):
        exceptions = ['musicip_puid', 'acoustid_id']
        if self.config.setting['preserved_tags']:
            exceptions.extend(re.split(r'\s+', self.config.setting['preserved_tags'].strip()))
        for tag in exceptions:
            self.saved_metadata[tag] = self.metadata[tag]
        self.metadata.copy(metadata)
        for tag in exceptions:
            self.metadata[tag] = self.saved_metadata.pop(tag)

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

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

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

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

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

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

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

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

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

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

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

    def _save_images(self, filename, metadata, settings):
        """Save the cover images to disk."""
        if not metadata.images:
            return
        overwrite = settings["save_images_overwrite"]
        image_filename = self._script_to_filename(
            settings["cover_image_filename"], metadata, settings)
        if not image_filename:
            image_filename = "cover"
        if os.path.isabs(image_filename):
            filename = image_filename
        else:
            filename = os.path.join(os.path.dirname(filename), image_filename)
        if settings['windows_compatible_filenames'] or sys.platform == 'win32':
            filename = filename.replace('./', '_/').replace('.\\', '_\\')
        filename = encode_filename(filename)
        i = 0
        for mime, data in metadata.images:
            image_filename = filename
            ext = mimetype.get_extension(mime, ".jpg")
            if i > 0:
                image_filename = "%s (%d)" % (filename, i)
            i += 1
            while os.path.exists(image_filename + ext) and not overwrite:
                if os.path.getsize(image_filename + ext) == len(data):
                    self.log.debug("Identical file size, not saving %r", image_filename)
                    break
                image_filename = "%s (%d)" % (filename, i)
                i += 1
            else:
                self.log.debug("Saving cover images to %r", image_filename)
                f = open(image_filename + ext, "wb")
                f.write(data)
                f.close()

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

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

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

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

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

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

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

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

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

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

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

    def can_autotag(self):
        return True

    def can_refresh(self):
        return False

    def can_view_info(self):
        return True

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

    def get_state(self):
        return self._state

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

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

    state = property(get_state, set_state)

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

    def _compare_to_track(self, track):
        """
        Compare file metadata to a MusicBrainz track.

        Weigths:
          * title                = 13
          * artist name          = 4
          * release name         = 5
          * length               = 10
          * number of tracks     = 4
          * album type           = 20
          * release country      = 2
          * format               = 2

        """
        total = 0.0
        parts = []
        w = self.comparison_weights

        if 'title' in self.metadata:
            a = self.metadata['title']
            b = track.title[0].text
            parts.append((similarity2(a, b), w["title"]))
            total += w["title"]

        if 'artist' in self.metadata:
            a = self.metadata['artist']
            b = artist_credit_from_node(track.artist_credit[0], self.config)[0]
            parts.append((similarity2(a, b), w["artist"]))
            total += w["artist"]

        a = self.metadata.length
        if a > 0 and 'length' in track.children:
            b = int(track.length[0].text)
            score = 1.0 - min(abs(a - b), 30000) / 30000.0
            parts.append((score, w["length"]))
            total += w["length"]

        releases = []
        if "release_list" in track.children and "release" in track.release_list[0].children:
            releases = track.release_list[0].release

        if not releases:
            return (total, None)

        scores = []
        for release in releases:
            t, p = self.metadata.compare_to_release(release, w, self.config)
            total_ = total + t
            parts_ = list(parts) + p
            scores.append((reduce(lambda x, y: x + y[0] * y[1] / total_, parts_, 0.0), release.id))

        return max(scores, key=lambda x: x[0])

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

        if self.state == File.REMOVED:
            return

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

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

        # multiple matches -- calculate similarities to each of them
        matches = []
        for track in tracks:
            score, release = self._compare_to_track(track)
            matches.append((score, track, release))
        matches.sort(reverse=True)
        #self.log.debug("Track matches: %r", matches)

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

        albumid = matches[0][2]
        track = matches[0][1]
        if lookuptype == 'puid':
            self.tagger.puidmanager.add(self.metadata['musicip_puid'], track.id)
        elif lookuptype == 'acoustid':
            self.tagger.acoustidmanager.add(self, track.id)
        if albumid:
            self.tagger.move_file_to_track(self, albumid, track.id)
        else:
            self.tagger.move_file_to_nat(self, track.id, node=track)

    def lookup_puid(self, puid):
        """ Try to identify the file using the PUID. """
        self.tagger.window.set_statusbar_message(N_("Looking up the PUID for file %s..."), self.filename)
        self.clear_lookup_task()
        self.lookup_task = self.tagger.xmlws.lookup_puid(puid, partial(self._lookup_finished, 'puid'))

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

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

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

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

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

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

    def _get_discnumber(self):
        try:
            return int(self.metadata["discnumber"])
        except:
            return 0
    discnumber = property(_get_discnumber, doc="The disc number as an int.")
Пример #37
0
class File(QtCore.QObject, Item):

    metadata_images_changed = QtCore.pyqtSignal()

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

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

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

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

        self.similarity = 1.0
        self.parent = None

        self.lookup_task = None
        self.item = None

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

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

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

    def _load_check(self, filename):
        # Check that file has not been removed since thread was queued
        # Don't load if we are stopping.
        if self.state != File.PENDING:
            log.debug("File not loaded because it was removed: %r",
                      self.filename)
            return None
        if self.tagger.stopping:
            log.debug("File not loaded because %s is stopping: %r",
                      PICARD_APP_NAME, self.filename)
            return None
        return self._load(filename)

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

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

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

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

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

        if acoustid:
            self.metadata["acoustid_id"] = acoustid
        self.metadata_images_changed.emit()

    def keep_original_images(self):
        self.metadata.images = self.orig_metadata.images[:]
        self.update()
        self.metadata_images_changed.emit()

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

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

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

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

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

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

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

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

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

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

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

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

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

        new_path = os.path.join(new_dirname, new_filename)
        try:
            return os.path.realpath(new_path)
        except FileNotFoundError:
            # os.path.realpath can fail if cwd doesn't exist
            return new_path

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

        if old_filename == new_filename + ext:
            return old_filename

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

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

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

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

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

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

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

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

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

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

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

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

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

    def can_autotag(self):
        return True

    def can_refresh(self):
        return False

    def can_view_info(self):
        return True

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

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

    def get_state(self):
        return self._state

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

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

    state = property(get_state, set_state)

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

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

        if self.state == File.REMOVED:
            return
        if error:
            log.error(
                "Network error encountered during the lookup for %s. Error code: %s",
                self.filename, error)
        try:
            if lookuptype == "metadata":
                tracks = document['recordings']
            elif lookuptype == "acoustid":
                tracks = document['recordings']
        except (KeyError, TypeError):
            tracks = None

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

        # multiple matches -- calculate similarities to each of them
        match = sorted(
            (self.metadata.compare_to_track(track, self.comparison_weights)
             for track in tracks),
            reverse=True,
            key=itemgetter(0))[0]
        if lookuptype != 'acoustid' and match[0] < config.setting[
                'file_lookup_threshold']:
            self.tagger.window.set_statusbar_message(N_(
                "No matching tracks above the threshold for file '%(filename)s'"
            ), {'filename': self.filename},
                                                     timeout=3000)
            self.clear_pending()
            return

        self.tagger.window.set_statusbar_message(
            N_("File '%(filename)s' identified!"), {'filename': self.filename},
            timeout=3000)

        self.clear_pending()

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

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

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

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

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

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

    def _get_tracknumber(self):
        try:
            return self.metadata["tracknumber"]
        except:
            return 0

    tracknumber = property(_get_tracknumber, doc="The track number as an int.")

    def _get_discnumber(self):
        try:
            return self.metadata["discnumber"]
        except:
            return 0

    discnumber = property(_get_discnumber, doc="The disc number as an int.")
Пример #38
0
    def _parse_release(self, document):
        self.log.debug("Loading release %r", self.id)

        release_node = document.metadata[0].release[0]
        if release_node.id != self.id:
            album = self.tagger.get_album_by_id(release_node.id)
            self.tagger.albumids[self.id] = release_node.id
            self.id = release_node.id
            if album:
                album.match_files(self.unmatched_files.files)
                album.update()
                self.tagger.remove_album(self)
                self.log.debug("Release %r already loaded", self.id)
                return False

        # Get release metadata
        m = self._new_metadata
        m.length = 0
        release_to_metadata(release_node, m, config=self.config, album=self)

        self.format_str = media_formats_from_node(release_node.medium_list[0])
        self.rgid = release_node.release_group[0].id
        if self._discid:
            m['musicbrainz_discid'] = self._discid

        # 'Translate' artist name
        if self.config.setting['translate_artist_names']:
            m['albumartist'] = translate_artist(m['albumartist'], m['albumartistsort'])

        # Custom VA name
        if m['musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID:
            m['albumartistsort'] = m['albumartist'] = self.config.setting['va_name']

        ignore_tags = [s.strip() for s in self.config.setting['ignore_tags'].split(',')]
        first_artist = None
        compilation = False
        track_counts = []

        m['totaldiscs'] = release_node.medium_list[0].count

        self._metadata_processors = [partial(run_album_metadata_processors, self, m, release_node)]

        for medium_node in release_node.medium_list[0].medium:
            mm = Metadata()
            mm.copy(m)
            medium_to_metadata(medium_node, mm)
            track_counts.append(mm['totaltracks'])

            for track_node in medium_node.track_list[0].track:
                t = Track(track_node.recording[0].id, self)
                self._new_tracks.append(t)

                # Get track metadata
                tm = t.metadata
                tm.copy(mm)
                track_to_metadata(track_node, t, self.config)
                m.length += tm.length

                artist_id = tm['musicbrainz_artistid']
                if compilation is False:
                    if first_artist is None:
                        first_artist = artist_id
                    if first_artist != artist_id:
                        compilation = True
                        for track in self._new_tracks:
                            track.metadata['compilation'] = '1'
                else:
                    tm['compilation'] = '1'

                t._customize_metadata(ignore_tags)
                plugins = partial(run_track_metadata_processors, self, tm, release_node, track_node)
                self._metadata_processors.append(plugins)

        m["~totalalbumtracks"] = str(sum(map(int, track_counts)))
        self.tracks_str = " + ".join(track_counts)

        return True
Пример #39
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.")
Пример #40
0
    def _load_tracks(self):
        artists = set()
        all_media = []
        absolutetracknumber = 0

        va = self._new_metadata[
            'musicbrainz_albumartistid'] == VARIOUS_ARTISTS_ID

        djmix_ars = {}
        if hasattr(self._new_metadata, "_djmix_ars"):
            djmix_ars = self._new_metadata._djmix_ars

        for medium_node in self._release_node['media']:
            mm = Metadata()
            mm.copy(self._new_metadata)
            medium_to_metadata(medium_node, mm)
            format = medium_node.get('format')
            if format:
                all_media.append(format)

            for dj in djmix_ars.get(mm["discnumber"], []):
                mm.add("djmixer", dj)

            if va:
                mm["compilation"] = "1"
            else:
                del mm["compilation"]

            if 'discs' in medium_node:
                discids = [disc.get('id') for disc in medium_node['discs']]
                mm['~musicbrainz_discids'] = discids
                mm['musicbrainz_discid'] = list(
                    self._discids.intersection(discids))

            if "pregap" in medium_node:
                absolutetracknumber += 1
                mm['~discpregap'] = '1'
                extra_metadata = {
                    '~pregap': '1',
                    '~absolutetracknumber': absolutetracknumber,
                }
                self._finalize_loading_track(medium_node['pregap'], mm,
                                             artists, extra_metadata)

            track_count = medium_node['track-count']
            if track_count:
                tracklist_node = medium_node['tracks']
                for track_node in tracklist_node:
                    absolutetracknumber += 1
                    extra_metadata = {
                        '~absolutetracknumber': absolutetracknumber,
                    }
                    self._finalize_loading_track(track_node, mm, artists,
                                                 extra_metadata)

            if "data-tracks" in medium_node:
                for track_node in medium_node['data-tracks']:
                    absolutetracknumber += 1
                    extra_metadata = {
                        '~datatrack': '1',
                        '~absolutetracknumber': absolutetracknumber,
                    }
                    self._finalize_loading_track(track_node, mm, artists,
                                                 extra_metadata)

        totalalbumtracks = absolutetracknumber
        self._new_metadata['~totalalbumtracks'] = totalalbumtracks
        # Generate a list of unique media, but keep order of first appearance
        self._new_metadata['media'] = " / ".join(
            list(OrderedDict.fromkeys(all_media)))

        for track in self._new_tracks:
            track.metadata["~totalalbumtracks"] = totalalbumtracks
            if len(artists) > 1:
                track.metadata["~multiartist"] = "1"
        del self._release_node
        del self._release_artist_nodes
        self._tracks_loaded = True