From 4e5817dd2bda46ddee884723709734a8403bf935 Mon Sep 17 00:00:00 2001 From: xal3xhx Date: Fri, 7 Aug 2026 15:27:06 -0400 Subject: [PATCH] Add per-list management UI for TF2BD player lists The old auto-update flow only knew how to refresh files that already existed in tf2bd_lists/, using their embedded file_info.update_url. It had no concept of where the lists came from, no UI for enabling/disabling them, no way to add a custom URL, and no way to tell the user what happened during the (silent, --noconsole) update. Changes: - consts.py: replace single DEFAULT_TF2BD_LISTS with BUILTIN_TF2BD_LISTS containing 5 verified working community lists (Cleffy/joekiller/qfoxb/Classic-Gaming/TF2BD-ASEAN-LIST, total 3156 players, all carry file_info.update_url so future refreshes keep working). - list_manager.py: switch to a config-driven model backed by cfg/tf2bd_lists.json. Each entry tracks {name, url, filename, enabled, auto_update, is_builtin, last_updated, last_status, last_player_count}. - New: get_lists / set_list_enabled / set_list_auto_update / add_custom_list / remove_list / _load_lists_config / _save_lists_config - bootstrap_default_lists / update_tf2bd_lists / force_update_now / _download_list_to_file all rewritten to drive off self.lists_config instead of the hardcoded BUILTIN_TF2BD_LISTS - Built-ins are seeded into the config on first run; user toggles win over the defaults on subsequent loads. Built-ins can't be removed (only disabled) so curated defaults survive upgrades. - ui/ui_qt_settings.py: new 'TF2BD Player Lists' GroupBox with a 6-column table (Name | Players | Enabled | Auto-Update | Last Status | URL) and buttons: Add Custom URL, Remove Selected, Update All Now, Open tf2bd_lists/ Folder. Toggle changes are written immediately to cfg/tf2bd_lists.json. Last run status is shown under the table. - ui/ui_qt_aux_windows.py: 'Download/Update TF2BD Lists' button in the User List Manager window, kept for users who don't open Settings. Verified end-to-end against a fresh cfg/: all 5 lists download, the second call correctly reports 'already up to date' for 4/5 and refreshes the one that changed, cfg/tf2bd_lists.json is created and updated, and remove_list refuses to remove a built-in while allowing custom removals. --- sentry_app/consts.py | 39 ++++ sentry_app/list_manager.py | 315 +++++++++++++++++++++++++++-- sentry_app/ui/ui_qt_aux_windows.py | 17 ++ sentry_app/ui/ui_qt_settings.py | 242 +++++++++++++++++++++- 4 files changed, 597 insertions(+), 16 deletions(-) diff --git a/sentry_app/consts.py b/sentry_app/consts.py index 08e5bc8..b3eab0f 100644 --- a/sentry_app/consts.py +++ b/sentry_app/consts.py @@ -1,5 +1,44 @@ APP_VERSION = "1.2.0" +# Built-in TF2BD community player lists shipped with the app. Each entry is +# (display_name, url, filename) where `filename` is what the JSON will be +# saved as locally under tf2bd_lists/ and what the in-app list manager +# keys on. The URL is what we fetch from; once a file is saved, its own +# file_info.update_url becomes the source of truth for subsequent +# auto-updates. +# +# These were verified to be reachable and use the v3 TF2BD schema. The +# official PazerOP list isn't hosted publicly (it ships inside the TF2BD +# binary, and that repo was archived March 2024), so we fall back to +# community-maintained lists. +BUILTIN_TF2BD_LISTS = [ + ( + "Cleffy's TF2BD List", + 'https://raw.githubusercontent.com/Cl3ffy/cleffy-list/main/playerlist.cleffy.json', + 'playerlist.cleffy.json', + ), + ( + "joekiller's TF2BD List", + 'https://raw.githubusercontent.com/joekiller/joekiller-list/main/playerlist.joekiller.json', + 'playerlist.joekiller.json', + ), + ( + "qfoxb's TF2BD List", + 'https://raw.githubusercontent.com/qfoxb/tf2bd-lists/main/playerlist.qfoxb.json', + 'playerlist.qfoxb.json', + ), + ( + "Classic's TF2BD List (US East)", + 'https://raw.githubusercontent.com/Classic-Gaming/tf2db/main/playerlist.classic.json', + 'playerlist.classic.json', + ), + ( + "TF2 Bot Detector ASEAN List", + 'https://raw.githubusercontent.com/Critical-Cookie/TF2BD-ASEAN-LIST/main/playerlist.asean.json', + 'playerlist.asean.json', + ), +] + DEFAULT_SETTINGS = { 'User': '[U:1:XXXXXXXXXX]', 'Use_Manual_SteamID': 'False', diff --git a/sentry_app/list_manager.py b/sentry_app/list_manager.py index 8bf018b..9430c99 100644 --- a/sentry_app/list_manager.py +++ b/sentry_app/list_manager.py @@ -7,8 +7,16 @@ import requests from .utils import atomic_write_bytes, convert_steamid64_to_steamid3 from .models import PlayerInstance +from .consts import BUILTIN_TF2BD_LISTS + class ListManager: + # File that stores per-list settings (enabled / auto_update / url / + # filename) for both built-in and user-added lists. Built-ins start + # with defaults (enabled + auto_update) the first time the user runs + # the app; toggles here override the defaults. + LISTS_CONFIG_FILENAME = 'tf2bd_lists.json' + def __init__(self, config_manager, state_lock): self.cfg = config_manager self.lock = state_lock @@ -16,6 +24,7 @@ def __init__(self, config_manager, state_lock): self.cfg_dir = 'cfg' self.tf2bd_dir = 'tf2bd_lists' self.userlist_path = os.path.join(self.cfg_dir, 'userlist.json') + self.lists_config_path = os.path.join(self.cfg_dir, self.LISTS_CONFIG_FILENAME) self.tf2bd_data = {} self.tf2bd_cheaters = [] @@ -30,7 +39,20 @@ def __init__(self, config_manager, state_lock): self.userlist_error = None self.tf2bd_error = None + # Ordered list of dicts: {name, url, filename, enabled, auto_update, + # is_builtin, last_updated, last_status, last_player_count}. The + # UI reads/writes this through the methods below; the file scan in + # _read_tf2bd_lists() is the source of truth for player contents, + # but this list decides which URLs we fetch and what we display. + self.lists_config = [] + + # Status of the last auto-update / bootstrap run. The UI reads this + # so the user can see what happened (instead of getting silent + # print()s into a --noconsole build). + self.last_update_status = "" + self._ensure_dirs() + self._load_lists_config() def _ensure_dirs(self): os.makedirs(self.cfg_dir, exist_ok=True) @@ -43,6 +65,152 @@ def load_all(self): self.load_tf2bd_data() self.load_user_entries() + # --- TF2BD lists config (cfg/tf2bd_lists.json) ------------------------- + # + # Each entry is a dict: + # {name, url, filename, enabled, auto_update, is_builtin, + # last_updated (epoch), last_status (str), last_player_count (int)} + # + # Built-ins live in BUILTIN_TF2BD_LISTS in consts.py and get added the + # first time the app runs (or whenever a new build of Sentry introduces + # a new built-in). User-added entries live in the JSON file with + # is_builtin=False and can be removed. + + def _load_lists_config(self): + """Load cfg/tf2bd_lists.json. On first run (or after an upgrade that + added new built-ins), seed it with the BUILTIN_TF2BD_LISTS so the UI + shows a sensible default. Existing user entries are preserved.""" + try: + with open(self.lists_config_path, 'r', encoding='utf-8') as f: + data = json.load(f) + if not isinstance(data, dict) or 'lists' not in data: + data = {'lists': []} + except (FileNotFoundError, json.JSONDecodeError): + data = {'lists': []} + + # Merge in built-ins: any built-in that isn't already represented + # in the saved config gets added (with default enabled/auto_update). + existing_urls = {entry.get('url') for entry in data['lists']} + for name, url, filename in BUILTIN_TF2BD_LISTS: + if url not in existing_urls: + data['lists'].append({ + 'name': name, + 'url': url, + 'filename': filename, + 'enabled': True, + 'auto_update': True, + 'is_builtin': True, + 'last_updated': 0.0, + 'last_status': '', + 'last_player_count': 0, + }) + else: + # Make sure is_builtin is set correctly (in case the user + # hand-edited the file). + for entry in data['lists']: + if entry.get('url') == url: + entry['is_builtin'] = True + break + + self.lists_config = data['lists'] + self._save_lists_config() + + def _save_lists_config(self): + try: + atomic_write_bytes( + self.lists_config_path, + json.dumps({'lists': self.lists_config}, indent=2).encode('utf-8'), + ) + except Exception as e: + print(f"Error saving lists config: {e}") + + def get_lists(self): + """Return a deep-ish copy of the lists config for the UI to render. + Mutations go back through set_list_* / add_custom_list / remove_list.""" + return [dict(entry) for entry in self.lists_config] + + def _find_list_index(self, url): + for i, entry in enumerate(self.lists_config): + if entry.get('url') == url: + return i + return -1 + + def set_list_enabled(self, url, enabled): + i = self._find_list_index(url) + if i < 0: return False + self.lists_config[i]['enabled'] = bool(enabled) + self._save_lists_config() + return True + + def set_list_auto_update(self, url, auto_update): + i = self._find_list_index(url) + if i < 0: return False + self.lists_config[i]['auto_update'] = bool(auto_update) + self._save_lists_config() + return True + + def add_custom_list(self, name, url, enabled=True, auto_update=True): + """Add a user-supplied list. Returns True on success, False if the + URL is already present or invalid.""" + if not url or not isinstance(url, str): + return False + if self._find_list_index(url) >= 0: + return False + # Derive a local filename from the URL basename; fall back to a + # sanitized version of the name if the URL has no obvious file. + from urllib.parse import urlparse + path = urlparse(url).path + basename = os.path.basename(path.rstrip('/')) if path else '' + if not basename or not basename.lower().endswith('.json'): + safe = ''.join(c for c in name if c.isalnum() or c in ('-', '_')).strip() + basename = f"playerlist.{safe or 'custom'}.json" + + self.lists_config.append({ + 'name': name or basename, + 'url': url, + 'filename': basename, + 'enabled': bool(enabled), + 'auto_update': bool(auto_update), + 'is_builtin': False, + 'last_updated': 0.0, + 'last_status': '', + 'last_player_count': 0, + }) + self._save_lists_config() + return True + + def remove_list(self, url): + """Remove a user-added list. Built-in lists cannot be removed (only + disabled) to keep the curated defaults available across upgrades.""" + i = self._find_list_index(url) + if i < 0: return False + if self.lists_config[i].get('is_builtin'): + return False + # Also delete the local file so the list doesn't keep showing up + # in the in-game tables after the user removes it. + fn = self.lists_config[i].get('filename') + if fn: + fpath = os.path.join(self.tf2bd_dir, fn) + try: + if os.path.exists(fpath): + os.remove(fpath) + except OSError: + pass + del self.lists_config[i] + self._save_lists_config() + self._reload_tf2bd_from_disk() + return True + + def _record_list_result(self, url, ok, status_msg, player_count): + i = self._find_list_index(url) + if i < 0: return + self.lists_config[i]['last_updated'] = time.time() + self.lists_config[i]['last_status'] = status_msg + self.lists_config[i]['last_player_count'] = player_count + self._save_lists_config() + + # --- end TF2BD lists config -------------------------------------------- + def load_tf2bd_data(self): self._reload_tf2bd_from_disk() if self.cfg.get_bool("Auto_Update_TF2BD_Lists"): @@ -50,13 +218,95 @@ def load_tf2bd_data(self): def _background_update_worker(self): print("[Auto-Update] Starting background update...") + messages = [] + + # If the user has no lists yet, download the default ones first so + # future runs of update_tf2bd_lists() have something to refresh. try: - self.update_tf2bd_lists() - print("[Auto-Update] Update complete. Reloading lists...") + existing = [f for f in os.listdir(self.tf2bd_dir) if f.endswith('.json')] + if not existing: + boot_msgs = self.bootstrap_default_lists() + messages.extend(boot_msgs) + except Exception as e: + messages.append(f"Bootstrap error: {e}") + + try: + update_msgs = self.update_tf2bd_lists() + messages.extend(update_msgs) + except Exception as e: + messages.append(f"Update error: {e}") + + try: + self._reload_tf2bd_from_disk() + messages.append("Lists reloaded.") + except Exception as e: + messages.append(f"Reload error: {e}") + + self.last_update_status = " | ".join(m for m in messages if m) + print(f"[Auto-Update] {self.last_update_status}") + + def bootstrap_default_lists(self): + """Download every enabled entry in self.lists_config that doesn't + have a local file yet. Returns a list of human-readable status + messages.""" + messages = [] + for entry in self.lists_config: + if not entry.get('enabled', True): + continue + filename = entry.get('filename') + if not filename: + continue + fpath = os.path.join(self.tf2bd_dir, filename) + if os.path.exists(fpath): + continue + try: + msg = self._download_list_to_file(entry['url'], filename) + messages.append(msg) + except Exception as e: + messages.append(f"Failed to fetch {entry['url']}: {e}") + return messages + + def _download_list_to_file(self, url, filename): + """Fetch a single TF2BD-format JSON list from `url` and save it to + tf2bd_lists/. Validates that it has the expected schema + fields, preserves the URL as file_info.update_url so future + auto-updates keep working, and returns a status string.""" + resp = requests.get(url, timeout=15) + resp.raise_for_status() + data = resp.json() + + if not isinstance(data, dict) or 'players' not in data: + raise ValueError(f"Response from {url} is not a valid TF2BD list (no 'players' field)") + + if 'file_info' not in data or not isinstance(data['file_info'], dict): + data['file_info'] = {} + # Preserve the URL so subsequent runs of update_tf2bd_lists() will + # refresh this file in place instead of re-bootstrapping it. + data['file_info']['update_url'] = url + + fpath = os.path.join(self.tf2bd_dir, filename) + json_bytes = json.dumps(data, indent=2).encode('utf-8') + atomic_write_bytes(fpath, json_bytes) + + title = data.get('file_info', {}).get('title', filename) + n_players = len(data.get('players', [])) + self._record_list_result(url, True, f"Downloaded ({n_players} players)", n_players) + return f"Downloaded {filename} ({n_players} players, title={title!r})" + + def force_update_now(self): + """Run bootstrap + per-file update synchronously for all enabled + lists and return a human-readable summary string. Intended for + the UI's manual 'Update' button so the user sees something happen.""" + messages = [] + try: + messages.extend(self.bootstrap_default_lists()) + messages.extend(self.update_tf2bd_lists()) self._reload_tf2bd_from_disk() - print("[Auto-Update] Lists reloaded successfully.") + messages.append("Lists reloaded.") except Exception as e: - print(f"[Auto-Update] Error: {e}") + messages.append(f"Error: {e}") + self.last_update_status = " | ".join(m for m in messages if m) + return self.last_update_status def _reload_tf2bd_from_disk(self): new_data, error_msg = self._read_tf2bd_lists() @@ -122,18 +372,40 @@ def _read_tf2bd_lists(self): return all_data, err_msg def update_tf2bd_lists(self): - print("[Auto-Update] Checking TF2BD lists...") - for fname in os.listdir(self.tf2bd_dir): - if fname.endswith('.json'): - self._update_json_file(os.path.join(self.tf2bd_dir, fname)) - - def _update_json_file(self, fpath): + """For each enabled list with auto_update=True, refresh its file from + the URL embedded in the file's own file_info.update_url (or, if + missing, the URL stored in self.lists_config). Returns a list of + human-readable status messages.""" + messages = [] + for entry in self.lists_config: + if not entry.get('enabled', True): + continue + if not entry.get('auto_update', True): + continue + filename = entry.get('filename') + if not filename: + continue + fpath = os.path.join(self.tf2bd_dir, filename) + if not os.path.exists(fpath): + # Skip silently - bootstrap_default_lists() handles downloads. + continue + msg = self._update_json_file(fpath, entry.get('url')) + if msg: + messages.append(msg) + print(msg) + return messages + + def _update_json_file(self, fpath, fallback_url=None): + """Refresh fpath from its embedded file_info.update_url. If that's + missing but fallback_url is provided, use that. Records the result + on the matching lists_config entry. Returns a status string.""" try: with open(fpath, 'r', encoding='utf-8') as f: data = json.load(f) - url = data.get('file_info', {}).get('update_url') - if not url: return + url = data.get('file_info', {}).get('update_url') or fallback_url + if not url: + return None resp = requests.get(url, timeout=10) resp.raise_for_status() @@ -151,9 +423,24 @@ def _update_json_file(self, fpath): json_bytes = json.dumps(new_data, indent=2).encode('utf-8') atomic_write_bytes(fpath, json_bytes) - print(f"Updated {os.path.basename(fpath)}") + n_players = len(new_data.get('players', [])) + self._record_list_result(url, True, f"Updated ({n_players} players)", n_players) + return f"Updated {os.path.basename(fpath)}" + else: + n_players = len(data.get('players', [])) if isinstance(data.get('players'), list) else 0 + self._record_list_result(url, True, "Already up to date", n_players) + return f"{os.path.basename(fpath)}: already up to date" except Exception as e: - print(f"Error updating {os.path.basename(fpath)}: {e}") + msg = f"Error updating {os.path.basename(fpath)}: {e}" + try: + with open(fpath, 'r', encoding='utf-8') as f: + d = json.load(f) + url = d.get('file_info', {}).get('update_url') or fallback_url + if url: + self._record_list_result(url, False, msg, 0) + except Exception: + pass + return msg def load_user_entries(self): if not os.path.exists(self.userlist_path): return diff --git a/sentry_app/ui/ui_qt_aux_windows.py b/sentry_app/ui/ui_qt_aux_windows.py index 6ce0a82..2bd6dac 100644 --- a/sentry_app/ui/ui_qt_aux_windows.py +++ b/sentry_app/ui/ui_qt_aux_windows.py @@ -163,16 +163,33 @@ def __init__(self, parent, logic, px_func): btn_layout = QHBoxLayout() export_btn = QPushButton("Export list to TF2BD format") export_btn.clicked.connect(self.export_list) + download_btn = QPushButton("Download/Update TF2BD Lists") + download_btn.setToolTip( + "Fetch the default TF2BD community list if tf2bd_lists/ is empty,\n" + "and refresh any existing lists via their embedded update_url." + ) + download_btn.clicked.connect(self.download_lists) close_btn = QPushButton("Close") close_btn.clicked.connect(self.close) btn_layout.addWidget(export_btn) + btn_layout.addWidget(download_btn) btn_layout.addStretch() btn_layout.addWidget(close_btn) self.layout.addLayout(btn_layout) self.refresh() + def download_lists(self): + from .ui_qt_dialogs import custom_popup + result = self.logic.lists.force_update_now() + custom_popup( + self, self.px, + "TF2BD List Update", + result if result else "No changes." + ) + self.refresh() + def on_double_click(self, row, col): sid_item = self.table.item(row, self.steamid_col) name_item = self.table.item(row, self.name_col) diff --git a/sentry_app/ui/ui_qt_settings.py b/sentry_app/ui/ui_qt_settings.py index b932e51..e542872 100644 --- a/sentry_app/ui/ui_qt_settings.py +++ b/sentry_app/ui/ui_qt_settings.py @@ -1,7 +1,11 @@ +import os +import sys from PySide6.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QCheckBox, QSpinBox, QDoubleSpinBox, QPushButton, QGroupBox, QScrollArea, - QWidget, QColorDialog, QFormLayout, QGridLayout) + QWidget, QColorDialog, QFormLayout, QGridLayout, + QTableWidget, QTableWidgetItem, QHeaderView, + QAbstractItemView) from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QIntValidator, QColor @@ -107,12 +111,73 @@ def __init__(self, parent, logic, px_func): lay_ext.addRow("SteamHistory API Key:", hb_api) - self.vars['Auto_Update_TF2BD_Lists'] = QCheckBox("Auto-update TF2BD lists on startup") + self.vars['Auto_Update_TF2BD_Lists'] = QCheckBox("Auto-update enabled lists on startup") self.vars['Auto_Update_TF2BD_Lists'].setChecked(self.logic.get_setting_bool("Auto_Update_TF2BD_Lists")) lay_ext.addRow(self.vars['Auto_Update_TF2BD_Lists']) self.form_layout.addWidget(grp_ext) + # ---- TF2BD Player Lists ---- + grp_lists = QGroupBox("TF2BD Player Lists") + lay_lists = QVBoxLayout(grp_lists) + lay_lists.setContentsMargins(8, 8, 8, 8) + + lbl_lists_help = QLabel( + "Pick which cheater / suspicious-player lists to load. Built-in lists " + "update automatically when the upstream repo changes; custom URLs are " + "downloaded once and then refreshed via their embedded update_url.\n" + "Files are saved under tf2bd_lists/ next to Sentry.exe." + ) + lbl_lists_help.setWordWrap(True) + lbl_lists_help.setStyleSheet("color: gray;") + lay_lists.addWidget(lbl_lists_help) + + # Table of lists: Name | Players | Enabled | Auto-update | Last Status | URL + self.lists_table = QTableWidget(0, 6) + self.lists_table.setHorizontalHeaderLabels( + ['Name', 'Players', 'Enabled', 'Auto-Update', 'Last Status', 'URL'] + ) + self.lists_table.verticalHeader().setVisible(False) + self.lists_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.lists_table.setSelectionMode(QAbstractItemView.SingleSelection) + self.lists_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + hdr = self.lists_table.horizontalHeader() + hdr.setSectionResizeMode(0, QHeaderView.Interactive) + hdr.setSectionResizeMode(1, QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(2, QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(3, QHeaderView.ResizeToContents) + hdr.setSectionResizeMode(4, QHeaderView.Interactive) + hdr.setSectionResizeMode(5, QHeaderView.Stretch) + self.lists_table.setColumnWidth(0, self.px(180)) + self.lists_table.setColumnWidth(4, self.px(220)) + self.lists_table.verticalHeader().setDefaultSectionSize(self.px(22)) + lay_lists.addWidget(self.lists_table) + + lists_btn_row = QHBoxLayout() + self.btn_lists_add = QPushButton("Add Custom URL...") + self.btn_lists_add.clicked.connect(self.lists_add_custom) + self.btn_lists_remove = QPushButton("Remove Selected") + self.btn_lists_remove.clicked.connect(self.lists_remove_selected) + self.btn_lists_update = QPushButton("Update All Now") + self.btn_lists_update.clicked.connect(self.lists_update_now) + self.btn_lists_open = QPushButton("Open tf2bd_lists/ Folder") + self.btn_lists_open.clicked.connect(self.lists_open_folder) + lists_btn_row.addWidget(self.btn_lists_add) + lists_btn_row.addWidget(self.btn_lists_remove) + lists_btn_row.addWidget(self.btn_lists_update) + lists_btn_row.addStretch() + lists_btn_row.addWidget(self.btn_lists_open) + lay_lists.addLayout(lists_btn_row) + + self.lists_status_label = QLabel("") + self.lists_status_label.setWordWrap(True) + self.lists_status_label.setStyleSheet("color: gray; font-style: italic;") + lay_lists.addWidget(self.lists_status_label) + + self.form_layout.addWidget(grp_lists) + + # ---- end TF2BD Player Lists ---- + grp_auto = QGroupBox("Automation") lay_auto = QGridLayout(grp_auto) @@ -230,6 +295,10 @@ def add_color_row_grid(row, label_text, key): lay_app.addRow(color_grid) self.form_layout.addWidget(grp_app) + # Populate the TF2BD lists table with whatever's currently + # configured, then wire up its checkbox toggles. + self.refresh_lists_table() + action_layout = QHBoxLayout() action_layout.addStretch() btn_cancel = QPushButton("Cancel") @@ -281,6 +350,175 @@ def reset_color(self, key): self.color_vars[key] = hex_c self.color_widgets[key].setStyleSheet(f"background-color: {hex_c}; border: 1px solid black;") + # ---- TF2BD lists table handlers ---- + + def refresh_lists_table(self): + """Re-render the lists table from ListManager.lists_config. Disables + cell editing on the Name/URL/Players/Last Status columns and wires + the Enabled/Auto-Update cells back to the manager on toggle.""" + from .ui_qt_dialogs import custom_askstring + self._custom_askstring = custom_askstring # keep ref alive + + # Avoid firing itemChanged during rebuild + self.lists_table.blockSignals(True) + try: + self.lists_table.setRowCount(0) + entries = self.logic.lists.get_lists() + for entry in entries: + row = self.lists_table.rowCount() + self.lists_table.insertRow(row) + + # Name + name_item = QTableWidgetItem(entry.get('name', '')) + if entry.get('is_builtin'): + name_item.setData(Qt.UserRole, entry['url']) + suffix = ' (built-in)' + name_item.setText(entry.get('name', '') + suffix) + name_item.setForeground(Qt.gray) + else: + name_item.setData(Qt.UserRole, entry['url']) + self.lists_table.setItem(row, 0, name_item) + + # Players + pc = entry.get('last_player_count', 0) + pc_text = str(pc) if pc else '—' + pc_item = QTableWidgetItem(pc_text) + pc_item.setTextAlignment(Qt.AlignCenter) + self.lists_table.setItem(row, 1, pc_item) + + # Enabled + en_chk = QCheckBox() + en_chk.setChecked(bool(entry.get('enabled', True))) + en_chk.toggled.connect( + lambda checked, url=entry['url']: self._on_list_enabled_toggled(url, checked) + ) + self.lists_table.setCellWidget(row, 2, en_chk) + + # Auto-update + au_chk = QCheckBox() + au_chk.setChecked(bool(entry.get('auto_update', True))) + au_chk.toggled.connect( + lambda checked, url=entry['url']: self._on_list_autoupdate_toggled(url, checked) + ) + self.lists_table.setCellWidget(row, 3, au_chk) + + # Last status + status_item = QTableWidgetItem(entry.get('last_status', '') or '—') + status_item.setToolTip(status_item.text()) + self.lists_table.setItem(row, 4, status_item) + + # URL + url_item = QTableWidgetItem(entry.get('url', '')) + url_item.setToolTip(entry.get('url', '')) + self.lists_table.setItem(row, 5, url_item) + finally: + self.lists_table.blockSignals(False) + + # Show last update status as the group caption's helper text. + last = self.logic.lists.last_update_status + if last: + self.lists_status_label.setText(f"Last update: {last}") + else: + self.lists_status_label.setText("") + + def _on_list_enabled_toggled(self, url, checked): + self.logic.lists.set_list_enabled(url, checked) + + def _on_list_autoupdate_toggled(self, url, checked): + self.logic.lists.set_list_auto_update(url, checked) + + def lists_add_custom(self): + from .ui_qt_dialogs import custom_askstring + url = custom_askstring( + self, self.px, + "Add Custom TF2BD List", + "Paste the URL of a TF2BD-format JSON list:", + "https://" + ) + if not url: + return + url = url.strip() + if not (url.startswith('http://') or url.startswith('https://')): + from .ui_qt_dialogs import custom_popup + custom_popup(self, self.px, "Invalid URL", + "URL must start with http:// or https://") + return + # Derive a default name from the URL basename + from urllib.parse import urlparse + path_basename = os.path.basename(urlparse(url).path.rstrip('/')) or 'custom' + default_name = path_basename.replace('.json', '').replace('playerlist.', '') + from .ui_qt_dialogs import custom_askstring + name = custom_askstring( + self, self.px, + "List Name", + "Display name for this list:", + default_name.capitalize() + " List" + ) + if not name: + return + + ok = self.logic.lists.add_custom_list(name.strip(), url) + if not ok: + from .ui_qt_dialogs import custom_popup + custom_popup(self, self.px, "Already Exists", + "A list with that URL is already configured.") + return + self.refresh_lists_table() + + def lists_remove_selected(self): + row = self.lists_table.currentRow() + if row < 0: + return + url = self.lists_table.item(row, 0).data(Qt.UserRole) + entry = next((e for e in self.logic.lists.get_lists() if e.get('url') == url), None) + if not entry: + return + if entry.get('is_builtin'): + from .ui_qt_dialogs import custom_popup + custom_popup( + self, self.px, "Built-in List", + "Built-in lists cannot be removed. Disable it instead " + "(uncheck the Enabled column) and it won't be loaded." + ) + return + from .ui_qt_dialogs import custom_popup + if custom_popup( + self, self.px, "Remove List?", + f"Remove '{entry.get('name')}' and delete its file from " + "tf2bd_lists/?", + is_confirmation=True, + ): + self.logic.lists.remove_list(url) + self.refresh_lists_table() + + def lists_update_now(self): + from .ui_qt_dialogs import custom_popup + result = self.logic.lists.force_update_now() + self.refresh_lists_table() + custom_popup( + self, self.px, + "TF2BD List Update", + result if result else "No changes." + ) + + def lists_open_folder(self): + import subprocess + # The tf2bd_lists/ directory sits at the working directory the + # binary was launched from, which for a --onefile PyInstaller build + # is the directory containing Sentry.exe. Resolve relative to that. + exe_dir = os.path.dirname(os.path.abspath(sys.executable)) if getattr(sys, 'frozen', False) \ + else os.getcwd() + folder = os.path.join(exe_dir, 'tf2bd_lists') + try: + os.makedirs(folder, exist_ok=True) + subprocess.Popen(['explorer', folder]) + except Exception as e: + from .ui_qt_dialogs import custom_popup + custom_popup(self, self.px, "Error", + f"Could not open folder:\n{e}") + + # ---- end TF2BD lists table handlers ---- + def save_all(self): for key, widget in self.vars.items(): val = None