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:
+2
-5
@@ -52,10 +52,10 @@ def user_data_path(*parts):
|
||||
return USER_DATA_DIR.joinpath(*parts)
|
||||
|
||||
# Redump 7z store (browsable in the Store tab). Env override; GUI setting wins.
|
||||
STORE_ROOT = Path(os.environ.get("PSP_STORE_DIR", "/media/downloads/Sony - PlayStation Portable"))
|
||||
STORE_ROOT = Path(os.environ.get("PSP_STORE_DIR", "/data/store"))
|
||||
|
||||
# Remote store server (HTTPS). Used by the Store tab as the primary source.
|
||||
STORE_SERVER_URL = os.environ.get("PSP_STORE_SERVER", "https://psp.ceaf.dev")
|
||||
STORE_SERVER_URL = os.environ.get("PSP_STORE_SERVER", "https://psp.example.com")
|
||||
STORE_TOKEN = os.environ.get("PSP_STORE_TOKEN", "")
|
||||
|
||||
# Optional direct URLs for the offline cover bundle zips. When unset, the GUI
|
||||
@@ -76,9 +76,6 @@ ARCHIVE_LOGGED_IN_USER = os.environ.get("PSP_ARCHIVE_LOGGED_IN_USER", "")
|
||||
NPS_OUTPUT_DIR = Path(os.environ.get("PSP_NPS_DIR", str(STATE_DIR / "nps")))
|
||||
NPS_TSV_BASE = os.environ.get("PSP_NPS_TSV_BASE", "https://nopaystation.com/tsv/")
|
||||
|
||||
# pkg2zip binary used to decrypt/extract NPS .pkg files (blank = auto-detect).
|
||||
PKG2ZIP_PATH = os.environ.get("PSP_PKG2ZIP_PATH", "")
|
||||
|
||||
# Scan roots (used by the scanner phase). Comma-separated in env.
|
||||
GAMES_ROOTS = [Path(p) for p in os.environ.get("PSP_GAMES_ROOTS", "/data/umd_games").split(",") if p.strip()]
|
||||
VIDEO_ROOTS = [Path(p) for p in os.environ.get("PSP_VIDEO_ROOTS", "/data/umd_videos").split(",") if p.strip()]
|
||||
|
||||
+166
-97
@@ -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 & 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 & 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 & 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 & 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)
|
||||
|
||||
+35
-1
@@ -111,6 +111,36 @@ def normalize_name(name):
|
||||
return _normalize(name)
|
||||
|
||||
|
||||
_EUROPEAN_REGIONS = {
|
||||
"europe", "germany", "france", "spain", "italy", "russia", "netherlands",
|
||||
"portugal", "poland", "united kingdom", "greece", "norway", "sweden",
|
||||
"denmark", "finland", "austria", "belgium", "switzerland",
|
||||
}
|
||||
|
||||
|
||||
def region_matches(region, prefer):
|
||||
"""True if ``region`` satisfies the user's preferred-region hint."""
|
||||
if not prefer or not region:
|
||||
return False
|
||||
tokens = {t.strip().lower() for t in str(region).split(",") if t.strip()}
|
||||
if not tokens:
|
||||
return False
|
||||
prefer = prefer.strip().lower()
|
||||
if prefer in tokens:
|
||||
return True
|
||||
if prefer == "europe":
|
||||
return bool(tokens & _EUROPEAN_REGIONS)
|
||||
return False
|
||||
|
||||
|
||||
def apply_region_preference(candidates, prefer):
|
||||
"""Narrow ``candidates`` to the preferred region (fall back to all)."""
|
||||
if not prefer:
|
||||
return candidates
|
||||
filtered = [c for c in candidates if region_matches(c.get("region"), prefer)]
|
||||
return filtered or candidates
|
||||
|
||||
|
||||
def serial_candidates(conn, record):
|
||||
"""Return all catalog releases matching the disc's serial (size-narrowed)."""
|
||||
serial = record.get("serial")
|
||||
@@ -131,7 +161,7 @@ def serial_candidates(conn, record):
|
||||
return _narrow(conn, [dict(r) for r in rows], record.get("size"))
|
||||
|
||||
|
||||
def match(conn, record):
|
||||
def match(conn, record, prefer_region=None):
|
||||
size = record.get("size")
|
||||
|
||||
# 1. Hash is the most precise key; serials are shared across revisions.
|
||||
@@ -171,6 +201,10 @@ def match(conn, record):
|
||||
for cand in candidates:
|
||||
if cand["id"] == saved["release_id"]:
|
||||
return "choice", cand
|
||||
if prefer_region:
|
||||
filtered = [c for c in candidates if region_matches(c.get("region"), prefer_region)]
|
||||
if len(filtered) == 1:
|
||||
return "region", filtered[0]
|
||||
return "unknown", None
|
||||
|
||||
# 4. Fuzzy fallback (filename + PARAM.SFO title), size-disambiguated.
|
||||
|
||||
+4
-91
@@ -9,14 +9,12 @@ folder — no Wine or ``pkg2zip`` required.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from typing import List
|
||||
|
||||
from . import config
|
||||
|
||||
@@ -167,31 +165,6 @@ def fetch_items(content_type: str, url: str = "", timeout: int = 60, force: bool
|
||||
raise
|
||||
|
||||
|
||||
def find_pkg2zip(configured_path: str = "") -> Optional[Path]:
|
||||
"""Locate pkg2zip: configured path, then the bundled binary, then PATH."""
|
||||
if configured_path:
|
||||
p = Path(configured_path)
|
||||
return p if p.is_file() else None
|
||||
bundled = config.DATA_DIR / "bin" / "pkg2zip.exe"
|
||||
if bundled.is_file():
|
||||
return bundled
|
||||
found = shutil.which("pkg2zip")
|
||||
return Path(found) if found else None
|
||||
|
||||
|
||||
def _pkg2zip_cmd(pkg2zip: Path) -> List[str]:
|
||||
"""Command prefix for pkg2zip, wrapping the bundled .exe in Wine on Linux."""
|
||||
if pkg2zip.suffix.lower() == ".exe" and os.name != "nt":
|
||||
wine = shutil.which("wine") or shutil.which("wine64")
|
||||
if not wine:
|
||||
raise RuntimeError(
|
||||
"The bundled pkg2zip is a Windows binary and Wine is not installed — "
|
||||
"install Wine (or point Settings → Store at a native pkg2zip)."
|
||||
)
|
||||
return [wine, str(pkg2zip)]
|
||||
return [str(pkg2zip)]
|
||||
|
||||
|
||||
def _safe_folder(value: str) -> str:
|
||||
import re
|
||||
cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper()
|
||||
@@ -211,12 +184,11 @@ def _ensure_keys_bin(folder, kind):
|
||||
pass
|
||||
|
||||
|
||||
def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
||||
def extract_pkg(pkg_path, title_id: str, dest_dir,
|
||||
kind_hint: str = None, progress_cb=None, cancel_event=None) -> Path:
|
||||
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
|
||||
|
||||
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls
|
||||
back to pkg2zip only if that fails and a binary is available. Returns the
|
||||
Uses the bundled pure-Python decryptor (no Wine or pkg2zip). Returns the
|
||||
destination game folder path.
|
||||
"""
|
||||
from . import pkg as pkgmod
|
||||
@@ -227,7 +199,6 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
||||
if dest.is_dir() and any(dest.iterdir()):
|
||||
return dest
|
||||
|
||||
# 1. Pure-Python decryptor (primary).
|
||||
try:
|
||||
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint,
|
||||
progress_cb, cancel_event)
|
||||
@@ -236,62 +207,4 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
|
||||
except TransferCancelled:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
py_exc = exc
|
||||
log.warning("python pkg decrypt failed (%s); trying pkg2zip fallback", exc)
|
||||
|
||||
# 2. pkg2zip fallback (needs a binary; usually Wine + the bundled .exe).
|
||||
if pkg2zip is None:
|
||||
pkg2zip = find_pkg2zip()
|
||||
if pkg2zip is None:
|
||||
raise RuntimeError(
|
||||
f"could not decrypt {pkg_path.name} (no pkg2zip available): {py_exc}"
|
||||
)
|
||||
|
||||
folder_name = _safe_folder(title_id)
|
||||
tmp = Path(dest_dir).with_name(".nps-tmp-" + folder_name)
|
||||
if tmp.exists():
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
tmp.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if progress_cb is not None:
|
||||
progress_cb("Extracting", 0.0, "")
|
||||
try:
|
||||
cmd = _pkg2zip_cmd(Path(pkg2zip)) + ["-x", str(pkg_path)]
|
||||
env = dict(os.environ)
|
||||
env["WINEDEBUG"] = "-all" # silence Wine's console chatter
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=str(tmp), stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, env=env,
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for _line in proc.stdout:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
proc.kill()
|
||||
raise TransferCancelled()
|
||||
proc.wait()
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"pkg2zip failed (exit {proc.returncode}) for {pkg_path.name}")
|
||||
finally:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
raise TransferCancelled()
|
||||
|
||||
eboots = list(tmp.rglob("EBOOT.PBP"))
|
||||
if not eboots:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
raise RuntimeError(f"pkg2zip produced no EBOOT.PBP from {pkg_path.name}")
|
||||
src_folder = eboots[0].parent
|
||||
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for p in sorted(src_folder.rglob("*")):
|
||||
if not p.is_file():
|
||||
continue
|
||||
rel = p.relative_to(src_folder)
|
||||
target = dest / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(p, target)
|
||||
if progress_cb is not None:
|
||||
progress_cb("Extracting", 1.0, "")
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
return dest
|
||||
raise RuntimeError(f"could not decrypt {pkg_path.name}: {exc}") from exc
|
||||
|
||||
+2
-2
@@ -2,9 +2,9 @@
|
||||
from . import iso_reader, matcher, namer
|
||||
|
||||
|
||||
def identify(conn, record):
|
||||
def identify(conn, record, prefer_region=None):
|
||||
"""Return ``{method, release, serial, canonical}`` for a scanned disc record."""
|
||||
method, release = matcher.match(conn, record)
|
||||
method, release = matcher.match(conn, record, prefer_region=prefer_region)
|
||||
return identify_with_release(record, release, method)
|
||||
|
||||
|
||||
|
||||
+8
-5
@@ -7,6 +7,8 @@ single cohesive dark theme here makes the whole UI consistent.
|
||||
"""
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
|
||||
from . import config
|
||||
|
||||
# Design tokens
|
||||
BG = "#1e1f24" # window background
|
||||
SURFACE = "#26272e" # cards, inputs, table headers
|
||||
@@ -19,6 +21,7 @@ TEXT_FAINT = "#6f7079"
|
||||
ACCENT = "#4a9eff"
|
||||
ACCENT_HOVER = "#6bb0ff"
|
||||
|
||||
|
||||
_STYLESHEET = f"""
|
||||
QMainWindow {{
|
||||
background: {BG};
|
||||
@@ -105,10 +108,9 @@ QComboBox::drop-down {{
|
||||
width: 22px;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: none;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid {TEXT_DIM};
|
||||
image: url(__CHEVRON__);
|
||||
width: 12px;
|
||||
height: 8px;
|
||||
margin-right: 6px;
|
||||
}}
|
||||
QComboBox QAbstractItemView {{
|
||||
@@ -310,4 +312,5 @@ def apply_theme(app):
|
||||
"""Apply the dark Fusion theme to ``app`` (call once before showing UI)."""
|
||||
app.setStyle("Fusion")
|
||||
app.setPalette(_dark_palette())
|
||||
app.setStyleSheet(_STYLESHEET)
|
||||
chevron = str(config.resource_path("img", "chevron.png"))
|
||||
app.setStyleSheet(_STYLESHEET.replace("__CHEVRON__", chevron))
|
||||
|
||||
+3
-7
@@ -476,11 +476,10 @@ class NoPayStationSource(Source):
|
||||
|
||||
label = "NoPayStation"
|
||||
|
||||
def __init__(self, content_type, tsv_url="", pkg2zip_path="", force=False):
|
||||
def __init__(self, content_type, tsv_url="", force=False):
|
||||
from . import nopaystation
|
||||
self.content_type = content_type
|
||||
self.tsv_url = tsv_url or nopaystation.tsv_url(content_type)
|
||||
self.pkg2zip = pkg2zip_path or ""
|
||||
self.force = force
|
||||
|
||||
def scan(self, root=None):
|
||||
@@ -492,10 +491,7 @@ class NoPayStationSource(Source):
|
||||
dest_dir = Path(dest_dir)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly;
|
||||
# pkg2zip is only an optional fallback if a binary happens to be
|
||||
# configured (and only supports games/PSX).
|
||||
pkg2zip = nopaystation.find_pkg2zip(self.pkg2zip)
|
||||
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly.
|
||||
title_id = item.get("title_id") or item.get("content_id") or ""
|
||||
pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg"
|
||||
tmp = Path(tempfile.mkdtemp(prefix="nps-download-"))
|
||||
@@ -505,7 +501,7 @@ class NoPayStationSource(Source):
|
||||
progress_cb("Downloading", 0.0, "")
|
||||
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event)
|
||||
kind_hint = self.content_type if self.content_type in ("theme", "dlc") else None
|
||||
folder = nopaystation.extract_pkg(pkg, title_id, dest_dir, pkg2zip,
|
||||
folder = nopaystation.extract_pkg(pkg, title_id, dest_dir,
|
||||
kind_hint, progress_cb, cancel_event)
|
||||
if self.content_type in ("theme", "dlc"):
|
||||
_write_sidecar(folder, item)
|
||||
|
||||
Reference in New Issue
Block a user