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:
@@ -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=
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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 & metadata</b>"))
|
self._subtitle(form, "Games — covers & 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 & posters</b>"))
|
self._subtitle(form, "Videos — metadata & 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 273 B |
Reference in New Issue
Block a user