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

- Rework Settings tab into grouped sections
- Add preferred-region matching and full-hash-by-default setting
- Replace combo-box square arrow with a proper chevron
- Fix UMD Refresh button (clicked bool was clearing the view)
- Remove pkg2zip.exe fallback (pure-Python decryptor is the primary path)
- Move LAN IP/share paths to .env; use example defaults in config
This commit is contained in:
2026-09-13 20:03:44 -04:00
parent 1ec2e81e15
commit c19717402c
14 changed files with 249 additions and 265 deletions
+12
View File
@@ -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 <token>`.
PSP_API_TOKEN=
+3
View File
@@ -17,3 +17,6 @@ data/ps1_templates/
# nps tsv files # nps tsv files
data/nps/*.tsv data/nps/*.tsv
# Local environment overrides (LAN paths, secrets). See .env.example
.env
+7 -12
View File
@@ -109,10 +109,6 @@ Discs are matched by hash first (most precise), then serial, then filename.
to drawn art when they're absent. to drawn art when they're absent.
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev; - `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev;
a packaged build stores them under the state dir instead). 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` - State dir (`~/.local/state/psp-preserve/` on Linux, `%LOCALAPPDATA%\PSPPreserve`
on Windows, or `$PSP_STATE_DIR`): on Windows, or `$PSP_STATE_DIR`):
- `library.sqlite` — the catalog + library database. - `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_ROOTS` | `/data/ps1` | comma-separated PS1 disc roots |
| `PSP_PS1_OUTPUT_DIR` | `/data/ps1_converted` | converted EBOOT.PBP output folder | | `PSP_PS1_OUTPUT_DIR` | `/data/ps1_converted` | converted EBOOT.PBP output folder |
| `PSP_SCAN_WORKERS` | `1` | parallel hashing workers | | `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_SERVER` | `https://psp.example.com` | store server base URL |
| `PSP_STORE_TOKEN` | *(empty)* | store server bearer token | | `PSP_STORE_TOKEN` | *(empty)* | store server bearer token |
| `PSP_ARCHIVE_ITEM` | `psp-redump-roms321.com` | archive.org item identifier | | `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_ARCHIVE_LOGGED_IN_USER` | *(empty)* | archive.org `logged-in-user` cookie |
| `PSP_NPS_DIR` | `<state>/nps` | NoPayStation output folder | | `PSP_NPS_DIR` | `<state>/nps` | NoPayStation output folder |
| `PSP_NPS_TSV_BASE` | `https://nopaystation.com/tsv/` | NoPayStation TSV base URL | | `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_API_TOKEN` | *(empty)* | server API bearer token (unset = open) |
| `PSP_USER_DATA_DIR` | `<state>/data` (packaged) / `./data` (dev) | user-writable data (credentials, manual covers, PS1 templates, NPS cache) | | `PSP_USER_DATA_DIR` | `<state>/data` (packaged) / `./data` (dev) | user-writable data (credentials, manual covers, PS1 templates, NPS cache) |
| `PSP_CREDENTIALS` | `$PSP_USER_DATA_DIR/credentials.json` | credentials file | | `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** 1. `.pkg` files are decrypted by the built-in **pure-Python decryptor**
(`app/pkg.py`, powered by `pycryptodome` for speed) — no Wine and no (`app/pkg.py`, powered by `pycryptodome` for speed) — no Wine and no
`pkg2zip` install needed. A bundled `pkg2zip.exe` is kept as a fallback `pkg2zip` needed.
only, and only runs if Python decryption fails and Wine is present.
2. Set **NoPayStation output dir** in **Settings → Store** (default 2. Set **NoPayStation output dir** in **Settings → Store** (default
`$PSP_STATE_DIR/nps`). `$PSP_STATE_DIR/nps`).
3. Downloads land in the NPS folder and appear in the **Library**: 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): macvlan bridge so it gets its own LAN IP (e.g. `192.168.1.100`, plain HTTP on 8000):
```bash ```bash
# in /path/to/psp-preserve/ — adjust the volume paths for your shares # in /path/to/psp-preserve/
printf 'PSP_API_TOKEN=<long-random-secret>\n' > .env cp .env.example .env # then edit: LAN IP, share paths, API token
docker compose up -d --build docker compose up -d --build
``` ```
The compose file mounts your Unraid shares into `/data/store`, `/data/umd_games` The compose file reads the LAN IP and Unraid share paths from `.env` and
and `/data/umd_videos` (the store is mounted read-only). mounts them into `/data/store`, `/data/umd_games` and `/data/umd_videos`
(the store is mounted read-only).
### TLS (optional) ### TLS (optional)
+2 -5
View File
@@ -52,10 +52,10 @@ def user_data_path(*parts):
return USER_DATA_DIR.joinpath(*parts) return USER_DATA_DIR.joinpath(*parts)
# Redump 7z store (browsable in the Store tab). Env override; GUI setting wins. # 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. # 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", "") STORE_TOKEN = os.environ.get("PSP_STORE_TOKEN", "")
# Optional direct URLs for the offline cover bundle zips. When unset, the GUI # 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_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/") 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. # 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()] 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()] VIDEO_ROOTS = [Path(p) for p in os.environ.get("PSP_VIDEO_ROOTS", "/data/umd_videos").split(",") if p.strip()]
+166 -97
View File
@@ -18,7 +18,7 @@ from PySide6.QtWidgets import (
QFileDialog, QFormLayout, QGridLayout, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QFileDialog, QFormLayout, QGridLayout, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit,
QProgressBar, QPushButton, QScrollArea, QSpinBox, QSplitter, QStackedWidget, 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 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() cancelled = Signal()
failed = Signal(str) 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__() super().__init__()
self.roots = roots self.roots = roots
self.db_path = db_path self.db_path = db_path
self.hash_files = hash_files self.hash_files = hash_files
self.workers = workers self.workers = workers
self.prefer_region = prefer_region
self._cancel = threading.Event() self._cancel = threading.Event()
self._reply = [None] self._reply = [None]
self._reply_event = threading.Event() self._reply_event = threading.Event()
@@ -69,17 +70,27 @@ class ScanWorker(QObject):
self.roots, conn=conn, workers=self.workers or config.SCAN_WORKERS, self.roots, conn=conn, workers=self.workers or config.SCAN_WORKERS,
hash_files=self.hash_files, cancel=self._cancel, 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(): if ident["method"] == "unknown" and rec.get("serial") and not self._cancel.is_set():
candidates = matcher.serial_candidates(conn, rec) candidates = matcher.serial_candidates(conn, rec)
if len(candidates) > 1: 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: if chosen is not None:
conn.execute( conn.execute(
"INSERT OR REPLACE INTO choices (serial, size, release_id) VALUES (?,?,?)", "INSERT OR REPLACE INTO choices (serial, size, release_id) VALUES (?,?,?)",
(rec["serial"].upper(), rec["size"], chosen["id"]), (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 {} rel = ident.get("release") or {}
conn.execute( conn.execute(
"UPDATE files SET release_id = ? WHERE path = ?", "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, 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, 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, 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) super().__init__(parent)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
self.tabs = QTabWidget()
outer.addWidget(self.tabs, 1)
s = QSettings("PSPPreserve", "PSPPreserve") s = QSettings("PSPPreserve", "PSPPreserve")
# -- General ----------------------------------------------------- # outer = QVBoxLayout(self)
page, form = self._new_form() outer.setContentsMargins(16, 16, 16, 16)
self.games_edit = self._dir_row(form, "Games directory:", games_dir) outer.setSpacing(12)
self.videos_edit = self._dir_row(form, "Videos directory:", videos_dir)
self.ps1_edit = self._dir_row(form, "PS1 directory:", ps1_dir) # Header ---------------------------------------------------------- #
self.ps1_out_edit = self._dir_row(form, "PS1 output directory:", ps1_output_dir) header = QHBoxLayout()
self.ps1_base_edit = self._file_row(form, "PS1 base.pbp (PSN classic):", ps1_base_pbp) 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 = QCheckBox("Scan directories automatically on startup")
self.auto_scan.setChecked(str(s.value("auto_scan", False)).lower() == "true") self.auto_scan.setChecked(str(s.value("auto_scan", False)).lower() == "true")
form.addRow(self.auto_scan) 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 = QSpinBox()
self.workers_spin.setRange(1, 16) self.workers_spin.setRange(1, 16)
self.workers_spin.setValue(int(s.value("scan_workers", 1))) 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.") self.workers_spin.setToolTip("Raise on SSDs / fast network shares; keep 1 on spinning disks.")
form.addRow("Parallel scan workers:", self.workers_spin) form.addRow("Parallel scan workers", self.workers_spin)
self.tabs.addTab(page, "General") col.addWidget(box)
# -- Store ------------------------------------------------------- # # -- Matching -------------------------------------------------------- #
page, form = self._new_form() box, form = self._group("Matching")
self.store_server_edit = self._line_row(form, "Server URL:", store_server_url) self.region_combo = QComboBox()
self.store_token_edit = self._secret_row(form, "API token:", store_token) self.region_combo.addItem("No preference — ask me", "")
self.archive_edit = self._line_row(form, "Archive.org item:", store_archive_id) for label in ("USA", "Europe", "Japan", "Asia", "Korea", "World"):
self.archive_sig_edit = self._secret_row(form, "Archive.org logged-in-sig:", store_archive_sig) self.region_combo.addItem(label, label)
self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user:", store_archive_user) idx = self.region_combo.findData(str(s.value("prefer_region", "") or ""))
self.store_edit = self._dir_row(form, "Local store dir:", store_dir) self.region_combo.setCurrentIndex(idx if idx >= 0 else 0)
self.nps_edit = self._dir_row(form, "NoPayStation output dir:", nps_dir) form.addRow("Preferred region", self.region_combo)
self.pkg2zip_edit = self._file_row(form, "pkg2zip executable (optional fallback):", pkg2zip_path) self.region_hint = QLabel(
self.tabs.addTab(page, "Store") "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 ------------------------------------------------------- # # -- Cover sources --------------------------------------------------- #
page, form = self._new_form() box, form = self._group("Cover sources")
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()
creds = sources.load() creds = sources.load()
self.fields = {} self.fields = {}
@@ -1185,21 +1250,22 @@ class SettingsPage(QWidget):
"game-cover source. The keys below are optional upgrades." "game-cover source. The keys below are optional upgrades."
) )
note.setWordWrap(True) note.setWordWrap(True)
note.setStyleSheet("color: #9a9ba5;")
form.addRow(note) form.addRow(note)
form.addRow(QLabel("<b>Games — covers &amp; metadata</b>")) self._subtitle(form, "Games — covers &amp; metadata")
add_field("IGDB Client ID:", "igdb_client_id") add_field("IGDB Client ID", "igdb_client_id")
add_field("IGDB Client Secret:", "igdb_client_secret", password=True) add_field("IGDB Client Secret", "igdb_client_secret", password=True)
add_field("ScreenScraper dev ID:", "screenscraper_devid") add_field("ScreenScraper dev ID", "screenscraper_devid")
add_field("ScreenScraper dev password:", "screenscraper_devpassword", password=True) add_field("ScreenScraper dev password", "screenscraper_devpassword", password=True)
add_field("ScreenScraper user ID:", "screenscraper_ssid") add_field("ScreenScraper user ID", "screenscraper_ssid")
add_field("ScreenScraper user password:", "screenscraper_sspassword", password=True) add_field("ScreenScraper user password", "screenscraper_sspassword", password=True)
add_field("ScreenScraper software name:", "screenscraper_softname") add_field("ScreenScraper software name", "screenscraper_softname")
form.addRow(QLabel("<b>Videos — metadata &amp; posters</b>")) self._subtitle(form, "Videos — metadata &amp; posters")
add_field("TheMovieDB API key:", "tmdb_api_key", password=True) add_field("TheMovieDB API key", "tmdb_api_key", password=True)
add_field("Google API key:", "google_api_key", password=True) add_field("Google API key", "google_api_key", password=True)
add_field("Google Search Engine ID (cx):", "google_cx") add_field("Google Search Engine ID (cx)", "google_cx")
hint = QLabel( hint = QLabel(
"Manual UMD covers: drop images into " "Manual UMD covers: drop images into "
@@ -1207,28 +1273,27 @@ class SettingsPage(QWidget):
"(subfolders <tt>games/</tt> and <tt>videos/</tt>)." "(subfolders <tt>games/</tt> and <tt>videos/</tt>)."
) )
hint.setWordWrap(True) hint.setWordWrap(True)
hint.setStyleSheet("color: #9a9ba5;")
form.addRow(hint) form.addRow(hint)
self.clear_covers_btn = QPushButton("Clear cover cache (re-fetch all covers)") self.clear_covers_btn = QPushButton("Clear cover cache (re-fetch all covers)")
self.clear_covers_btn.clicked.connect(self._clear_cover_cache) self.clear_covers_btn.clicked.connect(self._clear_cover_cache)
form.addRow(self.clear_covers_btn) form.addRow(self.clear_covers_btn)
self.tabs.addTab(page, "Cover sources") col.addWidget(box)
self.save_btn = QPushButton("Save settings") col.addStretch(1)
self.save_btn.clicked.connect(self._save)
outer.addWidget(self.save_btn)
def _new_form(self): def _group(self, title):
scroll = QScrollArea() box = QGroupBox(title)
scroll.setWidgetResizable(True) form = QFormLayout(box)
scroll.setFrameShape(QScrollArea.NoFrame) form.setContentsMargins(16, 18, 16, 16)
page = QWidget()
form = QFormLayout(page)
form.setContentsMargins(16, 16, 16, 16)
form.setHorizontalSpacing(16) form.setHorizontalSpacing(16)
form.setVerticalSpacing(10) form.setVerticalSpacing(10)
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
scroll.setWidget(page) return box, form
return scroll, form
def _subtitle(self, form, text):
label = QLabel(f"<b>{text}</b>")
form.addRow(label)
def _dir_row(self, form, label, value): def _dir_row(self, form, label, value):
edit = QLineEdit(value) edit = QLineEdit(value)
@@ -1304,9 +1369,6 @@ class SettingsPage(QWidget):
def nps_dir(self): def nps_dir(self):
return self.nps_edit.text().strip() return self.nps_edit.text().strip()
def pkg2zip_path(self):
return self.pkg2zip_edit.text().strip()
def ppsspp_saves_dir(self): def ppsspp_saves_dir(self):
return self.ppsspp_edit.text().strip() return self.ppsspp_edit.text().strip()
@@ -1337,6 +1399,9 @@ class SettingsPage(QWidget):
def ps1_base_pbp(self): def ps1_base_pbp(self):
return self.ps1_base_edit.text().strip() return self.ps1_base_edit.text().strip()
def prefer_region(self):
return self.region_combo.currentData() or ""
def _save(self): def _save(self):
sources.save({key: edit.text() for key, edit in self.fields.items()}) sources.save({key: edit.text() for key, edit in self.fields.items()})
covers.clear_miss_cache() covers.clear_miss_cache()
@@ -1344,6 +1409,8 @@ class SettingsPage(QWidget):
s.setValue("auto_scan", self.auto_scan.isChecked()) s.setValue("auto_scan", self.auto_scan.isChecked())
s.setValue("scan_workers", self.workers_spin.value()) s.setValue("scan_workers", self.workers_spin.value())
s.setValue("auto_sync_saves", self.auto_sync.isChecked()) 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() self.saved.emit()
def _clear_cover_cache(self): def _clear_cover_cache(self):
@@ -1677,7 +1744,6 @@ class StorePage(QWidget):
self._archive_sig = "" self._archive_sig = ""
self._archive_user = "" self._archive_user = ""
self._nps_dir = "" self._nps_dir = ""
self._pkg2zip = ""
self._items = [] self._items = []
self._filtered = [] self._filtered = []
self._lib_norm = set() self._lib_norm = set()
@@ -1795,7 +1861,7 @@ class StorePage(QWidget):
layout.addWidget(self.status) layout.addWidget(self.status)
def set_store_config(self, local_dir, server_url, token, archive_id="", archive_sig="", archive_user="", 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._store_dir = local_dir or ""
self._server_url = server_url or "" self._server_url = server_url or ""
self._token = token or "" self._token = token or ""
@@ -1803,7 +1869,6 @@ class StorePage(QWidget):
self._archive_sig = archive_sig or "" self._archive_sig = archive_sig or ""
self._archive_user = archive_user or "" self._archive_user = archive_user or ""
self._nps_dir = nps_dir or "" self._nps_dir = nps_dir or ""
self._pkg2zip = pkg2zip_path or ""
if self._server_url: if self._server_url:
self.status.setText(f"Store: {self._server_url}") self.status.setText(f"Store: {self._server_url}")
elif self._archive_id: elif self._archive_id:
@@ -1820,7 +1885,7 @@ class StorePage(QWidget):
return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user) return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)
if self.source_combo.currentIndex() == 3: if self.source_combo.currentIndex() == 3:
ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()] 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) return transfers.LocalShareSource(self._store_dir)
def _on_nps_type_changed(self, _idx): def _on_nps_type_changed(self, _idx):
@@ -1889,7 +1954,7 @@ class StorePage(QWidget):
ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()] ct = ("psp", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
log.info("store: fetching NoPayStation %s list", ct) log.info("store: fetching NoPayStation %s list", ct)
self.status.setText(f"Fetching NoPayStation ({ct.upper()})…") 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: else:
if not self._store_dir: if not self._store_dir:
self.status.setText("Set the local store folder in Settings (Advanced)") self.status.setText("Set the local store folder in Settings (Advanced)")
@@ -2845,7 +2910,7 @@ class PspSyncPage(QWidget):
bar = QHBoxLayout() bar = QHBoxLayout()
bar.addWidget(QLabel("UMD (USB UMD mode):")) bar.addWidget(QLabel("UMD (USB UMD mode):"))
self.umd_refresh_btn = QPushButton("Refresh") 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) bar.addWidget(self.umd_refresh_btn)
self.umd_dump_btn = QPushButton("Copy to library") self.umd_dump_btn = QPushButton("Copy to library")
self.umd_dump_btn.setEnabled(False) 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_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._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._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("games dir: %s", self._games_dir or "(none)")
log.info("videos dir: %s", self._videos_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)") 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 = StorePage()
self.store_page.set_store_config(self._store_dir, self._store_server_url, self._store_token, 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._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.download_requested.connect(self._on_store_download)
self.store_page.scrape_status.connect(self._on_store_scrape_status) self.store_page.scrape_status.connect(self._on_store_scrape_status)
self.store_page.scrape_complete.connect(self.load_library) self.store_page.scrape_complete.connect(self.load_library)
@@ -4385,6 +4450,8 @@ class MainWindow(QMainWindow):
self.apply_btn.clicked.connect(self._apply_renames) self.apply_btn.clicked.connect(self._apply_renames)
controls.addWidget(self.apply_btn) controls.addWidget(self.apply_btn)
self.hash_check = QCheckBox("Verify integrity (full hash)") 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) controls.addWidget(self.hash_check)
self.progress = QProgressBar() self.progress = QProgressBar()
controls.addWidget(self.progress, 1) controls.addWidget(self.progress, 1)
@@ -4428,7 +4495,7 @@ class MainWindow(QMainWindow):
self._store_archive_sig, self._store_archive_user, self._iso_category, self._store_archive_sig, self._store_archive_user, self._iso_category,
self._ps1_category, self._psp_category, self._ps1_category, self._psp_category,
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp, 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) self.settings_page.saved.connect(self._on_settings_saved)
return self.settings_page return self.settings_page
@@ -4451,7 +4518,7 @@ class MainWindow(QMainWindow):
self.settings.setValue("store_archive_user", self._store_archive_user) 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_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._store_archive_id, self._store_archive_sig, self._store_archive_user,
self._nps_dir, self._pkg2zip) self._nps_dir)
self.store_page.refresh() self.store_page.refresh()
self._ppsspp_saves_dir = self.settings_page.ppsspp_saves_dir() self._ppsspp_saves_dir = self.settings_page.ppsspp_saves_dir()
self._cloud_saves_dir = self.settings_page.cloud_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_output_dir = self.settings_page.ps1_output_dir()
self._ps1_base_pbp = self.settings_page.ps1_base_pbp() self._ps1_base_pbp = self.settings_page.ps1_base_pbp()
self._nps_dir = self.settings_page.nps_dir() 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_dir", self._ps1_dir)
self.settings.setValue("ps1_output_dir", self._ps1_output_dir) self.settings.setValue("ps1_output_dir", self._ps1_output_dir)
self.settings.setValue("ps1_base_pbp", self._ps1_base_pbp) self.settings.setValue("ps1_base_pbp", self._ps1_base_pbp)
self.settings.setValue("nps_dir", self._nps_dir) 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.ps1_page.set_dirs(self._ps1_dir, self._ps1_output_dir)
self.settings.setValue("ppsspp_saves_dir", self._ppsspp_saves_dir) self.settings.setValue("ppsspp_saves_dir", self._ppsspp_saves_dir)
self.settings.setValue("cloud_saves_dir", self._cloud_saves_dir) self.settings.setValue("cloud_saves_dir", self._cloud_saves_dir)
@@ -5198,6 +5266,7 @@ class MainWindow(QMainWindow):
roots, config.DB_PATH, roots, config.DB_PATH,
hash_files=self.hash_check.isChecked(), hash_files=self.hash_check.isChecked(),
workers=workers, workers=workers,
prefer_region=self._prefer_region,
) )
self.worker.moveToThread(self.thread) self.worker.moveToThread(self.thread)
self.thread.started.connect(self.worker.run) self.thread.started.connect(self.worker.run)
+35 -1
View File
@@ -111,6 +111,36 @@ def normalize_name(name):
return _normalize(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): def serial_candidates(conn, record):
"""Return all catalog releases matching the disc's serial (size-narrowed).""" """Return all catalog releases matching the disc's serial (size-narrowed)."""
serial = record.get("serial") 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")) 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") size = record.get("size")
# 1. Hash is the most precise key; serials are shared across revisions. # 1. Hash is the most precise key; serials are shared across revisions.
@@ -171,6 +201,10 @@ def match(conn, record):
for cand in candidates: for cand in candidates:
if cand["id"] == saved["release_id"]: if cand["id"] == saved["release_id"]:
return "choice", cand 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 return "unknown", None
# 4. Fuzzy fallback (filename + PARAM.SFO title), size-disambiguated. # 4. Fuzzy fallback (filename + PARAM.SFO title), size-disambiguated.
+4 -91
View File
@@ -9,14 +9,12 @@ folder — no Wine or ``pkg2zip`` required.
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import shutil import shutil
import subprocess
import urllib.parse import urllib.parse
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import List
from . import config from . import config
@@ -167,31 +165,6 @@ def fetch_items(content_type: str, url: str = "", timeout: int = 60, force: bool
raise 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: def _safe_folder(value: str) -> str:
import re import re
cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper() cleaned = re.sub(r"[^A-Za-z0-9]", "", value or "").upper()
@@ -211,12 +184,11 @@ def _ensure_keys_bin(folder, kind):
pass 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: kind_hint: str = None, progress_cb=None, cancel_event=None) -> Path:
"""Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``. """Decrypt/extract a ``.pkg`` into ``dest_dir/<TITLE_ID>/``.
Uses the bundled pure-Python decryptor (no Wine / pkg2zip needed); falls Uses the bundled pure-Python decryptor (no Wine or pkg2zip). Returns the
back to pkg2zip only if that fails and a binary is available. Returns the
destination game folder path. destination game folder path.
""" """
from . import pkg as pkgmod 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()): if dest.is_dir() and any(dest.iterdir()):
return dest return dest
# 1. Pure-Python decryptor (primary).
try: try:
_kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint, _kind, _tid, folder = pkgmod.extract(pkg_path, dest_dir, kind_hint,
progress_cb, cancel_event) progress_cb, cancel_event)
@@ -236,62 +207,4 @@ def extract_pkg(pkg_path, title_id: str, dest_dir, pkg2zip: Path = None,
except TransferCancelled: except TransferCancelled:
raise raise
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
py_exc = exc raise RuntimeError(f"could not decrypt {pkg_path.name}: {exc}") from 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
+2 -2
View File
@@ -2,9 +2,9 @@
from . import iso_reader, matcher, namer 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.""" """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) return identify_with_release(record, release, method)
+8 -5
View File
@@ -7,6 +7,8 @@ single cohesive dark theme here makes the whole UI consistent.
""" """
from PySide6.QtGui import QColor, QPalette from PySide6.QtGui import QColor, QPalette
from . import config
# Design tokens # Design tokens
BG = "#1e1f24" # window background BG = "#1e1f24" # window background
SURFACE = "#26272e" # cards, inputs, table headers SURFACE = "#26272e" # cards, inputs, table headers
@@ -19,6 +21,7 @@ TEXT_FAINT = "#6f7079"
ACCENT = "#4a9eff" ACCENT = "#4a9eff"
ACCENT_HOVER = "#6bb0ff" ACCENT_HOVER = "#6bb0ff"
_STYLESHEET = f""" _STYLESHEET = f"""
QMainWindow {{ QMainWindow {{
background: {BG}; background: {BG};
@@ -105,10 +108,9 @@ QComboBox::drop-down {{
width: 22px; width: 22px;
}} }}
QComboBox::down-arrow {{ QComboBox::down-arrow {{
image: none; image: url(__CHEVRON__);
border-left: 4px solid transparent; width: 12px;
border-right: 4px solid transparent; height: 8px;
border-top: 5px solid {TEXT_DIM};
margin-right: 6px; margin-right: 6px;
}} }}
QComboBox QAbstractItemView {{ QComboBox QAbstractItemView {{
@@ -310,4 +312,5 @@ def apply_theme(app):
"""Apply the dark Fusion theme to ``app`` (call once before showing UI).""" """Apply the dark Fusion theme to ``app`` (call once before showing UI)."""
app.setStyle("Fusion") app.setStyle("Fusion")
app.setPalette(_dark_palette()) app.setPalette(_dark_palette())
app.setStyleSheet(_STYLESHEET) chevron = str(config.resource_path("img", "chevron.png"))
app.setStyleSheet(_STYLESHEET.replace("__CHEVRON__", chevron))
+3 -7
View File
@@ -476,11 +476,10 @@ class NoPayStationSource(Source):
label = "NoPayStation" 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 from . import nopaystation
self.content_type = content_type self.content_type = content_type
self.tsv_url = tsv_url or nopaystation.tsv_url(content_type) self.tsv_url = tsv_url or nopaystation.tsv_url(content_type)
self.pkg2zip = pkg2zip_path or ""
self.force = force self.force = force
def scan(self, root=None): def scan(self, root=None):
@@ -492,10 +491,7 @@ class NoPayStationSource(Source):
dest_dir = Path(dest_dir) dest_dir = Path(dest_dir)
dest_dir.mkdir(parents=True, exist_ok=True) dest_dir.mkdir(parents=True, exist_ok=True)
# The bundled Python decryptor handles PSP/PSX/themes/DLC directly; # 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)
title_id = item.get("title_id") or item.get("content_id") or "" 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" pkg_name = re.sub(r"[^A-Za-z0-9_-]", "", title_id) or "pkg"
tmp = Path(tempfile.mkdtemp(prefix="nps-download-")) tmp = Path(tempfile.mkdtemp(prefix="nps-download-"))
@@ -505,7 +501,7 @@ class NoPayStationSource(Source):
progress_cb("Downloading", 0.0, "") progress_cb("Downloading", 0.0, "")
download_url(item["pkg_url"], pkg, None, progress_cb, cancel_event) 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 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) kind_hint, progress_cb, cancel_event)
if self.content_type in ("theme", "dlc"): if self.content_type in ("theme", "dlc"):
_write_sidecar(folder, item) _write_sidecar(folder, item)
-38
View File
@@ -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: <https://github.com/mmozeiko/pkg2zip>
- 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.
Binary file not shown.
+6 -6
View File
@@ -6,7 +6,7 @@ services:
# Plain HTTP for now; see Caddyfile to add Let's Encrypt TLS later. # Plain HTTP for now; see Caddyfile to add Let's Encrypt TLS later.
networks: networks:
br0: br0:
ipv4_address: 192.168.10.100 ipv4_address: ${PSP_LAN_IP}
environment: environment:
PSP_DATA_DIR: /data PSP_DATA_DIR: /data
PSP_DATS_DIR: /data/dats PSP_DATS_DIR: /data/dats
@@ -16,14 +16,14 @@ services:
PSP_VIDEO_ROOTS: /data/umd_videos PSP_VIDEO_ROOTS: /data/umd_videos
PSP_STORE_DIR: /data/store PSP_STORE_DIR: /data/store
# Secret token the desktop app sends as `Authorization: Bearer <token>`. # Secret token the desktop app sends as `Authorization: Bearer <token>`.
# 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:-} PSP_API_TOKEN: ${PSP_API_TOKEN:-}
volumes: volumes:
- ./data:/data - ./data:/data
# Adjust host paths to your Unraid share layout: # Host paths come from .env (see .env.example):
- /mnt/user/downloads/Sony - PlayStation Portable:/data/store:ro - ${PSP_STORE_HOST}:/data/store:ro
- /mnt/user/psp/umd_games:/data/umd_games:ro - ${PSP_UMD_GAMES_HOST}:/data/umd_games:ro
- /mnt/user/psp/umd_videos:/data/umd_videos:ro - ${PSP_UMD_VIDEOS_HOST}:/data/umd_videos:ro
restart: unless-stopped restart: unless-stopped
networks: networks:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 B