Пример #1
0
class Export(SongsMenuPlugin):
    PLUGIN_ID = "ExportMeta"
    PLUGIN_NAME = _("Export Metadata")
    PLUGIN_DESC = _("Exports metadata of selected songs as a .tags file.")
    PLUGIN_ICON = Icons.DOCUMENT_SAVE_AS
    REQUIRES_ACTION = True

    plugin_handles = each_song(is_finite)

    def plugin_album(self, songs):
        songs.sort(key=sort_key_for)
        chooser = filechooser(save=True, title=songs[0]('album'))
        resp = chooser.run()
        fn = chooser.get_filename()
        chooser.destroy()
        if resp != Gtk.ResponseType.ACCEPT:
            return
        base, ext = splitext(fn)
        if not ext:
            fn = extsep.join([fn, 'tags'])

        global lastfolder
        lastfolder = dirname(fn)

        export_metadata(songs, fn)
Пример #2
0
class IFPUpload(SongsMenuPlugin):
    PLUGIN_ID = "Send to iFP"
    PLUGIN_NAME = _("Send to iFP")
    PLUGIN_DESC = _("Uploads songs to an iRiver iFP device.")
    PLUGIN_ICON = Icons.MULTIMEDIA_PLAYER

    plugin_handles = each_song(is_a_file)

    def plugin_songs(self, songs):
        if os.system("ifp typestring"):
            qltk.ErrorMessage(
                None, _("No iFP device found"),
                _("Unable to contact your iFP device. Check "
                  "that the device is powered on and plugged "
                  "in, and that you have ifp-line "
                  "(http://ifp-driver.sf.net) installed.")).run()
            return True
        self.__madedir = []

        w = WaitLoadWindow(
            None, len(songs), _("Uploading %(current)d/%(total)d"))
        w.show()

        for i, song in enumerate(songs):
            if self.__upload(song) or w.step():
                w.destroy()
                return True
        else:
            w.destroy()

    def __upload(self, song):
        filename = song["~filename"]
        basename = song("~basename")
        dirname = os.path.basename(os.path.dirname(filename))
        target = os.path.join(dirname, basename)

        # Avoid spurious calls to ifp mkdir; this can take a long time
        # on a noisy USB line.
        if dirname not in self.__madedir:
            os.system("ifp mkdir %r> /dev/null 2>/dev/null" % dirname)
            self.__madedir.append(dirname)
        if os.system("ifp upload %r %r > /dev/null" % (filename, target)):
            qltk.ErrorMessage(
                None, _("Error uploading"),
                _("Unable to upload <b>%s</b>. The device may be "
                  "out of space, or turned off.") % (
                util.escape(filename))).run()
            return True
Пример #3
0
class APEv2toID3v2(SongsMenuPlugin):
    PLUGIN_ID = "APEv2 to ID3v2"
    PLUGIN_NAME = _("APEv2 to ID3v2")
    PLUGIN_DESC = _("Converts your APEv2 tags to ID3v2 tags. This will delete "
                    "the APEv2 tags after conversion.")
    PLUGIN_ICON = Icons.EDIT_FIND_REPLACE

    plugin_handles = each_song(is_an_mp3, is_writable)

    def plugin_song(self, song):
        try:
            apesong = APEv2File(song["~filename"])
        except:
            return  # File doesn't have an APEv2 tag
        song.update(apesong)
        mutagen.apev2.delete(song["~filename"])
        song._song.write()
Пример #4
0
class AcoustidSubmit(SongsMenuPlugin):
    PLUGIN_ID = "AcoustidSubmit"
    PLUGIN_NAME = _("Submit Acoustic Fingerprints")
    PLUGIN_DESC = _("Generates acoustic fingerprints using chromaprint "
                    "and submits them to acoustid.org.")
    PLUGIN_ICON = Icons.NETWORK_WORKGROUP

    plugin_handles = each_song(is_finite, is_writable)

    def plugin_songs(self, songs):
        if not get_api_key():
            ErrorMessage(
                self, _("API Key Missing"),
                _("You have to specify an acoustid.org API key in the plugin "
                  "preferences before you can submit fingerprints.")).run()
        else:
            FingerprintDialog(songs)

    @classmethod
    def PluginPreferences(self, win):
        box = Gtk.VBox(spacing=12)

        # api key section
        def key_changed(entry, *args):
            config.set("plugins", "fingerprint_acoustid_api_key",
                       entry.get_text())

        button = Button(_("Request API key"), Icons.NETWORK_WORKGROUP)
        button.connect("clicked",
                       lambda s: util.website("https://acoustid.org/api-key"))
        key_box = Gtk.HBox(spacing=6)
        entry = UndoEntry()
        entry.set_text(get_api_key())
        entry.connect("changed", key_changed)
        label = Gtk.Label(label=_("API _key:"))
        label.set_use_underline(True)
        label.set_mnemonic_widget(entry)
        key_box.pack_start(label, False, True, 0)
        key_box.pack_start(entry, True, True, 0)
        key_box.pack_start(button, False, True, 0)

        box.pack_start(Frame(_("AcoustID Web Service"), child=key_box), True,
                       True, 0)

        return box
