Polish settings, add matching defaults, drop pkg2zip, externalize infra config

- Rework Settings tab into grouped sections
- Add preferred-region matching and full-hash-by-default setting
- Replace combo-box square arrow with a proper chevron
- Fix UMD Refresh button (clicked bool was clearing the view)
- Remove pkg2zip.exe fallback (pure-Python decryptor is the primary path)
- Move LAN IP/share paths to .env; use example defaults in config
This commit is contained in:
2026-09-13 20:03:44 -04:00
parent 1ec2e81e15
commit c19717402c
14 changed files with 249 additions and 265 deletions
+166 -97
View File
@@ -18,7 +18,7 @@ from PySide6.QtWidgets import (
QFileDialog, QFormLayout, QGridLayout, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit,
QProgressBar, QPushButton, QScrollArea, QSpinBox, QSplitter, QStackedWidget,
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QGroupBox,
)
from . import config, covers, info, ingest, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, style, theme, transfers
@@ -34,12 +34,13 @@ class ScanWorker(QObject):
cancelled = Signal()
failed = Signal(str)
def __init__(self, roots, db_path, hash_files=True, workers=None):
def __init__(self, roots, db_path, hash_files=True, workers=None, prefer_region=None):
super().__init__()
self.roots = roots
self.db_path = db_path
self.hash_files = hash_files
self.workers = workers
self.prefer_region = prefer_region
self._cancel = threading.Event()
self._reply = [None]
self._reply_event = threading.Event()
@@ -69,17 +70,27 @@ class ScanWorker(QObject):
self.roots, conn=conn, workers=self.workers or config.SCAN_WORKERS,
hash_files=self.hash_files, cancel=self._cancel,
):
ident = pipeline.identify(conn, rec)
ident = pipeline.identify(conn, rec, prefer_region=self.prefer_region)
if ident["method"] == "unknown" and rec.get("serial") and not self._cancel.is_set():
candidates = matcher.serial_candidates(conn, rec)
if len(candidates) > 1:
chosen = self._ask(rec, candidates)
if self.prefer_region:
candidates = matcher.apply_region_preference(candidates, self.prefer_region)
if len(candidates) > 1:
chosen = self._ask(rec, candidates)
method = "choice"
elif len(candidates) == 1:
chosen = candidates[0]
method = "region"
else:
chosen = None
method = None
if chosen is not None:
conn.execute(
"INSERT OR REPLACE INTO choices (serial, size, release_id) VALUES (?,?,?)",
(rec["serial"].upper(), rec["size"], chosen["id"]),
)
ident = pipeline.identify_with_release(rec, chosen, "choice")
ident = pipeline.identify_with_release(rec, chosen, method)
rel = ident.get("release") or {}
conn.execute(
"UPDATE files SET release_id = ? WHERE path = ?",
@@ -1104,70 +1115,124 @@ class SettingsPage(QWidget):
def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe,
store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user,
iso_category, ps1_category, psp_category, ps1_dir, ps1_output_dir, ps1_base_pbp,
nps_dir="", pkg2zip_path="", parent=None):
nps_dir="", parent=None):
super().__init__(parent)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
self.tabs = QTabWidget()
outer.addWidget(self.tabs, 1)
s = QSettings("PSPPreserve", "PSPPreserve")
# -- General ----------------------------------------------------- #
page, form = self._new_form()
self.games_edit = self._dir_row(form, "Games directory:", games_dir)
self.videos_edit = self._dir_row(form, "Videos directory:", videos_dir)
self.ps1_edit = self._dir_row(form, "PS1 directory:", ps1_dir)
self.ps1_out_edit = self._dir_row(form, "PS1 output directory:", ps1_output_dir)
self.ps1_base_edit = self._file_row(form, "PS1 base.pbp (PSN classic):", ps1_base_pbp)
outer = QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header ---------------------------------------------------------- #
header = QHBoxLayout()
title_col = QVBoxLayout()
title_col.setSpacing(2)
title = QLabel("Settings")
title.setStyleSheet("font-size: 17px; font-weight: 600;")
subtitle = QLabel("Library folders, store access, devices, and cover sources.")
subtitle.setStyleSheet("color: #9a9ba5;")
title_col.addWidget(title)
title_col.addWidget(subtitle)
header.addLayout(title_col)
header.addStretch(1)
self.save_btn = QPushButton("Save settings")
self.save_btn.setDefault(True)
self.save_btn.clicked.connect(self._save)
header.addWidget(self.save_btn)
outer.addLayout(header)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
body = QWidget()
col = QVBoxLayout(body)
col.setContentsMargins(0, 4, 8, 4)
col.setSpacing(12)
scroll.setWidget(body)
outer.addWidget(scroll, 1)
# -- Library folders ------------------------------------------------ #
box, form = self._group("Library folders")
self.games_edit = self._dir_row(form, "Games directory", games_dir)
self.videos_edit = self._dir_row(form, "Videos directory", videos_dir)
col.addWidget(box)
# -- PS1 converter --------------------------------------------------- #
box, form = self._group("PS1 converter")
self.ps1_edit = self._dir_row(form, "PS1 source directory", ps1_dir)
self.ps1_out_edit = self._dir_row(form, "Converted output directory", ps1_output_dir)
self.ps1_base_edit = self._file_row(form, "base.pbp (PSN classic)", ps1_base_pbp)
col.addWidget(box)
# -- Store ----------------------------------------------------------- #
box, form = self._group("Store")
self.store_server_edit = self._line_row(form, "Server URL", store_server_url)
self.store_token_edit = self._secret_row(form, "API token", store_token)
self.archive_edit = self._line_row(form, "Archive.org item", store_archive_id)
self.archive_sig_edit = self._secret_row(form, "Archive.org logged-in-sig", store_archive_sig)
self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user", store_archive_user)
self.store_edit = self._dir_row(form, "Local store directory", store_dir)
self.nps_edit = self._dir_row(form, "NoPayStation output directory", nps_dir)
col.addWidget(box)
# -- Saves ----------------------------------------------------------- #
box, form = self._group("Saves")
self.ppsspp_edit = self._dir_row(form, "PPSSPP saves directory", ppsspp_dir)
self.cloud_edit = self._dir_row(form, "Cloud saves directory", cloud_dir)
self.ppsspp_exe_edit = self._file_row(form, "PPSSPP executable", ppsspp_exe)
self.auto_sync = QCheckBox("Auto-sync saves when the PSP is connected")
self.auto_sync.setChecked(str(s.value("auto_sync_saves", "false")).lower() == "true")
form.addRow(self.auto_sync)
col.addWidget(box)
# -- PSP device ------------------------------------------------------ #
box, form = self._group("PSP device (Category Lite)")
self.iso_category_edit = QLineEdit(iso_category)
self.iso_category_edit.setPlaceholderText("e.g. Homebrew (blank = ISO root)")
form.addRow("ISO category", self.iso_category_edit)
self.ps1_category_edit = QLineEdit(ps1_category)
self.ps1_category_edit.setPlaceholderText("e.g. PS1 (blank = PSP/GAME root)")
form.addRow("PS1 category", self.ps1_category_edit)
self.psp_category_edit = QLineEdit(psp_category)
self.psp_category_edit.setPlaceholderText("e.g. psp (blank = PSP/GAME root)")
form.addRow("PSP game category", self.psp_category_edit)
col.addWidget(box)
# -- Scanning -------------------------------------------------------- #
box, form = self._group("Scanning")
self.auto_scan = QCheckBox("Scan directories automatically on startup")
self.auto_scan.setChecked(str(s.value("auto_scan", False)).lower() == "true")
form.addRow(self.auto_scan)
self.hash_default = QCheckBox("Verify integrity (full hash) by default")
self.hash_default.setChecked(str(s.value("hash_default", False)).lower() == "true")
form.addRow(self.hash_default)
self.workers_spin = QSpinBox()
self.workers_spin.setRange(1, 16)
self.workers_spin.setValue(int(s.value("scan_workers", 1)))
self.workers_spin.setToolTip("Raise on SSDs / fast network shares; keep 1 on spinning disks.")
form.addRow("Parallel scan workers:", self.workers_spin)
self.tabs.addTab(page, "General")
form.addRow("Parallel scan workers", self.workers_spin)
col.addWidget(box)
# -- Store ------------------------------------------------------- #
page, form = self._new_form()
self.store_server_edit = self._line_row(form, "Server URL:", store_server_url)
self.store_token_edit = self._secret_row(form, "API token:", store_token)
self.archive_edit = self._line_row(form, "Archive.org item:", store_archive_id)
self.archive_sig_edit = self._secret_row(form, "Archive.org logged-in-sig:", store_archive_sig)
self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user:", store_archive_user)
self.store_edit = self._dir_row(form, "Local store dir:", store_dir)
self.nps_edit = self._dir_row(form, "NoPayStation output dir:", nps_dir)
self.pkg2zip_edit = self._file_row(form, "pkg2zip executable (optional fallback):", pkg2zip_path)
self.tabs.addTab(page, "Store")
# -- Matching -------------------------------------------------------- #
box, form = self._group("Matching")
self.region_combo = QComboBox()
self.region_combo.addItem("No preference — ask me", "")
for label in ("USA", "Europe", "Japan", "Asia", "Korea", "World"):
self.region_combo.addItem(label, label)
idx = self.region_combo.findData(str(s.value("prefer_region", "") or ""))
self.region_combo.setCurrentIndex(idx if idx >= 0 else 0)
form.addRow("Preferred region", self.region_combo)
self.region_hint = QLabel(
"When several catalog entries share a serial, auto-pick this region "
"instead of asking. Europe also covers individual European countries."
)
self.region_hint.setWordWrap(True)
self.region_hint.setStyleSheet("color: #9a9ba5;")
form.addRow(self.region_hint)
col.addWidget(box)
# -- Saves ------------------------------------------------------- #
page, form = self._new_form()
self.ppsspp_edit = self._dir_row(form, "PPSSPP saves dir:", ppsspp_dir)
self.cloud_edit = self._dir_row(form, "Cloud saves dir:", cloud_dir)
self.ppsspp_exe_edit = self._file_row(form, "PPSSPP executable:", ppsspp_exe)
self.auto_sync = QCheckBox("Auto-sync saves when the PSP is connected")
self.auto_sync.setChecked(str(s.value("auto_sync_saves", "false")).lower() == "true")
form.addRow(self.auto_sync)
self.tabs.addTab(page, "Saves")
# -- PSP --------------------------------------------------------- #
page, form = self._new_form()
self.iso_category_edit = QLineEdit(iso_category)
self.iso_category_edit.setPlaceholderText("e.g. Homebrew (blank = ISO root)")
form.addRow("ISO category (Category Lite):", self.iso_category_edit)
self.ps1_category_edit = QLineEdit(ps1_category)
self.ps1_category_edit.setPlaceholderText("e.g. PS1 (blank = PSP/GAME root)")
form.addRow("PS1 category (Category Lite):", self.ps1_category_edit)
self.psp_category_edit = QLineEdit(psp_category)
self.psp_category_edit.setPlaceholderText("e.g. psp (blank = PSP/GAME root)")
form.addRow("PSP game category (Category Lite):", self.psp_category_edit)
self.tabs.addTab(page, "PSP")
# -- Cover sources ------------------------------------------------ #
page, form = self._new_form()
# -- Cover sources --------------------------------------------------- #
box, form = self._group("Cover sources")
creds = sources.load()
self.fields = {}
@@ -1185,21 +1250,22 @@ class SettingsPage(QWidget):
"game-cover source. The keys below are optional upgrades."
)
note.setWordWrap(True)
note.setStyleSheet("color: #9a9ba5;")
form.addRow(note)
form.addRow(QLabel("<b>Games — covers &amp; metadata</b>"))
add_field("IGDB Client ID:", "igdb_client_id")
add_field("IGDB Client Secret:", "igdb_client_secret", password=True)
add_field("ScreenScraper dev ID:", "screenscraper_devid")
add_field("ScreenScraper dev password:", "screenscraper_devpassword", password=True)
add_field("ScreenScraper user ID:", "screenscraper_ssid")
add_field("ScreenScraper user password:", "screenscraper_sspassword", password=True)
add_field("ScreenScraper software name:", "screenscraper_softname")
self._subtitle(form, "Games — covers &amp; metadata")
add_field("IGDB Client ID", "igdb_client_id")
add_field("IGDB Client Secret", "igdb_client_secret", password=True)
add_field("ScreenScraper dev ID", "screenscraper_devid")
add_field("ScreenScraper dev password", "screenscraper_devpassword", password=True)
add_field("ScreenScraper user ID", "screenscraper_ssid")
add_field("ScreenScraper user password", "screenscraper_sspassword", password=True)
add_field("ScreenScraper software name", "screenscraper_softname")
form.addRow(QLabel("<b>Videos — metadata &amp; posters</b>"))
add_field("TheMovieDB API key:", "tmdb_api_key", password=True)
add_field("Google API key:", "google_api_key", password=True)
add_field("Google Search Engine ID (cx):", "google_cx")
self._subtitle(form, "Videos — metadata &amp; posters")
add_field("TheMovieDB API key", "tmdb_api_key", password=True)
add_field("Google API key", "google_api_key", password=True)
add_field("Google Search Engine ID (cx)", "google_cx")
hint = QLabel(
"Manual UMD covers: drop images into "
@@ -1207,28 +1273,27 @@ class SettingsPage(QWidget):
"(subfolders <tt>games/</tt> and <tt>videos/</tt>)."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: #9a9ba5;")
form.addRow(hint)
self.clear_covers_btn = QPushButton("Clear cover cache (re-fetch all covers)")
self.clear_covers_btn.clicked.connect(self._clear_cover_cache)
form.addRow(self.clear_covers_btn)
self.tabs.addTab(page, "Cover sources")
col.addWidget(box)
self.save_btn = QPushButton("Save settings")
self.save_btn.clicked.connect(self._save)
outer.addWidget(self.save_btn)
col.addStretch(1)
def _new_form(self):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
page = QWidget()
form = QFormLayout(page)
form.setContentsMargins(16, 16, 16, 16)
def _group(self, title):
box = QGroupBox(title)
form = QFormLayout(box)
form.setContentsMargins(16, 18, 16, 16)
form.setHorizontalSpacing(16)
form.setVerticalSpacing(10)
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
scroll.setWidget(page)
return scroll, form
return box, form
def _subtitle(self, form, text):
label = QLabel(f"<b>{text}</b>")
form.addRow(label)
def _dir_row(self, form, label, value):
edit = QLineEdit(value)
@@ -1304,9 +1369,6 @@ class SettingsPage(QWidget):
def nps_dir(self):
return self.nps_edit.text().strip()
def pkg2zip_path(self):
return self.pkg2zip_edit.text().strip()
def ppsspp_saves_dir(self):
return self.ppsspp_edit.text().strip()
@@ -1337,6 +1399,9 @@ class SettingsPage(QWidget):
def ps1_base_pbp(self):
return self.ps1_base_edit.text().strip()
def prefer_region(self):
return self.region_combo.currentData() or ""
def _save(self):
sources.save({key: edit.text() for key, edit in self.fields.items()})
covers.clear_miss_cache()
@@ -1344,6 +1409,8 @@ class SettingsPage(QWidget):
s.setValue("auto_scan", self.auto_scan.isChecked())
s.setValue("scan_workers", self.workers_spin.value())
s.setValue("auto_sync_saves", self.auto_sync.isChecked())
s.setValue("hash_default", self.hash_default.isChecked())
s.setValue("prefer_region", self.region_combo.currentData() or "")
self.saved.emit()
def _clear_cover_cache(self):
@@ -1677,7 +1744,6 @@ class StorePage(QWidget):
self._archive_sig = ""
self._archive_user = ""
self._nps_dir = ""
self._pkg2zip = ""
self._items = []
self._filtered = []
self._lib_norm = set()
@@ -1795,7 +1861,7 @@ class StorePage(QWidget):
layout.addWidget(self.status)
def set_store_config(self, local_dir, server_url, token, archive_id="", archive_sig="", archive_user="",
nps_dir="", pkg2zip_path=""):
nps_dir=""):
self._store_dir = local_dir or ""
self._server_url = server_url or ""
self._token = token or ""
@@ -1803,7 +1869,6 @@ class StorePage(QWidget):
self._archive_sig = archive_sig or ""
self._archive_user = archive_user or ""
self._nps_dir = nps_dir or ""
self._pkg2zip = pkg2zip_path or ""
if self._server_url:
self.status.setText(f"Store: {self._server_url}")
elif self._archive_id:
@@ -1820,7 +1885,7 @@ class StorePage(QWidget):
return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)
if self.source_combo.currentIndex() == 3:
ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
return transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip)
return transfers.NoPayStationSource(ct)
return transfers.LocalShareSource(self._store_dir)
def _on_nps_type_changed(self, _idx):
@@ -1889,7 +1954,7 @@ class StorePage(QWidget):
ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
log.info("store: fetching NoPayStation %s list", ct)
self.status.setText(f"Fetching NoPayStation ({ct.upper()})…")
worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip, force=force))
worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, force=force))
else:
if not self._store_dir:
self.status.setText("Set the local store folder in Settings (Advanced)")
@@ -2845,7 +2910,7 @@ class PspSyncPage(QWidget):
bar = QHBoxLayout()
bar.addWidget(QLabel("UMD (USB UMD mode):"))
self.umd_refresh_btn = QPushButton("Refresh")
self.umd_refresh_btn.clicked.connect(self.refresh_umd)
self.umd_refresh_btn.clicked.connect(lambda: self.refresh_umd())
bar.addWidget(self.umd_refresh_btn)
self.umd_dump_btn = QPushButton("Copy to library")
self.umd_dump_btn.setEnabled(False)
@@ -4208,7 +4273,7 @@ class MainWindow(QMainWindow):
self._ps1_output_dir = str(self.settings.value("ps1_output_dir", str(config.PS1_OUTPUT_DIR)) or "")
self._ps1_base_pbp = str(self.settings.value("ps1_base_pbp", "") or "")
self._nps_dir = str(self.settings.value("nps_dir", str(config.NPS_OUTPUT_DIR)) or "")
self._pkg2zip = str(self.settings.value("pkg2zip_path", config.PKG2ZIP_PATH) or "")
self._prefer_region = str(self.settings.value("prefer_region", "") or "")
log.info("games dir: %s", self._games_dir or "(none)")
log.info("videos dir: %s", self._videos_dir or "(none)")
log.info("store: %s", self._store_server_url or self._store_dir or "(none)")
@@ -4240,7 +4305,7 @@ class MainWindow(QMainWindow):
self.store_page = StorePage()
self.store_page.set_store_config(self._store_dir, self._store_server_url, self._store_token,
self._store_archive_id, self._store_archive_sig, self._store_archive_user,
self._nps_dir, self._pkg2zip)
self._nps_dir)
self.store_page.download_requested.connect(self._on_store_download)
self.store_page.scrape_status.connect(self._on_store_scrape_status)
self.store_page.scrape_complete.connect(self.load_library)
@@ -4385,6 +4450,8 @@ class MainWindow(QMainWindow):
self.apply_btn.clicked.connect(self._apply_renames)
controls.addWidget(self.apply_btn)
self.hash_check = QCheckBox("Verify integrity (full hash)")
self.hash_check.setChecked(
str(self.settings.value("hash_default", False)).lower() == "true")
controls.addWidget(self.hash_check)
self.progress = QProgressBar()
controls.addWidget(self.progress, 1)
@@ -4428,7 +4495,7 @@ class MainWindow(QMainWindow):
self._store_archive_sig, self._store_archive_user, self._iso_category,
self._ps1_category, self._psp_category,
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp,
self._nps_dir, self._pkg2zip)
self._nps_dir)
self.settings_page.saved.connect(self._on_settings_saved)
return self.settings_page
@@ -4451,7 +4518,7 @@ class MainWindow(QMainWindow):
self.settings.setValue("store_archive_user", self._store_archive_user)
self.store_page.set_store_config(self._store_dir, self._store_server_url, self._store_token,
self._store_archive_id, self._store_archive_sig, self._store_archive_user,
self._nps_dir, self._pkg2zip)
self._nps_dir)
self.store_page.refresh()
self._ppsspp_saves_dir = self.settings_page.ppsspp_saves_dir()
self._cloud_saves_dir = self.settings_page.cloud_saves_dir()
@@ -4464,12 +4531,13 @@ class MainWindow(QMainWindow):
self._ps1_output_dir = self.settings_page.ps1_output_dir()
self._ps1_base_pbp = self.settings_page.ps1_base_pbp()
self._nps_dir = self.settings_page.nps_dir()
self._pkg2zip = self.settings_page.pkg2zip_path()
self.settings.setValue("ps1_dir", self._ps1_dir)
self.settings.setValue("ps1_output_dir", self._ps1_output_dir)
self.settings.setValue("ps1_base_pbp", self._ps1_base_pbp)
self.settings.setValue("nps_dir", self._nps_dir)
self.settings.setValue("pkg2zip_path", self._pkg2zip)
self._prefer_region = self.settings_page.prefer_region()
self.settings.setValue("prefer_region", self._prefer_region)
self.hash_check.setChecked(str(self.settings.value("hash_default", False)).lower() == "true")
self.ps1_page.set_dirs(self._ps1_dir, self._ps1_output_dir)
self.settings.setValue("ppsspp_saves_dir", self._ppsspp_saves_dir)
self.settings.setValue("cloud_saves_dir", self._cloud_saves_dir)
@@ -5198,6 +5266,7 @@ class MainWindow(QMainWindow):
roots, config.DB_PATH,
hash_files=self.hash_check.isChecked(),
workers=workers,
prefer_region=self._prefer_region,
)
self.worker.moveToThread(self.thread)
self.thread.started.connect(self.worker.run)