Dark theme, scan auto-ingest, packaging prep, and README refresh

This commit is contained in:
2026-09-09 18:17:23 -04:00
parent 74164b33ec
commit 8c5f9b6ea0
11 changed files with 440 additions and 48 deletions
+26 -20
View File
@@ -3,15 +3,16 @@ the name is terrible, i know.
![AI Generated](https://raw.githubusercontent.com/dotnetdave/ai-usage-badges/refs/heads/main/badges/png/1x/ai-generated.png) ![AI Generated](https://raw.githubusercontent.com/dotnetdave/ai-usage-badges/refs/heads/main/badges/png/1x/ai-generated.png)
Native Linux GUI (PySide6) for preserving PSP UMD **games** and **videos** as A cross-platform (Linux + Windows) desktop GUI — PySide6 — for preserving PSP
`.iso` files. It scans your discs, verifies them against the full Redump catalog UMD **games** and **videos** as `.iso` files. It scans your discs, verifies them
(Redump DATs), reports what's missing, and retags/renames discs to a canonical against the Redump/No-Intro catalogs, reports what's missing, and retags/renames
scheme. It also fetches cover art and metadata, syncs discs and save-data with a discs to a canonical scheme. It fetches cover art and metadata, syncs discs and
real PSP, can pull missing discs from your own Redump 7z archive collection, and save-data with a real PSP, can pull missing discs from a Redump 7z archive
converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew. collection, downloads PSN content from NoPayStation, and converts PlayStation 1
ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
![PSP Preservation Tool](img/qt-gui.png) ![PSP Preservation Tool](img/qt-gui.png)
* Use default app theme for Qt. * Dark theme with a blue accent — the Library tab in cover view. *
## Features ## Features
@@ -35,9 +36,9 @@ converts PlayStation 1 ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew.
## Requirements ## Requirements
- Linux + Python 3 - Python 3.10+ on Linux or Windows
- `PySide6` + `pycryptodome` (`pip install -r requirements.txt`) - `PySide6`, `curl_cffi`, `pycryptodome` (`pip install -r requirements.txt`)
- `7z` (p7zip) — used to peek/extract disc images from store archives - `7z` (p7zip / 7-Zip) — used to peek/extract disc images from store archives
- PPSSPP (optional) — to launch discs from the details panel - PPSSPP (optional) — to launch discs from the details panel
NoPayStation `.pkg` files are decrypted by the bundled **pure-Python NoPayStation `.pkg` files are decrypted by the bundled **pure-Python
@@ -66,14 +67,15 @@ python3 -m app.main
Redump lacks — keeps serial-based matching working). Redump lacks — keeps serial-based matching working).
- videos: No-Intro `Sony - PlayStation Portable (UMD Video)` (Redump has - videos: No-Intro `Sony - PlayStation Portable (UMD Video)` (Redump has
no UMD Video DAT). no UMD Video DAT).
2. Ingest them into the local SQLite library: 2. Start the app — any DATs missing from the library are ingested automatically
at startup (no manual step). You can also re-ingest on demand:
```bash ```bash
python3 -m app.ingest python3 -m app.ingest
``` ```
3. Open the GUI and point **Settings → General → Games/Videos directory** at 3. Point **Settings → General → Games/Videos directory** at your disc folders
your disc folders (or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`). (or set `PSP_GAMES_ROOTS` / `PSP_VIDEO_ROOTS`).
4. (Optional) PS1 conversion — copy any PSN PS1 classic `EBOOT.PBP` to 4. (Optional) PS1 conversion — copy any PSN PS1 classic `EBOOT.PBP` to
`data/base.PBP` (it supplies Sony's encrypted loader). Then set the **PS1 `data/base.PBP` (it supplies Sony's encrypted loader). Then set the **PS1
directory**, **PS1 output directory** and **PS1 base.pbp** in directory**, **PS1 output directory** and **PS1 base.pbp** in
@@ -105,19 +107,22 @@ Discs are matched by hash first (most precise), then serial, then filename.
- `data/ps1_templates/` — optional PS1 Classics art templates - `data/ps1_templates/` — optional PS1 Classics art templates
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back (`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
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; drop - `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev;
`PSP_GAMES.tsv` / `PSX_GAMES.tsv` manually to work offline). a packaged build stores them under the state dir instead).
- `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept - `data/bin/pkg2zip.exe` — bundled `pkg2zip` binary (MIT, by mmozeiko) kept
only as an optional fallback decryptor; the app normally uses its own only as an optional fallback decryptor; the app normally uses its own
pure-Python decryptor (`app/pkg.py`) and never needs Wine. pure-Python decryptor (`app/pkg.py`) and never needs Wine.
See `data/bin/ATTRIBUTION.md`. See `data/bin/ATTRIBUTION.md`.
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`): - State dir (`~/.local/state/psp-preserve/` on Linux, `%LOCALAPPDATA%\PSPPreserve`
on Windows, or `$PSP_STATE_DIR`):
- `library.sqlite` — the catalog + library database. - `library.sqlite` — the catalog + library database.
- `covers/` — downloaded cover cache. - `covers/` — downloaded cover cache.
- `data/` — user-writable data in packaged builds (credentials, manual
covers, PS1 templates, NPS TSV cache).
SQLite is kept on a local disk because SQLite locking is unreliable over SQLite is kept on a local disk because SQLite locking is unreliable over
network filesystems; the DATs, manual covers and credentials stay in `data/` network filesystems. Read-only resources (DATs, the POPS loader, icons) are
(which may be a network share). bundled with the app; user-writable data lives in the state dir.
## Configuration ## Configuration
@@ -146,7 +151,8 @@ QSettings under `PSPPreserve`) taking precedence where noted.
| `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_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_CREDENTIALS` | `$PSP_DATA_DIR/credentials.json` | credentials file | | `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_LIBRETRO_COVERS_URL` | *(empty)* | direct URL for `libretro-covers.zip` (else derived from store server) | | `PSP_LIBRETRO_COVERS_URL` | *(empty)* | direct URL for `libretro-covers.zip` (else derived from store server) |
| `PSP_OVGDB_COVERS_URL` | *(empty)* | direct URL for `openvgdb-covers.zip` (else derived from store server) | | `PSP_OVGDB_COVERS_URL` | *(empty)* | direct URL for `openvgdb-covers.zip` (else derived from store server) |
@@ -235,7 +241,7 @@ Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` /
`PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc `PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc
PBP); disc data is DEFLATE-compressed (level 1 by default, `--complevel 0-9`). PBP); disc data is DEFLATE-compressed (level 1 by default, `--complevel 0-9`).
## Store — archive.org and local folder ## Store — four sources
The **Store** tab downloads missing discs from a Redump 7z archive collection The **Store** tab downloads missing discs from a Redump 7z archive collection
(`Name (Region) (tags).7z`, containing the `.iso`/`.cso` inside). It has three (`Name (Region) (tags).7z`, containing the `.iso`/`.cso` inside). It has three
+28 -3
View File
@@ -1,8 +1,20 @@
"""Central configuration, driven by environment variables.""" """Central configuration, driven by environment variables."""
import os import os
import sys
from pathlib import Path from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent # PyInstaller sets ``sys.frozen`` and unpacks bundled files to ``sys._MEIPASS``.
# Read-only resources (DATs, loader, icons) then live in the bundle; otherwise
# they live in the project checkout.
IS_FROZEN = getattr(sys, "frozen", False)
_BUNDLE_DIR = getattr(sys, "_MEIPASS", None)
PROJECT_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent
def resource_path(*parts):
"""Absolute path to a read-only bundled resource (works when frozen)."""
return PROJECT_ROOT.joinpath(*parts)
DATA_DIR = Path(os.environ.get("PSP_DATA_DIR", str(PROJECT_ROOT / "data"))) DATA_DIR = Path(os.environ.get("PSP_DATA_DIR", str(PROJECT_ROOT / "data")))
DATS_DIR = Path(os.environ.get("PSP_DATS_DIR", str(DATA_DIR / "dats"))) DATS_DIR = Path(os.environ.get("PSP_DATS_DIR", str(DATA_DIR / "dats")))
@@ -12,9 +24,12 @@ def _default_state_dir():
"""Local state directory (fast, reliable local disk). """Local state directory (fast, reliable local disk).
SQLite locking is unreliable over network filesystems (SMB/FUSE), so the SQLite locking is unreliable over network filesystems (SMB/FUSE), so the
library DB and the cover cache live on local storage while DATs, manual library DB and the cover cache live on local storage while read-only DATs
covers and credentials stay in ``DATA_DIR`` (which may be a network share). stay bundled with the app.
""" """
if os.name == "nt":
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or str(Path.home())
return Path(base) / "PSPPreserve"
xdg = os.environ.get("XDG_STATE_HOME") xdg = os.environ.get("XDG_STATE_HOME")
base = Path(xdg) if xdg else Path.home() / ".local" / "state" base = Path(xdg) if xdg else Path.home() / ".local" / "state"
return base / "psp-preserve" return base / "psp-preserve"
@@ -23,9 +38,19 @@ def _default_state_dir():
STATE_DIR = Path(os.environ.get("PSP_STATE_DIR", str(_default_state_dir()))) STATE_DIR = Path(os.environ.get("PSP_STATE_DIR", str(_default_state_dir())))
DB_PATH = Path(os.environ.get("PSP_DB_PATH", str(STATE_DIR / "library.sqlite"))) DB_PATH = Path(os.environ.get("PSP_DB_PATH", str(STATE_DIR / "library.sqlite")))
COVERS_DIR = Path(os.environ.get("PSP_COVERS_DIR", str(STATE_DIR / "covers"))) COVERS_DIR = Path(os.environ.get("PSP_COVERS_DIR", str(STATE_DIR / "covers")))
# User-writable data (credentials, manual covers, PS1 templates, NPS TSV cache).
# In dev this is the checkout's ``data/``; when frozen the bundle is read-only,
# so it points at local state that survives app upgrades.
USER_DATA_DIR = Path(os.environ.get(
"PSP_USER_DATA_DIR", str(DATA_DIR if not IS_FROZEN else STATE_DIR / "data")))
LEGACY_DB_PATH = DATA_DIR / "library.sqlite" LEGACY_DB_PATH = DATA_DIR / "library.sqlite"
LEGACY_COVERS_DIR = DATA_DIR / "covers" LEGACY_COVERS_DIR = DATA_DIR / "covers"
def user_data_path(*parts):
"""Absolute path to a user-writable data file/folder (works when frozen)."""
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", "/media/downloads/Sony - PlayStation Portable"))
+2 -2
View File
@@ -52,7 +52,7 @@ _IGDB_IMAGE = "https://images.igdb.com/igdb/image/upload/"
_TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie" _TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie"
_TMDB_IMAGE = "https://image.tmdb.org/t/p/" _TMDB_IMAGE = "https://image.tmdb.org/t/p/"
_GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1" _GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1"
_MANUAL_DIR = config.DATA_DIR / "manual_covers" _MANUAL_DIR = config.USER_DATA_DIR / "manual_covers"
_OVGDB_ZIP = config.DATA_DIR / "covers" / "openvgdb-covers.zip" _OVGDB_ZIP = config.DATA_DIR / "covers" / "openvgdb-covers.zip"
_LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-covers.zip" _LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-covers.zip"
@@ -811,7 +811,7 @@ def compose_game_cover(key_art_bytes):
return bytes(ba) return bytes(ba)
_PS1_TEMPLATE_DIR = config.DATA_DIR / "ps1_templates" _PS1_TEMPLATE_DIR = config.USER_DATA_DIR / "ps1_templates"
def _ps1_template_bytes(name): def _ps1_template_bytes(name):
+25 -18
View File
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
) )
from . import config, covers, info, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, theme, transfers from . import config, covers, info, ingest, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, style, theme, transfers
from .db import get_connection, init_db from .db import get_connection, init_db
log = logging.getLogger("gui") log = logging.getLogger("gui")
@@ -622,7 +622,7 @@ class GameDetailsPanel(QWidget):
content = QWidget() content = QWidget()
self.layout = QVBoxLayout(content) self.layout = QVBoxLayout(content)
self.layout.setContentsMargins(6, 6, 6, 6) self.layout.setContentsMargins(6, 6, 16, 6)
self.layout.setSpacing(6) self.layout.setSpacing(6)
scroll.setWidget(content) scroll.setWidget(content)
@@ -681,32 +681,28 @@ class GameDetailsPanel(QWidget):
self.convert_btn.setToolTip("Convert this PS1 disc to an EBOOT.PBP") self.convert_btn.setToolTip("Convert this PS1 disc to an EBOOT.PBP")
self.layout.addWidget(self.convert_btn) self.layout.addWidget(self.convert_btn)
btn_grid = QGridLayout()
btn_grid.setHorizontalSpacing(6)
btn_grid.setVerticalSpacing(6)
self.link_btn = QPushButton("Scrape…") self.link_btn = QPushButton("Scrape…")
self.link_btn.clicked.connect(self.link_requested.emit) self.link_btn.clicked.connect(self.link_requested.emit)
btn_grid.addWidget(self.link_btn, 0, 0) self.layout.addWidget(self.link_btn)
self.edit_btn = QPushButton("Edit details…") self.edit_btn = QPushButton("Edit details…")
self.edit_btn.clicked.connect(self.edit_requested.emit) self.edit_btn.clicked.connect(self.edit_requested.emit)
btn_grid.addWidget(self.edit_btn, 0, 1) self.layout.addWidget(self.edit_btn)
self.copy_btn = QPushButton("Copy to PSP") self.copy_btn = QPushButton("Copy to PSP")
self.copy_btn.clicked.connect(self.copy_requested.emit) self.copy_btn.clicked.connect(self.copy_requested.emit)
self.copy_btn.setEnabled(False) self.copy_btn.setEnabled(False)
self.copy_btn.setToolTip("Copy this disc to the connected PSP") self.copy_btn.setToolTip("Copy this disc to the connected PSP")
btn_grid.addWidget(self.copy_btn, 1, 0) self.layout.addWidget(self.copy_btn)
self.delete_btn = QPushButton("Delete") self.delete_btn = QPushButton("Delete")
self.delete_btn.clicked.connect(self.delete_requested.emit) self.delete_btn.clicked.connect(self.delete_requested.emit)
self.delete_btn.setStyleSheet("color: #e06c6c;") self.delete_btn.setStyleSheet("color: #e06c6c;")
self.delete_btn.setToolTip("Permanently delete this disc from disk") self.delete_btn.setToolTip("Permanently delete this disc from disk")
btn_grid.addWidget(self.delete_btn, 1, 1) self.layout.addWidget(self.delete_btn)
self.del_psp_btn = QPushButton("Delete from PSP") self.del_psp_btn = QPushButton("Delete from PSP")
self.del_psp_btn.clicked.connect(self.delete_psp_requested.emit) self.del_psp_btn.clicked.connect(self.delete_psp_requested.emit)
self.del_psp_btn.setEnabled(False) self.del_psp_btn.setEnabled(False)
self.del_psp_btn.setStyleSheet("color: #e06c6c;") self.del_psp_btn.setStyleSheet("color: #e06c6c;")
self.del_psp_btn.setToolTip("Delete this disc from the connected PSP") self.del_psp_btn.setToolTip("Delete this disc from the connected PSP")
btn_grid.addWidget(self.del_psp_btn, 2, 0) self.layout.addWidget(self.del_psp_btn)
self.layout.addLayout(btn_grid)
self.layout.addStretch(1) self.layout.addStretch(1)
@@ -1207,7 +1203,7 @@ class SettingsPage(QWidget):
hint = QLabel( hint = QLabel(
"Manual UMD covers: drop images into " "Manual UMD covers: drop images into "
f"<tt>{config.DATA_DIR / 'manual_covers'}</tt> " f"<tt>{config.USER_DATA_DIR / 'manual_covers'}</tt> "
"(subfolders <tt>games/</tt> and <tt>videos/</tt>)." "(subfolders <tt>games/</tt> and <tt>videos/</tt>)."
) )
hint.setWordWrap(True) hint.setWordWrap(True)
@@ -1450,7 +1446,7 @@ class StoreDetailsPanel(QWidget):
outer.addWidget(scroll) outer.addWidget(scroll)
content = QWidget() content = QWidget()
self.layout = QVBoxLayout(content) self.layout = QVBoxLayout(content)
self.layout.setContentsMargins(6, 6, 6, 6) self.layout.setContentsMargins(6, 6, 16, 6)
self.layout.setSpacing(6) self.layout.setSpacing(6)
scroll.setWidget(content) scroll.setWidget(content)
@@ -2503,8 +2499,10 @@ class QueuePage(QWidget):
self.table = QTableWidget(0, 7) self.table = QTableWidget(0, 7)
self.table.setHorizontalHeaderLabels(["Item", "Type", "Status", "Progress", "Destination", "", ""]) self.table.setHorizontalHeaderLabels(["Item", "Type", "Status", "Progress", "Destination", "", ""])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.Fixed)
self.table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeToContents) self.table.horizontalHeader().setSectionResizeMode(6, QHeaderView.Fixed)
self.table.setColumnWidth(5, 80)
self.table.setColumnWidth(6, 34)
self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setVisible(False)
layout.addWidget(self.table) layout.addWidget(self.table)
@@ -2531,11 +2529,14 @@ class QueuePage(QWidget):
self.table.setCellWidget(row, 3, bar) self.table.setCellWidget(row, 3, bar)
self.table.setItem(row, 4, QTableWidgetItem(job["dest"])) self.table.setItem(row, 4, QTableWidgetItem(job["dest"]))
cancel = QPushButton("Cancel") cancel = QPushButton("Cancel")
cancel.setFixedHeight(22)
cancel.setStyleSheet("padding: 0px 8px;")
cancel.clicked.connect(lambda _=False, jid=job["id"]: self.queue.cancel(jid)) cancel.clicked.connect(lambda _=False, jid=job["id"]: self.queue.cancel(jid))
self.table.setCellWidget(row, 5, cancel) self.table.setCellWidget(row, 5, cancel)
delete = QPushButton("✕") delete = QPushButton("✕")
delete.setToolTip("Remove this entry from the queue history") delete.setToolTip("Remove this entry from the queue history")
delete.setFixedWidth(28) delete.setFixedSize(22, 22)
delete.setStyleSheet("padding: 0px;")
delete.clicked.connect(lambda _=False, jid=job["id"]: self.queue.remove(jid)) delete.clicked.connect(lambda _=False, jid=job["id"]: self.queue.remove(jid))
self.table.setCellWidget(row, 6, delete) self.table.setCellWidget(row, 6, delete)
self._rows[job["id"]] = row self._rows[job["id"]] = row
@@ -4092,7 +4093,7 @@ class MainWindow(QMainWindow):
layout.setContentsMargins(8, 8, 0, 8) layout.setContentsMargins(8, 8, 0, 8)
self.tabs = QTabWidget() self.tabs = QTabWidget()
self.tabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 0px; }") self.tabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 10px 0 0 0; }")
layout.addWidget(self.tabs) layout.addWidget(self.tabs)
self.queue = queue.TransferQueue(self) self.queue = queue.TransferQueue(self)
self.queue.job_finished.connect(self._on_queue_job_done) self.queue.job_finished.connect(self._on_queue_job_done)
@@ -4175,7 +4176,7 @@ class MainWindow(QMainWindow):
splitter = QSplitter(Qt.Horizontal) splitter = QSplitter(Qt.Horizontal)
self.library_subtabs = QTabWidget() self.library_subtabs = QTabWidget()
self.library_subtabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 0px; }") self.library_subtabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 10px 0 0 0; }")
self.library_subtabs.addTab(self._build_library_view(), "Library") self.library_subtabs.addTab(self._build_library_view(), "Library")
self.library_subtabs.addTab(self._build_scan_view(), "Scan") self.library_subtabs.addTab(self._build_scan_view(), "Scan")
splitter.addWidget(self.library_subtabs) splitter.addWidget(self.library_subtabs)
@@ -5310,8 +5311,14 @@ def _start_bundle_check(window):
def main(): def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s")
init_db() # migrate legacy on-share DB/covers to local storage, then schema init_db() # migrate legacy on-share DB/covers to local storage, then schema
# Auto-ingest any missing DATs so scanning matches against the catalog
# even on a fresh/reset database (no manual `python -m app.ingest` step).
n = ingest.ensure_catalog()
if n:
log.info("ingested %d DAT(s) into the catalog", n)
log.info("starting psp-preserve (db: %s)", config.DB_PATH) log.info("starting psp-preserve (db: %s)", config.DB_PATH)
app = QApplication(sys.argv) app = QApplication(sys.argv)
style.apply_theme(app)
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png" icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
if icon_path.is_file(): if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path))) app.setWindowIcon(QIcon(str(icon_path)))
+36
View File
@@ -88,6 +88,42 @@ def ingest_dat(conn, path):
return dat_name, system, release_count, rom_count return dat_name, system, release_count, rom_count
def ensure_catalog(dats_dir=None, conn=None):
"""Ingest any DAT files missing from the DB (idempotent, cheap when current).
Called once at startup so a fresh/reset database still has the catalog and
scanning produces matches instead of everything coming back ``unknown``.
Returns the number of DATs ingested.
"""
dats_dir = Path(dats_dir) if dats_dir else config.DATS_DIR
if not dats_dir.is_dir():
return 0
files = sorted(p for p in dats_dir.glob("*.dat") if p.is_file())
if not files:
return 0
own = conn is None
if own:
conn = get_connection(config.DB_PATH)
try:
known = {r["source_path"] for r in conn.execute("SELECT source_path FROM dats")}
count = 0
for path in files:
if str(path) in known:
continue
try:
ingest_dat(conn, path)
except Exception as exc: # noqa: BLE001 - a bad DAT shouldn't block startup
print(f"warning: could not ingest {path.name}: {exc}", file=sys.stderr)
continue
count += 1
conn.commit()
return count
finally:
if own:
conn.close()
def main(argv=None): def main(argv=None):
parser = argparse.ArgumentParser(description="Ingest clrmamepro DAT files into SQLite") parser = argparse.ArgumentParser(description="Ingest clrmamepro DAT files into SQLite")
parser.add_argument("--dats", help="directory containing .dat files (default: data/dats)") parser.add_argument("--dats", help="directory containing .dat files (default: data/dats)")
+4 -2
View File
@@ -48,8 +48,10 @@ def launch_ppsspp(iso, exe=""):
else: else:
return False, "PPSSPP not found. Install it or set its path in Settings → Saves." return False, "PPSSPP not found. Install it or set its path in Settings → Saves."
try: try:
subprocess.Popen(cmd, start_new_session=True, env=_env(), kwargs = {"env": _env(), "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL}
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) if os.name != "nt":
kwargs["start_new_session"] = True # POSIX-only: detach from our process group
subprocess.Popen(cmd, **kwargs)
return True, "" return True, ""
except OSError as exc: except OSError as exc:
return False, str(exc) return False, str(exc)
+1 -1
View File
@@ -56,7 +56,7 @@ def tsv_url(content_type: str, base: str = "") -> str:
def _tsv_dir() -> Path: def _tsv_dir() -> Path:
d = config.DATA_DIR / "nps" d = config.USER_DATA_DIR / "nps"
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return d return d
+4 -1
View File
@@ -40,6 +40,7 @@ import io
import os import os
import re import re
import struct import struct
import sys
import zlib import zlib
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -88,7 +89,9 @@ SYNC_PATTERN = b"\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00"
# (ARK/PRO/ME) loads this ELF directly as the DATA.PSP section, sidestepping # (ARK/PRO/ME) loads this ELF directly as the DATA.PSP section, sidestepping
# the base PBP's encrypted ~PSP loader which frequently fails to link on # the base PBP's encrypted ~PSP loader which frequently fails to link on
# 6.60/6.61 (error 80020148 "game could not be started"). # 6.60/6.61 (error 80020148 "game could not be started").
_LOADER_DIR = Path(__file__).resolve().parent.parent / "data" / "loader" _BUNDLE_DIR = getattr(sys, "_MEIPASS", None)
_APP_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent
_LOADER_DIR = _APP_ROOT / "data" / "loader"
BUNDLED_DATA_PSP = _LOADER_DIR / "DATA.PSP" BUNDLED_DATA_PSP = _LOADER_DIR / "DATA.PSP"
BUNDLED_KEYS_BIN = _LOADER_DIR / "KEYS.BIN" BUNDLED_KEYS_BIN = _LOADER_DIR / "KEYS.BIN"
+1 -1
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from . import config from . import config
CREDENTIALS_PATH = Path( CREDENTIALS_PATH = Path(
os.environ.get("PSP_CREDENTIALS", str(config.DATA_DIR / "credentials.json")) os.environ.get("PSP_CREDENTIALS", str(config.USER_DATA_DIR / "credentials.json"))
) )
FIELDS = [ FIELDS = [
+313
View File
@@ -0,0 +1,313 @@
"""Global dark theme for the GUI (Fusion style + palette + stylesheet).
Applied once at startup from ``gui.main``. The per-widget ``setStyleSheet``
colors sprinkled through ``gui.py`` (``#999`` secondary text, ``#e06c6c`` delete
buttons, ``#14151a`` preview panels) were written for a dark background, so a
single cohesive dark theme here makes the whole UI consistent.
"""
from PySide6.QtGui import QColor, QPalette
# Design tokens
BG = "#1e1f24" # window background
SURFACE = "#26272e" # cards, inputs, table headers
RAISED = "#2d2e36" # buttons, combos, menus
BORDER = "#3a3b44"
BORDER_SOFT = "#33343d"
TEXT = "#e6e6ea"
TEXT_DIM = "#9a9ba5"
TEXT_FAINT = "#6f7079"
ACCENT = "#4a9eff"
ACCENT_HOVER = "#6bb0ff"
_STYLESHEET = f"""
QMainWindow {{
background: {BG};
}}
QWidget {{
color: {TEXT};
}}
/* --- Tabs --- */
QTabWidget::pane {{
border: 1px solid {BORDER};
border-radius: 6px;
top: -1px;
}}
QTabBar::tab {{
background: transparent;
color: {TEXT_DIM};
padding: 8px 18px;
border: none;
border-bottom: 2px solid transparent;
margin-right: 2px;
}}
QTabBar::tab:hover:!selected {{
color: {TEXT};
}}
QTabBar::tab:selected {{
color: {TEXT};
border-bottom: 2px solid {ACCENT};
}}
/* --- Buttons --- */
QPushButton {{
background: {RAISED};
border: 1px solid {BORDER};
border-radius: 6px;
padding: 6px 12px;
}}
QPushButton:hover {{
background: #343640;
border-color: {ACCENT_HOVER};
}}
QPushButton:pressed {{
background: {SURFACE};
}}
QPushButton:disabled {{
color: {TEXT_FAINT};
background: {SURFACE};
border-color: {BORDER_SOFT};
}}
QPushButton:checked {{
background: {ACCENT};
border-color: {ACCENT};
color: #101116;
}}
/* --- Inputs --- */
QLineEdit, QSpinBox, QPlainTextEdit, QTextEdit {{
background: {SURFACE};
border: 1px solid {BORDER};
border-radius: 6px;
padding: 5px 8px;
selection-background-color: {ACCENT};
selection-color: #101116;
}}
QLineEdit:focus, QSpinBox:focus, QPlainTextEdit:focus, QTextEdit:focus {{
border-color: {ACCENT};
}}
QLineEdit:disabled, QSpinBox:disabled {{
color: {TEXT_FAINT};
}}
/* --- Combo boxes --- */
QComboBox {{
background: {RAISED};
border: 1px solid {BORDER};
border-radius: 6px;
padding: 4px 10px;
}}
QComboBox:hover {{
border-color: {ACCENT_HOVER};
}}
QComboBox::drop-down {{
border: none;
width: 22px;
}}
QComboBox::down-arrow {{
image: none;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid {TEXT_DIM};
margin-right: 6px;
}}
QComboBox QAbstractItemView {{
background: {RAISED};
border: 1px solid {BORDER};
border-radius: 6px;
selection-background-color: rgba(74, 158, 255, 0.25);
selection-color: {TEXT};
outline: 0;
}}
/* --- Tables --- */
QTableWidget {{
background: transparent;
border: none;
gridline-color: {BORDER_SOFT};
}}
QHeaderView::section {{
background: {SURFACE};
color: {TEXT_DIM};
border: none;
border-bottom: 1px solid {BORDER};
padding: 6px 8px;
}}
QTableWidget::item {{
padding: 4px 6px;
}}
QTableWidget::item:selected {{
background: rgba(74, 158, 255, 0.22);
color: {TEXT};
}}
QTableCornerButton::section {{
background: {SURFACE};
border: none;
border-bottom: 1px solid {BORDER};
}}
/* --- Cover grid / lists --- */
QListWidget {{
background: transparent;
border: none;
}}
QListWidget::item {{
border-radius: 6px;
}}
QListWidget::item:hover:!selected {{
background: rgba(255, 255, 255, 0.04);
}}
QListWidget::item:selected {{
background: rgba(74, 158, 255, 0.22);
}}
/* --- Cards (details panels use QScrollArea) --- */
QScrollArea {{
background: {SURFACE};
border: none;
}}
/* --- Menus --- */
QMenu {{
background: {RAISED};
border: 1px solid {BORDER};
border-radius: 6px;
padding: 4px;
}}
QMenu::item {{
padding: 6px 26px 6px 12px;
border-radius: 4px;
}}
QMenu::item:selected {{
background: rgba(74, 158, 255, 0.25);
}}
QMenu::separator {{
height: 1px;
background: {BORDER};
margin: 4px 8px;
}}
/* --- Scrollbars --- */
QScrollBar:vertical {{
background: transparent;
width: 10px;
margin: 0;
}}
QScrollBar::handle:vertical {{
background: {BORDER};
border-radius: 5px;
min-height: 30px;
}}
QScrollBar::handle:vertical:hover {{
background: {ACCENT};
}}
QScrollBar:horizontal {{
background: transparent;
height: 10px;
margin: 0;
}}
QScrollBar::handle:horizontal {{
background: {BORDER};
border-radius: 5px;
min-width: 30px;
}}
QScrollBar::handle:horizontal:hover {{
background: {ACCENT};
}}
QScrollBar::add-line, QScrollBar::sub-line {{
width: 0;
height: 0;
}}
QScrollBar::add-page, QScrollBar::sub-page {{
background: transparent;
}}
/* --- Splitter --- */
QSplitter::handle {{
background: transparent;
}}
QSplitter::handle:horizontal {{
width: 1px;
}}
QSplitter::handle:vertical {{
height: 1px;
}}
/* --- Misc --- */
QToolTip {{
background: {RAISED};
color: {TEXT};
border: 1px solid {BORDER};
padding: 4px 6px;
}}
QStatusBar {{
background: {SURFACE};
color: {TEXT_DIM};
border-top: 1px solid {BORDER};
}}
QProgressBar {{
background: {SURFACE};
border: 1px solid {BORDER};
border-radius: 6px;
text-align: center;
}}
QProgressBar::chunk {{
background: {ACCENT};
border-radius: 5px;
}}
QCheckBox {{
spacing: 8px;
}}
QCheckBox::indicator {{
width: 16px;
height: 16px;
border: 1px solid {BORDER};
border-radius: 4px;
background: {SURFACE};
}}
QCheckBox::indicator:checked {{
background: {ACCENT};
border-color: {ACCENT};
}}
QGroupBox {{
border: 1px solid {BORDER};
border-radius: 6px;
margin-top: 10px;
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 4px;
color: {TEXT_DIM};
}}
"""
def _dark_palette():
p = QPalette()
p.setColor(QPalette.Window, QColor(BG))
p.setColor(QPalette.WindowText, QColor(TEXT))
p.setColor(QPalette.Base, QColor(SURFACE))
p.setColor(QPalette.AlternateBase, QColor(RAISED))
p.setColor(QPalette.ToolTipBase, QColor(RAISED))
p.setColor(QPalette.ToolTipText, QColor(TEXT))
p.setColor(QPalette.Text, QColor(TEXT))
p.setColor(QPalette.Button, QColor(RAISED))
p.setColor(QPalette.ButtonText, QColor(TEXT))
p.setColor(QPalette.BrightText, QColor("#ff5c5c"))
p.setColor(QPalette.Link, QColor(ACCENT))
p.setColor(QPalette.Highlight, QColor(ACCENT))
p.setColor(QPalette.HighlightedText, QColor("#101116"))
p.setColor(QPalette.PlaceholderText, QColor(TEXT_FAINT))
faint = QColor(TEXT_FAINT)
for role in (QPalette.WindowText, QPalette.Text, QPalette.ButtonText):
p.setColor(QPalette.Disabled, role, faint)
return p
def apply_theme(app):
"""Apply the dark Fusion theme to ``app`` (call once before showing UI)."""
app.setStyle("Fusion")
app.setPalette(_dark_palette())
app.setStyleSheet(_STYLESHEET)
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 799 KiB

After

Width:  |  Height:  |  Size: 740 KiB