Пример #5
0
class BurnCD(SongsMenuPlugin):
    PLUGIN_ID = 'Burn CD'
    PLUGIN_NAME = _('Burn CD')
    PLUGIN_DESC = _('Burns CDs with K3b, Brasero or xfburn.')
    PLUGIN_ICON = Icons.MEDIA_OPTICAL

    plugin_handles = each_song(is_a_file)

    burn_programs = {
        # args, reverse order
        'K3b': (['k3b', '--audiocd'], False),
        'Brasero': (['brasero', '--audio'], False),
        'Xfburn': (['xfburn', '--audio-composition'], True),
    }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.prog_name = None

        items = self.burn_programs.items()
        progs = [(iscommand(x[1][0][0]), x) for x in items]
        progs.sort(reverse=True)

        submenu = Gtk.Menu()
        for (is_cmd, (name, (args, reverse))) in progs:
            item = Gtk.MenuItem(label=name)
            if not is_cmd:
                item.set_sensitive(False)
            else:
                connect_obj(item, 'activate', self.__set, name)
            submenu.append(item)
        self.set_submenu(submenu)

    def __set(self, name):
        self.prog_name = name

    def plugin_songs(self, songs):
        if self.prog_name is None:
            return

        args, reverse = self.burn_programs[self.prog_name]
        songs = sorted(songs, key=lambda s: s.sort_key, reverse=reverse)
        util.spawn(args + [song['~filename'] for song in songs])
Пример #6
0
class AcoustidSearch(SongsMenuPlugin):
    PLUGIN_ID = "AcoustidSearch"
    PLUGIN_NAME = _("Acoustic Fingerprint Lookup")
    PLUGIN_DESC = _("Looks up song metadata through acoustic fingerprinting.")
    PLUGIN_ICON = Icons.NETWORK_WORKGROUP

    plugin_handles = each_song(is_finite, is_writable)

    def plugin_songs(self, songs):
        from .search import SearchWindow

        window = SearchWindow(songs, title=self.PLUGIN_NAME)
        window.show()

        # plugin_done checks for metadata changes and opens the write dialog
        window.connect("destroy", self.__plugin_done)

    def __plugin_done(self, *args):
        self.plugin_finish()
Пример #7
0
class MyBrainz(SongsMenuPlugin):
    PLUGIN_ID = "MusicBrainz lookup"
    PLUGIN_NAME = _("MusicBrainz Lookup")
    PLUGIN_ICON = Icons.MEDIA_OPTICAL
    PLUGIN_DESC = _('Re-tags an album based on a MusicBrainz search.')

    plugin_handles = each_song(is_writable, is_finite)

    def plugin_albums(self, albums):
        if not albums:
            return

        def win_finished_cb(widget, *args):
            if albums:
                start_processing(albums.pop(0))
            else:
                self.plugin_finish()

        def start_processing(disc):
            win = SearchWindow(self.plugin_window, disc)
            win.connect("destroy", win_finished_cb)
            win.show()

        start_processing(albums.pop(0))

    @classmethod
    def PluginPreferences(self, win):
        items = [
            ('year_only', _('Only use year for "date" tag')),
            ('albumartist', _('Write "_albumartist" when needed')),
            ('artist_sort', _('Write sort tags for artist names')),
            ('standard', _('Write _standard MusicBrainz tags')),
            ('labelid2', _('Write "labelid" tag')),
        ]

        vb = Gtk.VBox()
        vb.set_spacing(8)

        for key, label in items:
            ccb = pconfig.ConfigCheckButton(label, key, populate=True)
            vb.pack_start(ccb, True, True, 0)

        return vb
Пример #8
0
class Export(SongsMenuPlugin):
    PLUGIN_ID = "ExportMeta"
    PLUGIN_NAME = _("Export Metadata")
    PLUGIN_DESC = _("Exports metadata of selected songs as a .tags file.")
    PLUGIN_ICON = Icons.DOCUMENT_SAVE_AS
    REQUIRES_ACTION = True

    plugin_handles = each_song(is_finite)

    def plugin_album(self, songs):

        songs.sort(lambda a, b: cmp(a('~#track'), b('~#track')) or
                                cmp(a('~basename'), b('~basename')) or
                                cmp(a, b))

        chooser = filechooser(save=True, title=songs[0]('album'))
        resp = chooser.run()
        fn = chooser.get_filename()
        chooser.destroy()
        if resp != Gtk.ResponseType.ACCEPT:
            return
        base, ext = splitext(fn)
        if not ext:
            fn = extsep.join([fn, 'tags'])

        global lastfolder
        lastfolder = dirname(fn)
        out = open(fn, 'w')

        for song in songs:
            print>>out, str(song('~basename'))
            keys = song.keys()
            keys.sort()
            for key in keys:
                if key.startswith('~'):
                    continue
                for val in song.list(key):
                    print>>out, '%s=%s' % (key, val.encode('utf-8'))
            print>>out
