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)
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
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()
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
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])
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()
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
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
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)
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)
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
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
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)
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]))
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:]))