From c19717402c99e46560a07010cddf76b41e9e841e Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 13 Sep 2026 20:03:44 -0400 Subject: [PATCH] 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 --- .env.example | 12 ++ .gitignore | 5 +- README.md | 19 ++- app/config.py | 7 +- app/gui.py | 263 +++++++++++++++++++++++++--------------- app/matcher.py | 36 +++++- app/nopaystation.py | 95 +-------------- app/pipeline.py | 4 +- app/style.py | 13 +- app/transfers.py | 10 +- data/bin/ATTRIBUTION.md | 38 ------ data/bin/pkg2zip.exe | Bin 84480 -> 0 bytes docker-compose.yml | 12 +- img/chevron.png | Bin 0 -> 273 bytes 14 files changed, 249 insertions(+), 265 deletions(-) create mode 100644 .env.example delete mode 100644 data/bin/ATTRIBUTION.md delete mode 100644 data/bin/pkg2zip.exe create mode 100644 img/chevron.png diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..452ad22 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy this file to `.env` and fill in your values. `.env` is git-ignored. + +# Static LAN IP for the API container on Unraid's macvlan bridge (br0). +PSP_LAN_IP=192.168.10.100 + +# Host paths mounted into the container (Unraid shares). +PSP_STORE_HOST=/mnt/user/downloads/Sony - PlayStation Portable +PSP_UMD_GAMES_HOST=/mnt/user/psp/umd_games +PSP_UMD_VIDEOS_HOST=/mnt/user/psp/umd_videos + +# Secret token the desktop app sends as `Authorization: Bearer `. +PSP_API_TOKEN= diff --git a/.gitignore b/.gitignore index 8c1e9ec..05e1871 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,7 @@ data/*.pbp data/ps1_templates/ # nps tsv files -data/nps/*.tsv \ No newline at end of file +data/nps/*.tsv + +# Local environment overrides (LAN paths, secrets). See .env.example +.env \ No newline at end of file diff --git a/README.md b/README.md index f90e493..6d0a7ab 100644 --- a/README.md +++ b/README.md @@ -109,10 +109,6 @@ Discs are matched by hash first (most precise), then serial, then filename. to drawn art when they're absent. - `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev; a packaged build stores them under the state dir instead). -- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept - only as an optional fallback decryptor; the app normally uses its own - pure-Python decryptor (`app/pkg.py`) and never needs Wine. - See `data/bin/ATTRIBUTION.md`. - State dir (`~/.local/state/psp-preserve/` on Linux, `%LOCALAPPDATA%\PSPPreserve` on Windows, or `$PSP_STATE_DIR`): - `library.sqlite` — the catalog + library database. @@ -141,7 +137,7 @@ QSettings under `PSPPreserve`) taking precedence where noted. | `PSP_PS1_ROOTS` | `/data/ps1` | comma-separated PS1 disc roots | | `PSP_PS1_OUTPUT_DIR` | `/data/ps1_converted` | converted EBOOT.PBP output folder | | `PSP_SCAN_WORKERS` | `1` | parallel hashing workers | -| `PSP_STORE_DIR` | `/media/downloads/Sony - PlayStation Portable` | local 7z store folder | +| `PSP_STORE_DIR` | `/data/store` | local 7z store folder | | `PSP_STORE_SERVER` | `https://psp.example.com` | store server base URL | | `PSP_STORE_TOKEN` | *(empty)* | store server bearer token | | `PSP_ARCHIVE_ITEM` | `psp-redump-roms321.com` | archive.org item identifier | @@ -149,7 +145,6 @@ QSettings under `PSPPreserve`) taking precedence where noted. | `PSP_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie | | `PSP_NPS_DIR` | `/nps` | NoPayStation output folder | | `PSP_NPS_TSV_BASE` | `https://nopaystation.com/tsv/` | NoPayStation TSV base URL | -| `PSP_PKG2ZIP_PATH` | *(empty)* | pkg2zip binary path (else auto-detected) | | `PSP_API_TOKEN` | *(empty)* | server API bearer token (unset = open) | | `PSP_USER_DATA_DIR` | `/data` (packaged) / `./data` (dev) | user-writable data (credentials, manual covers, PS1 templates, NPS cache) | | `PSP_CREDENTIALS` | `$PSP_USER_DATA_DIR/credentials.json` | credentials file | @@ -293,8 +288,7 @@ Requirements & behaviour: 1. `.pkg` files are decrypted by the built-in **pure-Python decryptor** (`app/pkg.py`, powered by `pycryptodome` for speed) — no Wine and no - `pkg2zip` install needed. A bundled `pkg2zip.exe` is kept as a fallback - only, and only runs if Python decryption fails and Wine is present. + `pkg2zip` needed. 2. Set **NoPayStation output dir** in **Settings → Store** (default `$PSP_STATE_DIR/nps`). 3. Downloads land in the NPS folder and appear in the **Library**: @@ -360,13 +354,14 @@ Token protection: set `PSP_API_TOKEN`; the client sends macvlan bridge so it gets its own LAN IP (e.g. `192.168.1.100`, plain HTTP on 8000): ```bash -# in /path/to/psp-preserve/ — adjust the volume paths for your shares -printf 'PSP_API_TOKEN=\n' > .env +# in /path/to/psp-preserve/ +cp .env.example .env # then edit: LAN IP, share paths, API token docker compose up -d --build ``` -The compose file mounts your Unraid shares into `/data/store`, `/data/umd_games` -and `/data/umd_videos` (the store is mounted read-only). +The compose file reads the LAN IP and Unraid share paths from `.env` and +mounts them into `/data/store`, `/data/umd_games` and `/data/umd_videos` +(the store is mounted read-only). ### TLS (optional) diff --git a/app/config.py b/app/config.py index cdf6c6a..109ae61 100644 --- a/app/config.py +++ b/app/config.py @@ -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()] diff --git a/app/gui.py b/app/gui.py index 9f6bc64..86dd8c2 100644 --- a/app/gui.py +++ b/app/gui.py @@ -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("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") + 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("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") + 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 games/ and videos/)." ) 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"{text}") + 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) diff --git a/app/matcher.py b/app/matcher.py index 98c2f99..985462d 100644 --- a/app/matcher.py +++ b/app/matcher.py @@ -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. diff --git a/app/nopaystation.py b/app/nopaystation.py index a47a495..38b4e5c 100644 --- a/app/nopaystation.py +++ b/app/nopaystation.py @@ -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//``. - 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 diff --git a/app/pipeline.py b/app/pipeline.py index 3fdb1d3..b8f0c12 100644 --- a/app/pipeline.py +++ b/app/pipeline.py @@ -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) diff --git a/app/style.py b/app/style.py index 8676005..edb7bb9 100644 --- a/app/style.py +++ b/app/style.py @@ -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)) diff --git a/app/transfers.py b/app/transfers.py index 5513272..788eee6 100644 --- a/app/transfers.py +++ b/app/transfers.py @@ -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) diff --git a/data/bin/ATTRIBUTION.md b/data/bin/ATTRIBUTION.md deleted file mode 100644 index c163ef5..0000000 --- a/data/bin/ATTRIBUTION.md +++ /dev/null @@ -1,38 +0,0 @@ -# pkg2zip - -The `pkg2zip.exe` binary in this folder is kept as an optional fallback for -decrypting NoPayStation `.pkg` files (PSP / PSX / PS3 / PSVita content). The -app normally uses its own pure-Python decryptor (`app/pkg.py`) and only runs -this binary if that fails. - -- Project: -- Author: Marcin Mikołajczak (mmozeiko) -- Version: v1.8 (bundled) -- License: MIT - -The Windows binary is run through Wine on Linux/macOS (see -`app/nopaystation.py`). - -## License - -MIT License - -Copyright (c) 2017 Marcin Mikołajczak (mmozeiko) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/data/bin/pkg2zip.exe b/data/bin/pkg2zip.exe deleted file mode 100644 index 5f7f02db9c90cfa276e75a4f7af4ff001b5daf93..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 84480 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 KcUq;_0{;MvkLMHs diff --git a/docker-compose.yml b/docker-compose.yml index 8973c68..57262fa 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: # Plain HTTP for now; see Caddyfile to add Let's Encrypt TLS later. networks: br0: - ipv4_address: 192.168.10.100 + ipv4_address: ${PSP_LAN_IP} environment: PSP_DATA_DIR: /data PSP_DATS_DIR: /data/dats @@ -16,14 +16,14 @@ services: PSP_VIDEO_ROOTS: /data/umd_videos PSP_STORE_DIR: /data/store # Secret token the desktop app sends as `Authorization: Bearer `. - # Set via a .env file next to this compose file. + # Set in the .env file next to this compose file. PSP_API_TOKEN: ${PSP_API_TOKEN:-} volumes: - ./data:/data - # Adjust host paths to your Unraid share layout: - - /mnt/user/downloads/Sony - PlayStation Portable:/data/store:ro - - /mnt/user/psp/umd_games:/data/umd_games:ro - - /mnt/user/psp/umd_videos:/data/umd_videos:ro + # Host paths come from .env (see .env.example): + - ${PSP_STORE_HOST}:/data/store:ro + - ${PSP_UMD_GAMES_HOST}:/data/umd_games:ro + - ${PSP_UMD_VIDEOS_HOST}:/data/umd_videos:ro restart: unless-stopped networks: diff --git a/img/chevron.png b/img/chevron.png new file mode 100644 index 0000000000000000000000000000000000000000..1d237a0ad8276bc9a6763d87845700f2fd6abcb6 GIT binary patch literal 273 zcmeAS@N?(olHy`uVBq!ia0vp^5@Kf0NjfD3ImW@ggQ5O~gyQM6mdP+`?3zcR?yHUK<|vOj5d@ z^k06ODzm*DQ^U5s^Jc7MWJrGUD8^K-v(<`?0mu#HTr}w@-@Wq6tUJH;(l;Jnz1iXQ z(n*ruwOo&Cr%QX=D%^Z-zFCHS!AlOIzRO=S>=>V~nwYK`G%;dMy5_WMmA7rX?C$ri zsb$_G9bA{f`i7OkLAsx*P{riq548o(+aB*eV75sK*qw25q#q6(FtbgDa S*7zCd9R^QVKbLh*2~7Yz(Pqp5 literal 0 HcmV?d00001