class ExactRating(SongsMenuPlugin): PLUGIN_ID = "exact-rating" PLUGIN_NAME = _("Set Exact Rating") PLUGIN_DESC = _("Allows setting the rating of songs with a number.") REQUIRES_ACTION = True PLUGIN_ICON = Icons.USER_BOOKMARKS plugin_handles = any_song(lambda s: s.can_change()) def plugin_songs(self, songs): value = -1 while not 0 <= value <= 1: input_string = GetStringDialog( self.plugin_window, self.PLUGIN_NAME, _("Please give your desired rating on a scale " "from 0.0 to 1.0"), _("_Apply"), Icons.NONE).run() if input_string is None: return try: value = float(input_string) except ValueError: continue count = len(songs) if (count > 1 and config.getboolean("browsers", "rating_confirm_multiple")): confirm_dialog = ConfirmRateMultipleDialog(self.plugin_window, count, value) if confirm_dialog.run() != Gtk.ResponseType.YES: return for song in songs: song["~#rating"] = value
class RemoveID3TLEN(SongsMenuPlugin): PLUGIN_ID = "RemoveID3TLEN" PLUGIN_NAME = _("Fix MP3 Duration") PLUGIN_DESC = _("Removes TLEN frames from ID3 tags which can be the cause " "for invalid song durations.") PLUGIN_ICON = Icons.EDIT_CLEAR plugin_handles = any_song(is_an_id3, is_writable) def plugin_songs(self, songs): for song in songs: song = song._song if not isinstance(song, ID3File): continue filename = song["~filename"] try: tag = ID3(filename) except Exception: util.print_exc() continue if not tag.getall("TLEN"): continue tag.delall("TLEN") try: tag.save() except Exception: util.print_exc() continue app.librarian.reload(song)
class ForceWrite(SongsMenuPlugin): PLUGIN_ID = "Force Write" PLUGIN_NAME = _("Force Write") PLUGIN_DESC = _("Saves the files again. This will make sure play counts " "and ratings are up to date.") PLUGIN_ICON = Icons.DOCUMENT_SAVE plugin_handles = any_song(is_writable) def plugin_song(self, song): song._needs_write = True
class ForceWrite(SongsMenuPlugin): PLUGIN_ID = "Force Write" PLUGIN_NAME = _("Update Tags in Files") PLUGIN_DESC = _("Update modified tags in files. " "This will ensure play counts and ratings are up to date.") PLUGIN_ICON = Icons.DOCUMENT_SAVE plugin_handles = any_song(is_writable) def plugin_song(self, song): song._needs_write = True
class SplitAlbum(SongsMenuPlugin): PLUGIN_ID = "Split Album" PLUGIN_NAME = _("Split Album") PLUGIN_DESC = _("Split out disc number.") PLUGIN_ICON = Icons.EDIT_FIND_REPLACE plugin_handles = any_song(has_album_splittable) def plugin_song(self, song): if has_album_splittable(song): album, disc = split_album(song["album"]) if album: song["album"] = album if disc: song["discnumber"] = disc
class BrowseFolders(SongsMenuPlugin): PLUGIN_ID = 'Browse Folders' PLUGIN_NAME = _('Browse Folders') PLUGIN_DESC = _("Opens the songs' folders in a file manager.") PLUGIN_ICON = Icons.DOCUMENT_OPEN def plugin_songs(self, songs): songs = [s for s in songs if s.is_file] print_d("Trying to browse folders...") if not show_songs(songs): ErrorMessage(self.plugin_window, _("Unable to open folders"), _("No program available to open folders.")).run() plugin_handles = any_song(is_a_file) """By default, any single song being a file is good enough"""
class DownloadCoverArt(SongsMenuPlugin): """Download and save album (cover) art from a variety of sources""" PLUGIN_ID = 'Download Cover Art' PLUGIN_NAME = _('Download Cover Art') PLUGIN_DESC = _('Downloads high-quality album covers using cover plugins.') PLUGIN_ICON = Icons.INSERT_IMAGE REQUIRES_ACTION = True plugin_handles = any_song(is_a_file) def plugin_album(self, songs): manager = app.cover_manager dialog = CoverArtWindow(songs, manager, Config()) ret = dialog.run() if ret == Gtk.ResponseType.APPLY: manager.cover_changed(songs) dialog.destroy()
class MakeSortTags(SongsMenuPlugin): PLUGIN_ID = "SortTags" PLUGIN_NAME = _("Create Sort Tags") PLUGIN_DESC = _("Converts album and artist names to sort names, poorly.") PLUGIN_ICON = Icons.EDIT plugin_handles = any_song(is_writable, is_finite) def plugin_song(self, song): for tag in ["album"]: values = filter(None, map(album_to_sort, song.list(tag))) if values and (tag + "sort") not in song: song[tag + "sort"] = "\n".join(values) for tag in ["artist", "albumartist", "performer"]: values = filter(None, map(artist_to_sort, song.list(tag))) if values and (tag + "sort") not in song: song[tag + "sort"] = "\n".join(values)
class FilterBrowser(SongsMenuPlugin): PLUGIN_ID = 'filterbrowser' PLUGIN_NAME = _('Filter on Directory') PLUGIN_DESC = _("Filters on directory in a new browser window.") PLUGIN_ICON = Icons.EDIT_SELECT_ALL plugin_handles = any_song(is_a_file) def plugin_songs(self, songs): tag = "~dirname" values = [] for song in songs: values.extend(song.list(tag)) browser = LibraryBrowser.open(browsers.get("SearchBar"), app.library, app.player) browser.browser.filter(tag, set(values))
class BrowseFolders(SongsMenuPlugin): PLUGIN_ID = 'Browse Folders' PLUGIN_NAME = _('Browse Folders') PLUGIN_DESC = _("Opens the songs' folders in a file manager.") PLUGIN_ICON = Icons.DOCUMENT_OPEN _HANDLERS = [ browse_folders_fdo, browse_folders_thunar, browse_folders_xdg_open, browse_folders_gnome_open, browse_folders_win_explorer, browse_folders_finder ] def plugin_songs(self, songs): songs = [s for s in songs if s.is_file] print_d("Trying to browse folders...") if not self._handle(songs): ErrorMessage(self.plugin_window, _("Unable to open folders"), _("No program available to open folders.")).run() plugin_handles = any_song(is_a_file) """By default, any single song being a file is good enough""" def _handle(self, songs): """ Uses the first successful handler in callable list `_HANDLERS` to handle `songs` Returns False if none could be used """ for handler in self._HANDLERS: name = handler.__name__ try: print_d("Trying %r..." % name) handler(songs) except BrowseError as e: print_d("...failed: %r" % e) else: print_d("...success!") return True print_d("No handlers could be used.") return False
class DownloadAlbumArt(SongsMenuPlugin, PluginConfigMixin): """Download and save album (cover) art from a variety of sources""" PLUGIN_ID = 'Download Album Art' PLUGIN_NAME = _('Download Album Art') PLUGIN_DESC = _('Downloads album covers from various websites.') PLUGIN_ICON = Icons.INSERT_IMAGE CONFIG_SECTION = PLUGIN_CONFIG_SECTION REQUIRES_ACTION = True plugin_handles = any_song(is_a_file) @classmethod def PluginPreferences(cls, window): table = Gtk.Table(n_rows=len(ENGINES), n_columns=2) table.props.expand = False table.set_col_spacings(6) table.set_row_spacings(6) frame = qltk.Frame(_("Sources"), child=table) for i, eng in enumerate(sorted(ENGINES, key=lambda x: x["url"])): check = cls.ConfigCheckButton(eng['config_id'].title(), CONFIG_ENG_PREFIX + eng['config_id'], True) table.attach(check, 0, 1, i, i + 1) button = Gtk.Button(label=eng['url']) button.connect('clicked', lambda s: util.website(s.get_label())) table.attach(button, 1, 2, i, i + 1, xoptions=Gtk.AttachOptions.FILL | Gtk.AttachOptions.SHRINK) return frame def plugin_album(self, songs): return AlbumArtWindow(songs)
class SplitTags(SongsMenuPlugin): PLUGIN_ID = "Split Tags" PLUGIN_NAME = _("Split Tags") PLUGIN_DESC = _("Splits the disc number from the album and the version " "from the title at the same time.") PLUGIN_ICON = Icons.EDIT_FIND_REPLACE plugin_handles = any_song(has_title_splittable) def plugin_song(self, song): if has_title_splittable(song): title, versions = split_title(song["title"]) if title: song["title"] = title if versions: song["version"] = "\n".join(versions) if has_album_splittable(song): album, disc = split_album(song["album"]) if album: song["album"] = album if disc: song["discnumber"] = disc
class Duplicates(SongsMenuPlugin, PluginConfigMixin): PLUGIN_ID = 'Duplicates' PLUGIN_NAME = _('Duplicates Browser') PLUGIN_DESC = _('Finds and displays similarly tagged versions of songs.') PLUGIN_ICON = Icons.EDIT_SELECT_ALL MIN_GROUP_SIZE = 2 _CFG_KEY_KEY = "key_expression" __DEFAULT_KEY_VALUE = "~artist~title~version" _CFG_REMOVE_WHITESPACE = 'remove_whitespace' _CFG_REMOVE_DIACRITICS = 'remove_diacritics' _CFG_REMOVE_PUNCTUATION = 'remove_punctuation' _CFG_CASE_INSENSITIVE = 'case_insensitive' plugin_handles = any_song(is_finite) # Cached values key_expression = None __cfg_cache = {} # Faster than a speeding bullet if PY2: __trans = "".join(map(chr, range(256))) else: __trans = str.maketrans({ord(k): None for k in string.punctuation}) @classmethod def get_key_expression(cls): if not cls.key_expression: cls.key_expression = ( cls.config_get(cls._CFG_KEY_KEY, cls.__DEFAULT_KEY_VALUE)) return cls.key_expression @classmethod def PluginPreferences(cls, window): def key_changed(entry): cls.key_expression = None cls.config_set(cls._CFG_KEY_KEY, entry.get_text().strip()) vb = Gtk.VBox(spacing=10) vb.set_border_width(0) hbox = Gtk.HBox(spacing=6) # TODO: construct a decent validator and use ValidatingEntry e = UndoEntry() e.set_text(cls.get_key_expression()) e.connect("changed", key_changed) e.set_tooltip_markup(_("Accepts QL tag expressions like " "<tt>~artist~title</tt> or <tt>musicbrainz_track_id</tt>")) lbl = Gtk.Label(label=_("_Group duplicates by:")) lbl.set_mnemonic_widget(e) lbl.set_use_underline(True) hbox.pack_start(lbl, False, True, 0) hbox.pack_start(e, True, True, 0) frame = qltk.Frame(label=_("Duplicate Key"), child=hbox) vb.pack_start(frame, True, True, 0) # Matching Option toggles = [ (cls._CFG_REMOVE_WHITESPACE, _("Remove _Whitespace")), (cls._CFG_REMOVE_DIACRITICS, _("Remove _Diacritics")), (cls._CFG_REMOVE_PUNCTUATION, _("Remove _Punctuation")), (cls._CFG_CASE_INSENSITIVE, _("Case _Insensitive")), ] vb2 = Gtk.VBox(spacing=6) for key, label in toggles: ccb = ConfigCheckButton(label, 'plugins', cls._config_key(key)) ccb.set_active(cls.config_get_bool(key)) vb2.pack_start(ccb, True, True, 0) frame = qltk.Frame(label=_("Matching options"), child=vb2) vb.pack_start(frame, False, True, 0) vb.show_all() return vb @staticmethod def remove_accents(s): return "".join(c for c in unicodedata.normalize('NFKD', text_type(s)) if not unicodedata.combining(c)) @classmethod def get_key(cls, song): key = str(song(cls.get_key_expression())) if cls.config_get_bool(cls._CFG_REMOVE_DIACRITICS): key = cls.remove_accents(key) if cls.config_get_bool(cls._CFG_CASE_INSENSITIVE): key = key.lower() if cls.config_get_bool(cls._CFG_REMOVE_PUNCTUATION): key = (key.translate(cls.__trans, string.punctuation) if PY2 else key.translate(cls.__trans)) if cls.config_get_bool(cls._CFG_REMOVE_WHITESPACE): key = "_".join(key.split()) return key def plugin_songs(self, songs): model = DuplicatesTreeModel() self.__cfg_cache = {} # Index all songs by our custom key # TODO: make this cache-friendly print_d("Calculating duplicates for %d song(s)..." % len(songs)) groups = {} for song in songs: key = self.get_key(song) if key and key in groups: groups[key].add(song._song) elif key: groups[key] = {song._song} for song in app.library: key = self.get_key(song) if key in groups: groups[key].add(song) # Now display the grouped duplicates for (key, children) in groups.items(): if len(children) < self.MIN_GROUP_SIZE: continue # The parent (group) label model.add_group(key, children) dialog = DuplicateDialog(model) dialog.show()
def test_any_song(self): FakeSongsMenuPlugin.plugin_handles = any_song(even) p = FakeSongsMenuPlugin(self.songs, None) self.failUnless(p.plugin_handles(self.songs)) self.failIf(p.plugin_handles(self.songs[:1]))
def test_any_song_multiple(self): FakeSongsMenuPlugin.plugin_handles = any_song(even, never) p = FakeSongsMenuPlugin(self.songs, None) self.failIf(p.plugin_handles(self.songs)) self.failIf(p.plugin_handles(self.songs[:1]))
class Bookmarks(SongsMenuPlugin): PLUGIN_ID = "Go to Bookmark" PLUGIN_NAME = _(u"Go to Bookmark") PLUGIN_DESC = _("Manages bookmarks in the selected files.") PLUGIN_ICON = Icons.GO_JUMP plugin_handles = any_song(has_bookmark) def __init__(self, songs, *args, **kwargs): super(Bookmarks, self).__init__(songs, *args, **kwargs) self.__menu = Gtk.Menu() self.__menu.connect('map', self.__map, songs) self.__menu.connect('unmap', self.__unmap) self.set_submenu(self.__menu) class FakePlayer(object): def __init__(self, song): self.song = song def seek(self, time): if app.player.go_to(self.song._song, explicit=True): app.player.seek(time) get_position = lambda *x: 0 def __map(self, menu, songs): for song in songs: marks = song.bookmarks if marks: fake_player = self.FakePlayer(song) song_item = Gtk.MenuItem(song.comma("title")) song_menu = Gtk.Menu() song_item.set_submenu(song_menu) menu.append(song_item) items = qltk.bookmarks.MenuItems(marks, fake_player, True) for item in items: song_menu.append(item) song_menu.append(SeparatorMenuItem()) i = qltk.MenuItem(_(u"_Edit Bookmarks…"), Icons.EDIT) def edit_bookmarks_cb(menu_item): window = EditBookmarks(self.plugin_window, app.library, fake_player) window.show() i.connect('activate', edit_bookmarks_cb) song_menu.append(i) if menu.get_active() is None: no_marks = Gtk.MenuItem(_("No Bookmarks")) no_marks.set_sensitive(False) menu.append(no_marks) menu.show_all() def __unmap(self, menu): for child in self.__menu.get_children(): self.__menu.remove(child) def plugin_songs(self, songs): pass
class EditEmbedded(SongsMenuPlugin): PLUGIN_ID = "embedded_edit" PLUGIN_NAME = _("Edit Embedded Images") PLUGIN_DESC = _("Removes or replaces embedded images.") PLUGIN_ICON = Icons.INSERT_IMAGE plugin_handles = any_song(has_writable_image) """if any song supports editing, we are active""" def __init__(self, songs, *args, **kwargs): super(EditEmbedded, self).__init__(songs, *args, **kwargs) self.__menu = Gtk.Menu() self.__menu.connect('map', self.__map, songs) self.__menu.connect('unmap', self.__unmap) self.set_submenu(self.__menu) def __remove_images(self, menu_item, songs): win = WritingWindow(self.plugin_window, len(songs)) win.show() for song in songs: if song.has_images and song.can_change_images: song.clear_images() if win.step(): break win.destroy() self.plugin_finish() def __set_image(self, menu_item, songs): win = WritingWindow(self.plugin_window, len(songs)) win.show() for song in songs: if song.can_change_images: fileobj = app.cover_manager.get_cover(song) if fileobj: path = fileobj.name image = EmbeddedImage.from_path(path) if image: song.set_image(image) if win.step(): break win.destroy() self.plugin_finish() def __map(self, menu, songs): remove_item = MenuItem(_("_Remove all Images"), "edit-delete") remove_item.connect('activate', self.__remove_images, songs) menu.append(remove_item) set_item = MenuItem(_("_Embed Current Image"), "edit-paste") set_item.connect('activate', self.__set_image, songs) menu.append(set_item) menu.show_all() def __unmap(self, menu): for child in self.__menu.get_children(): self.__menu.remove(child) def plugin_songs(self, songs): return True