def errorhook(exc_info=None): """This is the main entry point Call in an exception context. Thread safe. def my_thread(): try: do_work() except Exception: errorhook() """ global _error_lock, _errorhook_enabled if not _errorhook_enabled: return if exc_info is None: exc_info = sys.exc_info() if exc_info[0] is None: # called outside of an exception context, just ignore print_e("no active exception!") return # In case something goes wrong during error handling print it first print_exc(exc_info) if not _error_lock.acquire(False): # Make sure only one of these is active at a time return # write error and logs to disk dump_dir = os.path.join(quodlibet.get_user_dir(), "dumps") dump_to_disk(dump_dir, exc_info) sentry = get_sentry() # For crashes the stack trace is not enough to differentiating different # crash sources. We need to give our own grouping key (fingerprint) based # on the stack trace provided by faulthandler. fingerprint = None if isinstance(exc_info[1], FaultHandlerCrash): fingerprint = ["{{ default }}", exc_info[1].get_grouping_key()] try: sentry_error = sentry.capture(exc_info, fingerprint=fingerprint) except SentryError: sentry_error = None def called_in_main_thread(): try: run_error_dialogs(exc_info, sentry_error) finally: _error_lock.release() if is_main_thread(): called_in_main_thread() else: GLib.idle_add(called_in_main_thread)
def parse_body(self, body, query_path_=None): if body is None: raise QueryPluginError body = body.strip().lower() # Use provided query file for testing if query_path_: query_path = query_path_ else: query_path = os.path.join(get_user_dir(), 'lists', 'queries.saved') try: with open(query_path, 'rU') as query_file: for query_string in query_file: name = next(query_file).strip().lower() if name == body: try: return Query(query_string.strip()) except QueryError: raise QueryPluginError # We've searched the whole file and haven't found a match raise QueryPluginError except IOError: raise QueryPluginError except StopIteration: # The file has an odd number of lines. This shouldn't happen unless # it has been externally modified raise QueryPluginError
def init(): global browsers # ignore double init (for the test suite) if browsers: return this_dir = os.path.dirname(__file__) load_pyc = os.name == 'nt' modules = load_dir_modules(this_dir, package=__package__, load_compiled=load_pyc) user_dir = os.path.join(quodlibet.get_user_dir(), "browsers") if os.path.isdir(user_dir): modules += load_dir_modules(user_dir, package="quodlibet.fake.browsers", load_compiled=load_pyc) for browser in modules: try: browsers.extend(browser.browsers) except AttributeError: print_w("%r doesn't contain any browsers." % browser.__name__) def is_browser(Kind): return isinstance(Kind, type) and issubclass(Kind, Browser) browsers = filter(is_browser, browsers) if not browsers: raise SystemExit("No browsers found!") browsers.sort(key=lambda Kind: Kind.priority)
def __init__(self, parent, player, library): super(TopBar, self).__init__() # play controls control_item = Gtk.ToolItem() self.insert(control_item, 0) t = PlayControls(player, library.librarian) self.volume = t.volume # only restore the volume in case it is managed locally, otherwise # this could affect the system volume if not player.has_external_volume: player.volume = config.getfloat("memory", "volume") connect_destroy(player, "notify::volume", self._on_volume_changed) control_item.add(t) self.insert(Gtk.SeparatorToolItem(), 1) info_item = Gtk.ToolItem() self.insert(info_item, 2) info_item.set_expand(True) box = Gtk.Box(spacing=6) info_item.add(box) qltk.add_css(self, "GtkToolbar {padding: 3px;}") self._pattern_box = Gtk.VBox() # song text info_pattern_path = os.path.join(quodlibet.get_user_dir(), "songinfo") text = SongInfo(library.librarian, player, info_pattern_path) self._pattern_box.pack_start(Align(text, border=3), True, True, 0) box.pack_start(self._pattern_box, True, True, 0) # cover image self.image = CoverImage(resize=True) connect_destroy(player, 'song-started', self.__new_song) # FIXME: makes testing easier if app.cover_manager: connect_destroy( app.cover_manager, 'cover-changed', self.__song_art_changed, library) box.pack_start(Align(self.image, border=2), False, True, 0) # On older Gtk+ (3.4, at least) # setting a margin on CoverImage leads to errors and result in the # QL window not being visible for some reason. assert self.image.props.margin == 0 for child in self.get_children(): child.show_all() context = self.get_style_context() context.add_class("primary-toolbar")
def __init__(self, filename=None, completion=None, accel_group=None, timeout=DEFAULT_TIMEOUT, validator=Query.validator, star=None): super(SearchBarBox, self).__init__(spacing=6) if filename is None: filename = os.path.join(quodlibet.get_user_dir(), "lists", "queries") combo = ComboBoxEntrySave(filename, count=8, validator=validator, title=_("Saved Searches"), edit_title=_(u"Edit saved searches…")) self.__deferred_changed = DeferredSignal(self.__filter_changed, timeout=timeout, owner=self) self.__combo = combo entry = combo.get_child() self.__entry = entry if completion: entry.set_completion(completion) self._star = star self._query = None self.__sig = combo.connect('text-changed', self.__text_changed) entry.connect('clear', self.__filter_changed) entry.connect('backspace', self.__text_changed) entry.connect('populate-popup', self.__menu) entry.connect('activate', self.__filter_changed) entry.connect('activate', self.__save_search) entry.connect('focus-out-event', self.__save_search) entry.connect('key-press-event', self.__key_pressed) entry.set_placeholder_text(_("Search")) entry.set_tooltip_text( _("Search your library, " "using free text or QL queries")) combo.enable_clear_button() self.pack_start(combo, True, True, 0) if accel_group: key, mod = Gtk.accelerator_parse("<Primary>L") accel_group.connect(key, mod, 0, lambda *x: entry.mnemonic_activate(True)) for child in self.get_children(): child.show_all()
def main(argv): import quodlibet from quodlibet.qltk import add_signal_watch, icons add_signal_watch(app.quit) opts = util.OptionParser("Ex Falso", const.VERSION, _("an audio tag editor"), "[%s]" % _("directory")) # FIXME: support unicode on Windows, sys.argv isn't good enough argv.append(os.path.abspath(fsnative(u"."))) opts, args = opts.parse(argv[1:]) args[0] = os.path.realpath(args[0]) config.init(os.path.join(quodlibet.get_user_dir(), "config")) app.name = "Ex Falso" app.id = "exfalso" quodlibet.init(icon=icons.EXFALSO, name=app.name, proc_title=app.id) import quodlibet.library import quodlibet.player app.library = quodlibet.library.init() app.player = quodlibet.player.init_player("nullbe", app.librarian) from quodlibet.qltk.songlist import PlaylistModel app.player.setup(PlaylistModel(), None, 0) pm = quodlibet.init_plugins() pm.rescan() from quodlibet.qltk.exfalsowindow import ExFalsoWindow dir_ = args[0] if os.name == "nt": dir_ = fsdecode(dir_) app.window = ExFalsoWindow(app.library, dir_) app.window.init_plugins() from quodlibet.util.cover import CoverManager app.cover_manager = CoverManager() app.cover_manager.init_plugins() from quodlibet.qltk import session session.init("exfalso") quodlibet.enable_periodic_save(save_library=False) quodlibet.main(app.window) quodlibet.finish_first_session(app.id) config.save() print_d("Finished shutdown.")
def main(argv=None): if argv is None: argv = sys_argv import quodlibet config_file = os.path.join(quodlibet.get_user_dir(), "config") quodlibet.init(config_file=config_file) from quodlibet.qltk import add_signal_watch add_signal_watch(app.quit) opts = util.OptionParser( "Ex Falso", const.VERSION, _("an audio tag editor"), "[%s]" % _("directory")) argv.append(os.path.abspath(fsnative(u"."))) opts, args = opts.parse(argv[1:]) args[0] = os.path.realpath(args[0]) app.name = "Ex Falso" app.description = _("Audio metadata editor") app.id = "exfalso" app.process_name = "exfalso" quodlibet.set_application_info(app) import quodlibet.library import quodlibet.player app.library = quodlibet.library.init() app.player = quodlibet.player.init_player("nullbe", app.librarian) from quodlibet.qltk.songlist import PlaylistModel app.player.setup(PlaylistModel(), None, 0) pm = quodlibet.init_plugins() pm.rescan() from quodlibet.qltk.exfalsowindow import ExFalsoWindow dir_ = args[0] app.window = ExFalsoWindow(app.library, dir_) app.window.init_plugins() from quodlibet.util.cover import CoverManager app.cover_manager = CoverManager() app.cover_manager.init_plugins() from quodlibet import session session_client = session.init(app) quodlibet.enable_periodic_save(save_library=False) quodlibet.run(app.window) quodlibet.finish_first_session("exfalso") config.save() session_client.close() util.print_d("Finished shutdown.")
def main(argv=None): if argv is None: argv = sys_argv import quodlibet config_file = os.path.join(quodlibet.get_user_dir(), "config") quodlibet.init(config_file=config_file) from quodlibet.qltk import add_signal_watch add_signal_watch(app.quit) opts = util.OptionParser("Ex Falso", const.VERSION, _("an audio tag editor"), "[%s]" % _("directory")) argv.append(os.path.abspath(fsnative(u"."))) opts, args = opts.parse(argv[1:]) args[0] = os.path.realpath(args[0]) app.name = "Ex Falso" app.description = _("Audio metadata editor") app.id = "exfalso" app.process_name = "exfalso" quodlibet.set_application_info(app) import quodlibet.library import quodlibet.player app.library = quodlibet.library.init() app.player = quodlibet.player.init_player("nullbe", app.librarian) from quodlibet.qltk.songlist import PlaylistModel app.player.setup(PlaylistModel(), None, 0) pm = quodlibet.init_plugins() pm.rescan() from quodlibet.qltk.exfalsowindow import ExFalsoWindow dir_ = args[0] app.window = ExFalsoWindow(app.library, dir_) app.window.init_plugins() from quodlibet.util.cover import CoverManager app.cover_manager = CoverManager() app.cover_manager.init_plugins() from quodlibet import session session_client = session.init(app) quodlibet.enable_periodic_save(save_library=False) quodlibet.run(app.window) quodlibet.finish_first_session("exfalso") config.save() session_client.close() util.print_d("Finished shutdown.")
def main(argv): global quodlibet from quodlibet.qltk import add_signal_watch, icons add_signal_watch(app.quit) opts = util.OptionParser("Ex Falso", const.VERSION, _("an audio tag editor"), "[%s]" % _("directory")) # FIXME: support unicode on Windows, sys.argv isn't good enough argv.append(os.path.abspath(fsnative(u"."))) opts, args = opts.parse(argv[1:]) args[0] = os.path.realpath(args[0]) config.init(os.path.join(quodlibet.get_user_dir(), "config")) app.name = "Ex Falso" app.id = "exfalso" quodlibet.init(icon=icons.EXFALSO, name=app.name, proc_title=app.id) import quodlibet.library import quodlibet.player app.library = quodlibet.library.init() app.player = quodlibet.player.init_player("nullbe", app.librarian) from quodlibet.qltk.songlist import PlaylistModel app.player.setup(PlaylistModel(), None, 0) pm = quodlibet.init_plugins() pm.rescan() from quodlibet.qltk.exfalsowindow import ExFalsoWindow dir_ = args[0] if os.name == "nt": dir_ = fsdecode(dir_) app.window = ExFalsoWindow(app.library, dir_) app.window.init_plugins() from quodlibet.util.cover import CoverManager app.cover_manager = CoverManager() app.cover_manager.init_plugins() from quodlibet.qltk import session session.init("exfalso") quodlibet.enable_periodic_save(save_library=False) quodlibet.main(app.window) quodlibet.finish_first_session(app.id) config.save() print_d("Finished shutdown.")
def update_feeds(subscriptions): feeds = [] with open(os.path.join(quodlibet.get_user_dir(), "feeds"), "rb") as f: try: feeds = pickle.load(f) except: print_d("Couldn't read feeds.") subbed = frozenset([f.uri for f in feeds]) newfeeds = list() for subscription in subscriptions: try: r = requests.get(subscription) except requests.exceptions.ConnectionError as e: print_d("ConnectionError %s - %s" % (subscription, e)); continue if not r.status_code == 200: print_d("Cannot access %s - %i" % (subscription, r.status_code)) continue feed = Feed(subscription) if feed.uri in subbed: print_d("Feed already subscribed: %s" % subscription) continue feed.changed = feed.parse() if feed: print_d("Appending %s" % subscription) feeds.append(feed) newfeeds.append(feed) else: print_d("Feed could not be added: %s" % subscription) print_d("Adding %i feeds." % len(newfeeds)) with open(os.path.join(quodlibet.get_user_dir(), "feeds"), "wb") as f: pickle.dump(feeds, f) app.browser.reload(app.library) # adds feeds
def update_feeds(subscriptions): feeds = [] with open(os.path.join(quodlibet.get_user_dir(), "feeds"), "rb") as f: try: feeds = pickle.load(f) except: print_d("Couldn't read feeds.") subbed = frozenset([f.uri for f in feeds]) newfeeds = list() for subscription in subscriptions: try: r = requests.get(subscription) except requests.exceptions.ConnectionError as e: print_d("ConnectionError %s - %s" % (subscription, e)) continue if not r.status_code == 200: print_d("Cannot access %s - %i" % (subscription, r.status_code)) continue feed = Feed(subscription) if feed.uri in subbed: print_d("Feed already subscribed: %s" % subscription) continue feed.changed = feed.parse() if feed: print_d("Appending %s" % subscription) feeds.append(feed) newfeeds.append(feed) else: print_d("Feed could not be added: %s" % subscription) print_d("Adding %i feeds." % len(newfeeds)) with open(os.path.join(quodlibet.get_user_dir(), "feeds"), "wb") as f: pickle.dump(feeds, f) app.browser.reload(app.library) # adds feeds
def __init__(self, filename=None, completion=None, accel_group=None, timeout=DEFAULT_TIMEOUT, validator=Query.validator, star=None): super(SearchBarBox, self).__init__(spacing=6) if filename is None: filename = os.path.join( quodlibet.get_user_dir(), "lists", "queries") combo = ComboBoxEntrySave(filename, count=8, validator=validator, title=_("Saved Searches"), edit_title=_(u"Edit saved searches…")) self.__deferred_changed = DeferredSignal( self.__filter_changed, timeout=timeout, owner=self) self.__combo = combo entry = combo.get_child() self.__entry = entry if completion: entry.set_completion(completion) self._star = star self._query = None self.__sig = combo.connect('text-changed', self.__text_changed) entry.connect('clear', self.__filter_changed) entry.connect('backspace', self.__text_changed) entry.connect('populate-popup', self.__menu) entry.connect('activate', self.__filter_changed) entry.connect('activate', self.__save_search) entry.connect('focus-out-event', self.__save_search) entry.connect('key-press-event', self.__key_pressed) entry.set_placeholder_text(_("Search")) entry.set_tooltip_text(_("Search your library, " "using free text or QL queries")) combo.enable_clear_button() self.pack_start(combo, True, True, 0) if accel_group: key, mod = Gtk.accelerator_parse("<Primary>L") accel_group.connect(key, mod, 0, lambda *x: entry.mnemonic_activate(True)) for child in self.get_children(): child.show_all()
def get_thumbnail_folder(): """Returns a path to the thumbnail folder. The returned path might not exist. """ if os.name == "nt": thumb_folder = os.path.join(quodlibet.get_user_dir(), "thumbnails") else: cache_folder = os.path.join(xdg_get_cache_home(), "thumbnails") thumb_folder = os.path.expanduser('~/.thumbnails') if os.path.exists(cache_folder) or not os.path.exists(thumb_folder): thumb_folder = cache_folder return thumb_folder
class QuodLibetUnixRemote(RemoteBase): _FIFO_NAME = "control" _PATH = os.path.join(get_user_dir(), _FIFO_NAME) def __init__(self, app, cmd_registry): self._app = app self._cmd_registry = cmd_registry self._fifo = fifo.FIFO(self._PATH, self._callback) @classmethod def remote_exists(cls): return fifo.fifo_exists(cls._PATH) @classmethod def send_message(cls, message): assert isinstance(message, fsnative) try: return fifo.write_fifo(cls._PATH, fsn2bytes(message, None)) except EnvironmentError as e: raise RemoteError(e) def start(self): try: self._fifo.open() except fifo.FIFOError as e: raise RemoteError(e) def stop(self): self._fifo.destroy() def _callback(self, data): try: messages = list(fifo.split_message(data)) except ValueError: print_w("invalid message: %r" % data) return for command, path in messages: command = bytes2fsn(command, None) response = self._cmd_registry.handle_line(self._app, command) if path is not None: path = bytes2fsn(path, None) with open(path, "wb") as h: if response is not None: assert isinstance(response, fsnative) h.write(fsn2bytes(response, None))
def init(): """Import all browsers from this package and from the user directory. After this is called the global `browers` list will contain all classes sorted by priority. Can be called multiple times. """ global browsers, default # ignore double init (for the test suite) if browsers: return this_dir = os.path.dirname(__file__) load_pyc = util.is_windows() or util.is_osx() modules = load_dir_modules(this_dir, package=__package__, load_compiled=load_pyc) user_dir = os.path.join(quodlibet.get_user_dir(), "browsers") if os.path.isdir(user_dir): modules += load_dir_modules(user_dir, package="quodlibet.fake.browsers", load_compiled=load_pyc) for browser in modules: try: browsers.extend(browser.browsers) except AttributeError: print_w("%r doesn't contain any browsers." % browser.__name__) def is_browser(Kind): return isinstance(Kind, type) and issubclass(Kind, Browser) browsers = list(filter(is_browser, browsers)) if not browsers: raise SystemExit("No browsers found!") browsers.sort(key=lambda Kind: Kind.priority) try: default = get("SearchBar") except ValueError: raise SystemExit("Default browser not found!")
def init(): """Import all browsers from this package and from the user directory. After this is called the global `browers` list will contain all classes sorted by priority. Can be called multiple times. """ global browsers, default # ignore double init (for the test suite) if browsers: return this_dir = os.path.dirname(__file__) load_pyc = util.is_windows() or util.is_osx() modules = load_dir_modules(this_dir, package=__package__, load_compiled=load_pyc) user_dir = os.path.join(quodlibet.get_user_dir(), "browsers") if os.path.isdir(user_dir): modules += load_dir_modules(user_dir, package="quodlibet.fake.browsers", load_compiled=load_pyc) for browser in modules: try: browsers.extend(browser.browsers) except AttributeError: print_w("%r doesn't contain any browsers." % browser.__name__) def is_browser(Kind): return isinstance(Kind, type) and issubclass(Kind, Browser) browsers = filter(is_browser, browsers) if not browsers: raise SystemExit("No browsers found!") browsers.sort(key=lambda Kind: Kind.priority) try: default = get("SearchBar") except ValueError: raise SystemExit("Default browser not found!")
def __init__(self, *args, show_multi=False, multi_filename=None, **kwargs): super().__init__(*args, **kwargs) self.multi_filename = os.path.join( quodlibet.get_user_dir(), "lists", "multiqueries") if multi_filename is None else multi_filename self._old_placeholder = self._entry.get_placeholder_text() self._old_tooltip = self._entry.get_tooltip_text() self._add_button = Gtk.Button.new_from_icon_name( "list-add", Gtk.IconSize.BUTTON) self._add_button.set_no_show_all(True) self.pack_start(self._add_button, False, True, 0) self._add_button.connect('clicked', self.activated) self._entry.connect('activate', self.activated) self.flow_box = Gtk.FlowBox(no_show_all=True, max_children_per_line=99, selection_mode=Gtk.SelectionMode.NONE) self.toggle_multi_bool(show_multi)
def update_feeds(subscriptions): if app.browser.name != u"Audio Feeds": print_d("Wrong browser!") return feeds = [] with open(os.path.join(quodlibet.get_user_dir(), "feeds"), "rb") as f: try: feeds = pickle.load(f) except: print_d("Couldn't read feeds.") subbed = frozenset([f.uri for f in feeds]) newfeeds = list() for subscription in subscriptions: try: r = requests.get(subscription) except requests.exceptions.ConnectionError, e: print_d("ConnectionError %s - %s" % (subscription, e)); continue if not r.status_code == 200: print_d("Cannot access %s - %i" % (subscription, r.status_code)) continue feed = Feed(subscription) if feed.uri in subbed: print_d("Feed already subscribed: %s" % subscription) continue feed.changed = feed.parse() if feed: print_d("Appending %s" % subscription) feeds.append(feed) newfeeds.append(feed) else: print_d("Feed could not be added: %s" % subscription)
def main(argv=None): if argv is None: argv = sys_argv import quodlibet config_file = os.path.join(quodlibet.get_user_dir(), "config") quodlibet.init_cli(config_file=config_file) try: # we want basic commands not to import gtk (doubles process time) assert "gi.repository.Gtk" not in sys.modules sys.modules["gi.repository.Gtk"] = None startup_actions, cmds_todo = process_arguments(argv) finally: sys.modules.pop("gi.repository.Gtk", None) quodlibet.init() from quodlibet import app from quodlibet.qltk import add_signal_watch, Icons add_signal_watch(app.quit) import quodlibet.player import quodlibet.library from quodlibet import config from quodlibet import browsers from quodlibet import util app.name = "Quod Libet" app.description = _("Music player and music library manager") app.id = "quodlibet" quodlibet.set_application_info(Icons.QUODLIBET, app.id, app.name) library_path = os.path.join(quodlibet.get_user_dir(), "songs") print_d("Initializing main library (%s)" % ( quodlibet.util.path.unexpand(library_path))) library = quodlibet.library.init(library_path) app.library = library # this assumes that nullbe will always succeed from quodlibet.player import PlayerError wanted_backend = environ.get( "QUODLIBET_BACKEND", config.get("player", "backend")) try: player = quodlibet.player.init_player(wanted_backend, app.librarian) except PlayerError: print_exc() player = quodlibet.player.init_player("nullbe", app.librarian) app.player = player environ["PULSE_PROP_media.role"] = "music" environ["PULSE_PROP_application.icon_name"] = "quodlibet" browsers.init() from quodlibet.qltk.songlist import SongList, get_columns headers = get_columns() SongList.set_all_column_headers(headers) for opt in config.options("header_maps"): val = config.get("header_maps", opt) util.tags.add(opt, val) in_all = ("~filename ~uri ~#lastplayed ~#rating ~#playcount ~#skipcount " "~#added ~#bitrate ~current ~#laststarted ~basename " "~dirname").split() for Kind in browsers.browsers: if Kind.headers is not None: Kind.headers.extend(in_all) Kind.init(library) pm = quodlibet.init_plugins("no-plugins" in startup_actions) if hasattr(player, "init_plugins"): player.init_plugins() from quodlibet.qltk import unity unity.init("quodlibet.desktop", player) from quodlibet.qltk.songsmenu import SongsMenu SongsMenu.init_plugins() from quodlibet.util.cover import CoverManager app.cover_manager = CoverManager() app.cover_manager.init_plugins() from quodlibet.plugins.playlist import PLAYLIST_HANDLER PLAYLIST_HANDLER.init_plugins() from quodlibet.plugins.query import QUERY_HANDLER QUERY_HANDLER.init_plugins() from gi.repository import GLib def exec_commands(*args): for cmd in cmds_todo: try: resp = cmd_registry.run(app, *cmd) except CommandError: pass else: if resp is not None: print_(resp, end="", flush=True) from quodlibet.qltk.quodlibetwindow import QuodLibetWindow, PlayerOptions # Call exec_commands after the window is restored, but make sure # it's after the mainloop has started so everything is set up. app.window = window = QuodLibetWindow( library, player, restore_cb=lambda: GLib.idle_add(exec_commands, priority=GLib.PRIORITY_HIGH)) app.player_options = PlayerOptions(window) from quodlibet.qltk.window import Window from quodlibet.plugins.events import EventPluginHandler from quodlibet.plugins.gui import UserInterfacePluginHandler pm.register_handler(EventPluginHandler(library.librarian, player, app.window.songlist)) pm.register_handler(UserInterfacePluginHandler()) from quodlibet.mmkeys import MMKeysHandler from quodlibet.remote import Remote, RemoteError from quodlibet.commands import registry as cmd_registry, CommandError from quodlibet.qltk.tracker import SongTracker, FSInterface try: from quodlibet.qltk.dbus_ import DBusHandler except ImportError: DBusHandler = lambda player, library: None mmkeys_handler = MMKeysHandler(app) mmkeys_handler.start() current_path = os.path.join(quodlibet.get_user_dir(), "current") fsiface = FSInterface(current_path, player) remote = Remote(app, cmd_registry) try: remote.start() except RemoteError: exit_(1, True) DBusHandler(player, library) tracker = SongTracker(library.librarian, player, window.playlist) from quodlibet.qltk import session session.init("quodlibet") quodlibet.enable_periodic_save(save_library=True) if "start-playing" in startup_actions: player.paused = False if "start-hidden" in startup_actions: Window.prevent_inital_show(True) # restore browser windows from quodlibet.qltk.browser import LibraryBrowser GLib.idle_add(LibraryBrowser.restore, library, player, priority=GLib.PRIORITY_HIGH) def before_quit(): print_d("Saving active browser state") try: app.browser.save() except NotImplementedError: pass print_d("Shutting down player device %r." % player.version_info) player.destroy() quodlibet.run(window, before_quit=before_quit) app.player_options.destroy() quodlibet.finish_first_session(app.id) mmkeys_handler.quit() remote.stop() fsiface.destroy() tracker.destroy() quodlibet.library.save() config.save() print_d("Finished shutdown.")
connect_obj, escape from quodlibet.util.i18n import numeric_phrase from quodlibet.util.path import uri_is_valid from quodlibet.util.string import decode, encode from quodlibet.util import print_w from quodlibet.qltk.views import AllTreeView from quodlibet.qltk.searchbar import SearchBarBox from quodlibet.qltk.completion import LibraryTagCompletion from quodlibet.qltk.x import MenuItem, Align, ScrolledWindow from quodlibet.qltk.x import SymbolicIconImage from quodlibet.qltk.menubutton import MenuButton STATION_LIST_URL = \ "https://quodlibet.github.io/radio/radiolist.bz2" STATIONS_FAV = os.path.join(quodlibet.get_user_dir(), "stations") STATIONS_ALL = os.path.join(quodlibet.get_user_dir(), "stations_all") # TODO: - Do the update in a thread # - Ranking: reduce duplicate stations (max 3 URLs per station) # prefer stations that match a genre? # Migration path for pickle sys.modules["browsers.iradio"] = sys.modules[__name__] class IRFile(RemoteFile): multisong = True can_add = False format = "Radio Station"
class LastFMSync(SongsMenuPlugin): PLUGIN_ID = "Last.fm Sync" PLUGIN_NAME = _("Last.fm Sync") PLUGIN_DESC = _("Updates your library's statistics from your " "Last.fm profile.") PLUGIN_ICON = Icons.EMBLEM_SHARED CACHE_PATH = os.path.join(quodlibet.get_user_dir(), "lastfmsync.db") def runner(self, cache): changed = True try: changed = cache.update_charts(self.progress) except: pass if changed: self.cache_shelf[cache.username] = cache self.cache_shelf.close() def progress(self, msg, frac): if self.running: GLib.idle_add(self.dialog.progress, msg, frac) return True else: return False def plugin_songs(self, songs): try: self.cache_shelf = shelve.open(self.CACHE_PATH) except: # some Python 2 DB types can't be opened in Python 3 self.cache_shelf = shelve.open(self.CACHE_PATH, "n") user = config_get('username', '') try: cache = self.cache_shelf.setdefault(user, LastFMSyncCache(user)) except Exception: # unpickle can fail in many ways. this is just cache, so ignore cache = self.cache_shelf[user] = LastFMSyncCache(user) self.dialog = LastFMSyncWindow(self.plugin_window) self.running = True thread = Thread(target=self.runner, args=(cache,)) thread.daemon = True thread.start() resp = self.dialog.run() if resp == Gtk.ResponseType.ACCEPT: cache.update_songs(songs) self.running = False self.dialog.destroy() @classmethod def PluginPreferences(klass, win): def entry_changed(entry): config.set('plugins', 'lastfmsync_username', entry.get_text()) label = Gtk.Label(label=_("_Username:"******"Account"), child=hbox)
from quodlibet.qltk.getstring import GetStringDialog from quodlibet.qltk.songsmenu import SongsMenu from quodlibet.qltk.notif import Task from quodlibet.qltk import Icons from quodlibet.util import copool, connect_destroy, sanitize_tags, connect_obj from quodlibet.util.string import decode, encode from quodlibet.util.uri import URI from quodlibet.qltk.views import AllTreeView from quodlibet.qltk.searchbar import SearchBarBox from quodlibet.qltk.completion import LibraryTagCompletion from quodlibet.qltk.x import MenuItem, Align, ScrolledWindow from quodlibet.qltk.x import SymbolicIconImage from quodlibet.qltk.menubutton import MenuButton STATION_LIST_URL = "http://bitbucket.org/lazka/quodlibet/downloads/radiolist.bz2" STATIONS_FAV = os.path.join(quodlibet.get_user_dir(), "stations") STATIONS_ALL = os.path.join(quodlibet.get_user_dir(), "stations_all") # TODO: - Do the update in a thread # - Ranking: reduce duplicate stations (max 3 URLs per station) # prefer stations that match a genre? # Migration path for pickle sys.modules["browsers.iradio"] = sys.modules[__name__] class IRFile(RemoteFile): multisong = True can_add = False format = "Radio Station"
# (at your option) any later version. import os import re from typing import Iterable, Generator, Optional import quodlibet from quodlibet import print_d, print_w from quodlibet.formats import AudioFile from quodlibet.library.base import Library from quodlibet.util.collection import (Playlist, XSPFBackedPlaylist, FileBackedPlaylist) from senf import text2fsn, _fsnative, fsn2text _DEFAULT_PLAYLIST_DIR = text2fsn( os.path.join(quodlibet.get_user_dir(), "playlists")) """Directory for playlist files""" HIDDEN_RE = re.compile(r'^\.\w[^.]*') """Hidden-like files, to ignored""" class PlaylistLibrary(Library[str, Playlist]): """A PlaylistLibrary listens to a SongLibrary, and keeps tracks of playlists of these songs. The library behaves like a dictionary: the keys are playlist names, the values are Playlist objects. """ def __init__(self, library: Library,
from glob import glob from gi.repository import Gtk, GLib, GdkPixbuf import quodlibet from quodlibet import util from quodlibet import app from quodlibet.devices._base import Device from quodlibet.library import SongFileLibrary from quodlibet.pattern import FileFromPattern from quodlibet.qltk.msg import ConfirmFileReplace from quodlibet.util.path import (mtime, escape_filename, strip_win32_incompat_from_path) CACHE = os.path.join(quodlibet.get_user_dir(), 'cache') class StorageDevice(Device): protocol = 'storage' defaults = { 'pattern': '<artist>/<album>/<title>', 'covers': True, 'unclutter': True, } __library = None __pattern = None def __init__(self, backend_id, device_id):
def __init__(self, library, player, headless=False, restore_cb=None): super(QuodLibetWindow, self).__init__(dialog=False) self.last_dir = get_home_dir() self.__destroyed = False self.__update_title(player) self.set_default_size(550, 450) main_box = Gtk.VBox() self.add(main_box) # create main menubar, load/restore accelerator groups self.__library = library ui = self.__create_menu(player, library) accel_group = ui.get_accel_group() self.add_accel_group(accel_group) def scroll_and_jump(*args): self.__jump_to_current(True, True) keyval, mod = Gtk.accelerator_parse("<control><shift>J") accel_group.connect(keyval, mod, 0, scroll_and_jump) # dbus app menu # Unity puts the app menu next to our menu bar. Since it only contains # menu items also available in the menu bar itself, don't add it. if not util.is_unity(): AppMenu(self, ui.get_action_groups()[0]) # custom accel map accel_fn = os.path.join(quodlibet.get_user_dir(), "accels") Gtk.AccelMap.load(accel_fn) # save right away so we fill the file with example comments of all # accels Gtk.AccelMap.save(accel_fn) menubar = ui.get_widget("/Menu") # Since https://git.gnome.org/browse/gtk+/commit/?id=b44df22895c79 # toplevel menu items show an empty 16x16 image. While we don't # need image items there UIManager creates them by default. # Work around by removing the empty GtkImages for child in menubar.get_children(): if isinstance(child, Gtk.ImageMenuItem): child.set_image(None) main_box.pack_start(menubar, False, True, 0) # get the playlist up before other stuff self.songlist = MainSongList(library, player) self.songlist.show_all() self.songlist.connect("key-press-event", self.__songlist_key_press) self.songlist.connect_after('drag-data-received', self.__songlist_drag_data_recv) self.song_scroller = SongListScroller( ui.get_widget("/Menu/View/SongList")) self.song_scroller.add(self.songlist) self.qexpander = QueueExpander(ui.get_widget("/Menu/View/Queue"), library, player) self.playlist = PlaylistMux(player, self.qexpander.model, self.songlist.model) top_bar = TopBar(self, player, library) main_box.pack_start(top_bar, False, True, 0) self.top_bar = top_bar self.__browserbox = Align(bottom=3) main_box.pack_start(self.__browserbox, True, True, 0) statusbox = StatusBarBox(self.songlist.model, player) self.order = statusbox.order self.repeat = statusbox.repeat self.statusbar = statusbox.statusbar main_box.pack_start(Align(statusbox, border=3, top=-3, right=3), False, True, 0) self.songpane = ConfigRVPaned("memory", "queue_position", 0.75) self.songpane.pack1(self.song_scroller, resize=True, shrink=False) self.songpane.pack2(self.qexpander, resize=True, shrink=False) self.__handle_position = self.songpane.get_property("position") def songpane_button_press_cb(pane, event): """If we start to drag the pane handle while the queue expander is unexpanded, expand it and move the handle to the bottom, so we can 'drag' the queue out """ if event.window != pane.get_handle_window(): return False if not self.qexpander.get_expanded(): self.qexpander.set_expanded(True) pane.set_relative(1.0) return False self.songpane.connect("button-press-event", songpane_button_press_cb) self.song_scroller.connect('notify::visible', self.__show_or) self.qexpander.connect('notify::visible', self.__show_or) self.qexpander.connect('notify::expanded', self.__expand_or) self.qexpander.connect('draw', self.__qex_size_allocate) self.songpane.connect('notify', self.__moved_pane_handle) try: orders = [] for e in config.getstringlist('memory', 'sortby', []): orders.append((e[1:], int(e[0]))) except ValueError: pass else: self.songlist.set_sort_orders(orders) self.browser = None self.ui = ui main_box.show_all() self._playback_error_dialog = None connect_destroy(player, 'song-started', self.__song_started) connect_destroy(player, 'paused', self.__update_paused, True) connect_destroy(player, 'unpaused', self.__update_paused, False) # make sure we redraw all error indicators before opening # a dialog (blocking the main loop), so connect after default handlers connect_after_destroy(player, 'error', self.__player_error) # connect after to let SongTracker update stats connect_after_destroy(player, "song-ended", self.__song_ended) # set at least the playlist. the song should be restored # after the browser emits the song list player.setup(self.playlist, None, 0) self.__restore_cb = restore_cb self.__first_browser_set = True restore_browser = not headless try: self.select_browser(self, config.get("memory", "browser"), library, player, restore_browser) except: config.set("memory", "browser", browsers.name(0)) config.save() raise self.showhide_playlist(ui.get_widget("/Menu/View/SongList")) self.showhide_playqueue(ui.get_widget("/Menu/View/Queue")) self.songlist.connect('popup-menu', self.__songs_popup_menu) self.songlist.connect('columns-changed', self.__cols_changed) self.songlist.connect('columns-changed', self.__hide_headers) self.songlist.info.connect("changed", self.__set_time) lib = library.librarian connect_destroy(lib, 'changed', self.__song_changed, player) targets = [("text/uri-list", Gtk.TargetFlags.OTHER_APP, DND_URI_LIST)] targets = [Gtk.TargetEntry.new(*t) for t in targets] self.drag_dest_set(Gtk.DestDefaults.ALL, targets, Gdk.DragAction.COPY) self.connect('drag-data-received', self.__drag_data_received) if not headless: on_first_map(self, self.__configure_scan_dirs, library) if config.getboolean('library', 'refresh_on_start'): self.__rebuild(None, False) self.connect("key-press-event", self.__key_pressed, player) self.connect("destroy", self.__destroy) self.enable_window_tracking("quodlibet")
def main(argv): import quodlibet quodlibet.init_cli() try: # we want basic commands not to import gtk (doubles process time) assert "gi.repository.Gtk" not in sys.modules sys.modules["gi.repository.Gtk"] = None startup_actions, cmds_todo = process_arguments(argv) finally: sys.modules.pop("gi.repository.Gtk", None) quodlibet.init() from quodlibet import app from quodlibet.qltk import add_signal_watch, Icons add_signal_watch(app.quit) import quodlibet.player import quodlibet.library from quodlibet import config from quodlibet import browsers from quodlibet import util from quodlibet.util.string import decode app.name = "Quod Libet" app.id = "quodlibet" quodlibet.set_application_info(Icons.QUODLIBET, app.id, app.name) config.init(os.path.join(quodlibet.get_user_dir(), "config")) library_path = os.path.join(quodlibet.get_user_dir(), "songs") print_d("Initializing main library (%s)" % ( quodlibet.util.path.unexpand(library_path))) library = quodlibet.library.init(library_path) app.library = library # this assumes that nullbe will always succeed from quodlibet.player import PlayerError wanted_backend = os.environ.get( "QUODLIBET_BACKEND", config.get("player", "backend")) backend_traceback = None for backend in [wanted_backend, "nullbe"]: try: player = quodlibet.player.init_player(backend, app.librarian) except PlayerError: backend_traceback = decode(traceback.format_exc()) else: break app.player = player os.environ["PULSE_PROP_media.role"] = "music" os.environ["PULSE_PROP_application.icon_name"] = "quodlibet" browsers.init() from quodlibet.qltk.songlist import SongList, get_columns from quodlibet.util.collection import Album try: cover_size = config.getint("browsers", "cover_size") except config.Error: pass else: if cover_size > 0: Album.COVER_SIZE = cover_size headers = get_columns() SongList.set_all_column_headers(headers) for opt in config.options("header_maps"): val = config.get("header_maps", opt) util.tags.add(opt, val) in_all = ("~filename ~uri ~#lastplayed ~#rating ~#playcount ~#skipcount " "~#added ~#bitrate ~current ~#laststarted ~basename " "~dirname").split() for Kind in browsers.browsers: if Kind.headers is not None: Kind.headers.extend(in_all) Kind.init(library) pm = quodlibet.init_plugins("no-plugins" in startup_actions) if hasattr(player, "init_plugins"): player.init_plugins() from quodlibet.qltk import unity unity.init("quodlibet.desktop", player) from quodlibet.qltk.songsmenu import SongsMenu SongsMenu.init_plugins() from quodlibet.util.cover import CoverManager app.cover_manager = CoverManager() app.cover_manager.init_plugins() from quodlibet.plugins.playlist import PLAYLIST_HANDLER PLAYLIST_HANDLER.init_plugins() from gi.repository import GLib def exec_commands(*args): for cmd in cmds_todo: try: resp = cmd_registry.run(app, *cmd) except CommandError: pass else: if resp is not None: print_(resp, end="") from quodlibet.qltk.quodlibetwindow import QuodLibetWindow, PlayerOptions # Call exec_commands after the window is restored, but make sure # it's after the mainloop has started so everything is set up. app.window = window = QuodLibetWindow( library, player, restore_cb=lambda: GLib.idle_add(exec_commands, priority=GLib.PRIORITY_HIGH)) app.player_options = PlayerOptions(window) from quodlibet.qltk.debugwindow import MinExceptionDialog from quodlibet.qltk.window import on_first_map if backend_traceback is not None: def show_backend_error(window): d = MinExceptionDialog(window, _("Audio Backend Failed to Load"), _("Loading the audio backend '%(name)s' failed. " "Audio playback will be disabled.") % {"name": wanted_backend}, backend_traceback) d.run() # so we show the main window first on_first_map(app.window, show_backend_error, app.window) from quodlibet.plugins.events import EventPluginHandler pm.register_handler(EventPluginHandler(library.librarian, player)) from quodlibet.mmkeys import MMKeysHandler from quodlibet.remote import Remote, RemoteError from quodlibet.commands import registry as cmd_registry, CommandError from quodlibet.qltk.tracker import SongTracker, FSInterface try: from quodlibet.qltk.dbus_ import DBusHandler except ImportError: DBusHandler = lambda player, library: None mmkeys_handler = MMKeysHandler(app.name, window, player) if "QUODLIBET_NO_MMKEYS" not in os.environ: mmkeys_handler.start() current_path = os.path.join(quodlibet.get_user_dir(), "current") fsiface = FSInterface(current_path, player) remote = Remote(app, cmd_registry) try: remote.start() except RemoteError: exit_(1, True) DBusHandler(player, library) tracker = SongTracker(library.librarian, player, window.playlist) from quodlibet.qltk import session session.init("quodlibet") quodlibet.enable_periodic_save(save_library=True) if "start-playing" in startup_actions: player.paused = False # restore browser windows from quodlibet.qltk.browser import LibraryBrowser GLib.idle_add(LibraryBrowser.restore, library, player, priority=GLib.PRIORITY_HIGH) def before_quit(): print_d("Saving active browser state") try: app.browser.save() except NotImplementedError: pass print_d("Shutting down player device %r." % player.version_info) player.destroy() quodlibet.main(window, before_quit=before_quit) app.player_options.destroy() quodlibet.finish_first_session(app.id) mmkeys_handler.quit() remote.stop() fsiface.destroy() tracker.destroy() quodlibet.library.save() config.save() print_d("Finished shutdown.")
def test_dirs(self): self.assertTrue(isinstance(quodlibet.get_base_dir(), fsnative)) self.assertTrue(isinstance(quodlibet.get_image_dir(), fsnative)) self.assertTrue(isinstance(quodlibet.get_user_dir(), fsnative))
def __init__(self, library, player, headless=False, restore_cb=None): super(QuodLibetWindow, self).__init__(dialog=False) self.last_dir = get_home_dir() self.__destroyed = False self.__update_title(player) self.set_default_size(550, 450) main_box = Gtk.VBox() self.add(main_box) # create main menubar, load/restore accelerator groups self.__library = library ui = self.__create_menu(player, library) accel_group = ui.get_accel_group() self.add_accel_group(accel_group) def scroll_and_jump(*args): self.__jump_to_current(True, True) keyval, mod = Gtk.accelerator_parse("<control><shift>J") accel_group.connect(keyval, mod, 0, scroll_and_jump) # dbus app menu # Unity puts the app menu next to our menu bar. Since it only contains # menu items also available in the menu bar itself, don't add it. if not util.is_unity(): AppMenu(self, ui.get_action_groups()[0]) # custom accel map accel_fn = os.path.join(quodlibet.get_user_dir(), "accels") Gtk.AccelMap.load(accel_fn) # save right away so we fill the file with example comments of all # accels Gtk.AccelMap.save(accel_fn) menubar = ui.get_widget("/Menu") # Since https://git.gnome.org/browse/gtk+/commit/?id=b44df22895c79 # toplevel menu items show an empty 16x16 image. While we don't # need image items there UIManager creates them by default. # Work around by removing the empty GtkImages for child in menubar.get_children(): if isinstance(child, Gtk.ImageMenuItem): child.set_image(None) main_box.pack_start(menubar, False, True, 0) # get the playlist up before other stuff self.songlist = MainSongList(library, player) self.songlist.show_all() self.songlist.connect("key-press-event", self.__songlist_key_press) self.songlist.connect_after( 'drag-data-received', self.__songlist_drag_data_recv) self.song_scroller = SongListScroller( ui.get_widget("/Menu/View/SongList")) self.song_scroller.add(self.songlist) self.qexpander = QueueExpander( ui.get_widget("/Menu/View/Queue"), library, player) self.playlist = PlaylistMux( player, self.qexpander.model, self.songlist.model) top_bar = TopBar(self, player, library) main_box.pack_start(top_bar, False, True, 0) self.top_bar = top_bar self.__browserbox = Align(bottom=3) main_box.pack_start(self.__browserbox, True, True, 0) statusbox = StatusBarBox(self.songlist.model, player) self.order = statusbox.order self.repeat = statusbox.repeat self.statusbar = statusbox.statusbar main_box.pack_start( Align(statusbox, border=3, top=-3, right=3), False, True, 0) self.songpane = ConfigRVPaned("memory", "queue_position", 0.75) self.songpane.pack1(self.song_scroller, resize=True, shrink=False) self.songpane.pack2(self.qexpander, resize=True, shrink=False) self.__handle_position = self.songpane.get_property("position") def songpane_button_press_cb(pane, event): """If we start to drag the pane handle while the queue expander is unexpanded, expand it and move the handle to the bottom, so we can 'drag' the queue out """ if event.window != pane.get_handle_window(): return False if not self.qexpander.get_expanded(): self.qexpander.set_expanded(True) pane.set_relative(1.0) return False self.songpane.connect("button-press-event", songpane_button_press_cb) self.song_scroller.connect('notify::visible', self.__show_or) self.qexpander.connect('notify::visible', self.__show_or) self.qexpander.connect('notify::expanded', self.__expand_or) self.qexpander.connect('draw', self.__qex_size_allocate) self.songpane.connect('notify', self.__moved_pane_handle) try: orders = [] for e in config.getstringlist('memory', 'sortby', []): orders.append((e[1:], int(e[0]))) except ValueError: pass else: self.songlist.set_sort_orders(orders) self.browser = None self.ui = ui main_box.show_all() self._playback_error_dialog = None connect_destroy(player, 'song-started', self.__song_started) connect_destroy(player, 'paused', self.__update_paused, True) connect_destroy(player, 'unpaused', self.__update_paused, False) # make sure we redraw all error indicators before opening # a dialog (blocking the main loop), so connect after default handlers connect_after_destroy(player, 'error', self.__player_error) # connect after to let SongTracker update stats connect_after_destroy(player, "song-ended", self.__song_ended) # set at least the playlist. the song should be restored # after the browser emits the song list player.setup(self.playlist, None, 0) self.__restore_cb = restore_cb self.__first_browser_set = True restore_browser = not headless try: self.select_browser( self, config.get("memory", "browser"), library, player, restore_browser) except: config.set("memory", "browser", browsers.name(0)) config.save() raise self.showhide_playlist(ui.get_widget("/Menu/View/SongList")) self.showhide_playqueue(ui.get_widget("/Menu/View/Queue")) self.songlist.connect('popup-menu', self.__songs_popup_menu) self.songlist.connect('columns-changed', self.__cols_changed) self.songlist.connect('columns-changed', self.__hide_headers) self.songlist.info.connect("changed", self.__set_time) lib = library.librarian connect_destroy(lib, 'changed', self.__song_changed, player) targets = [("text/uri-list", Gtk.TargetFlags.OTHER_APP, DND_URI_LIST)] targets = [Gtk.TargetEntry.new(*t) for t in targets] self.drag_dest_set( Gtk.DestDefaults.ALL, targets, Gdk.DragAction.COPY) self.connect('drag-data-received', self.__drag_data_received) if not headless: on_first_map(self, self.__configure_scan_dirs, library) if config.getboolean('library', 'refresh_on_start'): self.__rebuild(None, False) self.connect("key-press-event", self.__key_pressed, player) self.connect("destroy", self.__destroy) self.enable_window_tracking("quodlibet")
import quodlibet from quodlibet import config from quodlibet import util from quodlibet import qltk from quodlibet.util import connect_obj, connect_destroy, format_time_preferred from quodlibet.qltk import Icons, gtk_version, add_css from quodlibet.qltk.ccb import ConfigCheckButton from quodlibet.qltk.songlist import SongList, DND_QL, DND_URI_LIST from quodlibet.qltk.songsmenu import SongsMenu from quodlibet.qltk.songmodel import PlaylistModel from quodlibet.qltk.playorder import OrderInOrder, OrderShuffle from quodlibet.qltk.x import ScrolledWindow, SymbolicIconImage, \ SmallImageButton, MenuItem QUEUE = os.path.join(quodlibet.get_user_dir(), "queue") class PlaybackStatusIcon(Gtk.Box): """A widget showing a play/pause/stop symbolic icon""" def __init__(self): super(PlaybackStatusIcon, self).__init__() self._icons = {} def _set(self, name): if name not in self._icons: image = SymbolicIconImage(name, Gtk.IconSize.MENU) self._icons[name] = image image.show() else:
continue feed = Feed(subscription) if feed.uri in subbed: print_d("Feed already subscribed: %s" % subscription) continue feed.changed = feed.parse() if feed: print_d("Appending %s" % subscription) feeds.append(feed) newfeeds.append(feed) else: print_d("Feed could not be added: %s" % subscription) print_d("Adding %i feeds." % len(newfeeds)) with open(os.path.join(quodlibet.get_user_dir(), "feeds"), "wb") as f: pickle.dump(feeds, f) app.browser.reload(app.library) # adds feeds #app.browser.restore() def fetch_opml(url): try: outline = opml.parse(url) except IOError: print_d("Failed opening OPML %s" % url) return [] GObject.idle_add(lambda: update_feeds([x.xmlUrl for x in outline]))
from quodlibet import app from quodlibet.util import (connect_destroy, connect_after_destroy, format_time_preferred, print_exc, DeferredSignal) from quodlibet.qltk import Icons, gtk_version, add_css from quodlibet.qltk.ccb import ConfigCheckMenuItem from quodlibet.qltk.songlist import SongList, DND_QL, DND_URI_LIST from quodlibet.qltk.songsmenu import SongsMenu from quodlibet.qltk.menubutton import SmallMenuButton from quodlibet.qltk.songmodel import PlaylistModel from quodlibet.qltk.playorder import OrderInOrder, OrderShuffle from quodlibet.qltk.x import ScrolledWindow, SymbolicIconImage, \ SmallImageButton, SmallImageToggleButton, MenuItem, RadioMenuItem from quodlibet.qltk.songlistcolumns import CurrentColumn QUEUE = os.path.join(quodlibet.get_user_dir(), "queue") class PlaybackStatusIcon(Gtk.Box): """A widget showing a play/pause/stop symbolic icon""" def __init__(self): super().__init__() self._icons = {} def _set(self, name): if name not in self._icons: image = SymbolicIconImage(name, Gtk.IconSize.MENU) self._icons[name] = image image.show() else: image = self._icons[name]
class PlaylistsBrowser(Browser, DisplayPatternMixin): name = _("Playlists") accelerated_name = _("_Playlists") keys = ["Playlists", "PlaylistsBrowser"] priority = 2 replaygain_profiles = ["track"] __last_render = None _PATTERN_FN = os.path.join(quodlibet.get_user_dir(), "playlist_pattern") _DEFAULT_PATTERN_TEXT = DEFAULT_PATTERN_TEXT def __init__(self, songs_lib: SongFileLibrary, Confirmer=ConfirmationPrompt): super().__init__(spacing=6) self._lists = ObjectModelSort(model=ObjectStore()) self._lists.set_default_sort_func(ObjectStore._sort_on_value) self.songs_lib = songs_lib try: self.pl_lib: PlaylistLibrary = songs_lib.playlists except (AttributeError, TypeError): print_w("No playlist library available") model = self._lists.get_model() print_d(f"Reading playlists from library: {self.pl_lib}") for playlist in self.pl_lib: model.append(row=[playlist]) # this is instanced with the necessary gtkdialog-settings, and afterwards # its run-method is called to get a to-be-compared Gtk.ResponseType self.Confirmer = Confirmer self.set_orientation(Gtk.Orientation.VERTICAL) self.__render = self.__create_cell_renderer() self.__view = view = self.__create_playlists_view(self.__render) self.__embed_in_scrolledwin(view) self.__configure_buttons(songs_lib) self.__configure_dnd(view) self.__connect_signals(view) self._sb_box = self.__create_searchbar(songs_lib) self._rh_box = None self._main_box = self.__create_box() self.show_all() for child in self.get_children(): child.show_all() self._ids = [ self.pl_lib.connect('removed', self.__removed), self.pl_lib.connect('added', self.__added), self.pl_lib.connect('changed', self.__changed), ] print_d( f"Connected signals: {self._ids} from {self.pl_lib!r} for {self}") self.connect("destroy", self._destroy) def _destroy(self, _browser): for id_ in self._ids: self.pl_lib.disconnect(id_) del self._ids def pack(self, songpane): self._main_box.pack1(self, True, False) self._rh_box = rhbox = Gtk.VBox(spacing=6) align = Align(self._sb_box, left=0, right=6, top=6) rhbox.pack_start(align, False, True, 0) rhbox.pack_start(songpane, True, True, 0) self._main_box.pack2(rhbox, True, False) rhbox.show() align.show_all() return self._main_box def unpack(self, container, songpane): self._rh_box.remove(songpane) container.remove(self._rh_box) container.remove(self) @classmethod def init(klass, library): klass.load_pattern() def playlists(self): return [row[0] for row in self._lists] def changed(self, playlist, refresh=True): for row in self._lists: if row[0] is playlist: if refresh: # Changes affect aggregate caches etc print_d(f"Refreshing view in {self} for {playlist}") self._lists.row_changed(row.path, row.iter) if playlist == self._selected_playlist(): print_d(f"Updating songslist for selected {playlist}") self.songs_selected(playlist.songs) break def __removed(self, lib, playlists): for row in self.model: pl = row[0] if pl in playlists: print_d(f"Removing {pl} from view", str(self)) self.__playlist_deleted(row) self.activate() def __added(self, lib, playlists): for playlist in playlists: print_d(f"Looks like a new playlist: {playlist}") self.model.append(row=[playlist]) def __changed(self, lib, playlists): for playlist in playlists: self.changed(playlist) def cell_data(self, col, cell, model, iter, data): playlist = model[iter][0] cell.markup = markup = self.display_pattern % playlist if self.__last_render == markup: return self.__last_render = markup cell.markup = markup cell.set_property('markup', markup) def Menu(self, songs, library, items): model, iters = self.__get_selected_songs() remove = qltk.MenuItem(_("_Remove from Playlist"), Icons.LIST_REMOVE) qltk.add_fake_accel(remove, "Delete") connect_obj(remove, 'activate', self.__remove_songs, iters, model) playlist_iter = self.__selected_playlists()[1] remove.set_sensitive(bool(playlist_iter)) items.append([remove]) menu = super().Menu(songs, library, items) return menu def __get_selected_songs(self): songlist = qltk.get_top_parent(self).songlist model, rows = songlist.get_selection().get_selected_rows() iters = map(model.get_iter, rows) return model, iters @property def _query(self): return self._sb_box.get_query(SongList.star) def __destroy(self, *args): del self._sb_box def __create_box(self): box = qltk.ConfigRHPaned("browsers", "playlistsbrowser_pos", 0.4) box.show_all() return box def __create_searchbar(self, library): self.accelerators = Gtk.AccelGroup() completion = LibraryTagCompletion(library.librarian) sbb = SearchBarBox(completion=completion, accel_group=self.accelerators) sbb.connect('query-changed', self.__text_parse) sbb.connect('focus-out', self.__focus) return sbb def __embed_in_scrolledwin(self, view): swin = ScrolledWindow() swin.set_shadow_type(Gtk.ShadowType.IN) swin.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) swin.add(view) self.pack_start(swin, True, True, 0) def __configure_buttons(self, library): new_pl = qltk.Button(None, Icons.DOCUMENT_NEW, Gtk.IconSize.MENU) new_pl.set_tooltip_text(_("New")) new_pl.connect('clicked', self.__new_playlist, library) import_pl = qltk.Button(None, Icons.LIST_ADD, Gtk.IconSize.MENU) import_pl.set_tooltip_text(_("Import")) import_pl.connect('clicked', self.__import, library) fb = Gtk.FlowBox() fb.set_selection_mode(Gtk.SelectionMode.NONE) fb.set_homogeneous(True) fb.insert(new_pl, 0) fb.insert(import_pl, 1) fb.set_max_children_per_line(2) # The pref button is in its own flowbox instead of directly under the # HBox to make it the same height as the other buttons pref = PreferencesButton(self) fb2 = Gtk.FlowBox() fb2.insert(pref, 0) hb = Gtk.HBox() hb.pack_start(fb, True, True, 0) hb.pack_start(fb2, False, False, 0) self.pack_start(hb, False, False, 0) def __create_playlists_view(self, render): view = RCMHintedTreeView() view.set_enable_search(True) view.set_search_column(0) view.set_search_equal_func( lambda model, col, key, iter, data: not model[iter][col].name. lower().startswith(key.lower()), None) col = Gtk.TreeViewColumn("Playlists", render) col.set_cell_data_func(render, self.cell_data) view.append_column(col) view.set_model(self._lists) view.set_rules_hint(True) view.set_headers_visible(False) return view def __configure_dnd(self, view): targets = [("text/x-quodlibet-songs", Gtk.TargetFlags.SAME_APP, DND_QL), ("text/uri-list", 0, DND_URI_LIST), ("text/x-moz-url", 0, DND_MOZ_URL)] targets = [Gtk.TargetEntry.new(*t) for t in targets] view.drag_dest_set(Gtk.DestDefaults.ALL, targets, Gdk.DragAction.COPY) view.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, targets[:2], Gdk.DragAction.COPY) view.connect('drag-data-received', self.__drag_data_received) view.connect('drag-data-get', self._drag_data_get) view.connect('drag-motion', self.__drag_motion) view.connect('drag-leave', self.__drag_leave) def __connect_signals(self, view): view.connect('row-activated', lambda *x: self.songs_activated()) view.connect('popup-menu', self.__popup_menu, self.songs_lib) view.get_selection().connect('changed', self.activate) self.connect('key-press-event', self.__key_pressed) def __create_cell_renderer(self): render = Gtk.CellRendererText() render.set_padding(3, 3) render.set_property('ellipsize', Pango.EllipsizeMode.END) render.connect('editing-started', self.__start_editing) render.connect('edited', self.__edited) return render def key_pressed(self, event): if qltk.is_accel(event, "Delete"): self.__handle_songlist_delete() return True return False def __handle_songlist_delete(self, *args): model, iters = self.__get_selected_songs() self.__remove_songs(iters, model) def __key_pressed(self, widget, event): if qltk.is_accel(event, "Delete"): model, iter = self.__selected_playlists() if not iter: return False playlist = model[iter][0] if confirm_remove_playlist_dialog_invoke(self, playlist, self.Confirmer): playlist.delete() else: print_d("Playlist removal cancelled through prompt") return True elif qltk.is_accel(event, "F2"): model, iter = self.__selected_playlists() if iter: self._start_rename(model.get_path(iter)) return True elif qltk.is_accel(event, "<Primary>I"): songs = self._get_playlist_songs() if songs: window = Information(self.songs_lib.librarian, songs, self) window.show() return True elif qltk.is_accel(event, "<Primary>Return", "<Primary>KP_Enter"): qltk.enqueue(self._get_playlist_songs()) return True elif qltk.is_accel(event, "<alt>Return"): songs = self._get_playlist_songs() if songs: window = SongProperties(self.songs_lib.librarian, songs, self) window.show() return True return False def __playlist_deleted(self, row) -> None: self.model.remove(row.iter) def __drag_motion(self, view, ctx, x, y, time): targets = [t.name() for t in ctx.list_targets()] if "text/x-quodlibet-songs" in targets: view.set_drag_dest(x, y, into_only=True) return True else: # Highlighting the view itself doesn't work. view.get_parent().drag_highlight() return True def __drag_leave(self, view, ctx, time): view.get_parent().drag_unhighlight() def __remove_songs(self, iters, smodel): def song_at(itr): return smodel[smodel.get_path(itr)][0] def remove_from_model(iters, smodel): for it in iters: smodel.remove(it) model, iter = self.__selected_playlists() if iter: playlist = model[iter][0] # Build a {iter: song} dict, exhausting `iters` once. removals = { iter_remove: song_at(iter_remove) for iter_remove in iters } if not removals: print_w("No songs selected to remove") return if self._query is None or not self.get_filter_text(): # Calling playlist.remove_songs(songs) won't remove the # right ones if there are duplicates remove_from_model(removals.keys(), smodel) self.__rebuild_playlist_from_songs_model(playlist, smodel) # Emit manually self.songs_lib.emit('changed', removals.values()) else: playlist.remove_songs(removals.values(), True) remove_from_model(removals.keys(), smodel) print_d("Removed %d song(s) from %s" % (len(removals), playlist)) def __rebuild_playlist_from_songs_model(self, playlist, smodel): self.pl_lib.recreate(playlist, [row[0] for row in smodel]) def _selected_playlist(self) -> Optional[Playlist]: """The currently selected playlist's name, or None if non selected""" model, iter = self.__selected_playlists() if not iter: return None path = model.get_path(iter) playlist = model[path][0] return playlist def __drag_data_received(self, view, ctx, x, y, sel, tid, etime): # TreeModelSort doesn't support GtkTreeDragDestDrop. view.emit_stop_by_name('drag-data-received') model = view.get_model() if tid == DND_QL: filenames = qltk.selection_get_filenames(sel) songs = list( filter(None, [self.songs_lib.get(f) for f in filenames])) if not songs: Gtk.drag_finish(ctx, False, False, etime) return try: path, pos = view.get_dest_row_at_pos(x, y) except TypeError: playlist = self.pl_lib.create_from_songs(songs) GLib.idle_add(self._select_playlist, playlist) else: playlist = model[path][0] playlist.extend(songs) # self.changed(playlist) Gtk.drag_finish(ctx, True, False, etime) # Cause a refresh to the dragged-to playlist if it is selected # so that the dragged (duplicate) track(s) appears if playlist is self._selected_playlist(): model, plist_iter = self.__selected_playlists() songlist = qltk.get_top_parent(self).songlist self.activate(resort=not songlist.is_sorted()) else: if tid == DND_URI_LIST: uri = sel.get_uris()[0] name = os.path.basename(uri) elif tid == DND_MOZ_URL: data = sel.get_data() uri, name = data.decode('utf16', 'replace').split('\n') else: Gtk.drag_finish(ctx, False, False, etime) return name = _name_for(name or os.path.basename(uri)) try: sock = urlopen(uri) uri = uri.lower() if uri.endswith('.pls'): playlist = parse_pls(sock, name, songs_lib=self.songs_lib, pl_lib=self.pl_lib) elif uri.endswith('.m3u') or uri.endswith('.m3u8'): playlist = parse_m3u(sock, name, songs_lib=self.songs_lib, pl_lib=self.pl_lib) else: raise IOError self.songs_lib.add(playlist.songs) # TODO: change to use playlist library too? # self.changed(playlist) Gtk.drag_finish(ctx, True, False, etime) except IOError: Gtk.drag_finish(ctx, False, False, etime) qltk.ErrorMessage( qltk.get_top_parent(self), _("Unable to import playlist"), _("Quod Libet can only import playlists in the M3U/M3U8 " "and PLS formats.")).run() def _drag_data_get(self, view, ctx, sel, tid, etime): model, iters = self.__view.get_selection().get_selected_rows() songs = [] for itr in iters: if itr: songs += model[itr][0].songs if tid == 0: qltk.selection_set_songs(sel, songs) else: sel.set_uris([song("~uri") for song in songs]) def _select_playlist(self, playlist, scroll=False): view = self.__view model = view.get_model() for row in model: if row[0] is playlist: view.get_selection().select_iter(row.iter) if scroll: view.scroll_to_cell(row.path, use_align=True, row_align=0.5) def __popup_menu(self, view, library): model, itr = view.get_selection().get_selected() if itr is None: return songs = list(model[itr][0]) songs = [s for s in songs if isinstance(s, AudioFile)] menu = SongsMenu(library, songs, playlists=False, remove=False, ratings=False) menu.preseparate() def _remove(model, itr): playlist = model[itr][0] response = confirm_remove_playlist_dialog_invoke( self, playlist, self.Confirmer) if response: playlist.delete() else: print_d("Playlist removal cancelled through prompt") rem = MenuItem(_("_Delete"), Icons.EDIT_DELETE) connect_obj(rem, 'activate', _remove, model, itr) menu.prepend(rem) def _rename(path): self._start_rename(path) ren = qltk.MenuItem(_("_Rename"), Icons.EDIT) qltk.add_fake_accel(ren, "F2") connect_obj(ren, 'activate', _rename, model.get_path(itr)) menu.prepend(ren) playlist = model[itr][0] PLAYLIST_HANDLER.populate_menu(menu, library, self, [playlist]) menu.show_all() return view.popup_menu(menu, 0, Gtk.get_current_event_time()) def _start_rename(self, path): view = self.__view self.__render.set_property('editable', True) view.set_cursor(path, view.get_columns()[0], start_editing=True) def __focus(self, widget, *args): qltk.get_top_parent(widget).songlist.grab_focus() def __text_parse(self, bar, text): self.activate() def _set_text(self, text): self._sb_box.set_text(text) def activate(self, widget=None, resort=True): songs = self._get_playlist_songs() query = self._sb_box.get_query(SongList.star) if query and query.is_parsable: songs = query.filter(songs) GLib.idle_add(self.songs_selected, songs, resort) def refresh_all(self): print_d("Refreshing all items...") model = self._lists.get_model() for iter_, value in model.iterrows(): print_d(f"Refreshing row {iter_}") model.row_changed(model.get_path(iter_), iter_) @property def model(self): return self._lists.get_model() def _get_playlist_songs(self): model, iter = self.__selected_playlists() songs = iter and list(model[iter][0]) or [] return [s for s in songs if isinstance(s, AudioFile)] def can_filter_text(self): return True def filter_text(self, text): self._set_text(text) self.activate() def get_filter_text(self): return self._sb_box.get_text() def can_filter(self, key): # TODO: special-case the ~playlists tag maybe? return super().can_filter(key) def finalize(self, restore): config.set("browsers", "query_text", "") def unfilter(self): self.filter_text("") def active_filter(self, song): return (song in self._get_playlist_songs() and (self._query is None or self._query.search(song))) def save(self): model, iter = self.__selected_playlists() name = iter and model[iter][0].name or "" config.set("browsers", "playlist", name) text = self.get_filter_text() config.set("browsers", "query_text", text) def __new_playlist(self, activator, library): playlist = self.pl_lib.create() self._select_playlist(playlist, scroll=True) model, iter = self.__selected_playlists() path = model.get_path(iter) GLib.idle_add(self._start_rename, path) def __start_editing(self, render, editable, path): editable.set_text(self._lists[path][0].name) def __edited(self, render, path, newname): return self._rename(path, newname) def _rename(self, path, newname): playlist = self._lists[path][0] try: playlist.rename(newname) except ValueError as s: qltk.ErrorMessage(None, _("Unable to rename playlist"), s).run() else: row = self._lists[path] child_model = self.model child_model.remove(self._lists.convert_iter_to_child_iter( row.iter)) child_model.append(row=[playlist]) self._select_playlist(playlist, scroll=True) def __import(self, activator, library): formats = ["*.pls", "*.m3u", "*.m3u8"] cf = create_chooser_filter(_("Playlists"), formats) fns = choose_files(self, _("Import Playlist"), _("_Import"), cf) self._import_playlists(fns) def _import_playlists(self, fns) -> Tuple[int, int]: """ Import m3u / pls playlists into QL Returns the (total playlists, total songs) added TODO: move this to Playlists library and watch here for new playlists """ total_pls = 0 total_songs = 0 for filename in fns: name = _name_for(filename) with open(filename, "rb") as f: if filename.endswith(".m3u") or filename.endswith(".m3u8"): playlist = parse_m3u(f, name, songs_lib=self.songs_lib, pl_lib=self.pl_lib) elif filename.endswith(".pls"): playlist = parse_pls(f, name, songs_lib=self.songs_lib, pl_lib=self.pl_lib) else: print_w("Unsupported playlist type for '%s'" % filename) continue # Import all the songs in the playlist to the *songs* library total_songs += len(self.songs_lib.add(playlist)) total_pls += 1 return total_pls, total_songs def restore(self): try: name = config.get("browsers", "playlist") except config.Error as e: print_d("Couldn't get last playlist from config: %s" % e) else: self.__view.select_by_func(lambda r: r[0].name == name, one=True) try: text = config.get("browsers", "query_text") except config.Error as e: print_d("Couldn't get last search string from config: %s" % e) else: self._set_text(text) can_reorder = True def scroll(self, song): self.__view.iter_select_by_func(lambda r: song in r[0]) def reordered(self, songs): model, iter = self.__selected_playlists() playlist = None if iter: playlist = model[iter][0] playlist[:] = songs elif songs: playlist = self.pl_lib.create_from_songs(songs) GLib.idle_add(self._select_playlist, playlist) if playlist: self.changed(playlist, refresh=False) def __selected_playlists(self): """Returns a tuple of (model, iter) for the current playlist(s)""" return self.__view.get_selection().get_selected()
class PlaylistsBrowser(Browser, DisplayPatternMixin): name = _("Playlists") accelerated_name = _("_Playlists") keys = ["Playlists", "PlaylistsBrowser"] priority = 2 replaygain_profiles = ["track"] __last_render = None _PATTERN_FN = os.path.join(quodlibet.get_user_dir(), "playlist_pattern") _DEFAULT_PATTERN_TEXT = DEFAULT_PATTERN_TEXT def pack(self, songpane): self._main_box.pack1(self, True, False) self._rh_box = rhbox = Gtk.VBox(spacing=6) align = Align(self._sb_box, left=0, right=6, top=6) rhbox.pack_start(align, False, True, 0) rhbox.pack_start(songpane, True, True, 0) self._main_box.pack2(rhbox, True, False) rhbox.show() align.show_all() return self._main_box def unpack(self, container, songpane): self._rh_box.remove(songpane) container.remove(self._rh_box) container.remove(self) @classmethod def init(klass, library): klass.library = library model = klass.__lists.get_model() for playlist in os.listdir(PLAYLISTS): try: playlist = FileBackedPlaylist( PLAYLISTS, FileBackedPlaylist.unquote(playlist), library=library) model.append(row=[playlist]) except EnvironmentError: print_w("Invalid Playlist '%s'" % playlist) pass klass._ids = [ library.connect('removed', klass.__removed), library.connect('added', klass.__added), library.connect('changed', klass.__changed), ] klass.load_pattern() @classmethod def deinit(cls, library): model = cls.__lists.get_model() model.clear() for id_ in cls._ids: library.disconnect(id_) del cls._ids @classmethod def playlists(klass): return [row[0] for row in klass.__lists] @classmethod def changed(klass, playlist, refresh=True): model = klass.__lists for row in model: if row[0] is playlist: if refresh: print_d("Refreshing playlist %s..." % row[0]) klass.__lists.row_changed(row.path, row.iter) playlist.write() break else: model.get_model().append(row=[playlist]) playlist.write() @classmethod def __removed(klass, library, songs): for playlist in klass.playlists(): if playlist.remove_songs(songs): klass.changed(playlist) @classmethod def __added(klass, library, songs): filenames = {song("~filename") for song in songs} for playlist in klass.playlists(): if playlist.add_songs(filenames, library): klass.changed(playlist) @classmethod def __changed(klass, library, songs): for playlist in klass.playlists(): for song in songs: if song in playlist.songs: klass.changed(playlist) break def cell_data(self, col, cell, model, iter, data): playlist = model[iter][0] cell.markup = markup = self.display_pattern % playlist if self.__last_render == markup: return self.__last_render = markup cell.markup = markup cell.set_property('markup', markup) def Menu(self, songs, library, items): model, iters = self.__get_selected_songs() remove = qltk.MenuItem(_("_Remove from Playlist"), Icons.LIST_REMOVE) qltk.add_fake_accel(remove, "Delete") connect_obj(remove, 'activate', self.__remove, iters, model) playlist_iter = self.__view.get_selection().get_selected()[1] remove.set_sensitive(bool(playlist_iter)) items.append([remove]) menu = super(PlaylistsBrowser, self).Menu(songs, library, items) return menu def __get_selected_songs(self): songlist = qltk.get_top_parent(self).songlist model, rows = songlist.get_selection().get_selected_rows() iters = map(model.get_iter, rows) return model, iters __lists = ObjectModelSort(model=ObjectStore()) __lists.set_default_sort_func(ObjectStore._sort_on_value) def __init__(self, library): self.library = library super(PlaylistsBrowser, self).__init__(spacing=6) self.set_orientation(Gtk.Orientation.VERTICAL) self.__render = self.__create_cell_renderer() self.__view = view = self.__create_playlists_view(self.__render) self.__embed_in_scrolledwin(view) self.__configure_buttons(library) self.__configure_dnd(view, library) self.__connect_signals(view, library) self._sb_box = self.__create_searchbar(library) self._main_box = self.__create_box() self.show_all() self._query = None for child in self.get_children(): child.show_all() def __destroy(self, *args): del self._sb_box def __create_box(self): box = qltk.ConfigRHPaned("browsers", "playlistsbrowser_pos", 0.4) box.show_all() return box def __create_searchbar(self, library): self.accelerators = Gtk.AccelGroup() completion = LibraryTagCompletion(library.librarian) sbb = SearchBarBox(completion=completion, accel_group=self.accelerators) sbb.connect('query-changed', self.__text_parse) sbb.connect('focus-out', self.__focus) return sbb def __embed_in_scrolledwin(self, view): swin = ScrolledWindow() swin.set_shadow_type(Gtk.ShadowType.IN) swin.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) swin.add(view) self.pack_start(swin, True, True, 0) def __configure_buttons(self, library): new_pl = qltk.Button(_("_New"), Icons.DOCUMENT_NEW, Gtk.IconSize.MENU) new_pl.connect('clicked', self.__new_playlist) import_pl = qltk.Button(_("_Import"), Icons.LIST_ADD, Gtk.IconSize.MENU) import_pl.connect('clicked', self.__import, library) hb = Gtk.HBox(spacing=6) hb.set_homogeneous(False) hb.pack_start(new_pl, True, True, 0) hb.pack_start(import_pl, True, True, 0) hb2 = Gtk.HBox(spacing=0) hb2.pack_start(hb, True, True, 0) hb2.pack_start(PreferencesButton(self), False, False, 6) self.pack_start(Align(hb2, left=3, bottom=3), False, False, 0) def __create_playlists_view(self, render): view = RCMHintedTreeView() view.set_enable_search(True) view.set_search_column(0) view.set_search_equal_func( lambda model, col, key, iter, data: not model[iter][col].name. lower().startswith(key.lower()), None) col = Gtk.TreeViewColumn("Playlists", render) col.set_cell_data_func(render, self.cell_data) view.append_column(col) view.set_model(self.__lists) view.set_rules_hint(True) view.set_headers_visible(False) return view def __configure_dnd(self, view, library): targets = [("text/x-quodlibet-songs", Gtk.TargetFlags.SAME_APP, DND_QL), ("text/uri-list", 0, DND_URI_LIST), ("text/x-moz-url", 0, DND_MOZ_URL)] targets = [Gtk.TargetEntry.new(*t) for t in targets] view.drag_dest_set(Gtk.DestDefaults.ALL, targets, Gdk.DragAction.COPY) view.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, targets[:2], Gdk.DragAction.COPY) view.connect('drag-data-received', self.__drag_data_received, library) view.connect('drag-data-get', self.__drag_data_get) view.connect('drag-motion', self.__drag_motion) view.connect('drag-leave', self.__drag_leave) def __connect_signals(self, view, library): view.connect('row-activated', lambda *x: self.songs_activated()) view.connect('popup-menu', self.__popup_menu, library) view.get_selection().connect('changed', self.activate) model = view.get_model() s = model.connect('row-changed', self.__check_current) connect_obj(self, 'destroy', model.disconnect, s) self.connect('key-press-event', self.__key_pressed) def __create_cell_renderer(self): render = Gtk.CellRendererText() render.set_padding(3, 3) render.set_property('ellipsize', Pango.EllipsizeMode.END) render.connect('editing-started', self.__start_editing) render.connect('edited', self.__edited) return render def key_pressed(self, event): if qltk.is_accel(event, "Delete"): self.__handle_songlist_delete() return True return False def __handle_songlist_delete(self, *args): model, iters = self.__get_selected_songs() self.__remove(iters, model) def __key_pressed(self, widget, event): if qltk.is_accel(event, "Delete"): model, iter = self.__view.get_selection().get_selected() if not iter: return False playlist = model[iter][0] dialog = ConfirmRemovePlaylistDialog(self, playlist) if dialog.run() == Gtk.ResponseType.YES: playlist.delete() model.get_model().remove( model.convert_iter_to_child_iter(iter)) return True elif qltk.is_accel(event, "F2"): model, iter = self.__view.get_selection().get_selected() if iter: self._start_rename(model.get_path(iter)) return True return False def __check_current(self, model, path, iter): model, citer = self.__view.get_selection().get_selected() if citer and model.get_path(citer) == path: songlist = qltk.get_top_parent(self).songlist self.activate(resort=not songlist.is_sorted()) def __drag_motion(self, view, ctx, x, y, time): targets = [t.name() for t in ctx.list_targets()] if "text/x-quodlibet-songs" in targets: view.set_drag_dest(x, y, into_only=True) return True else: # Highlighting the view itself doesn't work. view.get_parent().drag_highlight() return True def __drag_leave(self, view, ctx, time): view.get_parent().drag_unhighlight() def __remove(self, iters, smodel): def song_at(itr): return smodel[smodel.get_path(itr)][0] def remove_from_model(iters, smodel): for it in iters: smodel.remove(it) model, iter = self.__view.get_selection().get_selected() if iter: playlist = model[iter][0] removals = [song_at(iter_remove) for iter_remove in iters] if self._query is None or not self.get_filter_text(): # Calling playlist.remove_songs(songs) won't remove the # right ones if there are duplicates remove_from_model(iters, smodel) self.__rebuild_playlist_from_songs_model(playlist, smodel) # Emit manually self.library.emit('changed', removals) else: print_d("Removing %d song(s) from %s" % (len(removals), playlist)) playlist.remove_songs(removals, True) remove_from_model(iters, smodel) self.changed(playlist) self.activate() def __rebuild_playlist_from_songs_model(self, playlist, smodel): playlist.inhibit = True playlist.clear() playlist.extend([row[0] for row in smodel]) playlist.inhibit = False def __drag_data_received(self, view, ctx, x, y, sel, tid, etime, library): # TreeModelSort doesn't support GtkTreeDragDestDrop. view.emit_stop_by_name('drag-data-received') model = view.get_model() if tid == DND_QL: filenames = qltk.selection_get_filenames(sel) songs = filter(None, map(library.get, filenames)) if not songs: Gtk.drag_finish(ctx, False, False, etime) return try: path, pos = view.get_dest_row_at_pos(x, y) except TypeError: playlist = FileBackedPlaylist.from_songs( PLAYLISTS, songs, library) GLib.idle_add(self._select_playlist, playlist) else: playlist = model[path][0] playlist.extend(songs) self.changed(playlist) Gtk.drag_finish(ctx, True, False, etime) else: if tid == DND_URI_LIST: uri = sel.get_uris()[0] name = os.path.basename(uri) elif tid == DND_MOZ_URL: data = sel.get_data() uri, name = data.decode('utf16', 'replace').split('\n') else: Gtk.drag_finish(ctx, False, False, etime) return name = name or os.path.basename(uri) or _("New Playlist") uri = uri.encode('utf-8') try: sock = urllib.urlopen(uri) f = NamedTemporaryFile() f.write(sock.read()) f.flush() if uri.lower().endswith('.pls'): playlist = parse_pls(f.name, library=library) elif uri.lower().endswith('.m3u'): playlist = parse_m3u(f.name, library=library) else: raise IOError library.add_filename(playlist) if name: playlist.rename(name) self.changed(playlist) Gtk.drag_finish(ctx, True, False, etime) except IOError: Gtk.drag_finish(ctx, False, False, etime) qltk.ErrorMessage( qltk.get_top_parent(self), _("Unable to import playlist"), _("Quod Libet can only import playlists in the M3U " "and PLS formats.")).run() def __drag_data_get(self, view, ctx, sel, tid, etime): model, iters = self.__view.get_selection().get_selected_rows() songs = [] for iter in filter(lambda i: i, iters): songs += list(model[iter][0]) if tid == 0: qltk.selection_set_songs(sel, songs) else: sel.set_uris([song("~uri") for song in songs]) def _select_playlist(self, playlist, scroll=False): view = self.__view model = view.get_model() for row in model: if row[0] is playlist: view.get_selection().select_iter(row.iter) if scroll: view.scroll_to_cell(row.path, use_align=True, row_align=0.5) def __popup_menu(self, view, library): model, itr = view.get_selection().get_selected() if itr is None: return songs = list(model[itr][0]) songs = filter(lambda s: isinstance(s, AudioFile), songs) menu = SongsMenu(library, songs, playlists=False, remove=False, ratings=False) menu.preseparate() def _remove(model, itr): playlist = model[itr][0] dialog = ConfirmRemovePlaylistDialog(self, playlist) if dialog.run() == Gtk.ResponseType.YES: playlist.delete() model.get_model().remove(model.convert_iter_to_child_iter(itr)) rem = MenuItem(_("_Delete"), Icons.EDIT_DELETE) connect_obj(rem, 'activate', _remove, model, itr) menu.prepend(rem) def _rename(path): self._start_rename(path) ren = qltk.MenuItem(_("_Rename"), Icons.EDIT) qltk.add_fake_accel(ren, "F2") connect_obj(ren, 'activate', _rename, model.get_path(itr)) menu.prepend(ren) playlist = model[itr][0] PLAYLIST_HANDLER.populate_menu(menu, library, self, [playlist]) menu.show_all() return view.popup_menu(menu, 0, Gtk.get_current_event_time()) def _start_rename(self, path): view = self.__view self.__render.set_property('editable', True) view.set_cursor(path, view.get_columns()[0], start_editing=True) def __focus(self, widget, *args): qltk.get_top_parent(widget).songlist.grab_focus() def __text_parse(self, bar, text): self.activate() def _set_text(self, text): self._sb_box.set_text(text) def activate(self, widget=None, resort=True): songs = self._get_playlist_songs() text = self.get_filter_text() # TODO: remove static dependency on Query if Query.is_parsable(text): self._query = Query(text, SongList.star) songs = self._query.filter(songs) GLib.idle_add(self.songs_selected, songs, resort) @classmethod def refresh_all(cls): model = cls.__lists.get_model() for iter_, value in model.iterrows(): model.row_changed(model.get_path(iter_), iter_) @property def model(self): return self.__lists.get_model() def _get_playlist_songs(self): model, iter = self.__view.get_selection().get_selected() songs = iter and list(model[iter][0]) or [] songs = filter(lambda s: isinstance(s, AudioFile), songs) return songs def can_filter_text(self): return True def filter_text(self, text): self._set_text(text) self.activate() def get_filter_text(self): return self._sb_box.get_text() def can_filter(self, key): # TODO: special-case the ~playlists tag maybe? return super(PlaylistsBrowser, self).can_filter(key) def finalize(self, restore): config.set("browsers", "query_text", "") def unfilter(self): self.filter_text("") def active_filter(self, song): return (song in self._get_playlist_songs() and (self._query is None or self._query.search(song))) def save(self): model, iter = self.__view.get_selection().get_selected() name = iter and model[iter][0].name or "" config.set("browsers", "playlist", name) text = self.get_filter_text() config.set("browsers", "query_text", text) def __new_playlist(self, activator): playlist = FileBackedPlaylist.new(PLAYLISTS) self.model.append(row=[playlist]) self._select_playlist(playlist, scroll=True) model, iter = self.__view.get_selection().get_selected() path = model.get_path(iter) GLib.idle_add(self._start_rename, path) def __start_editing(self, render, editable, path): editable.set_text(self.__lists[path][0].name) def __edited(self, render, path, newname): return self._rename(path, newname) def _rename(self, path, newname): playlist = self.__lists[path][0] try: playlist.rename(newname) except ValueError as s: qltk.ErrorMessage(None, _("Unable to rename playlist"), s).run() else: row = self.__lists[path] child_model = self.model child_model.remove( self.__lists.convert_iter_to_child_iter(row.iter)) child_model.append(row=[playlist]) self._select_playlist(playlist, scroll=True) def __import(self, activator, library): filt = lambda fn: fn.endswith(".pls") or fn.endswith(".m3u") from quodlibet.qltk.chooser import FileChooser chooser = FileChooser(self, _("Import Playlist"), filt, get_home_dir()) files = chooser.run() chooser.destroy() for filename in files: if filename.endswith(".m3u"): playlist = parse_m3u(filename, library=library) elif filename.endswith(".pls"): playlist = parse_pls(filename, library=library) else: qltk.ErrorMessage( qltk.get_top_parent(self), _("Unable to import playlist"), _("Quod Libet can only import playlists in the M3U " "and PLS formats.")).run() return self.changed(playlist) library.add(playlist) def restore(self): try: name = config.get("browsers", "playlist") except config.Error as e: print_d("Couldn't get last playlist from config: %s" % e) else: self.__view.select_by_func(lambda r: r[0].name == name, one=True) try: text = config.get("browsers", "query_text") except config.Error as e: print_d("Couldn't get last search string from config: %s" % e) else: self._set_text(text) can_reorder = True def scroll(self, song): self.__view.iter_select_by_func(lambda r: song in r[0]) def reordered(self, songs): model, iter = self.__view.get_selection().get_selected() playlist = None if iter: playlist = model[iter][0] playlist[:] = songs elif songs: playlist = FileBackedPlaylist.from_songs(PLAYLISTS, songs) GLib.idle_add(self._select_playlist, playlist) if playlist: self.changed(playlist, refresh=False)
class ExceptionDialog(Gtk.Window): """The windows which is shown if an unhandled exception occurred""" _running = False _instance = None DUMPDIR = os.path.join(quodlibet.get_user_dir(), "dumps") @classmethod def from_except(cls, type_, value, traceback): """Returns an instance or None.""" # Don't get in a recursive exception handler loop. if not cls._running: cls._running = True cls._instance = cls(type_, value, traceback) return cls._instance @property def dump_path(self): return os.path.join( self.DUMPDIR, time.strftime("Dump_%Y%m%d_%H%M%S.txt", self._time)) @property def minidump_path(self): return os.path.join( self.DUMPDIR, time.strftime("MiniDump_%Y%m%d_%H%M%S.txt", self._time)) def dump_to_disk(self, type_, value, traceback): """Writes the dump files to DUMDIR""" mkdir(self.DUMPDIR) header = format_dump_header(type_, value, traceback).encode("utf-8") log = format_dump_log().encode("utf-8") print(self.dump_path) with open(self.dump_path, "wb") as dump: with open(self.minidump_path, "wb") as minidump: minidump.write(header) dump.write(header) dump.write(log) def __init__(self, type_, value, traceback): # This is all implemented a bit different than the rest of Quod # Libet's windows since I want it to be as stupid as possible, to # minimize the chances of something going wrong with the thing # that handles things going wrong, i.e. it only uses GTK+ code, # no QLTK wrappers. self._time = time.localtime() Gtk.Window.__init__(self) self.set_default_size(400, 400) self.set_border_width(12) self.set_title(_("Error Occurred")) desc = _( "An exception has occured in Quod Libet. A dump file has " "been saved to <b >%(dump-path)s</b> that will help us debug the " "crash. " "Please file a new issue at %(new-issue-url)s" "and attach this file or include its contents. This " "file may contain some identifying information about you or your " "system, such as a list of recent files played. If this is " "unacceptable, send <b>%(mini-dump-path)s</b> instead with a " "description of what " "you were doing.") % { "dump-path": unexpand(self.dump_path), "mini-dump-path": unexpand(self.minidump_path), "new-issue-url": "https://github.com/quodlibet/quodlibet/issues/new", } suggestion = _( "Quod Libet may now be unstable. Closing it and " "restarting is recommended. Your library will be saved.") label = Gtk.Label(label=desc + "\n\n" + suggestion) label.set_selectable(True) label.set_use_markup(True) label.set_line_wrap(True) box = Gtk.VBox(spacing=6) buttons = Gtk.HButtonBox() viewbox = Gtk.VBox(spacing=4) buf = Gtk.TextBuffer() buf.set_text(text_type(value)) viewbox.add(Gtk.TextView(buffer=buf, editable=False)) view = Gtk.TreeView() viewbox.add(view) view.set_headers_visible(False) sw = Gtk.ScrolledWindow() sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.ALWAYS) sw.set_shadow_type(Gtk.ShadowType.IN) sw.add(viewbox) model = Gtk.ListStore(object, object, object) self.__fill_list(view, model, value, traceback) view.set_model(model) cancel = qltk.Button(_("_Cancel")) close = qltk.Button(_("_Quit"), Icons.APPLICATION_EXIT) buttons.pack_start(close, True, True, 0) buttons.pack_start(cancel, True, True, 0) box.pack_start(label, False, True, 0) box.pack_start(sw, True, True, 0) box.pack_start(buttons, False, True, 0) self.add(box) self.connect('destroy', self.__destroy) connect_obj(cancel, 'clicked', Gtk.Window.destroy, self) close.connect('clicked', lambda *x: Gtk.main_quit()) self.get_child().show_all() def first_draw(*args): filename = unexpand(self.dump_path) offset = gdecode(label.get_text()).find(filename) label.select_region(offset, offset + len(filename)) self.disconnect(self.__draw_id) self.__draw_id = self.connect("draw", first_draw) def __stack_row_activated(self, view, path, column): model = view.get_model() filename = model[path][0] line = model[path][2] util.spawn(["sensible-editor", "+%d" % line, filename]) def __fill_list(self, view, model, value, trace): for frame in reversed(extract_tb(trace)): (filename, line, function, text) = frame model.append(row=[filename, function, line]) view.connect('row-activated', self.__stack_row_activated) def cdf(column, cell, model, iter, data): row = model[iter] filename = fsn2text(unexpand(row[0])) function = row[1] line = row[2] cell.set_property( "markup", "<b>%s</b> line %d\n\t%s" % (util.escape(function), line, util.escape(filename))) render = Gtk.CellRendererText() col = Gtk.TreeViewColumn(u"", render) col.set_cell_data_func(render, cdf) col.set_visible(True) col.set_expand(True) view.append_column(col) def __destroy(self, window): type(self)._running = False type(self).instance = None window.destroy()
class CoverGrid(Browser, util.InstanceTracker, VisibleUpdate, DisplayPatternMixin): __gsignals__ = Browser.__gsignals__ __model = None __last_render = None __last_render_surface = None _PATTERN_FN = os.path.join(quodlibet.get_user_dir(), "album_pattern") _DEFAULT_PATTERN_TEXT = DEFAULT_PATTERN_TEXT STAR = ["~people", "album"] name = _("Cover Grid") accelerated_name = _("_Cover Grid") keys = ["CoverGrid"] priority = 5 def pack(self, songpane): container = self.songcontainer container.pack1(self, True, False) container.pack2(songpane, True, False) return container def unpack(self, container, songpane): container.remove(songpane) container.remove(self) @classmethod def init(klass, library): super(CoverGrid, klass).load_pattern() def finalize(self, restored): if not restored: # Select the "All Albums" album, which is None self.select_by_func(lambda r: r[0].album is None, one=True) @classmethod def _destroy_model(klass): klass.__model.destroy() klass.__model = None @classmethod def toggle_text(klass): on = config.getboolean("browsers", "album_text", True) for covergrid in klass.instances(): covergrid.__text_cells.set_visible(on) covergrid.view.queue_resize() @classmethod def toggle_wide(klass): wide = config.getboolean("browsers", "covergrid_wide", False) for covergrid in klass.instances(): covergrid.songcontainer.set_orientation( Gtk.Orientation.HORIZONTAL if wide else Gtk.Orientation.VERTICAL) @classmethod def update_mag(klass): mag = config.getfloat("browsers", "covergrid_magnification", 3.) for covergrid in klass.instances(): covergrid.__cover.set_property('width', get_cover_size() * mag + 8) covergrid.__cover.set_property('height', get_cover_size() * mag + 8) covergrid.view.set_item_width(get_cover_size() * mag + 8) covergrid.view.queue_resize() covergrid.redraw() def redraw(self): model = self.__model for iter_, item in model.iterrows(): album = item.album if album is not None: item.scanned = False model.row_changed(model.get_path(iter_), iter_) @classmethod def _init_model(klass, library): klass.__model = AlbumModel(library) klass.__library = library @classmethod def _refresh_albums(klass, albums): """We signal all other open album views that we changed something (Only needed for the cover atm) so they redraw as well.""" if klass.__library: klass.__library.albums.refresh(albums) @util.cached_property def _no_cover(self) -> Optional[cairo.Surface]: """Returns a cairo surface representing a missing cover""" mag = config.getfloat("browsers", "covergrid_magnification", 3.) cover_size = get_cover_size() scale_factor = self.get_scale_factor() * mag pb = get_no_cover_pixbuf(cover_size, cover_size, scale_factor) return get_surface_for_pixbuf(self, pb) def __init__(self, library): Browser.__init__(self, spacing=6) self.set_orientation(Gtk.Orientation.VERTICAL) self.songcontainer = qltk.paned.ConfigRVPaned( "browsers", "covergrid_pos", 0.4) if config.getboolean("browsers", "covergrid_wide", False): self.songcontainer.set_orientation(Gtk.Orientation.HORIZONTAL) self._register_instance() if self.__model is None: self._init_model(library) self._cover_cancel = Gio.Cancellable() self.scrollwin = sw = ScrolledWindow() sw.set_shadow_type(Gtk.ShadowType.IN) model_sort = AlbumSortModel(model=self.__model) model_filter = AlbumFilterModel(child_model=model_sort) self.view = view = IconView(model_filter) #view.set_item_width(get_cover_size() + 12) self.view.set_row_spacing(config.getint("browsers", "row_spacing", 6)) self.view.set_column_spacing(config.getint("browsers", "column_spacing", 6)) self.view.set_item_padding(config.getint("browsers", "item_padding", 6)) self.view.set_has_tooltip(True) self.view.connect("query-tooltip", self._show_tooltip) self.__bg_filter = background_filter() self.__filter = None model_filter.set_visible_func(self.__parse_query) mag = config.getfloat("browsers", "covergrid_magnification", 3.) self.view.set_item_width(get_cover_size() * mag + 8) self.__cover = render = Gtk.CellRendererPixbuf() render.set_property('width', get_cover_size() * mag + 8) render.set_property('height', get_cover_size() * mag + 8) view.pack_start(render, False) def cell_data_pb(view, cell, model, iter_, no_cover): item = model.get_value(iter_) if item.album is None: surface = None elif item.cover: pixbuf = item.cover pixbuf = add_border_widget(pixbuf, self.view) surface = get_surface_for_pixbuf(self, pixbuf) # don't cache, too much state has an effect on the result self.__last_render_surface = None else: surface = no_cover if self.__last_render_surface == surface: return self.__last_render_surface = surface cell.set_property("surface", surface) view.set_cell_data_func(render, cell_data_pb, self._no_cover) self.__text_cells = render = Gtk.CellRendererText() render.set_visible(config.getboolean("browsers", "album_text", True)) render.set_property('alignment', Pango.Alignment.CENTER) render.set_property('xalign', 0.5) render.set_property('ellipsize', Pango.EllipsizeMode.END) view.pack_start(render, False) def cell_data(view, cell, model, iter_, data): album = model.get_album(iter_) if album is None: text = "<b>%s</b>" % _("All Albums") text += "\n" + ngettext("%d album", "%d albums", len(model) - 1) % (len(model) - 1) markup = text else: markup = self.display_pattern % album if self.__last_render == markup: return self.__last_render = markup cell.markup = markup cell.set_property('markup', markup) view.set_cell_data_func(render, cell_data, None) view.set_selection_mode(Gtk.SelectionMode.MULTIPLE) sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) sw.add(view) view.connect('item-activated', self.__play_selection, None) self.__sig = connect_destroy( view, 'selection-changed', util.DeferredSignal(self.__update_songs, owner=self)) targets = [("text/x-quodlibet-songs", Gtk.TargetFlags.SAME_APP, 1), ("text/uri-list", 0, 2)] targets = [Gtk.TargetEntry.new(*t) for t in targets] view.drag_source_set( Gdk.ModifierType.BUTTON1_MASK, targets, Gdk.DragAction.COPY) view.connect("drag-data-get", self.__drag_data_get) # NOT WORKING connect_obj(view, 'button-press-event', self.__rightclick, view, library) connect_obj(view, 'popup-menu', self.__popup, view, library) self.accelerators = Gtk.AccelGroup() search = SearchBarBox(completion=AlbumTagCompletion(), accel_group=self.accelerators) search.connect('query-changed', self.__update_filter) connect_obj(search, 'focus-out', lambda w: w.grab_focus(), view) self.__search = search prefs = PreferencesButton(self, model_sort) search.pack_start(prefs, False, True, 0) self.pack_start(Align(search, left=6, top=6), False, True, 0) self.pack_start(sw, True, True, 0) self.connect("destroy", self.__destroy) self.enable_row_update(view, sw, self.view) self.__update_filter() self.connect('key-press-event', self.__key_pressed, library.librarian) if app.cover_manager: connect_destroy( app.cover_manager, "cover-changed", self._cover_changed) self.show_all() def _cover_changed(self, manager, songs): model = self.__model songs = set(songs) for iter_, item in model.iterrows(): album = item.album if album is not None and songs & album.songs: item.scanned = False model.row_changed(model.get_path(iter_), iter_) def __key_pressed(self, widget, event, librarian): if qltk.is_accel(event, "<Primary>I"): songs = self.__get_selected_songs() if songs: window = Information(librarian, songs, self) window.show() return True elif qltk.is_accel(event, "<Primary>Return", "<Primary>KP_Enter"): qltk.enqueue(self.__get_selected_songs(sort=True)) return True elif qltk.is_accel(event, "<alt>Return"): songs = self.__get_selected_songs() if songs: window = SongProperties(librarian, songs, self) window.show() return True return False def _row_needs_update(self, model, iter_): item = model.get_value(iter_) return item.album is not None and not item.scanned def _update_row(self, filter_model, iter_): sort_model = filter_model.get_model() model = sort_model.get_model() iter_ = filter_model.convert_iter_to_child_iter(iter_) iter_ = sort_model.convert_iter_to_child_iter(iter_) tref = Gtk.TreeRowReference.new(model, model.get_path(iter_)) mag = config.getfloat("browsers", "covergrid_magnification", 3.) def callback(): path = tref.get_path() if path is not None: model.row_changed(path, model.get_iter(path)) # XXX: icon view seems to ignore row_changed signals for pixbufs.. self.queue_draw() item = model.get_value(iter_) scale_factor = self.get_scale_factor() * mag item.scan_cover(scale_factor=scale_factor, callback=callback, cancel=self._cover_cancel) def __destroy(self, browser): self._cover_cancel.cancel() self.disable_row_update() self.view.set_model(None) klass = type(browser) if not klass.instances(): klass._destroy_model() def __update_filter(self, entry=None, text=None, scroll_up=True, restore=False): model = self.view.get_model() self.__filter = None query = self.__search.get_query(self.STAR) if not query.matches_all: self.__filter = query.search self.__bg_filter = background_filter() self.__inhibit() # If we're hiding "All Albums", then there will always # be something to filter — probably there's a better # way to implement this if (not restore or self.__filter or self.__bg_filter) or (not config.getboolean("browsers", "covergrid_all", True)): model.refilter() self.__uninhibit() def __parse_query(self, model, iter_, data): f, b = self.__filter, self.__bg_filter album = model.get_album(iter_) if f is None and b is None and album is not None: return True else: if album is None: return config.getboolean("browsers", "covergrid_all", True) elif b is None: return f(album) elif f is None: return b(album) else: return b(album) and f(album) def __search_func(self, model, column, key, iter_, data): album = model.get_album(iter_) if album is None: return config.getboolean("browsers", "covergrid_all", True) key = key.lower() title = album.title.lower() if key in title: return False if config.getboolean("browsers", "album_substrings"): people = (p.lower() for p in album.list("~people")) for person in people: if key in person: return False return True def __rightclick(self, view, event, library): x = int(event.x) y = int(event.y) current_path = view.get_path_at_pos(x, y) if event.button == Gdk.BUTTON_SECONDARY and current_path: if not view.path_is_selected(current_path): view.unselect_all() view.select_path(current_path) self.__popup(view, library) def __popup(self, view, library): albums = self.__get_selected_albums() songs = self.__get_songs_from_albums(albums) items = [] num = len(albums) button = MenuItem( ngettext("Reload album _cover", "Reload album _covers", num), Icons.VIEW_REFRESH) button.connect('activate', self.__refresh_album, view) items.append(button) menu = SongsMenu(library, songs, items=[items]) menu.show_all() popup_menu_at_widget(menu, view, Gdk.BUTTON_SECONDARY, Gtk.get_current_event_time()) def _show_tooltip(self, widget, x, y, keyboard_tip, tooltip): w = self.scrollwin.get_hadjustment().get_value() z = self.scrollwin.get_vadjustment().get_value() path = widget.get_path_at_pos(int(x + w), int(y + z)) if path is None: return False model = widget.get_model() iter = model.get_iter(path) album = model.get_album(iter) if album is None: text = "<b>%s</b>" % _("All Albums") text += "\n" + ngettext("%d album", "%d albums", len(model) - 1) % (len(model) - 1) markup = text else: markup = self.display_pattern % album tooltip.set_markup(markup) return True def __refresh_album(self, menuitem, view): items = self.__get_selected_items() for item in items: item.scanned = False model = self.view.get_model() for iter_, item in model.iterrows(): if item in items: model.row_changed(model.get_path(iter_), iter_) def __get_selected_items(self): model = self.view.get_model() paths = self.view.get_selected_items() return model.get_items(paths) def __get_selected_albums(self): model = self.view.get_model() paths = self.view.get_selected_items() return model.get_albums(paths) def __get_songs_from_albums(self, albums, sort=True): # Sort first by how the albums appear in the model itself, # then within the album using the default order. songs = [] if sort: for album in albums: songs.extend(sorted(album.songs, key=lambda s: s.sort_key)) else: for album in albums: songs.extend(album.songs) return songs def __get_selected_songs(self, sort=True): albums = self.__get_selected_albums() return self.__get_songs_from_albums(albums, sort) def __drag_data_get(self, view, ctx, sel, tid, etime): songs = self.__get_selected_songs() if tid == 1: qltk.selection_set_songs(sel, songs) else: sel.set_uris([song("~uri") for song in songs]) def __play_selection(self, view, indices, col): self.songs_activated() def active_filter(self, song): for album in self.__get_selected_albums(): if song in album.songs: return True return False def can_filter_text(self): return True def filter_text(self, text): self.__search.set_text(text) if Query(text).is_parsable: self.__update_filter(self.__search, text) # self.__inhibit() #self.view.set_cursor((0,), None, False) # self.__uninhibit() self.activate() def get_filter_text(self): return self.__search.get_text() def can_filter(self, key): # Numerics are different for collections, and although title works, # it's not of much use here. if key is not None and (key.startswith("~#") or key == "title"): return False return super().can_filter(key) def can_filter_albums(self): return True def list_albums(self): model = self.view.get_model() return [row[0].album.key for row in model if row[0].album] def select_by_func(self, func, scroll=True, one=False): model = self.view.get_model() if not model: return False selection = self.view.get_selected_items() first = True for row in model: if func(row): if not first: selection.select_path(row.path) continue self.view.unselect_all() self.view.select_path(row.path) self.view.set_cursor(row.path, None, False) if scroll: self.view.scroll_to_path(row.path, True, 0.5, 0.5) first = False if one: break return not first def filter_albums(self, values): self.__inhibit() changed = self.select_by_func( lambda r: r[0].album and r[0].album.key in values) self.view.grab_focus() self.__uninhibit() if changed: self.activate() def unfilter(self): self.filter_text("") def activate(self): self.view.emit('selection-changed') def __inhibit(self): self.view.handler_block(self.__sig) def __uninhibit(self): self.view.handler_unblock(self.__sig) def restore(self): text = config.gettext("browsers", "query_text") entry = self.__search entry.set_text(text) # update_filter expects a parsable query if Query(text).is_parsable: self.__update_filter(entry, text, scroll_up=False, restore=True) keys = config.gettext("browsers", "covergrid", "").split("\n") self.__inhibit() if keys != [""]: def select_fun(row): album = row[0].album if not album: # all return False return album.str_key in keys self.select_by_func(select_fun) else: self.select_by_func(lambda r: r[0].album is None) self.__uninhibit() def scroll(self, song): album_key = song.album_key select = lambda r: r[0].album and r[0].album.key == album_key self.select_by_func(select, one=True) def __get_config_string(self): model = self.view.get_model() paths = self.view.get_selected_items() # All is selected if model.contains_all(paths): return "" # All selected albums albums = model.get_albums(paths) confval = "\n".join((a.str_key for a in albums)) # ConfigParser strips a trailing \n so we move it to the front if confval and confval[-1] == "\n": confval = "\n" + confval[:-1] return confval def save(self): conf = self.__get_config_string() config.settext("browsers", "covergrid", conf) text = self.__search.get_text() config.settext("browsers", "query_text", text) def __update_songs(self, selection): songs = self.__get_selected_songs(sort=False) self.songs_selected(songs)
class AlbumList(Browser, util.InstanceTracker, VisibleUpdate, DisplayPatternMixin): __model = None __last_render = None __last_render_surface = None _PATTERN_FN = os.path.join(quodlibet.get_user_dir(), "album_pattern") _DEFAULT_PATTERN_TEXT = DEFAULT_PATTERN_TEXT name = _("Album List") accelerated_name = _("_Album List") keys = ["AlbumList"] priority = 4 def pack(self, songpane): container = qltk.ConfigRHPaned("browsers", "albumlist_pos", 0.4) container.pack1(self, True, False) container.pack2(songpane, True, False) return container def unpack(self, container, songpane): container.remove(songpane) container.remove(self) @classmethod def init(klass, library): super(AlbumList, klass).load_pattern() def finalize(self, restored): if not restored: self.view.set_cursor((0, )) @classmethod def _destroy_model(klass): klass.__model.destroy() klass.__model = None @classmethod def toggle_covers(klass): on = config.getboolean("browsers", "album_covers") for albumlist in klass.instances(): albumlist.__cover_column.set_visible(on) for column in albumlist.view.get_columns(): column.queue_resize() @classmethod def refresh_all(cls): cls.__model.refresh_all() @classmethod def _init_model(klass, library): klass.__model = AlbumModel(library) klass.__library = library @util.cached_property def _no_cover(self): """Returns a cairo surface representing a missing cover""" cover_size = get_cover_size() scale_factor = self.get_scale_factor() pb = get_no_cover_pixbuf(cover_size, cover_size, scale_factor) return get_surface_for_pixbuf(self, pb) def __init__(self, library): super(AlbumList, self).__init__(spacing=6) self.set_orientation(Gtk.Orientation.VERTICAL) self._register_instance() if self.__model is None: self._init_model(library) self._cover_cancel = Gio.Cancellable() sw = ScrolledWindow() sw.set_shadow_type(Gtk.ShadowType.IN) self.view = view = AllTreeView() view.set_headers_visible(False) model_sort = AlbumSortModel(model=self.__model) model_filter = AlbumFilterModel(child_model=model_sort) self.__bg_filter = background_filter() self.__filter = None model_filter.set_visible_func(self.__parse_query) render = Gtk.CellRendererPixbuf() self.__cover_column = column = Gtk.TreeViewColumn("covers", render) column.set_visible(config.getboolean("browsers", "album_covers")) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) column.set_fixed_width(get_cover_size() + 12) render.set_property('height', get_cover_size() + 8) render.set_property('width', get_cover_size() + 8) def cell_data_pb(column, cell, model, iter_, no_cover): item = model.get_value(iter_) if item.album is None: surface = None elif item.cover: pixbuf = item.cover pixbuf = add_border_widget(pixbuf, self.view) surface = get_surface_for_pixbuf(self, pixbuf) # don't cache, too much state has an effect on the result self.__last_render_surface = None else: surface = no_cover if self.__last_render_surface == surface: return self.__last_render_surface = surface cell.set_property("surface", surface) column.set_cell_data_func(render, cell_data_pb, self._no_cover) view.append_column(column) render = Gtk.CellRendererText() column = Gtk.TreeViewColumn("albums", render) column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) if view.supports_hints(): render.set_property('ellipsize', Pango.EllipsizeMode.END) def cell_data(column, cell, model, iter_, data): album = model.get_album(iter_) if album is None: text = "<b>%s</b>\n" % _("All Albums") text += numeric_phrase("%d album", "%d albums", len(model) - 1) markup = text else: markup = self.display_pattern % album if self.__last_render == markup: return self.__last_render = markup cell.markup = markup cell.set_property('markup', markup) column.set_cell_data_func(render, cell_data) view.append_column(column) view.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) view.set_rules_hint(True) view.set_search_equal_func(self.__search_func, None) view.set_search_column(0) view.set_model(model_filter) sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) sw.add(view) view.connect('row-activated', self.__play_selection) self.__sig = view.connect( 'selection-changed', util.DeferredSignal(self.__update_songs, owner=view)) targets = [("text/x-quodlibet-songs", Gtk.TargetFlags.SAME_APP, 1), ("text/uri-list", 0, 2)] targets = [Gtk.TargetEntry.new(*t) for t in targets] view.drag_source_set(Gdk.ModifierType.BUTTON1_MASK, targets, Gdk.DragAction.COPY) view.connect("drag-data-get", self.__drag_data_get) connect_obj(view, 'popup-menu', self.__popup, view, library) self.accelerators = Gtk.AccelGroup() search = SearchBarBox(completion=AlbumTagCompletion(), accel_group=self.accelerators) search.connect('query-changed', self.__update_filter) connect_obj(search, 'focus-out', lambda w: w.grab_focus(), view) self.__search = search prefs = PreferencesButton(self, model_sort) search.pack_start(prefs, False, True, 0) self.pack_start(Align(search, left=6, top=6), False, True, 0) self.pack_start(sw, True, True, 0) self.connect("destroy", self.__destroy) self.enable_row_update(view, sw, self.__cover_column) self.connect('key-press-event', self.__key_pressed, library.librarian) if app.cover_manager: connect_destroy(app.cover_manager, "cover-changed", self._cover_changed) self.show_all() def _cover_changed(self, manager, songs): model = self.__model songs = set(songs) for iter_, item in model.iterrows(): album = item.album if album is not None and songs & album.songs: item.scanned = False model.row_changed(model.get_path(iter_), iter_) def __key_pressed(self, widget, event, librarian): if qltk.is_accel(event, "<Primary>I"): songs = self.__get_selected_songs() if songs: window = Information(librarian, songs, self) window.show() return True elif qltk.is_accel(event, "<Primary>Return", "<Primary>KP_Enter"): qltk.enqueue(self.__get_selected_songs(sort=True)) return True elif qltk.is_accel(event, "<alt>Return"): songs = self.__get_selected_songs() if songs: window = SongProperties(librarian, songs, self) window.show() return True return False def _row_needs_update(self, model, iter_): item = model.get_value(iter_) return item.album is not None and not item.scanned def _update_row(self, filter_model, iter_): sort_model = filter_model.get_model() model = sort_model.get_model() iter_ = filter_model.convert_iter_to_child_iter(iter_) iter_ = sort_model.convert_iter_to_child_iter(iter_) tref = Gtk.TreeRowReference.new(model, model.get_path(iter_)) def callback(): path = tref.get_path() if path is not None: model.row_changed(path, model.get_iter(path)) item = model.get_value(iter_) scale_factor = self.get_scale_factor() item.scan_cover(scale_factor=scale_factor, callback=callback, cancel=self._cover_cancel) def __destroy(self, browser): self._cover_cancel.cancel() self.disable_row_update() self.view.set_model(None) klass = type(browser) if not klass.instances(): klass._destroy_model() def __update_filter(self, entry, text, scroll_up=True, restore=False): model = self.view.get_model() self.__filter = None query = self.__search.get_query(star=["~people", "album"]) if not query.matches_all: self.__filter = query.search self.__bg_filter = background_filter() self.__inhibit() # We could be smart and try to scroll to a selected album # but that introduces lots of wild scrolling. Feel free to change it. # Without scrolling the TV tries to stay at the same position # (40% down) which makes no sense, so always go to the top. if scroll_up: self.view.scroll_to_point(0, 0) # Don't filter on restore if there is nothing to filter if not restore or self.__filter or self.__bg_filter: model.refilter() self.__uninhibit() def __parse_query(self, model, iter_, data): f, b = self.__filter, self.__bg_filter if f is None and b is None: return True else: album = model.get_album(iter_) if album is None: return True elif b is None: return f(album) elif f is None: return b(album) else: return b(album) and f(album) def __search_func(self, model, column, key, iter_, data): album = model.get_album(iter_) if album is None: return True key = key.lower() title = album.title.lower() if key in title: return False if config.getboolean("browsers", "album_substrings"): people = (p.lower() for p in album.list("~people")) for person in people: if key in person: return False return True def __popup(self, view, library): albums = self.__get_selected_albums() songs = self.__get_songs_from_albums(albums) items = [] if self.__cover_column.get_visible(): num = len(albums) button = MenuItem( ngettext("Reload album _cover", "Reload album _covers", num), Icons.VIEW_REFRESH) button.connect('activate', self.__refresh_album, view) items.append(button) menu = SongsMenu(library, songs, items=[items]) menu.show_all() return view.popup_menu(menu, 0, Gtk.get_current_event_time()) def __refresh_album(self, menuitem, view): items = self.__get_selected_items() for item in items: item.scanned = False model = self.view.get_model() for iter_, item in model.iterrows(): if item in items: model.row_changed(model.get_path(iter_), iter_) def __get_selected_items(self): selection = self.view.get_selection() model, paths = selection.get_selected_rows() return model.get_items(paths) def __get_selected_albums(self): selection = self.view.get_selection() model, paths = selection.get_selected_rows() return model.get_albums(paths) def __get_songs_from_albums(self, albums, sort=True): # Sort first by how the albums appear in the model itself, # then within the album using the default order. songs = [] if sort: for album in albums: songs.extend(sorted(album.songs, key=lambda s: s.sort_key)) else: for album in albums: songs.extend(album.songs) return songs def __get_selected_songs(self, sort=True): albums = self.__get_selected_albums() return self.__get_songs_from_albums(albums, sort) def __drag_data_get(self, view, ctx, sel, tid, etime): songs = self.__get_selected_songs() if tid == 1: qltk.selection_set_songs(sel, songs) else: sel.set_uris([song("~uri") for song in songs]) def __play_selection(self, view, indices, col): self.songs_activated() def active_filter(self, song): for album in self.__get_selected_albums(): if song in album.songs: return True return False def can_filter_text(self): return True def filter_text(self, text): self.__search.set_text(text) if Query(text).is_parsable: self.__update_filter(self.__search, text) self.__inhibit() self.view.set_cursor((0, )) self.__uninhibit() self.activate() def get_filter_text(self): return self.__search.get_text() def can_filter(self, key): # Numerics are different for collections, and although title works, # it's not of much use here. if key is not None and (key.startswith("~#") or key == "title"): return False return super(AlbumList, self).can_filter(key) def can_filter_albums(self): return True def list_albums(self): model = self.view.get_model() return [row[0].album.key for row in model if row[0].album] def filter_albums(self, values): view = self.view self.__inhibit() changed = view.select_by_func( lambda r: r[0].album and r[0].album.key in values) self.view.grab_focus() self.__uninhibit() if changed: self.activate() def unfilter(self): self.filter_text("") self.view.set_cursor((0, )) def activate(self): self.view.get_selection().emit('changed') def __inhibit(self): self.view.handler_block(self.__sig) def __uninhibit(self): self.view.handler_unblock(self.__sig) def restore(self): text = config.gettext("browsers", "query_text") entry = self.__search entry.set_text(text) # update_filter expects a parsable query if Query(text).is_parsable: self.__update_filter(entry, text, scroll_up=False, restore=True) keys = config.gettext("browsers", "albums").split("\n") # FIXME: If albums is "" then it could be either all albums or # no albums. If it's "" and some other stuff, assume no albums, # otherwise all albums. self.__inhibit() if keys == [""]: self.view.set_cursor((0, )) else: def select_fun(row): album = row[0].album if not album: # all return False return album.str_key in keys self.view.select_by_func(select_fun) self.__uninhibit() def scroll(self, song): album_key = song.album_key select = lambda r: r[0].album and r[0].album.key == album_key self.view.select_by_func(select, one=True) def __get_config_string(self): selection = self.view.get_selection() model, paths = selection.get_selected_rows() # All is selected if model.contains_all(paths): return "" # All selected albums albums = model.get_albums(paths) confval = "\n".join((a.str_key for a in albums)) # ConfigParser strips a trailing \n so we move it to the front if confval and confval[-1] == "\n": confval = "\n" + confval[:-1] return confval def save(self): conf = self.__get_config_string() config.settext("browsers", "albums", conf) text = self.__search.get_text() config.settext("browsers", "query_text", text) def __update_songs(self, view, selection): songs = self.__get_selected_songs(sort=False) self.songs_selected(songs)
def main(argv=None): if argv is None: argv = sys_argv import quodlibet config_file = os.path.join(quodlibet.get_user_dir(), "config") quodlibet.init_cli(config_file=config_file) try: # we want basic commands not to import gtk (doubles process time) assert "gi.repository.Gtk" not in sys.modules sys.modules["gi.repository.Gtk"] = None startup_actions, cmds_todo = process_arguments(argv) finally: sys.modules.pop("gi.repository.Gtk", None) quodlibet.init() from quodlibet import app from quodlibet.qltk import add_signal_watch, Icons add_signal_watch(app.quit) import quodlibet.player import quodlibet.library from quodlibet import config from quodlibet import browsers from quodlibet import util app.name = "Quod Libet" app.id = "quodlibet" quodlibet.set_application_info(Icons.QUODLIBET, app.id, app.name) library_path = os.path.join(quodlibet.get_user_dir(), "songs") print_d("Initializing main library (%s)" % ( quodlibet.util.path.unexpand(library_path))) library = quodlibet.library.init(library_path) app.library = library # this assumes that nullbe will always succeed from quodlibet.player import PlayerError wanted_backend = environ.get( "QUODLIBET_BACKEND", config.get("player", "backend")) try: player = quodlibet.player.init_player(wanted_backend, app.librarian) except PlayerError: print_exc() player = quodlibet.player.init_player("nullbe", app.librarian) app.player = player environ["PULSE_PROP_media.role"] = "music" environ["PULSE_PROP_application.icon_name"] = "quodlibet" browsers.init() from quodlibet.qltk.songlist import SongList, get_columns headers = get_columns() SongList.set_all_column_headers(headers) for opt in config.options("header_maps"): val = config.get("header_maps", opt) util.tags.add(opt, val) in_all = ("~filename ~uri ~#lastplayed ~#rating ~#playcount ~#skipcount " "~#added ~#bitrate ~current ~#laststarted ~basename " "~dirname").split() for Kind in browsers.browsers: if Kind.headers is not None: Kind.headers.extend(in_all) Kind.init(library) pm = quodlibet.init_plugins("no-plugins" in startup_actions) if hasattr(player, "init_plugins"): player.init_plugins() from quodlibet.qltk import unity unity.init("quodlibet.desktop", player) from quodlibet.qltk.songsmenu import SongsMenu SongsMenu.init_plugins() from quodlibet.util.cover import CoverManager app.cover_manager = CoverManager() app.cover_manager.init_plugins() from quodlibet.plugins.playlist import PLAYLIST_HANDLER PLAYLIST_HANDLER.init_plugins() from quodlibet.plugins.query import QUERY_HANDLER QUERY_HANDLER.init_plugins() from gi.repository import GLib def exec_commands(*args): for cmd in cmds_todo: try: resp = cmd_registry.run(app, *cmd) except CommandError: pass else: if resp is not None: print_(resp, end="", flush=True) from quodlibet.qltk.quodlibetwindow import QuodLibetWindow, PlayerOptions # Call exec_commands after the window is restored, but make sure # it's after the mainloop has started so everything is set up. app.window = window = QuodLibetWindow( library, player, restore_cb=lambda: GLib.idle_add(exec_commands, priority=GLib.PRIORITY_HIGH)) app.player_options = PlayerOptions(window) from quodlibet.qltk.window import Window from quodlibet.plugins.events import EventPluginHandler from quodlibet.plugins.gui import UserInterfacePluginHandler pm.register_handler(EventPluginHandler(library.librarian, player, app.window.songlist)) pm.register_handler(UserInterfacePluginHandler()) from quodlibet.mmkeys import MMKeysHandler from quodlibet.remote import Remote, RemoteError from quodlibet.commands import registry as cmd_registry, CommandError from quodlibet.qltk.tracker import SongTracker, FSInterface try: from quodlibet.qltk.dbus_ import DBusHandler except ImportError: DBusHandler = lambda player, library: None mmkeys_handler = MMKeysHandler(app) mmkeys_handler.start() current_path = os.path.join(quodlibet.get_user_dir(), "current") fsiface = FSInterface(current_path, player) remote = Remote(app, cmd_registry) try: remote.start() except RemoteError: exit_(1, True) DBusHandler(player, library) tracker = SongTracker(library.librarian, player, window.playlist) from quodlibet.qltk import session session.init("quodlibet") quodlibet.enable_periodic_save(save_library=True) if "start-playing" in startup_actions: player.paused = False if "start-hidden" in startup_actions: Window.prevent_inital_show(True) # restore browser windows from quodlibet.qltk.browser import LibraryBrowser GLib.idle_add(LibraryBrowser.restore, library, player, priority=GLib.PRIORITY_HIGH) def before_quit(): print_d("Saving active browser state") try: app.browser.save() except NotImplementedError: pass print_d("Shutting down player device %r." % player.version_info) player.destroy() quodlibet.run(window, before_quit=before_quit) app.player_options.destroy() quodlibet.finish_first_session(app.id) mmkeys_handler.quit() remote.stop() fsiface.destroy() tracker.destroy() quodlibet.library.save() config.save() print_d("Finished shutdown.")
def __init__(self, library, player, headless=False, restore_cb=None): super().__init__(dialog=False) self.__destroyed = False self.__update_title(player) self.set_default_size(600, 480) main_box = Gtk.VBox() self.add(main_box) self.side_book = qltk.Notebook() # get the playlist up before other stuff self.songlist = MainSongList(library, player) self.songlist.connect("key-press-event", self.__songlist_key_press) self.songlist.connect_after('drag-data-received', self.__songlist_drag_data_recv) self.song_scroller = ScrolledWindow() self.song_scroller.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) self.song_scroller.set_shadow_type(Gtk.ShadowType.IN) self.song_scroller.add(self.songlist) self.qexpander = QueueExpander(library, player) self.qexpander.set_no_show_all(True) self.qexpander.set_visible(config.getboolean("memory", "queue")) def on_queue_visible(qex, param): config.set("memory", "queue", str(qex.get_visible())) self.qexpander.connect("notify::visible", on_queue_visible) self.playlist = PlaylistMux(player, self.qexpander.model, self.songlist.model) self.__player = player # create main menubar, load/restore accelerator groups self.__library = library ui = self.__create_menu(player, library) accel_group = ui.get_accel_group() self.add_accel_group(accel_group) def scroll_and_jump(*args): self.__jump_to_current(True, None, True) keyval, mod = Gtk.accelerator_parse("<Primary><shift>J") accel_group.connect(keyval, mod, 0, scroll_and_jump) # custom accel map accel_fn = os.path.join(quodlibet.get_user_dir(), "accels") Gtk.AccelMap.load(accel_fn) # save right away so we fill the file with example comments of all # accels Gtk.AccelMap.save(accel_fn) menubar = ui.get_widget("/Menu") # Since https://git.gnome.org/browse/gtk+/commit/?id=b44df22895c79 # toplevel menu items show an empty 16x16 image. While we don't # need image items there UIManager creates them by default. # Work around by removing the empty GtkImages for child in menubar.get_children(): if isinstance(child, Gtk.ImageMenuItem): child.set_image(None) main_box.pack_start(menubar, False, True, 0) top_bar = TopBar(self, player, library) main_box.pack_start(top_bar, False, True, 0) self.top_bar = top_bar self.__browserbox = Align(bottom=3) self.__paned = paned = ConfigRHPaned("memory", "sidebar_pos", 0.25) paned.pack1(self.__browserbox, resize=True) # We'll pack2 when necessary (when the first sidebar plugin is set up) main_box.pack_start(paned, True, True, 0) play_order = PlayOrderWidget(self.songlist.model, player) statusbox = StatusBarBox(play_order, self.qexpander) self.order = play_order self.statusbar = statusbox.statusbar main_box.pack_start(Align(statusbox, border=3, top=-3), False, True, 0) self.songpane = SongListPaned(self.song_scroller, self.qexpander) self.songpane.show_all() try: orders = [] for e in config.getstringlist('memory', 'sortby', []): orders.append((e[1:], int(e[0]))) except ValueError: pass else: self.songlist.set_sort_orders(orders) self.browser = None self.ui = ui main_box.show_all() self._playback_error_dialog = None connect_destroy(player, 'song-started', self.__song_started) connect_destroy(player, 'paused', self.__update_paused, True) connect_destroy(player, 'unpaused', self.__update_paused, False) # make sure we redraw all error indicators before opening # a dialog (blocking the main loop), so connect after default handlers connect_after_destroy(player, 'error', self.__player_error) # connect after to let SongTracker update stats connect_after_destroy(player, "song-ended", self.__song_ended) # set at least the playlist. the song should be restored # after the browser emits the song list player.setup(self.playlist, None, 0) self.__restore_cb = restore_cb self.__first_browser_set = True restore_browser = not headless try: self._select_browser(self, config.get("memory", "browser"), library, player, restore_browser) except: config.set("memory", "browser", browsers.name(browsers.default)) config.save() raise self.songlist.connect('popup-menu', self.__songs_popup_menu) self.songlist.connect('columns-changed', self.__cols_changed) self.songlist.connect('columns-changed', self.__hide_headers) self.songlist.info.connect("changed", self.__set_totals) lib = library.librarian connect_destroy(lib, 'changed', self.__song_changed, player) targets = [("text/uri-list", Gtk.TargetFlags.OTHER_APP, DND_URI_LIST)] targets = [Gtk.TargetEntry.new(*t) for t in targets] self.drag_dest_set(Gtk.DestDefaults.ALL, targets, Gdk.DragAction.COPY) self.connect('drag-data-received', self.__drag_data_received) if not headless: on_first_map(self, self.__configure_scan_dirs, library) if config.getboolean('library', 'refresh_on_start'): self.__rebuild(None, False) self.connect("key-press-event", self.__key_pressed, player) self.connect("destroy", self.__destroy) self.enable_window_tracking("quodlibet")
from quodlibet.plugins import PluginManager from quodlibet.pattern import FileFromPattern from quodlibet.qltk._editutils import FilterPluginBox, FilterCheckButton from quodlibet.qltk._editutils import EditingPluginHandler from quodlibet.qltk.views import TreeViewColumn from quodlibet.qltk.cbes import ComboBoxEntrySave from quodlibet.qltk.models import ObjectStore from quodlibet.qltk import Icons, Button from quodlibet.qltk.wlw import WritingWindow from quodlibet.util import connect_obj, gdecode from quodlibet.util.path import fsdecode, fsnative from quodlibet.util.path import strip_win32_incompat_from_path NBP = os.path.join(quodlibet.get_user_dir(), "lists", "renamepatterns") NBP_EXAMPLES = """\ <tracknumber>. <title> <tracknumber|<tracknumber>. ><title> <tracknumber> - <title> <tracknumber> - <artist> - <title> /path/<artist> - <album>/<tracknumber>. <title> /path/<artist>/<album>/<tracknumber> - <title>""" class SpacesToUnderscores(FilterCheckButton): _label = _("Replace spaces with _underscores") _section = "rename" _key = "spaces" _order = 1.0
import os from gi.repository import Gtk import quodlibet from quodlibet import formats, qltk from quodlibet.qltk.wlw import WaitLoadWindow from quodlibet.qltk.getstring import GetStringDialog from quodlibet.util import escape from quodlibet.util.collection import Playlist from quodlibet.util.path import mkdir, fsdecode, is_fsnative # Directory for playlist files from quodlibet.util.uri import URI PLAYLISTS = os.path.join(quodlibet.get_user_dir(), "playlists") assert is_fsnative(PLAYLISTS) if not os.path.isdir(PLAYLISTS): mkdir(PLAYLISTS) class ConfirmRemovePlaylistDialog(qltk.Message): def __init__(self, parent, playlist): title = _("Are you sure you want to delete the playlist '%s'?") % escape(playlist.name) description = _("All information about the selected playlist " "will be deleted and can not be restored.") super(ConfirmRemovePlaylistDialog, self).__init__( Gtk.MessageType.WARNING, parent, title, description, Gtk.ButtonsType.NONE ) self.add_buttons(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_DELETE, Gtk.ResponseType.YES)
def load(self): with open(os.path.join(get_user_dir(), "feeds"), "rb") as f: self.__feeds = pickle.load(f)
def test_dirs(self): self.assertTrue(is_fsnative(quodlibet.get_base_dir())) self.assertTrue(is_fsnative(quodlibet.get_image_dir())) self.assertTrue(is_fsnative(quodlibet.get_user_dir()))
def write(self): with open(os.path.join(get_user_dir(), "feeds"), "wb") as f: pickle.dump(self.__feeds, f)
from quodlibet import app from quodlibet.browsers import Browser from quodlibet.compat import listfilter, text_type, build_opener, PY2 from quodlibet.formats import AudioFile from quodlibet.formats.remote import RemoteFile from quodlibet.qltk.getstring import GetStringDialog from quodlibet.qltk.msg import ErrorMessage from quodlibet.qltk.views import AllTreeView from quodlibet.qltk import Icons from quodlibet.util import connect_obj, print_w from quodlibet.qltk.x import ScrolledWindow, Align, Button, MenuItem from quodlibet.util.picklehelper import pickle_load, pickle_dump, PickleError FEEDS = os.path.join(quodlibet.get_user_dir(), "feeds") DND_URI_LIST, DND_MOZ_URL = range(2) # Migration path for pickle sys.modules["browsers.audiofeeds"] = sys.modules[__name__] class InvalidFeed(ValueError): pass class Feed(list): def __init__(self, uri): self.name = _("Unknown") self.uri = uri self.changed = False
class WebsiteSearch(SongsMenuPlugin): """Loads a browser with a URL designed to search on tags of the song. This may include a standard web search engine, eg Google, or a more specific site look-up. The URLs are customisable using tag patterns. """ PLUGIN_ICON = Icons.APPLICATION_INTERNET PLUGIN_ID = "Website Search" PLUGIN_NAME = _("Website Search") PLUGIN_DESC = _("Searches your choice of website using any song tags.\n" "Supports patterns e.g. %(pattern-example)s.") % { "pattern-example": "https://google.com?q=<~artist~title>" } # Here are some starters... DEFAULT_URL_PATS = [ ("Google song search", "https://google.com/search?q=<artist~title>"), ("Wikipedia (en) artist entry", "https://wikipedia.org/wiki/<albumartist|<albumartist>|<artist>>"), ("Musicbrainz album listing", "https://musicbrainz.org/<musicbrainz_albumid|release/" "<musicbrainz_albumid>|search?query=<album>&type=release>"), ("Discogs album search", "https://www.discogs.com/search?type=release&artist=" "<albumartist|<albumartist>|<artist>>&title=<album>"), ("Youtube video search", "https://www.youtube.com/results?search_query=<artist~title>"), ("Go to ~website", "<website>"), ] PATTERNS_FILE = os.path.join(quodlibet.get_user_dir(), 'lists', 'searchsites') def __set_site(self, name): self.chosen_site = name def get_url_pattern(self, key): """Gets the pattern for a given key""" return dict(self._url_pats).get(key, self.DEFAULT_URL_PATS[0][1]) @classmethod def edit_patterns(cls, button): def valid_uri(s): # TODO: some pattern validation too (that isn't slow) try: p = Pattern(s) return (p and uri_is_valid(s)) except ValueError: return False win = StandaloneEditor(filename=cls.PATTERNS_FILE, title=_("Search URL patterns"), initial=cls.DEFAULT_URL_PATS, validator=valid_uri) win.show() @classmethod def PluginPreferences(cls, parent): hb = Gtk.HBox(spacing=3) hb.set_border_width(0) button = qltk.Button(_("Edit search URLs"), Icons.EDIT) button.connect("clicked", cls.edit_patterns) hb.pack_start(button, True, True, 0) hb.show_all() return hb def _get_saved_searches(self): filename = self.PATTERNS_FILE + ".saved" self._url_pats = StandaloneEditor.load_values(filename) # Failing all else... if not len(self._url_pats): print_d("No saved searches found in %s. Using defaults." % filename) self._url_pats = self.DEFAULT_URL_PATS def __init__(self, *args, **kwargs): super(WebsiteSearch, self).__init__(*args, **kwargs) self.chosen_site = None self._url_pats = [] submenu = Gtk.Menu() self._get_saved_searches() for name, url_pat in self._url_pats: item = Gtk.MenuItem(label=name) connect_obj(item, 'activate', self.__set_site, name) submenu.append(item) # Add link to editor configure = Gtk.MenuItem(label=_(u"Configure Searches…")) connect_obj(configure, 'activate', self.edit_patterns, configure) submenu.append(SeparatorMenuItem()) submenu.append(configure) if submenu.get_children(): self.set_submenu(submenu) else: self.set_sensitive(False) def plugin_songs(self, songs, launch=True) -> bool: # Check this is a launch, not a configure if self.chosen_site: url_pat = self.get_url_pattern(self.chosen_site) pat = Pattern(url_pat) # Remove Nones, and de-duplicate collection urls = set(filter(None, (website_for(pat, s) for s in songs))) if not urls: print_w("Couldn't build URLs using \"%s\"." "Check your pattern?" % url_pat) return False print_d("Got %d websites from %d songs" % (len(urls), len(songs))) if launch: for url in urls: website(url) return True
# # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. import os import io import quodlibet from quodlibet import _ from quodlibet import util from quodlibet.qltk import Icons from quodlibet.plugins.events import EventPlugin outfile = os.path.join(quodlibet.get_user_dir(), "jabber") format = """\ <tune xmlns='http://jabber.org/protocol/tune'> <artist>%s</artist> <title>%s</title> <source>%s</source> <track>%d</track> <length>%d</length> </tune>""" class JEP118(EventPlugin): PLUGIN_ID = "JEP-118" PLUGIN_NAME = _("JEP-118") PLUGIN_DESC = _("Outputs a Jabber User Tunes file to ~/.quodlibet/jabber.") PLUGIN_ICON = Icons.DOCUMENT_SAVE
def get_path(): out = os.path.join(quodlibet.get_user_dir(), "current.cover") return config.get("plugins", __name__, out)
from quodlibet import util from quodlibet import app from quodlibet.browsers import Browser from quodlibet.formats import AudioFile from quodlibet.formats.remote import RemoteFile from quodlibet.qltk.getstring import GetStringDialog from quodlibet.qltk.msg import ErrorMessage from quodlibet.qltk.views import AllTreeView from quodlibet.qltk import Icons from quodlibet.util import connect_obj, print_w from quodlibet.qltk.x import ScrolledWindow, Align, Button, MenuItem from quodlibet.util.picklehelper import pickle_load, pickle_dump, PickleError FEEDS = os.path.join(quodlibet.get_user_dir(), "feeds") DND_URI_LIST, DND_MOZ_URL = range(2) # Migration path for pickle sys.modules["browsers.audiofeeds"] = sys.modules[__name__] class InvalidFeed(ValueError): pass class Feed(list): def __init__(self, uri): self.name = _("Unknown") self.uri = uri self.changed = False
from quodlibet.qltk.views import AllTreeView from quodlibet.qltk.x import MenuItem, Align, ScrolledWindow, RadioMenuItem from quodlibet.qltk.x import SymbolicIconImage from quodlibet.qltk.searchbar import SearchBarBox from quodlibet.qltk.menubutton import MenuButton from quodlibet.qltk import Icons from quodlibet.util import copool, connect_destroy from quodlibet.util.library import background_filter from quodlibet.util import connect_obj, DeferredSignal from quodlibet.util.collection import Album from quodlibet.qltk.cover import get_no_cover_pixbuf from quodlibet.qltk.image import (get_pbosf_for_pixbuf, get_scale_factor, set_renderer_from_pbosf, add_border_widget) PATTERN_FN = os.path.join(quodlibet.get_user_dir(), "album_pattern") class AlbumTagCompletion(EntryWordCompletion): def __init__(self): super(AlbumTagCompletion, self).__init__() try: model = self.__model except AttributeError: model = type(self).__model = Gtk.ListStore(str) self.__refreshmodel() self.set_model(model) self.set_text_column(0) def __refreshmodel(self, *args): for tag in ["title", "album", "date", "people", "artist", "genre"]:
package=__package__, load_compiled=load_pyc) for mod in modules: try: devices.extend(mod.devices) except AttributeError: print_w("%r doesn't contain any devices." % mod.__name__) devices.sort() if not util.is_osx() and not util.is_windows(): init_devices() DEVICES = os.path.join(quodlibet.get_user_dir(), "devices") config = ConfigParser.RawConfigParser() config.read(DEVICES) def write(): f = file(DEVICES, 'w') config.write(f) f.close() # Return a constructor for a device given by a string def get(name): try: return devices[[d.__name__ for d in devices].index(name)]
def test_dirs(self): self.assertTrue(isinstance(quodlibet.get_base_dir(), fsnative)) self.assertTrue(isinstance(quodlibet.get_image_dir(), fsnative)) self.assertTrue(isinstance(quodlibet.get_user_dir(), fsnative)) self.assertTrue(isinstance(quodlibet.get_cache_dir(), fsnative))
from quodlibet.formats import AudioFileError from quodlibet.plugins import PluginManager from quodlibet.qltk._editutils import FilterPluginBox, FilterCheckButton from quodlibet.qltk._editutils import EditingPluginHandler, OverwriteWarning from quodlibet.qltk._editutils import WriteFailedError from quodlibet.qltk.wlw import WritingWindow from quodlibet.qltk.views import TreeViewColumn from quodlibet.qltk.cbes import ComboBoxEntrySave from quodlibet.qltk.models import ObjectStore from quodlibet.qltk import Icons from quodlibet.util.tagsfrompath import TagsFromPattern from quodlibet.util.string.splitters import split_value from quodlibet.util import connect_obj, gdecode from quodlibet.compat import itervalues TBP = os.path.join(quodlibet.get_user_dir(), "lists", "tagpatterns") TBP_EXAMPLES = """\ <tracknumber>. <title> <tracknumber> - <title> <tracknumber> - <artist> - <title> <artist> - <album>/<tracknumber>. <title> <artist>/<album>/<tracknumber> - <title>""" class UnderscoresToSpaces(FilterCheckButton): _label = _("Replace _underscores with spaces") _section = "tagsfrompath" _key = "underscores" _order = 1.0 def filter(self, tag, value):
class QLSubmitQueue(object): """Manages the submit queue for scrobbles. Works independently of the QLScrobbler plugin being enabled; other plugins may use submit() to queue songs for scrobbling. """ CLIENT = "qlb" CLIENT_VERSION = const.VERSION PROTOCOL_VERSION = "1.2" DUMP = os.path.join(quodlibet.get_user_dir(), "scrobbler_cache_v2") # These objects are shared across instances, to allow other plugins to # queue scrobbles in future versions of QL queue = [] changed_event = threading.Event() def set_nowplaying(self, song): """Send a Now Playing notification.""" formatted = self._format_song(song) if not formatted or self.nowplaying_song == formatted: return self.nowplaying_song = formatted self.nowplaying_sent = False self.changed() def submit(self, song, timestamp=0): """Submit a song. If 'timestamp' is 0, the current time will be used.""" formatted = self._format_song(song) if formatted is None: return if timestamp > 0: formatted['i'] = str(timestamp) elif timestamp == 0: formatted['i'] = str(int(time.time())) else: # TODO: Forging timestamps for submission from PMPs return self.queue.append(formatted) self.changed() def _format_song(self, song): """Returns a dict with the keys formatted as required by spec.""" store = { "l": str(song.get("~#length", 0)), "n": str(song("~#track")), "b": song.comma("album"), "m": song("musicbrainz_trackid"), "t": self.titlepat.format(song), "a": self.artpat.format(song), } # Spec requires title and artist at minimum if not (store.get("a") and store.get("t")): return None return store def __init__(self): self.nowplaying_song = None self.nowplaying_sent = False self.sessionid = None self.broken = False self.username, self.password, self.base_url = ('', '', '') # These need to be set early for _format_song to work self.titlepat = Pattern(config_get_title_pattern()) self.artpat = Pattern(config_get_artist_pattern()) try: with open(self.DUMP, 'rb') as disk_queue_file: disk_queue = pickle_load(disk_queue_file) os.unlink(self.DUMP) self.queue += disk_queue except (EnvironmentError, PickleError): pass @classmethod def dump_queue(klass): if klass.queue: try: with open(klass.DUMP, 'wb') as disk_queue_file: pickle_dump(klass.queue, disk_queue_file) except (EnvironmentError, PickleError): pass def _check_config(self): user = plugin_config.get('username') passw = md5(plugin_config.getbytes('password')).hexdigest() url = config_get_url() if not user or not passw or not url: if self.queue and not self.broken: self.quick_dialog( _("Please visit the Plugins window to set " "QLScrobbler up. Until then, songs will not be " "submitted."), Gtk.MessageType.INFO) self.broken = True elif (self.username, self.password, self.base_url) != (user, passw, url): self.username, self.password, self.base_url = (user, passw, url) self.broken = False self.handshake_sent = False self.offline = plugin_config.getboolean('offline') self.titlepat = Pattern(config_get_title_pattern()) self.artpat = Pattern(config_get_artist_pattern()) def changed(self): """Signal that settings or queue contents were changed.""" self._check_config() if not self.broken and not self.offline and ( self.queue or (self.nowplaying_song and not self.nowplaying_sent)): self.changed_event.set() return self.changed_event.clear() def run(self): """Submit songs from the queue. Call from a daemon thread.""" # The spec calls for exponential backoff of failed handshakes, with a # minimum of 1m and maximum of 120m delay between attempts. self.handshake_sent = False self.handshake_event = threading.Event() self.handshake_event.set() self.handshake_delay = 1 self.failures = 0 while True: self.changed_event.wait() if not self.handshake_sent: self.handshake_event.wait() if self.send_handshake(): self.failures = 0 self.handshake_delay = 1 self.handshake_sent = True else: self.handshake_event.clear() self.handshake_delay = min(self.handshake_delay * 2, 120) GLib.timeout_add(self.handshake_delay * 60 * 1000, self.handshake_event.set) continue self.changed_event.wait() if self.queue: if self.send_submission(): self.failures = 0 else: self.failures += 1 if self.failures >= 3: self.handshake_sent = False elif self.nowplaying_song and not self.nowplaying_sent: self.send_nowplaying() self.nowplaying_sent = True else: # Nothing left to do; wait until something changes self.changed_event.clear() def send_handshake(self, show_dialog=False): # construct url stamp = int(time.time()) auth = md5(self.password.encode("utf-8") + str(stamp).encode("utf-8")).hexdigest() url = "%s/?hs=true&p=%s&c=%s&v=%s&u=%s&a=%s&t=%d" % ( self.base_url, self.PROTOCOL_VERSION, self.CLIENT, self.CLIENT_VERSION, self.username, auth, stamp) print_d("Sending handshake to service.") try: resp = urlopen(url) except UrllibError: if show_dialog: self.quick_dialog( _("Could not contact service '%s'.") % util.escape(self.base_url), Gtk.MessageType.ERROR) else: print_d("Could not contact service. Queueing submissions.") return False except ValueError: self.quick_dialog(_("Authentication failed: invalid URL."), Gtk.MessageType.ERROR) self.broken = True return False # check response lines = resp.read().decode("utf-8", "ignore").rstrip().split("\n") status = lines.pop(0) print_d("Handshake status: %s" % status) if status == "OK": self.session_id, self.nowplaying_url, self.submit_url = lines self.handshake_sent = True print_d("Session ID: %s, NP URL: %s, Submit URL: %s" % (self.session_id, self.nowplaying_url, self.submit_url)) return True elif status == "BADAUTH": self.quick_dialog( _("Authentication failed: Invalid username '%s' " "or bad password.") % util.escape(self.username), Gtk.MessageType.ERROR) self.broken = True elif status == "BANNED": self.quick_dialog(_("Client is banned. Contact the author."), Gtk.MessageType.ERROR) self.broken = True elif status == "BADTIME": self.quick_dialog( _("Wrong system time. Submissions may fail " "until it is corrected."), Gtk.MessageType.ERROR) else: # "FAILED" self.quick_dialog(util.escape(status), Gtk.MessageType.ERROR) self.changed() return False def _check_submit(self, url, data): data_str = urlencode(data).encode("ascii") try: resp = urlopen(url, data_str) except EnvironmentError: print_d("Audioscrobbler server not responding, will try later.") return False resp_save = resp.read().decode("utf-8", "ignore") status = resp_save.rstrip().split("\n")[0] print_d("Submission status: %s" % status) if status == "OK": return True elif status == "BADSESSION": self.handshake_sent = False return False else: return False def send_submission(self): data = {'s': self.session_id} to_submit = self.queue[:min(len(self.queue), 50)] for idx, song in enumerate(to_submit): for key, val in song.items(): data['%s[%d]' % (key, idx)] = val.encode('utf-8') data['o[%d]' % idx] = 'P' data['r[%d]' % idx] = '' print_d('Submitting song(s): %s' % ('\n\t'.join(['%s - %s' % (s['a'], s['t']) for s in to_submit]))) if self._check_submit(self.submit_url, data): del self.queue[:len(to_submit)] return True else: return False def send_nowplaying(self): data = {'s': self.session_id} for key, val in self.nowplaying_song.items(): data[key] = val.encode('utf-8') print_d('Now playing song: %s - %s' % (self.nowplaying_song['a'], self.nowplaying_song['t'])) return self._check_submit(self.nowplaying_url, data) def quick_dialog_helper(self, dialog_type, msg): dialog = Message(dialog_type, app.window, "QLScrobbler", msg) dialog.connect('response', lambda dia, resp: dia.destroy()) dialog.show() def quick_dialog(self, msg, dialog_type): GLib.idle_add(self.quick_dialog_helper, dialog_type, msg)
from quodlibet.formats import AudioFileError from quodlibet.plugins import PluginManager from quodlibet.qltk._editutils import FilterPluginBox, FilterCheckButton from quodlibet.qltk._editutils import EditingPluginHandler, OverwriteWarning from quodlibet.qltk._editutils import WriteFailedError from quodlibet.qltk.wlw import WritingWindow from quodlibet.qltk.views import TreeViewColumn from quodlibet.qltk.cbes import ComboBoxEntrySave from quodlibet.qltk.models import ObjectStore from quodlibet.qltk import Icons from quodlibet.util.tagsfrompath import TagsFromPattern from quodlibet.util.string.splitters import split_value from quodlibet.util import connect_obj, gdecode TBP = os.path.join(quodlibet.get_user_dir(), "lists", "tagpatterns") TBP_EXAMPLES = """\ <tracknumber>. <title> <tracknumber> - <title> <tracknumber> - <artist> - <title> <artist> - <album>/<tracknumber>. <title> <artist>/<album>/<tracknumber> - <title>""" class UnderscoresToSpaces(FilterCheckButton): _label = _("Replace _underscores with spaces") _section = "tagsfrompath" _key = "underscores" _order = 1.0 def filter(self, tag, value):