Пример #9
0
class MyBrainz(SongsMenuPlugin):
    PLUGIN_ID = "Spotify lookup"
    PLUGIN_NAME = _("Spotify Lookup")
    PLUGIN_ICON = Icons.MEDIA_OPTICAL
    PLUGIN_DESC = _('Re-tags an album based on a Spotify search.')

    plugin_handles = each_song(is_writable, is_finite)

    def plugin_albums(self, albums):
        if not albums:
            return

        def win_finished_cb(widget, *args):
            if albums:
                start_processing(albums.pop(0))
            else:
                self.plugin_finish()

        def start_processing(disc):
            win = SearchWindow(self.plugin_window, disc)
            win.connect("destroy", win_finished_cb)
            win.show()

        start_processing(albums.pop(0))

    @classmethod
    def PluginPreferences(self, win):
        def id_entry_changed(entry):
            config.set('plugins', 'spotify_client_id', id_entry.get_text())

        id_label = Gtk.Label(label=_("_Client ID:"), use_underline=True)
        id_entry = UndoEntry()
        id_entry.set_text(config_get('client_id', ''))
        id_entry.connect('changed', id_entry_changed)
        id_label.set_mnemonic_widget(id_entry)

        id_hbox = Gtk.HBox()
        id_hbox.set_spacing(6)
        id_hbox.pack_start(id_label, False, True, 0)
        id_hbox.pack_start(id_entry, True, True, 0)

        def secret_entry_changed(entry):
            config.set('plugins', 'spotify_client_secret',
                       secret_entry.get_text())

        secret_label = Gtk.Label(label=_("_Client secret:"),
                                 use_underline=True)
        secret_entry = UndoEntry()
        secret_entry.set_text(config_get('client_secret', ''))
        secret_entry.connect('changed', secret_entry_changed)
        secret_label.set_mnemonic_widget(secret_entry)

        secret_hbox = Gtk.HBox()
        secret_hbox.set_spacing(6)
        secret_hbox.pack_start(secret_label, False, True, 0)
        secret_hbox.pack_start(secret_entry, True, True, 0)

        vb = Gtk.VBox()
        vb.set_spacing(8)
        vb.pack_start(id_hbox, True, True, 0)
        vb.pack_start(secret_hbox, True, True, 0)

        return Frame(_("Account"), child=vb)
Пример #10
0
class Import(SongsMenuPlugin):
    PLUGIN_ID = "ImportMeta"
    PLUGIN_NAME = _("Import Metadata")
    PLUGIN_DESC = _("Imports metadata for selected songs from a .tags file.")
    PLUGIN_ICON = Icons.DOCUMENT_OPEN
    REQUIRES_ACTION = True

    plugin_handles = each_song(is_writable, is_a_file)

    # Note: the usage of plugin_album here is sometimes NOT what you want. It
    # supports fixing up tags on several already-known albums just by walking
    # them via the plugin system and just selecting a new .tags; this mimics
    # export of several albums.
    #
    # However if one of the songs in your album is different from the rest
    # (e.g.
    # one isn't tagged, or only one is) it will be passed in as two different
    # invocations, neither of which has the right size. If you find yourself in
    # that scenario a lot more than the previous one, change this to
    #   def plugin_songs(self, songs):
    # and comment out the songs.sort line for safety.
    def plugin_album(self, songs):

        songs.sort(key=sort_key_for)

        chooser = filechooser(save=False, title=songs[0]('album'))
        box = Gtk.HBox()
        rename = Gtk.CheckButton("Rename Files")
        rename.set_active(False)
        box.pack_start(rename, True, True, 0)
        append = Gtk.CheckButton("Append Metadata")
        append.set_active(True)
        box.pack_start(append, True, True, 0)
        box.show_all()
        chooser.set_extra_widget(box)

        resp = chooser.run()
        append = append.get_active()
        rename = rename.get_active()
        fn = chooser.get_filename()
        chooser.destroy()
        if resp != Gtk.ResponseType.ACCEPT:
            return

        global lastfolder
        lastfolder = dirname(fn)

        metadata = []
        names = []
        index = 0
        for line in open(fn, 'rU'):
            if index == len(metadata):
                names.append(line[:line.rfind('.')])
                metadata.append({})
            elif line == '\n':
                index = len(metadata)
            else:
                key, value = line[:-1].split('=', 1)
                value = value.decode('utf-8')
                try:
                    metadata[index][key].append(value)
                except KeyError:
                    metadata[index][key] = [value]

        if not (len(songs) == len(metadata) == len(names)):
            ErrorMessage(None, "Songs mismatch",
                         "There are %(select)d songs selected, but %(meta)d "
                         "songs in the file. Aborting." %
                         dict(select=len(songs), meta=len(metadata))).run()
            return

        for song, meta, name in zip(songs, metadata, names):
            for key, values in iteritems(meta):
                if append and key in song:
                    values = song.list(key) + values
                song[key] = '\n'.join(values)
            if rename:
                origname = song['~filename']
                newname = name + origname[origname.rfind('.'):]
                app.library.rename(origname, newname)
