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 5f7f02d..0000000 Binary files a/data/bin/pkg2zip.exe and /dev/null differ 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 0000000..1d237a0 Binary files /dev/null and b/img/chevron.png differ