diff --git a/dzgui/api/bm.py b/dzgui/api/bm.py index c2597f0..c8c85db 100644 --- a/dzgui/api/bm.py +++ b/dzgui/api/bm.py @@ -1,14 +1,18 @@ import logging import requests from pathlib import Path +from typing import Optional, TYPE_CHECKING -from dzgui.api.servers import Record from dzgui.config.query import lookup from dzgui.const.endpoints import BM_SERVERS from dzgui.const.enum import Preferences logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from dzgui.api.servers import Record + + def get_attributes(config: Path, uid: int) -> str: # TODO: handle if key is not set # TODO: tests for malformed IDs/values @@ -18,7 +22,7 @@ def get_attributes(config: Path, uid: int) -> str: payload: dict[str, str] = { "filter[game]": "dayz", "sort": "-players", - "filter[ids][whitelist]": str(uid) + "filter[ids][whitelist]": str(uid), } res = requests.get(BM_SERVERS, params=payload, headers=hdr) res.raise_for_status() @@ -26,13 +30,14 @@ def get_attributes(config: Path, uid: int) -> str: return j -def map_id_to_record(config: Path, uid: int) -> Record | None: +def map_id_to_record(config: Path, uid: int) -> Optional["Record"]: + from dzgui.api.servers import Record + try: record = get_attributes(config, uid) ip = record["ip"] port = record["port"] qport = record["portQuery"] return Record(ip, port, qport) - except Exception as e: - logger.warn(e) + except Exception: return None diff --git a/dzgui/api/servers.py b/dzgui/api/servers.py index 06e0b05..c77c5f0 100644 --- a/dzgui/api/servers.py +++ b/dzgui/api/servers.py @@ -12,6 +12,7 @@ import typing # noqa from dataclasses import dataclass from typing import Union +from dzgui.api.bm import map_id_to_record from dzgui.const.constants import REQUEST_TIMEOUT from dzgui.const.endpoints import STEAM_SERVERS from dzgui.util import strings @@ -451,3 +452,27 @@ def get_rules(ip: str, qport: int) -> list[int]: except Exception as e: logger.critical(e) return [] + + +def response_to_fq_ip(res: dict) -> str: + ip = res["addr"].split(":")[0] + gameport = res["gameport"] + qport = res["addr"].split(":")[1] + return f"{ip}:{gameport}:{qport}" + + +def query_id_or_ip(addr: str) -> None: + # NOTE: Battlemetrics + if addr.isdigit(): + try: + config = self.controller.get_prefs().paths.config + resolved = map_id_to_record(config, addr) + res = query_direct(resolved.ip, resolved.qport) + except Exception as e: + logger.critical(e) + return None + else: + record = addr.split(":") + ip, qport = record[0], record[1] + res = query_direct(ip, int(qport)) + return res diff --git a/dzgui/controllers/mc.py b/dzgui/controllers/mc.py index 379dae5..020ec39 100644 --- a/dzgui/controllers/mc.py +++ b/dzgui/controllers/mc.py @@ -113,8 +113,8 @@ class Controller(GObject.GObject): self.emitter = Emitter() self.emitter.connect("map_selection_changed", self._on_map_selection_changed) self.emitter.connect("check_toggled", self._on_check_toggled) - self.emitter.connect("servers_loaded", self._on_servers_loaded) self.emitter.connect("servers_loaded_init", self._on_servers_loaded_init) + #self.emitter.connect("servers_loaded", self._on_servers_loaded) # NOTE: suppress requests until entire UI is loaded self.loaded = False @@ -861,7 +861,11 @@ class Controller(GObject.GObject): self.filter_man = filter_man def populate_model(self, tv: Gtk.TreeView) -> None: - ServerModelManager(self, tv, first_iteration=True) + # NOTE: skip on previously loaded tabs + if tv.is_loaded(): + self.emitter.emit("servers_loaded", tv.get_enum()) + return + ServerModelManager(self, tv, first_iteration=True).load() def get_favorite(self) -> tuple[str, str] | tuple[None, None]: fav = str(self.query_config(Preferences.FAV_LBL)) @@ -913,14 +917,14 @@ class Controller(GObject.GObject): store = self.get_map_store() self.emitter.emit("load_maps", store) - def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: - return - # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None - # TODO: this should be internal to servers page - state = self.has_server_model() - tv = self.get_active_treeview() - tv.set_headers_visible(state) - tv.set_headers_clickable(state) + #def _on_servers_loaded(self, emitter: "Emitter", tab: "ServerTab") -> None: + # return + # # NOTE: workaround for GTK bug where fullscreen causes headers to vanish when model is None + # # TODO: this should be internal to servers page + # state = self.has_server_model() + # tv = self.get_active_treeview() + # tv.set_headers_visible(state) + # tv.set_headers_clickable(state) def has_server_model(self) -> bool: treeview = self.get_active_treeview() @@ -946,6 +950,9 @@ class Controller(GObject.GObject): return self.mediator.notebook def get_servers(self) -> "Notebook": + return self.mediator.servers + + def get_server_notebook(self) -> "Notebook": return self.mediator.servers.notebook def get_window(self) -> "OuterWindow": diff --git a/dzgui/managers/connection.py b/dzgui/managers/connection.py new file mode 100644 index 0000000..f4d9772 --- /dev/null +++ b/dzgui/managers/connection.py @@ -0,0 +1,35 @@ +from typing import TYPE_CHECKING + +import dzgui.api.servers as Servers +from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager +from dzgui.util.strings import api_warn_msg, dialog +from dzgui.views.dialogs.generic import ExceptionDialog + + +import gi + +gi.require_version("Gtk", "3.0") +from gi.repository import Gtk # noqa E402 + +if TYPE_CHECKING: + from dzgui.controllers.mc import Controller + + +class ConnectionManager: + def __init__(self, controller: "Controller") -> None: + + self.controller = controller + self.thread_man = ThreadingManager(parent=controller) + + @call_on_thread(dialog.querying) + def _connect_by_id_or_ip(self, addr: str) -> None: + res = Servers.query_id_or_ip(addr) + if res is None: + self.thread_man.set_cleanup_func(StoredFunc(self._connection_failure)) + # TODO: add to history if successful + print(res) + + def _connection_failure(self) -> None: + # TODO: more explicit warning message, not necessarily API failure? + dialog = ExceptionDialog(self.controller, api_warn_msg) + dialog.run() diff --git a/dzgui/managers/thread_man.py b/dzgui/managers/thread_man.py index 036416d..7eafcf6 100644 --- a/dzgui/managers/thread_man.py +++ b/dzgui/managers/thread_man.py @@ -21,7 +21,6 @@ def call_on_thread(dialog_str: str) -> Callable: def wrapper(*args, **kwargs): self = args[0] stored = StoredFunc(func, *args, **kwargs) - print("SELF", self) if not hasattr(self, "thread_man"): raise AttributeError if type(self.thread_man) is not ThreadingManager: diff --git a/dzgui/model/servers.py b/dzgui/model/servers.py index 8fe14f7..8647944 100644 --- a/dzgui/model/servers.py +++ b/dzgui/model/servers.py @@ -1,40 +1,36 @@ -from datetime import datetime import logging import threading - -from concurrent.futures import wait, as_completed +from concurrent.futures import as_completed from concurrent.futures import ThreadPoolExecutor +from typing import Optional, TYPE_CHECKING import dzgui.api.servers as Servers +from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.const.constants import ( APPID_DAYZ, APPID_DAYZ_EXP, ) -from dzgui.const.enum import FilterMode, Preferences, ServerTab from dzgui.managers.thread_man import call_on_thread, StoredFunc, ThreadingManager from dzgui.util.strings import api_warn_msg, dialog from dzgui.views.dialogs.generic import ExceptionDialog -from typing import Optional, TYPE_CHECKING - import gi gi.require_version("Gtk", "3.0") -from gi.repository import Gtk, GLib # noqa E402 +from gi.repository import Gtk # noqa E402 LAN_TIMEOUT = 0.5 API_TIMEOUT = 3 if TYPE_CHECKING: from dzgui.controllers.mc import Controller - from dzgui.controllers.emitter import Emitter from dzgui.model.filtered_model import FilteredModelManager logger = logging.getLogger(__name__) # TODO: failure: spawns error dialog -# TODO: non failure with empty model: updates statusbar +# TODO: non failure with empty model: updates statusbar with help text class ServerModelManager: @@ -42,28 +38,32 @@ class ServerModelManager: self, controller: "Controller", tv: Gtk.TreeView, first_iteration=False ) -> None: - enum = tv.get_enum() + self.tv = tv + self.enum = tv.get_enum() self.controller = controller self.emitter = controller.get_emitter() - if tv.is_loaded(): - self.emitter.emit("servers_loaded", enum) - return - self.tv = tv self.jobs = 1 + # NOTE: store filter man for access inside thread self.filter_man = tv.get_filter_man() + + # FIXME: change WaitDialog to use parent window only self.thread_man = ThreadingManager(parent=controller) # TODO: if first iteration, clear filter man control model # literal first load: iteration 1 # refresh: iteration 1 (wipe model) # filter: iteration N+1 + # TODO: can drop first iteration arg and process in methods self.first_iteration = first_iteration - # TODO: pass servers.saved treeview when using conpan regardless of current context - - match enum: + def load(self) -> None: + """ + There may be cases where you want to instantiate this class without dumping servers, + e.g., adding saved servers from another tab + """ + match self.enum: case ServerTab.BROWSER: # NOTE: extra DAYZ_EXP param self.thread_man.set_job_count(len(Servers.params) + 1) @@ -71,14 +71,12 @@ class ServerModelManager: case ServerTab.SAVED: self._dump_favorites() case ServerTab.RECENT: - # TODO: get row count self._dump_history() case ServerTab.LAN: # NOTE: LAN tab is only loaded on demand pass case _: pass - # self._dump_lan() @call_on_thread(dialog.fetching) def _dump_api(self) -> None: @@ -113,7 +111,6 @@ class ServerModelManager: j = res.json servers += j["response"]["servers"] - # TODO: try/except when parsing parsed = Servers.parse_json(servers) self._push_data(parsed, FilterMode.INITIAL) @@ -150,11 +147,10 @@ class ServerModelManager: parsed = Servers.parse_json(servers) self._push_data(parsed, FilterMode.INITIAL) - # TODO: strings - @call_on_thread("dumping ips") + @call_on_thread(dialog.fetching) def _dump_ips(self, ips: list[str]) -> None: # NOTE: block malformed records (TODO: add github issue no.) - # TODO: sanitize ip list at config time, drop this + # TODO: sanitize ip list at config time and drop this ips = [ip for ip in ips if len(ip.split(":")) == 3 and ip.split(":")[2] != ""] job = Servers.query_direct with ThreadPoolExecutor() as executor: @@ -183,42 +179,32 @@ class ServerModelManager: parsed = Servers.parse_json(servers) self._push_data(parsed, FilterMode.INITIAL) - def _query_ip_id(self, addr: str) -> None: - # NOTE: Battlemetrics - if addr.isdigit(): - # FIXME: - config = self.controller.get_prefs().paths.config - resolved = map_id_to_record(config, addr) - res = Servers.query_direct(resolved.ip, resolved.qport) - else: - record = addr.split(":") - ip, qport = record[0], record[1] - res = Servers.query_direct(ip, int(qport)) - return res - - # TODO: strings - @call_on_thread("querying address") - def _connect_by_id_or_ip(self, addr: str) -> None: - res = self._query_ip_id(addr) - # TODO: unimplemented - - @call_on_thread("querying address") + @call_on_thread(dialog.querying) def _add_by_id_or_ip(self, addr: str) -> None: - res = self.query_ip_id(addr) - # TODO: investigate this + res = Servers.query_id_or_ip(addr) if res is None: - self.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_failure)) return - # NOTE: single record insertion - self.insert_record = Servers.parse_json([res]) - # TODO: add into saved servers file - # TODO: update saved servers model - # NOTE: this can be called from other tabs--if current focus is not ServerTab.SAVED, update label only - # TODO: saved servers might not be loaded yet, in which case should just update local file only - # TODO: perform simple equality comparison of self.tv.get_enum() == ServerTab.SAVED - # FIXME: filter man is saved on a per tab basis, so this will mismatch - # check if servers.get_active_treeview() is same as self.tv - self.set_cleanup_func(StoredFunc(self._cleanup_on_insert)) + + record = Servers.parse_json([res]) + + filter_man = self._get_filter_man() + model = filter_man.get_control() + + # TODO: make this a method of ConfigManager + fqip = Servers.response_to_fq_ip(res) + ips = self.controller.query_config(Preferences.IP_LIST) + ips.append(fqip) + self.controller.update_config(Preferences.IP_LIST, ips) + + if model is not None: + # NOTE: single record insertion + model.append(record[0]) + # TODO: if all filters are already applied, strange behavior may occur + # -> need to insert and update per current filters + filter_man.filter(FilterMode.INITIAL) + + self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_single_ip)) def _dump_history(self) -> None: history = self.controller.get_prefs().paths.history @@ -242,6 +228,7 @@ class ServerModelManager: def _dump_favorites(self) -> None: ips = self.controller.query_config(Preferences.IP_LIST) self.thread_man.set_job_count(len(ips)) + # TODO: customize statusbar to mention how records can be added via contextmenu if len(ips) == 0: # FIXME: this is not a failure, just a quiet exit with custom statusbar @@ -252,25 +239,20 @@ class ServerModelManager: return self._dump_ips(ips) - def _cleanup_on_insert(self) -> None: - filter_man = self.get_filter_man() - model = filter_man.get_control() - - # FIXME: this is a single row insertion, - # but refiltration should occur in thread for consistency/scalability - model.append(self.insert_record[0]) - proxy = filter_man.filter(FilterMode.INITIAL) - - # TODO: get proxy model out of thread - proxy = self.get_filter_man().get_proxy_model() + def _cleanup_single_ip(self) -> None: + proxy = self._get_filter_man().get_proxy_model() self.tv.set_model(proxy) - # TODO: update statusbar - context = self.tv.get_enum() + # TODO: if current tab != self.saved, add label # TODO: adding a row may update available maps - # TODO: if all filters are already applied, strange behavior may occur - # -> need to insert and update per current filters - self.emitter.emit("servers_loaded", context) + self.emitter.emit("servers_loaded", self.enum) + self._update_maps() + + def _update_maps(self) -> None: + map_man = self.tv.get_map_man() + map_man.set_unique_maps(self._get_new_maps()) + self.emitter.emit("servers_loaded_init") + self.first_iteration = False def _cleanup_on_success(self) -> None: self.tv.set_model(self.to_insert) @@ -285,14 +267,8 @@ class ServerModelManager: context = self.tv.get_enum() self.emitter.emit("servers_loaded", context) - # CHORE: this is placeholder logic, - # refills map combo with new maps if self.first_iteration: - map_man = self.tv.get_map_man() - map_man.set_unique_maps(self.new_maps) - self.emitter.emit("servers_loaded_init") - self.first_iteration = False - self.new_maps = None + self._update_maps() def _cleanup_on_failure(self, show_dialog=True) -> None: map_man = self.tv.get_map_man() @@ -334,10 +310,16 @@ class ServerModelManager: self.to_insert = manager.get_proxy_model() u_maps = set([row[1] for row in data]) - self.new_maps = sorted(u_maps) + self._set_new_maps(sorted(u_maps)) self.thread_man.set_cleanup_func(StoredFunc(self._cleanup_on_success)) + def _set_new_maps(self, maps: list[str]) -> None: + self.new_maps = maps + + def _get_new_maps(self) -> list[str]: + return self.new_maps + def _get_filter_man(self) -> "FilteredModelManager": return self.filter_man diff --git a/dzgui/views/components/connect_panel.py b/dzgui/views/components/connect_panel.py index 7dbde37..117c442 100644 --- a/dzgui/views/components/connect_panel.py +++ b/dzgui/views/components/connect_panel.py @@ -1,5 +1,6 @@ from typing import TYPE_CHECKING +from dzgui.managers.connection import ConnectionManager from dzgui.model.servers import ServerModelManager from dzgui.util.strings import connect_panel, fav_panel, lan_panel from dzgui.views.components.buttons import ( @@ -178,6 +179,7 @@ class AddPanel(Gtk.Frame): self.add_server.connect("clicked", self._on_add_clicked) self.conn_server = SteamConnectButton() + self.conn_server.connect("clicked", self._on_connect_clicked) self.conn_server.set_sensitive(False) self.add_server.set_sensitive(False) @@ -199,11 +201,14 @@ class AddPanel(Gtk.Frame): self.add(self.grid) + def _on_connect_clicked(self, button: Gtk.Button) -> None: + text = self.entry.get_text() + ConnectionManager(self.controller)._connect_by_id_or_ip(text) + def _submit_query(self) -> None: text = self.entry.get_text() - # TODO: call servermodelmanager - ServerModelManager().add_by_id_or_ip(text) - # self.controller.add_by_id_or_ip(text) + saved = self.controller.get_servers().get_saved() + ServerModelManager(self.controller, saved)._add_by_id_or_ip(text) def _on_activate(self, entry: Gtk.Entry) -> None: if not self.add_server.is_sensitive(): diff --git a/dzgui/views/components/crumbs.py b/dzgui/views/components/crumbs.py index 4405b28..6588b93 100644 --- a/dzgui/views/components/crumbs.py +++ b/dzgui/views/components/crumbs.py @@ -22,7 +22,7 @@ class Breadcrumbs(Gtk.Label): self.set_markup(crumbs) notebook = controller.get_notebook() - servers = controller.get_servers() + servers = controller.get_server_notebook() notebook.connect_after("switch-page", self._on_notebook_page_changed) servers.connect_after("switch-page", self._on_server_tab_changed) diff --git a/dzgui/views/pages/servers.py b/dzgui/views/pages/servers.py index 4fce415..e03b25d 100644 --- a/dzgui/views/pages/servers.py +++ b/dzgui/views/pages/servers.py @@ -2,7 +2,6 @@ import logging from typing import Optional, Self, TYPE_CHECKING -from dzgui.controllers.mc import StoredFunc from dzgui.const.enum import ContextMenuGroup, ServerTab from dzgui.views.trees.tree_servers import ServerTreeView from dzgui.util.strings import server_labels @@ -16,6 +15,7 @@ logger = logging.getLogger(__name__) if TYPE_CHECKING: from dzgui.controllers.mc import Controller + from dzgui.controllers.mc import Emitter class ServerNotebook(Gtk.ScrolledWindow):