Пример #11
0
class ReplayGain(SongsMenuPlugin, PluginConfigMixin):
    PLUGIN_ID = 'ReplayGain'
    PLUGIN_NAME = _('Replay Gain')
    PLUGIN_DESC_MARKUP = (_(
        'Analyzes and updates %(rg_link)s information, '
        'using GStreamer. Results are grouped by album.'
    ) % {
        "rg_link":
        "<a href=\"https://en.wikipedia.org/wiki/ReplayGain\">ReplayGain</a>"
    })
    PLUGIN_ICON = Icons.MULTIMEDIA_VOLUME_CONTROL
    CONFIG_SECTION = 'replaygain'

    plugin_handles = each_song(is_finite, is_writable)

    def plugin_albums(self, albums):
        mode = self.config_get("process_if", UpdateMode.ALWAYS)
        win = RGDialog(albums, parent=self.plugin_window, process_mode=mode)
        win.show_all()
        win.start_analysis()

        # plugin_done checks for metadata changes and opens the write dialog
        win.connect("destroy", self.__plugin_done)

    def __plugin_done(self, win):
        self.plugin_finish()

    @classmethod
    def PluginPreferences(cls, parent):
        vb = Gtk.VBox(spacing=12)

        # Tabulate all settings for neatness
        table = Gtk.Table(n_rows=1, n_columns=2)
        table.props.expand = False
        table.set_col_spacings(6)
        table.set_row_spacings(6)
        rows = []

        def process_option_changed(combo):
            # xcode = combo.get_child().get_text()
            model = combo.get_model()
            lbl, value = model[combo.get_active()]
            cls.config_set("process_if", value)

        def create_model():
            model = Gtk.ListStore(str, str)
            model.append(["<b>%s</b>" % _("always"), UpdateMode.ALWAYS])
            model.append([
                _("if <b>any</b> RG tags are missing"), UpdateMode.ANY_MISSING
            ])
            model.append([
                _("if <b>album</b> RG tags are missing"),
                UpdateMode.ALBUM_MISSING
            ])
            return model

        def set_active(value):
            for i, item in enumerate(model):
                if value == item[1]:
                    combo.set_active(i)

        model = create_model()
        combo = Gtk.ComboBox(model=model)
        set_active(cls.config_get("process_if", UpdateMode.ALWAYS))
        renderer = Gtk.CellRendererText()
        combo.connect('changed', process_option_changed)
        combo.pack_start(renderer, True)
        combo.add_attribute(renderer, "markup", 0)

        rows.append((_("_Process albums:"), combo))

        for (row, (label_text, entry)) in enumerate(rows):
            label = Gtk.Label(label=label_text)
            label.set_alignment(0.0, 0.5)
            label.set_use_underline(True)
            label.set_mnemonic_widget(entry)
            table.attach(label,
                         0,
                         1,
                         row,
                         row + 1,
                         xoptions=Gtk.AttachOptions.FILL)
            table.attach(entry, 1, 2, row, row + 1)

        # Server settings Frame
        frame = Frame(_("Existing Tags"), table)

        vb.pack_start(frame, True, True, 0)
        return vb
Пример #12
0
class EditPlaycount(SongsMenuPlugin):
    PLUGIN_ID = "editplaycount"
    PLUGIN_NAME = _("Edit Playcount")
    PLUGIN_DESC = _("Edit a song's ~#playcount and ~#skipcount."
                    "\n\n"
                    "When multiple songs are selected, counts will be "
                    "incremented, rather than set."
                    "\n\n"
                    "When setting a song's ~#playcount to 0, the "
                    "~#lastplayed and ~#laststarted entries will be cleared. "
                    "However, when setting a 0-play song to a positive play "
                    "count, no play times will be created.")
    PLUGIN_ICON = Icons.EDIT
    REQUIRES_ACTION = True

    plugin_handles = each_song(is_writable)

    def plugin_songs(self, songs):
        # This is just here so the spinner has something to call. >.>
        def response(win, response_id):
            dlg.response(response_id)
            return

        # Create a dialog.
        dlg = Gtk.Dialog(title=_("Edit Playcount"),
                         flags=(Gtk.DialogFlags.MODAL
                                | Gtk.DialogFlags.DESTROY_WITH_PARENT))
        dlg.add_button(_("_Cancel"), Gtk.ResponseType.REJECT)
        dlg.add_button(_("_Apply"), Gtk.ResponseType.APPLY)
        dlg.set_default_response(Gtk.ResponseType.APPLY)
        dlg.set_border_width(4)
        dlg.vbox.set_spacing(4)

        # Create some spinners.
        play = Gtk.SpinButton()
        play.set_adjustment(Gtk.Adjustment(0, -1000, 1000, 1, 1))
        skip = Gtk.SpinButton()
        skip.set_adjustment(Gtk.Adjustment(0, -1000, 1000, 1, 1))

        # Connect the signals.
        play.connect("activate", response, Gtk.ResponseType.APPLY)
        skip.connect("activate", response, Gtk.ResponseType.APPLY)

        # Set some defaults.
        play.set_numeric(True)
        skip.set_numeric(True)

        # Put all this stuff in a pretty table.
        table = Gtk.Table(rows=2, columns=2)
        table.set_row_spacings(4)
        table.set_col_spacings(4)
        table.attach(Gtk.Label(_("Play Count")), 0, 1, 0, 1)
        table.attach(Gtk.Label(_("Skip Count")), 0, 1, 1, 2)
        table.attach(play, 1, 2, 0, 1)
        table.attach(skip, 1, 2, 1, 2)
        dlg.vbox.add(table)

        # Make a couple tweaks based on the current mode.
        if len(songs) == 1:
            play.set_adjustment(Gtk.Adjustment(0, 0, 9999, 1, 1))
            skip.set_adjustment(Gtk.Adjustment(0, 0, 9999, 1, 1))
            play.set_value(songs[0].get('~#playcount', 0))
            skip.set_value(songs[0].get('~#skipcount', 0))
        else:
            note = Gtk.Label()
            note.set_justify(Gtk.Justification.CENTER)
            note.set_markup("<b>Multiple files selected.</b>\n"
                            "Counts will be incremented.")
            dlg.vbox.add(note)

        dlg.show_all()

        # Only operate if apply is pressed.
        if dlg.run() == Gtk.ResponseType.APPLY:
            for song in songs:
                # Increment when not in single mode.
                if len(songs) == 1:
                    song['~#playcount'] = play.get_value_as_int()
                    song['~#skipcount'] = skip.get_value_as_int()
                else:  # Can't use += here because these tags might not exist.
                    song['~#playcount'] = max(
                        0,
                        (song.get('~#playcount', 0) + play.get_value_as_int()))
                    song['~#skipcount'] = max(
                        0,
                        (song.get('~#skipcount', 0) + skip.get_value_as_int()))

                # When the playcount is set to 0, delete the playcount
                # itself and the last played/started time. We don't
                # want unused or impossible data floating around.
                if song.get('~#playcount', 0) == 0:
                    for tag in [
                            '~#playcount', '~#lastplayed', '~#laststarted'
                    ]:
                        song.pop(tag, None)

                # Also delete the skip count if it's zero.
                if song.get('~#skipcount', 0) == 0:
                    song.pop('~#skipcount', None)

        dlg.destroy()
        return
