pax e8f72c6fe6 Add Network tab to settings — shows all connected hosts
Logs every outgoing connection (API requests and image downloads)
with timestamps. Network tab in Settings shows all hosts contacted
this session with request counts. No telemetry, just transparency.
2026-04-04 21:11:01 -05:00

660 lines
24 KiB
Python

"""Settings dialog."""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QDialog,
QVBoxLayout,
QHBoxLayout,
QFormLayout,
QTabWidget,
QWidget,
QLabel,
QPushButton,
QSpinBox,
QComboBox,
QCheckBox,
QLineEdit,
QListWidget,
QMessageBox,
QGroupBox,
QProgressBar,
)
from ..core.db import Database
from ..core.cache import cache_size_bytes, cache_file_count, clear_cache, evict_oldest
from ..core.config import (
data_dir, cache_dir, thumbnails_dir, db_path, IS_WINDOWS,
)
class SettingsDialog(QDialog):
"""Full settings panel with tabs."""
settings_changed = Signal()
favorites_imported = Signal()
def __init__(self, db: Database, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._db = db
self.setWindowTitle("Settings")
self.setMinimumSize(550, 450)
layout = QVBoxLayout(self)
self._tabs = QTabWidget()
layout.addWidget(self._tabs)
self._tabs.addTab(self._build_general_tab(), "General")
self._tabs.addTab(self._build_cache_tab(), "Cache")
self._tabs.addTab(self._build_blacklist_tab(), "Blacklist")
self._tabs.addTab(self._build_paths_tab(), "Paths")
self._tabs.addTab(self._build_theme_tab(), "Theme")
self._tabs.addTab(self._build_network_tab(), "Network")
# Bottom buttons
btns = QHBoxLayout()
btns.addStretch()
save_btn = QPushButton("Save")
save_btn.clicked.connect(self._save_and_close)
btns.addWidget(save_btn)
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
btns.addWidget(cancel_btn)
layout.addLayout(btns)
# -- General tab --
def _build_general_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
form = QFormLayout()
# Page size
self._page_size = QSpinBox()
self._page_size.setRange(10, 100)
self._page_size.setValue(self._db.get_setting_int("page_size"))
form.addRow("Results per page:", self._page_size)
# Thumbnail size
self._thumb_size = QSpinBox()
self._thumb_size.setRange(100, 400)
self._thumb_size.setSingleStep(20)
self._thumb_size.setValue(self._db.get_setting_int("thumbnail_size"))
form.addRow("Thumbnail size (px):", self._thumb_size)
# Default rating
self._default_rating = QComboBox()
self._default_rating.addItems(["all", "general", "sensitive", "questionable", "explicit"])
current_rating = self._db.get_setting("default_rating")
idx = self._default_rating.findText(current_rating)
if idx >= 0:
self._default_rating.setCurrentIndex(idx)
form.addRow("Default rating filter:", self._default_rating)
# Default min score
self._default_score = QSpinBox()
self._default_score.setRange(0, 99999)
self._default_score.setValue(self._db.get_setting_int("default_score"))
form.addRow("Default minimum score:", self._default_score)
# Preload thumbnails
self._preload = QCheckBox("Load thumbnails automatically")
self._preload.setChecked(self._db.get_setting_bool("preload_thumbnails"))
form.addRow("", self._preload)
# File dialog platform (Linux only)
self._file_dialog_combo = None
if not IS_WINDOWS:
self._file_dialog_combo = QComboBox()
self._file_dialog_combo.addItems(["qt", "gtk"])
current = self._db.get_setting("file_dialog_platform")
idx = self._file_dialog_combo.findText(current)
if idx >= 0:
self._file_dialog_combo.setCurrentIndex(idx)
form.addRow("File dialog (restart required):", self._file_dialog_combo)
layout.addLayout(form)
layout.addStretch()
return w
# -- Cache tab --
def _build_cache_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
# Cache stats
stats_group = QGroupBox("Cache Statistics")
stats_layout = QFormLayout(stats_group)
images, thumbs = cache_file_count()
total_bytes = cache_size_bytes()
total_mb = total_bytes / (1024 * 1024)
self._cache_images_label = QLabel(f"{images}")
stats_layout.addRow("Cached images:", self._cache_images_label)
self._cache_thumbs_label = QLabel(f"{thumbs}")
stats_layout.addRow("Cached thumbnails:", self._cache_thumbs_label)
self._cache_size_label = QLabel(f"{total_mb:.1f} MB")
stats_layout.addRow("Total size:", self._cache_size_label)
self._fav_count_label = QLabel(f"{self._db.favorite_count()}")
stats_layout.addRow("Favorites:", self._fav_count_label)
layout.addWidget(stats_group)
# Cache limits
limits_group = QGroupBox("Cache Limits")
limits_layout = QFormLayout(limits_group)
self._max_cache = QSpinBox()
self._max_cache.setRange(100, 50000)
self._max_cache.setSuffix(" MB")
self._max_cache.setValue(self._db.get_setting_int("max_cache_mb"))
limits_layout.addRow("Max cache size:", self._max_cache)
self._auto_evict = QCheckBox("Auto-evict oldest when limit reached")
self._auto_evict.setChecked(self._db.get_setting_bool("auto_evict"))
limits_layout.addRow("", self._auto_evict)
self._clear_on_exit = QCheckBox("Clear cache on exit (session-only cache)")
self._clear_on_exit.setChecked(self._db.get_setting_bool("clear_cache_on_exit"))
limits_layout.addRow("", self._clear_on_exit)
layout.addWidget(limits_group)
# Cache actions
actions_group = QGroupBox("Actions")
actions_layout = QVBoxLayout(actions_group)
btn_row1 = QHBoxLayout()
clear_thumbs_btn = QPushButton("Clear Thumbnails")
clear_thumbs_btn.clicked.connect(self._clear_thumbnails)
btn_row1.addWidget(clear_thumbs_btn)
clear_cache_btn = QPushButton("Clear Image Cache")
clear_cache_btn.clicked.connect(self._clear_image_cache)
btn_row1.addWidget(clear_cache_btn)
actions_layout.addLayout(btn_row1)
btn_row2 = QHBoxLayout()
clear_all_btn = QPushButton("Clear Everything")
clear_all_btn.setStyleSheet(f"QPushButton {{ color: #ff4444; }}")
clear_all_btn.clicked.connect(self._clear_all)
btn_row2.addWidget(clear_all_btn)
evict_btn = QPushButton("Evict to Limit Now")
evict_btn.clicked.connect(self._evict_now)
btn_row2.addWidget(evict_btn)
actions_layout.addLayout(btn_row2)
layout.addWidget(actions_group)
layout.addStretch()
return w
# -- Blacklist tab --
def _build_blacklist_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(QLabel("Posts with these tags will be hidden from results:"))
self._bl_list = QListWidget()
self._refresh_blacklist()
layout.addWidget(self._bl_list)
add_row = QHBoxLayout()
self._bl_input = QLineEdit()
self._bl_input.setPlaceholderText("Tag to blacklist...")
self._bl_input.returnPressed.connect(self._bl_add)
add_row.addWidget(self._bl_input, stretch=1)
add_btn = QPushButton("Add")
add_btn.clicked.connect(self._bl_add)
add_row.addWidget(add_btn)
remove_btn = QPushButton("Remove")
remove_btn.clicked.connect(self._bl_remove)
add_row.addWidget(remove_btn)
layout.addLayout(add_row)
io_row = QHBoxLayout()
export_bl_btn = QPushButton("Export")
export_bl_btn.clicked.connect(self._bl_export)
io_row.addWidget(export_bl_btn)
import_bl_btn = QPushButton("Import")
import_bl_btn.clicked.connect(self._bl_import)
io_row.addWidget(import_bl_btn)
clear_bl_btn = QPushButton("Clear All")
clear_bl_btn.clicked.connect(self._bl_clear)
io_row.addWidget(clear_bl_btn)
layout.addLayout(io_row)
return w
# -- Paths tab --
def _build_paths_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
form = QFormLayout()
data = QLineEdit(str(data_dir()))
data.setReadOnly(True)
form.addRow("Data directory:", data)
cache = QLineEdit(str(cache_dir()))
cache.setReadOnly(True)
form.addRow("Image cache:", cache)
thumbs = QLineEdit(str(thumbnails_dir()))
thumbs.setReadOnly(True)
form.addRow("Thumbnails:", thumbs)
db = QLineEdit(str(db_path()))
db.setReadOnly(True)
form.addRow("Database:", db)
layout.addLayout(form)
open_btn = QPushButton("Open Data Folder")
open_btn.clicked.connect(self._open_data_folder)
layout.addWidget(open_btn)
layout.addStretch()
# Export / Import
exp_group = QGroupBox("Backup")
exp_layout = QHBoxLayout(exp_group)
export_btn = QPushButton("Export Favorites")
export_btn.clicked.connect(self._export_favorites)
exp_layout.addWidget(export_btn)
import_btn = QPushButton("Import Favorites")
import_btn.clicked.connect(self._import_favorites)
exp_layout.addWidget(import_btn)
layout.addWidget(exp_group)
return w
# -- Theme tab --
def _build_theme_tab(self) -> QWidget:
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(QLabel(
"Customize the app's appearance with a Qt stylesheet (QSS).\n"
"Place a custom.qss file in your data directory.\n"
"Restart the app after editing."
))
css_path = data_dir() / "custom.qss"
path_label = QLineEdit(str(css_path))
path_label.setReadOnly(True)
layout.addWidget(path_label)
btn_row = QHBoxLayout()
edit_btn = QPushButton("Edit custom.qss")
edit_btn.clicked.connect(self._edit_custom_css)
btn_row.addWidget(edit_btn)
create_btn = QPushButton("Create from Template")
create_btn.clicked.connect(self._create_css_template)
btn_row.addWidget(create_btn)
guide_btn = QPushButton("View Guide")
guide_btn.clicked.connect(self._view_css_guide)
btn_row.addWidget(guide_btn)
layout.addLayout(btn_row)
delete_btn = QPushButton("Delete custom.qss (Reset to Default)")
delete_btn.clicked.connect(self._delete_custom_css)
layout.addWidget(delete_btn)
layout.addStretch()
return w
# -- Network tab --
def _build_network_tab(self) -> QWidget:
from ..core.cache import get_connection_log
w = QWidget()
layout = QVBoxLayout(w)
layout.addWidget(QLabel(
"All hosts contacted this session. booru-viewer only connects\n"
"to the booru sites you configure — no telemetry or analytics."
))
self._net_list = QListWidget()
self._net_list.setAlternatingRowColors(True)
layout.addWidget(self._net_list)
refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(self._refresh_network)
layout.addWidget(refresh_btn)
self._refresh_network()
return w
def _refresh_network(self) -> None:
from ..core.cache import get_connection_log
self._net_list.clear()
log = get_connection_log()
if not log:
self._net_list.addItem("No connections made yet")
return
for host, times in log.items():
self._net_list.addItem(f"{host} ({len(times)} requests, last: {times[-1]})")
def _edit_custom_css(self) -> None:
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
css_path = data_dir() / "custom.qss"
if not css_path.exists():
css_path.write_text("/* booru-viewer custom stylesheet */\n\n")
QDesktopServices.openUrl(QUrl.fromLocalFile(str(css_path)))
def _create_css_template(self) -> None:
css_path = data_dir() / "custom.qss"
if css_path.exists():
reply = QMessageBox.question(
self, "Confirm", "Overwrite existing custom.qss with template?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
template = (
"/* booru-viewer custom stylesheet */\n"
"/* Edit and restart the app to apply changes */\n\n"
"/* -- Accent color -- */\n"
"/* QWidget { color: #00ff00; } */\n\n"
"/* -- Background -- */\n"
"/* QWidget { background-color: #000000; } */\n\n"
"/* -- Font -- */\n"
"/* QWidget { font-family: monospace; font-size: 13px; } */\n\n"
"/* -- Buttons -- */\n"
"/* QPushButton { padding: 6px 16px; border-radius: 4px; } */\n"
"/* QPushButton:hover { border-color: #00ff00; } */\n\n"
"/* -- Inputs -- */\n"
"/* QLineEdit { padding: 6px 10px; border-radius: 4px; } */\n"
"/* QLineEdit:focus { border-color: #00ff00; } */\n\n"
"/* -- Scrollbar -- */\n"
"/* QScrollBar:vertical { width: 10px; } */\n\n"
"/* -- Video seek bar -- */\n"
"/* QSlider::groove:horizontal { background: #333; height: 6px; } */\n"
"/* QSlider::handle:horizontal { background: #00ff00; width: 14px; } */\n"
)
css_path.write_text(template)
QMessageBox.information(self, "Done", f"Template created at:\n{css_path}")
def _view_css_guide(self) -> None:
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
dest = data_dir() / "custom_css_guide.txt"
# Copy guide to appdata if not already there
if not dest.exists():
import sys
# Try source tree, then PyInstaller bundle
for candidate in [
Path(__file__).parent / "custom_css_guide.txt",
Path(getattr(sys, '_MEIPASS', __file__)) / "booru_viewer" / "gui" / "custom_css_guide.txt",
]:
if candidate.is_file():
dest.write_text(candidate.read_text())
break
if dest.exists():
QDesktopServices.openUrl(QUrl.fromLocalFile(str(dest)))
else:
# Fallback: show in dialog
from PySide6.QtWidgets import QTextEdit, QDialog, QVBoxLayout
dlg = QDialog(self)
dlg.setWindowTitle("Custom CSS Guide")
dlg.resize(600, 500)
layout = QVBoxLayout(dlg)
text = QTextEdit()
text.setReadOnly(True)
text.setPlainText(
"booru-viewer Custom Stylesheet Guide\n"
"=====================================\n\n"
"Place a file named 'custom.qss' in your data directory.\n"
f"Path: {data_dir() / 'custom.qss'}\n\n"
"WIDGET REFERENCE\n"
"----------------\n"
"QMainWindow, QPushButton, QLineEdit, QComboBox, QScrollBar,\n"
"QLabel, QStatusBar, QTabWidget, QTabBar, QListWidget,\n"
"QMenu, QMenuBar, QToolTip, QDialog, QSplitter, QProgressBar,\n"
"QSpinBox, QCheckBox, QSlider\n\n"
"STATES: :hover, :pressed, :focus, :selected, :disabled\n\n"
"PROPERTIES: color, background-color, border, border-radius,\n"
"padding, margin, font-family, font-size\n\n"
"EXAMPLE\n"
"-------\n"
"QPushButton {\n"
" background: #333; color: white;\n"
" border: 1px solid #555; border-radius: 4px;\n"
" padding: 6px 16px;\n"
"}\n"
"QPushButton:hover { background: #555; }\n\n"
"Restart the app after editing custom.qss."
)
layout.addWidget(text)
dlg.exec()
def _delete_custom_css(self) -> None:
css_path = data_dir() / "custom.qss"
if css_path.exists():
css_path.unlink()
QMessageBox.information(self, "Done", "Deleted. Restart to use default theme.")
else:
QMessageBox.information(self, "Info", "No custom.qss found.")
# -- Actions --
def _refresh_stats(self) -> None:
images, thumbs = cache_file_count()
total_bytes = cache_size_bytes()
total_mb = total_bytes / (1024 * 1024)
self._cache_images_label.setText(f"{images}")
self._cache_thumbs_label.setText(f"{thumbs}")
self._cache_size_label.setText(f"{total_mb:.1f} MB")
def _clear_thumbnails(self) -> None:
count = clear_cache(clear_images=False, clear_thumbnails=True)
QMessageBox.information(self, "Done", f"Deleted {count} thumbnails.")
self._refresh_stats()
def _clear_image_cache(self) -> None:
reply = QMessageBox.question(
self, "Confirm",
"Delete all cached images? (Favorites stay in the database but cached files are removed.)",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
count = clear_cache(clear_images=True, clear_thumbnails=False)
QMessageBox.information(self, "Done", f"Deleted {count} cached images.")
self._refresh_stats()
def _clear_all(self) -> None:
reply = QMessageBox.question(
self, "Confirm",
"Delete ALL cached images and thumbnails?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
count = clear_cache(clear_images=True, clear_thumbnails=True)
QMessageBox.information(self, "Done", f"Deleted {count} files.")
self._refresh_stats()
def _evict_now(self) -> None:
max_bytes = self._max_cache.value() * 1024 * 1024
# Protect favorited file paths
protected = set()
for fav in self._db.get_favorites(limit=999999):
if fav.cached_path:
protected.add(fav.cached_path)
count = evict_oldest(max_bytes, protected)
QMessageBox.information(self, "Done", f"Evicted {count} files.")
self._refresh_stats()
def _refresh_blacklist(self) -> None:
self._bl_list.clear()
for tag in self._db.get_blacklisted_tags():
self._bl_list.addItem(tag)
def _bl_add(self) -> None:
tag = self._bl_input.text().strip()
if tag:
self._db.add_blacklisted_tag(tag)
self._bl_input.clear()
self._refresh_blacklist()
def _bl_remove(self) -> None:
item = self._bl_list.currentItem()
if item:
self._db.remove_blacklisted_tag(item.text())
self._refresh_blacklist()
def _bl_export(self) -> None:
from .dialogs import save_file
path = save_file(self, "Export Blacklist", "blacklist.txt", "Text (*.txt)")
if not path:
return
tags = self._db.get_blacklisted_tags()
with open(path, "w") as f:
f.write("\n".join(tags))
QMessageBox.information(self, "Done", f"Exported {len(tags)} tags.")
def _bl_import(self) -> None:
from .dialogs import open_file
path = open_file(self, "Import Blacklist", "Text (*.txt)")
if not path:
return
try:
with open(path) as f:
tags = [line.strip() for line in f if line.strip()]
count = 0
for tag in tags:
self._db.add_blacklisted_tag(tag)
count += 1
self._refresh_blacklist()
QMessageBox.information(self, "Done", f"Imported {count} tags.")
except Exception as e:
QMessageBox.warning(self, "Error", str(e))
def _bl_clear(self) -> None:
reply = QMessageBox.question(
self, "Confirm", "Remove all blacklisted tags?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
for tag in self._db.get_blacklisted_tags():
self._db.remove_blacklisted_tag(tag)
self._refresh_blacklist()
def _open_data_folder(self) -> None:
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
QDesktopServices.openUrl(QUrl.fromLocalFile(str(data_dir())))
def _export_favorites(self) -> None:
from .dialogs import save_file
import json
path = save_file(self, "Export Favorites", "favorites.json", "JSON (*.json)")
if not path:
return
favs = self._db.get_favorites(limit=999999)
data = [
{
"post_id": f.post_id,
"site_id": f.site_id,
"file_url": f.file_url,
"preview_url": f.preview_url,
"tags": f.tags,
"rating": f.rating,
"score": f.score,
"source": f.source,
"folder": f.folder,
"favorited_at": f.favorited_at,
}
for f in favs
]
with open(path, "w") as fp:
json.dump(data, fp, indent=2)
QMessageBox.information(self, "Done", f"Exported {len(data)} favorites.")
def _import_favorites(self) -> None:
from .dialogs import open_file
import json
path = open_file(self, "Import Favorites", "JSON (*.json)")
if not path:
return
try:
with open(path) as fp:
data = json.load(fp)
count = 0
for item in data:
try:
folder = item.get("folder")
self._db.add_favorite(
site_id=item["site_id"],
post_id=item["post_id"],
file_url=item["file_url"],
preview_url=item.get("preview_url"),
tags=item.get("tags", ""),
rating=item.get("rating"),
score=item.get("score"),
source=item.get("source"),
folder=folder,
)
if folder:
self._db.add_folder(folder)
count += 1
except Exception:
pass
QMessageBox.information(self, "Done", f"Imported {count} favorites.")
self.favorites_imported.emit()
except Exception as e:
QMessageBox.warning(self, "Error", str(e))
# -- Save --
def _save_and_close(self) -> None:
self._db.set_setting("page_size", str(self._page_size.value()))
self._db.set_setting("thumbnail_size", str(self._thumb_size.value()))
self._db.set_setting("default_rating", self._default_rating.currentText())
self._db.set_setting("default_score", str(self._default_score.value()))
self._db.set_setting("preload_thumbnails", "1" if self._preload.isChecked() else "0")
self._db.set_setting("max_cache_mb", str(self._max_cache.value()))
self._db.set_setting("auto_evict", "1" if self._auto_evict.isChecked() else "0")
self._db.set_setting("clear_cache_on_exit", "1" if self._clear_on_exit.isChecked() else "0")
if self._file_dialog_combo is not None:
self._db.set_setting("file_dialog_platform", self._file_dialog_combo.currentText())
self.settings_changed.emit()
self.accept()