Пример #13
0
class ImportExportTagsAndTrackUserDataPlugin(SongsMenuPlugin):
    PLUGIN_ID = _PLUGIN_ID
    PLUGIN_NAME = _("Import / Export")
    PLUGIN_DESC = _("Imports and exports tags and track user data.")
    PLUGIN_ICON = Icons.EDIT_COPY

    plugin_handles = each_song(is_finite)

    _album_id_to_export_path: MutableMapping[AlbumId, Path]

    def PluginPreferences(self, *args):
        vbox = Gtk.VBox(spacing=6)

        def asd_toggled(button, *args):
            CONFIG.need_user_check_if_number_of_albums_differs = button.get_active(
            )

        def tsd_toggled(button, *args):
            CONFIG.need_user_check_if_number_of_tracks_differs = button.get_active(
            )

        def de_toggled(button, *args):
            CONFIG.delete_exports_after_importing = button.get_active()

        def pp_toggled(button, *args):
            CONFIG.pretty_print_json = button.get_active()

        def mt_scale_changed(scale):
            CONFIG.max_track_similarity_to_need_user_check = scale.get_value()

        def ma_scale_changed(scale):
            CONFIG.max_album_similarity_to_need_user_check = scale.get_value()

        info_box = Gtk.VBox(spacing=6)
        info_frame = qltk.Frame(_("Further information"), child=info_box)
        vbox.pack_start(info_frame, False, True, 0)

        info_text = _(
            "The term 'track user data' includes the playlists in which the "
            "selected tracks are and the following metadata:\n\n<tt>%s</tt>\n"
            "\nBe aware that whatever you chose to export will be imported. "
            "If you exported the file stems (file names without extension), "
            "then, on import, the selected files will be renamed.\n\nAfter "
            "exporting an album you can import the data into another version "
            "of the album. Order and number of tracks can be different. "
            "The plugin matches the exported data to the new tracks, even if "
            "the names of the tracks are slightly different. The automatic "
            "matching is not always correct, so it is better to not reduce "
            "the following similarity values too much.") % ", ".join(MIGRATE)

        info_lbl = Gtk.Label(label=info_text, use_markup=True, wrap=True)
        info_box.pack_start(info_lbl, True, True, 0)

        manual_box = Gtk.VBox(spacing=6)
        manual_frame = qltk.Frame(_("User interaction on import"),
                                  child=manual_box)
        vbox.pack_start(manual_frame, False, True, 0)

        tsd = Gtk.CheckButton(
            label=_("Require confirmation if number of tracks differs"))
        tsd.set_active(CONFIG.need_user_check_if_number_of_tracks_differs)
        tsd.connect("toggled", tsd_toggled)
        manual_box.pack_start(tsd, True, True, 0)

        asd = Gtk.CheckButton(
            label=_("Require confirmation if number of albums differs"))
        asd.set_active(CONFIG.need_user_check_if_number_of_albums_differs)
        asd.connect("toggled", asd_toggled)
        manual_box.pack_start(asd, True, True, 0)

        desc = _(
            "Percentage below which the user will have to manually check and "
            "optionally change which track is matched with which.")

        perc_table = Gtk.Table(n_rows=2, n_columns=2)
        perc_table.set_col_spacings(6)
        perc_table.set_row_spacings(6)
        manual_box.pack_start(perc_table, True, True, 0)

        def format_perc(scale, value):
            return _("%d %%") % (value * 100)

        def add_perc_scale_with_label(ratio, col, lbl_text, tooltip_text,
                                      on_change):
            scale = Gtk.HScale(
                adjustment=Gtk.Adjustment.new(0, 0, 1, 0.01, 0.01, 0))
            scale.set_digits(2)
            scale.set_tooltip_text(tooltip_text)
            scale.set_value_pos(Gtk.PositionType.RIGHT)
            scale.set_value(ratio)
            scale.connect('format-value', format_perc)
            scale.connect('value-changed', on_change)

            label = Gtk.Label(label=lbl_text)
            label.set_alignment(0.0, 0.5)
            label.set_padding(0, 6)
            label.set_mnemonic_widget(scale)

            xoptions = Gtk.AttachOptions.FILL | Gtk.AttachOptions.SHRINK
            perc_table.attach(label, 0, 1, col, col + 1, xoptions=xoptions)
            perc_table.attach(scale, 1, 2, col, col + 1)

        add_perc_scale_with_label(
            CONFIG.max_track_similarity_to_need_user_check, 0,
            _("Track similarity:"), desc, mt_scale_changed)

        add_perc_scale_with_label(
            CONFIG.max_album_similarity_to_need_user_check, 1,
            _("Album similarity:"), desc, ma_scale_changed)

        export_box = Gtk.VBox(spacing=6)
        export_frame = qltk.Frame(_("Export files"), child=export_box)
        vbox.pack_start(export_frame, False, True, 0)

        pp = Gtk.CheckButton(label=_("Write pretty and clear JSON (slower)"))
        pp.set_active(CONFIG.pretty_print_json)
        pp.connect("toggled", pp_toggled)
        export_box.pack_start(pp, True, True, 0)

        de = Gtk.CheckButton(
            label=_("Delete export files after they've been imported"))
        de.set_active(CONFIG.delete_exports_after_importing)
        de.connect("toggled", de_toggled)
        export_box.pack_start(de, True, True, 0)

        return vbox

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._export_collectors = []
        self._import_or_export_option_index = None

        self._album_id_matcher: ObjectListMatcher[AlbumId] = ObjectListMatcher(
            {  #
                lambda a: a.title: 9,  # title is the most reliable
                lambda a: a.artist: 4.5,  #
                lambda a: a.tracks: 1.2,  #
                lambda a: a.last_directory_parts:
                1,  # needed in case the album has no tags
                lambda a: a.discs:
                0.8,  # multi disc albums sometimes become single disc
                lambda a: a.id_value:
                0.5,  # is likely to change unless exact same album
            })
        # We want check similarity afterwards, so it needs be as accurate as possible
        self._album_id_matcher.should_store_similarity_matrix = True
        self._album_id_matcher.should_go_through_every_attribute = True

        self._track_id_matcher: ObjectListMatcher[TrackId] = ObjectListMatcher(
            {  #
                lambda t: t.title: 8,  #
                lambda t: t.artist: 3.5,  #
                lambda t: t.track: 1.2,  #
                lambda t: t.file_stem:
                1,  # needed in case the track has no tags
                lambda t: t.disc: 0.8,  #
            })
        self._track_id_matcher.should_store_similarity_matrix = True
        self._album_id_matcher.should_go_through_every_attribute = True

        self._album_id_to_export_path = {}

        submenu = Gtk.Menu()
        self._init_collectors_and_menu(submenu)

        if submenu.get_children():
            self.set_submenu(submenu)
        else:
            self.set_sensitive(False)

    def _init_collectors_and_menu(self, submenu):
        import_item = Gtk.MenuItem(label=_("Import"))
        connect_obj(import_item, 'activate',
                    self.__set_import_export_option_index, -1)

        submenu.append(import_item)
        submenu.append(SeparatorMenuItem())

        for idx, (name, query) in enumerate(EXPORT_OPTIONS):
            collector = track_data_collector_for(query)
            self._export_collectors.append(collector)

            item = Gtk.MenuItem(label=name)
            connect_obj(item, 'activate',
                        self.__set_import_export_option_index, idx)
            submenu.append(item)

        submenu.append(SeparatorMenuItem())
        open_dir_item = Gtk.MenuItem(label=_("Open Export Directory"))

        def open_export_dir(_):
            show_files(path2fsn(EXPORT_DIR_PATH),
                       [path2fsn(TAGS_AND_USERDATA_INDEX_FILE_PATH.name)])

        connect_obj(open_dir_item, 'activate', open_export_dir, None)
        submenu.append(open_dir_item)

    def __set_import_export_option_index(self, index):
        self._import_or_export_option_index = index

    def _error_msg(self, message):
        title = _("Error in %s") % self.PLUGIN_NAME
        ErrorMessage(app.window, title, message).run()

    def plugin_albums(self, albums):
        index = self._import_or_export_option_index

        if index is None or index >= len(self._export_collectors):
            return

        if index < 0:
            self.import_data_to_albums(albums)
        else:
            collect_data = self._export_collectors[index]
            self.export_albums(albums, collect_data)

        self._rewrite_index()
        self._import_or_export_option_index = None

    def import_data_to_albums(self, albums):
        if not self._try_load_exports():
            return

        for exp_album_id, songs in self._iter_export_album_id_matched_to_songs(
                albums):
            if exp_album_id is not None:
                self.import_data(exp_album_id, songs)

    def _iter_export_album_id_matched_to_songs(self, albums):
        album_ids = [AlbumId.of_song(songs[0]) for songs in albums]
        exp_album_ids = list(self._album_id_to_export_path.keys())

        exp_indices = self._album_id_matcher.get_indices(
            album_ids, exp_album_ids)
        size_differs = len(exp_album_ids) != len(exp_album_ids)
        need_check = CONFIG.need_user_check_if_number_of_albums_differs and size_differs

        need_check = need_check or self._does_match_need_manual_check(
            self._album_id_matcher, exp_indices,
            CONFIG.max_album_similarity_to_need_user_check)

        if need_check:
            columns = [  #
                ColumnSpec(_('Discs'), lambda a: str(a.discs), False),
                ColumnSpec(_('Tracks'), lambda a: str(a.tracks), False),
                ColumnSpec(_('Title'), lambda a: a.title, True),
                ColumnSpec(_('Artist(s)'), lambda a: a.artist, True),
                ColumnSpec(_('End of path'), lambda a: a.last_directory_parts,
                           True),  #
            ]
            prompt = MatchListsDialog(album_ids,
                                      exp_album_ids,
                                      exp_indices,
                                      columns,
                                      _("Match Albums"),
                                      _("Continue"),
                                      id_for_window_tracking=self.PLUGIN_ID)
            exp_indices = prompt.run()

        for exp_idx, songs in zip(exp_indices, albums):
            if exp_idx is not None:
                yield exp_album_ids[exp_idx], songs

    def _try_load_exports(self) -> bool:
        """:return: Whether we could load the exports"""

        index_path = TAGS_AND_USERDATA_INDEX_FILE_PATH

        if not index_path.exists():
            self._warning_nothing_to_import()
            return False

        try:
            with index_path.open(encoding='utf-8') as f:
                album_json_key_to_export_file_name = json.load(f)
        except ValueError:
            self._handle_broken_index()
            return False

        if not album_json_key_to_export_file_name:
            self._warning_nothing_to_import()
            return False

        self._load_exports_in_index(album_json_key_to_export_file_name)
        return True

    def _warning_nothing_to_import(self):
        WarningMessage(
            app.window, _("Nothing to import"),
            _("You have to export something before you can import."))

    def _load_exports_in_index(self, album_json_key_to_export_file_name):
        for key, file_name in album_json_key_to_export_file_name.items():
            path = EXPORT_DIR_PATH / file_name
            if not path.exists():
                continue

            try:
                # album_id needed to be stored as a json string, since it's a tuple
                album_id = AlbumId(*json.loads(key))
            except ValueError:
                continue

            self._album_id_to_export_path[album_id] = path

    def _handle_broken_index(self):
        index_path = TAGS_AND_USERDATA_INDEX_FILE_PATH

        now = cur_datetime_as_str()
        new_path = index_path.with_name(
            f'index-broken-{now}.{EXPORT_EXTENSION}')
        index_path.rename(new_path)

        self._error_msg(_("The index was corrupt."))

    def import_data(self, export_album_id: AlbumId, songs: List[SongWrapper]):
        songs = [s for s in songs if is_writable(s)]
        if not songs:
            return
        songs.sort(key=sort_key_for_song)

        export_path = self._album_id_to_export_path[export_album_id]
        changed_songs = self.import_data_and_get_changed(songs, export_path)
        if changed_songs:
            background_check_wrapper_changed(app.library, changed_songs)

            # Remove used up export
            del self._album_id_to_export_path[export_album_id]
            if CONFIG.delete_exports_after_importing:
                try:
                    export_path.unlink()
                except FileNotFoundError:
                    pass
            else:
                move_export_to_used(export_path)

    def import_data_and_get_changed(
            self,
            songs: List[SongWrapper],  #
            source_path: Path) -> List[SongWrapper]:
        """:return: List of changed songs"""

        exported = self._try_read_source_json(source_path)
        if exported is None:
            return []

        # removes TrackId from exported
        exported_indices = self._get_exported_indices_matched_to_songs(
            exported, songs)
        if not exported_indices:
            return []

        changed_songs = []
        for song, exp_idx in zip(songs, exported_indices):
            if exp_idx is None:
                continue

            self._update_song(exported[exp_idx], song)

            if song._needs_write:
                changed_songs.append(song)

        return changed_songs

    def _try_read_source_json(self, path: Path):
        try:
            with path.open(encoding="utf-8") as f:
                return json.load(f)
        except ValueError:
            print_e(f"Couldn't parse JSON in {path}.")
            self._error_msg(_("Couldn't parse JSON in %s") % path)
            return None
        except OSError:
            print_e(f"Couldn't read {path}")
            self._error_msg(_("Couldn't read %s") % path)
            return None

    def _update_song(self, exported_data, song):
        file_stem = exported_data.pop(FILE_STEM_KEY, None)

        if file_stem is not None:
            file_ext = extension_of_file_name(song('~basename'))

            new_name = f'{file_stem}{file_ext}'
            new_song_path = os.path.join(song('~dirname'), new_name)
            try:
                app.library.rename(song._song, new_song_path)
            except ValueError:
                print_e(f'Could not rename {song._song} to {new_song_path}.')

        for pl_name in exported_data.pop(PLAYLISTS_KEY, []):
            add_song_to_playlist(pl_name, song)

        for tag_key, tag_value in exported_data.items():
            if tag_key in song and song[tag_key] == tag_value:
                continue

            song[tag_key] = tag_value
            song._needs_write = True

    def _rewrite_index(self):
        # AlbumId's are tuples, so we need to serialize them to a string for json
        obj = {
            json.dumps(k): p.name
            for k, p in self._album_id_to_export_path.items()
        }
        self._rewrite_json(obj, TAGS_AND_USERDATA_INDEX_FILE_PATH)

    def _rewrite_json(self, obj, path):
        try:
            with path.open('w+', encoding='utf-8') as f:
                json.dump(obj, f, indent=self._get_json_indent())
        except (ValueError, OSError):
            self._error_msg(_("Couldn't write '%s'") % path)
            print_e(f"Couldn't write {path} due to:")
            print_exc()

    def _get_exported_indices_matched_to_songs(self, exported, songs):
        songs_ids = [TrackId.of_song(s) for s in songs]
        export_ids = [TrackId(*md.pop(IDENTIFIER_KEY)) for md in exported]

        export_ids_indices = self._track_id_matcher.get_indices(
            songs_ids, export_ids)

        size_differs = len(exported) != len(songs)
        need_check = CONFIG.need_user_check_if_number_of_tracks_differs and size_differs

        need_check = need_check or self._does_match_need_manual_check(
            self._track_id_matcher, export_ids_indices,
            CONFIG.max_track_similarity_to_need_user_check)

        if need_check:
            columns = [  #
                ColumnSpec(_('Disc'), lambda t: t.disc_text, False),
                ColumnSpec(_('Track'), lambda t: t.track_text, False),
                ColumnSpec(_('Title'), lambda t: t.title, True),
                ColumnSpec(_('Artist(s)'), lambda t: t.artist, True),
                ColumnSpec(_('File name'), lambda t: t.file_name, True),  #
            ]
            prompt = MatchListsDialog(songs_ids,
                                      export_ids,
                                      export_ids_indices,
                                      columns,
                                      _("Match Tracks"),
                                      _("Import"),
                                      id_for_window_tracking=self.PLUGIN_ID)
            return prompt.run()

        return export_ids_indices

    def _does_match_need_manual_check(self, matcher, b_indices,
                                      max_similarity_to_need_manual_check):
        if max_similarity_to_need_manual_check <= 0.0:
            return False
        if max_similarity_to_need_manual_check >= 1.0:
            return True

        sim_matrix = matcher.similarity_matrix
        for a_idx, b_idx in enumerate(b_indices):
            if b_idx is None:
                continue

            sim = sim_matrix[a_idx][b_idx]
            if sim <= max_similarity_to_need_manual_check:
                return True
        return False

    def _get_json_indent(self):
        return 4 if CONFIG.pretty_print_json else None

    def export_albums(self, albums, collect_data):
        self._try_load_exports()

        for songs in albums:
            self.extract_data_and_export(songs, collect_data)

    def extract_data_and_export(self, songs, collect_data):
        songs.sort(key=sort_key_for_song)
        songs_data = [collect_data(s._song) for s in songs]

        album_id = AlbumId.of_song(songs[0])

        prev_path = self._album_id_to_export_path.get(album_id)
        print_d(prev_path)

        # this overrides export data with the same album key by design, so a user
        # can simply rerun the export on an album they've modified
        path = new_export_path_for_album(
            album_id) if prev_path is None else prev_path
        self._album_id_to_export_path[album_id] = path

        self._rewrite_json(songs_data, path)
Пример #14
0
 def test_each_song_multiple(self):
     FakeSongsMenuPlugin.plugin_handles = each_song(even, never)
     p = FakeSongsMenuPlugin(self.songs, None)
     self.failIf(p.plugin_handles(self.songs))
     self.failIf(p.plugin_handles(self.songs[:1]))
Пример #15
0
 def test_each_song(self):
     FakeSongsMenuPlugin.plugin_handles = each_song(even)
     p = FakeSongsMenuPlugin(self.songs, None)
     self.failIf(p.plugin_handles(self.songs))
     self.failUnless(p.plugin_handles(self.songs[1:]))
Пример #16
0
 def test_each_song_multiple(self):
     FakeSongsMenuPlugin.plugin_handles = each_song(even, never)
     p = FakeSongsMenuPlugin(self.songs, None)
     self.failIf(p.plugin_handles(self.songs))
     self.failIf(p.plugin_handles(self.songs[:1]))
Пример #17
0
 def test_each_song(self):
     FakeSongsMenuPlugin.plugin_handles = each_song(even)
     p = FakeSongsMenuPlugin(self.songs, None)
     self.failIf(p.plugin_handles(self.songs))
     self.failUnless(p.plugin_handles(self.songs[1:]))