Compare commits

...
6 Commits
Author SHA1 Message Date
quoije 733e3c9089 Fix UI freeze on first cover load (decode+scale off the GUI thread)
build / Linux x86_64 (push) Failing after 8m22s
build / Windows x64 (Wine) (push) Canceled after 17m46s
2026-09-13 23:20:08 -04:00
quoije dc7d6c9720 Rebrand to psppt (psp-preserve -> psppt) 2026-09-13 23:08:22 -04:00
quoije 5c5bb6e5d6 fix line break 2026-09-13 22:57:08 -04:00
quoije 35db22e3c5 Add grrr meme to README (resized to 156x148) 2026-09-13 22:55:46 -04:00
quoije b0cdac5483 Put contents_directory on EXE (COLLECT reads it from the exe) 2026-09-13 22:33:04 -04:00
quoije f367bbe298 Clean /usr/bin install: contents dir 'lib' + install.sh
build / Linux x86_64 (push) Successful in 13m11s
build / Windows x64 (Wine) (push) Successful in 20m38s
2026-09-13 21:47:32 -04:00
17 changed files with 183 additions and 64 deletions
+12 -8
View File
@@ -22,15 +22,19 @@ jobs:
pip install -r requirements-build.txt
- name: Build
run: pyinstaller --clean --noconfirm psp-preserve.spec
run: pyinstaller --clean --noconfirm psppt.spec
- name: Package tarball
run: tar -czf psp-preserve-linux-x86_64.tar.gz -C dist psp-preserve
run: |
cp packaging/install.sh dist/psppt/
cp packaging/psppt.desktop dist/psppt/
cp img/app-icon.png dist/psppt/
tar -czf psppt-linux-x86_64.tar.gz -C dist psppt
- uses: actions/upload-artifact@v3
with:
name: psp-preserve-linux-x86_64
path: psp-preserve-linux-x86_64.tar.gz
name: psppt-linux-x86_64
path: psppt-linux-x86_64.tar.gz
windows:
name: Windows x64 (Wine)
@@ -44,13 +48,13 @@ jobs:
winpython -m pip install -r requirements-build.txt
- name: Build
run: winpython -m PyInstaller --clean --noconfirm psp-preserve.spec
run: winpython -m PyInstaller --clean --noconfirm psppt.spec
- name: Package zip
run: |
cd dist && zip -rq ../psp-preserve-windows-x64.zip psp-preserve
cd dist && zip -rq ../psppt-windows-x64.zip psppt
- uses: actions/upload-artifact@v3
with:
name: psp-preserve-windows-x64
path: psp-preserve-windows-x64.zip
name: psppt-windows-x64
path: psppt-windows-x64.zip
+1 -1
View File
@@ -3,5 +3,5 @@
# Port-forward 80 and 443 on your router to this host.
hostname.com {
reverse_proxy psp-preserve:8000
reverse_proxy psppt:8000
}
+12 -8
View File
@@ -1,6 +1,10 @@
# PSP Preservation Tool
# psppt
the name is terrible, i know.
![grrr](img/grrr.png)
![AI Generated](https://raw.githubusercontent.com/dotnetdave/ai-usage-badges/refs/heads/main/badges/png/1x/ai-generated.png)
A cross-platform (Linux + Windows) desktop GUI — PySide6 — for preserving PSP
@@ -11,7 +15,7 @@ save-data with a real PSP, can pull missing discs from a Redump 7z archive
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)
![psppt](img/qt-gui.png)
* Dark theme with a blue accent — the Library tab in cover view. *
## Features
@@ -69,8 +73,8 @@ see the note inside).
python3 -m venv .venv-build
. .venv-build/bin/activate
pip install -r requirements-build.txt
pyinstaller --clean --noconfirm psp-preserve.spec
# output: dist/psp-preserve/ → run dist/psp-preserve/psp-preserve
pyinstaller --clean --noconfirm psppt.spec
# output: dist/psppt/ → run dist/psppt/psppt
```
Notes:
@@ -178,7 +182,7 @@ Discs are matched by hash first (most precise), then serial, then filename.
to drawn art when they're absent.
- `data/nps/` — NoPayStation TSV lists (cached here on first store scan in dev;
a packaged build stores them under the state dir instead).
- State dir (`~/.local/state/psp-preserve/` on Linux, `%LOCALAPPDATA%\PSPPreserve`
- State dir (`~/.local/state/psppt/` on Linux, `%LOCALAPPDATA%\psppt`
on Windows, or `$PSP_STATE_DIR`):
- `library.sqlite` — the catalog + library database.
- `covers/` — downloaded cover cache.
@@ -192,13 +196,13 @@ bundled with the app; user-writable data lives in the state dir.
## Configuration
Everything is driven by environment variables, with GUI settings (stored in
QSettings under `PSPPreserve`) taking precedence where noted.
QSettings under `psppt`) taking precedence where noted.
| Variable | Default | Purpose |
|---|---|---|
| `PSP_DATA_DIR` | `./data` | data root (DATs, credentials, manual covers) |
| `PSP_DATS_DIR` | `$PSP_DATA_DIR/dats` | DAT folder |
| `PSP_STATE_DIR` | `~/.local/state/psp-preserve` | local state root |
| `PSP_STATE_DIR` | `~/.local/state/psppt` | local state root |
| `PSP_DB_PATH` | `$PSP_STATE_DIR/library.sqlite` | SQLite library |
| `PSP_COVERS_DIR` | `$PSP_STATE_DIR/covers` | cover cache |
| `PSP_GAMES_ROOTS` | `/data/umd_games` | comma-separated game disc roots |
@@ -423,7 +427,7 @@ Token protection: set `PSP_API_TOKEN`; the client sends
macvlan bridge so it gets its own LAN IP (e.g. `192.168.1.100`, plain HTTP on 8000):
```bash
# in /path/to/psp-preserve/
# in /path/to/psppt/
cp .env.example .env # then edit: LAN IP, share paths, API token
docker compose up -d --build
```
+1 -1
View File
@@ -1 +1 @@
"""PSP Preservation Tool application package."""
"""psppt application package."""
+2 -2
View File
@@ -251,7 +251,7 @@ def _library(conn, system=None, refresh=False):
class Handler(BaseHTTPRequestHandler):
server_version = "PSPPreserve/0.1"
server_version = "psppt/0.1"
def do_GET(self):
parsed = urlparse(self.path)
@@ -262,7 +262,7 @@ class Handler(BaseHTTPRequestHandler):
try:
if path == "/":
_json(self, {
"name": "PSP Preservation Tool API",
"name": "psppt API",
"endpoints": [
"/api/summary",
"/api/releases?system=games|videos&q=&limit=&offset=",
+2 -2
View File
@@ -42,10 +42,10 @@ def _default_state_dir():
"""
if os.name == "nt":
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or str(Path.home())
return Path(base) / "PSPPreserve"
return Path(base) / "psppt"
xdg = os.environ.get("XDG_STATE_HOME")
base = Path(xdg) if xdg else Path.home() / ".local" / "state"
return base / "psp-preserve"
return base / "psppt"
STATE_DIR = Path(os.environ.get("PSP_STATE_DIR", str(_default_state_dir())))
+2 -2
View File
@@ -62,7 +62,7 @@ _WIKI_SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
_JPEG_MAGIC = b"\xff\xd8\xff"
_MAX_HOPS = 3 # follow libretro symlink text bodies at most this many times
_UA = {"User-Agent": "psp-preserve/0.1 (https://github.com; local tool)"}
_UA = {"User-Agent": "psppt/0.1 (https://github.com; local tool)"}
_igdb_token_state = {"token": "", "expires": 0.0} # IGDB OAuth2 token cache
_igdb_lock = threading.Lock()
_igdb_last = 0.0 # monotonic clock of the last IGDB API call
@@ -610,7 +610,7 @@ def _fetch_screenscraper(name, timeout, system="games"):
params = {
"devid": devid,
"devpassword": devpassword,
"softname": creds.get("screenscraper_softname") or "psp-preserve",
"softname": creds.get("screenscraper_softname") or "psppt",
"output": "json",
"systemeid": _platform(system)["ss_id"],
"romtype": "rom",
+51 -22
View File
@@ -1,4 +1,4 @@
"""Native Linux GUI for the PSP Preservation Tool (PySide6 / Qt).
"""Native Linux GUI for psppt (PySide6 / Qt).
Run with::
@@ -117,14 +117,16 @@ def _thread_running(thread):
class CoverLoader(QObject):
loaded = Signal(int, int, str) # gen, item index, cached image path
loaded = Signal(int, int, QImage) # gen, item index, fitted cover image
failed = Signal(int, int, str) # gen, item index, name (no cover found)
finished = Signal()
def __init__(self, items, gen):
def __init__(self, items, gen, icon_w=150, icon_h=220):
super().__init__()
self.items = items
self.gen = gen
self.icon_w = icon_w
self.icon_h = icon_h
self._cancel = threading.Event()
def cancel(self):
@@ -148,7 +150,11 @@ class CoverLoader(QObject):
except Exception: # noqa: BLE001
path = None
if path is not None:
self.loaded.emit(self.gen, i, str(path))
img = QImage(str(path))
if img.isNull():
self.failed.emit(self.gen, i, self.items[i]["name"])
else:
self.loaded.emit(self.gen, i, _fit_image(img, self.icon_w, self.icon_h))
else:
self.failed.emit(self.gen, i, self.items[i]["name"])
finally:
@@ -276,6 +282,25 @@ def _fit_pixmap(pm, w, h):
return canvas
def _fit_image(img, w, h):
"""Scale a QImage into a letterboxed ``w x h`` ARGB image (thread-safe).
Decoding + scaling happen off the GUI thread in the cover loaders; the
main thread only converts the result to a QPixmap (cheap).
"""
canvas = QImage(w, h, QImage.Format_ARGB32)
canvas.fill(Qt.transparent)
if img.isNull():
return canvas
scaled = img.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
x = (w - scaled.width()) // 2
y = (h - scaled.height()) // 2
painter = QPainter(canvas)
painter.drawImage(x, y, scaled)
painter.end()
return canvas
def _cover_pixmap(rid, system, max_w, max_h):
"""Return a scaled cover pixmap for an item, or None if unavailable."""
if rid is None:
@@ -429,7 +454,7 @@ class CoverShowcase(QWidget):
for loader in self._loaders:
loader.cancel()
self.thread = QThread(self)
self.loader = CoverLoader(self.items, gen)
self.loader = CoverLoader(self.items, gen, self.ICON_W, self.ICON_H)
self.loader.moveToThread(self.thread)
self.thread.started.connect(self.loader.run)
self.loader.loaded.connect(self._on_loaded)
@@ -454,12 +479,12 @@ class CoverShowcase(QWidget):
painter.end()
return QIcon(pm)
def _on_loaded(self, gen, index, path):
def _on_loaded(self, gen, index, image):
if gen != self._generation:
return
item = self.list.item(index)
if item is not None:
item.setIcon(self._fit_icon(QPixmap(path)))
item.setIcon(QIcon(QPixmap.fromImage(image)))
if 0 <= index < len(self.items):
self.cover_loaded.emit(self.items[index])
@@ -1117,7 +1142,7 @@ class SettingsPage(QWidget):
iso_category, ps1_category, psp_category, ps1_dir, ps1_output_dir, ps1_base_pbp,
nps_dir="", parent=None):
super().__init__(parent)
s = QSettings("PSPPreserve", "PSPPreserve")
s = QSettings("psppt", "psppt")
outer = QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
@@ -1289,7 +1314,7 @@ class SettingsPage(QWidget):
note = QLabel(
"Your library, covers, and credentials live here — outside the app "
"bundle, so they survive upgrades. On Linux this is the "
"~/.local/state/psp-preserve folder."
"~/.local/state/psppt folder."
)
note.setWordWrap(True)
note.setStyleSheet("color: #9a9ba5;")
@@ -1444,7 +1469,7 @@ class SettingsPage(QWidget):
def _save(self):
sources.save({key: edit.text() for key, edit in self.fields.items()})
covers.clear_miss_cache()
s = QSettings("PSPPreserve", "PSPPreserve")
s = QSettings("psppt", "psppt")
s.setValue("auto_scan", self.auto_scan.isChecked())
s.setValue("scan_workers", self.workers_spin.value())
s.setValue("auto_sync_saves", self.auto_sync.isChecked())
@@ -1479,14 +1504,16 @@ class StoreScanWorker(QObject):
class StoreCoverLoader(QObject):
loaded = Signal(int, int, str) # gen, grid row, cached image path
loaded = Signal(int, int, QImage) # gen, grid row, fitted cover image
finished = Signal()
def __init__(self, tasks, gen, quick=True):
def __init__(self, tasks, gen, quick=True, icon_w=150, icon_h=220):
super().__init__()
self.tasks = tasks # list of (grid_row, item_dict)
self.gen = gen
self.quick = quick
self.icon_w = icon_w
self.icon_h = icon_h
self._cancel = threading.Event()
def cancel(self):
@@ -1510,7 +1537,9 @@ class StoreCoverLoader(QObject):
except Exception: # noqa: BLE001
path = None
if path is not None:
self.loaded.emit(self.gen, row, str(path))
img = QImage(str(path))
if not img.isNull():
self.loaded.emit(self.gen, row, _fit_image(img, self.icon_w, self.icon_h))
finally:
pool.shutdown(wait=True, cancel_futures=True)
self.finished.emit()
@@ -2336,7 +2365,7 @@ class StorePage(QWidget):
if self._closing or not tasks:
return
thread = QThread(self)
loader = StoreCoverLoader(tasks, gen, quick=True)
loader = StoreCoverLoader(tasks, gen, quick=True, icon_w=self.ICON_W, icon_h=self.ICON_H)
loader.moveToThread(thread)
thread.started.connect(loader.run)
loader.loaded.connect(self._on_cover_loaded)
@@ -2346,12 +2375,12 @@ class StorePage(QWidget):
self._loaders.append(loader)
thread.start()
def _on_cover_loaded(self, gen, row, path):
def _on_cover_loaded(self, gen, row, image):
if gen != self._generation:
return
item = self.grid.item(row)
if item is not None:
item.setIcon(QIcon(_fit_pixmap(QPixmap(path), self.ICON_W, self.ICON_H)))
item.setIcon(QIcon(QPixmap.fromImage(image)))
it = item.data(Qt.UserRole)
shown = getattr(self.details, "_item", None)
if it is not None and shown is not None and it.get("path") == shown.get("path"):
@@ -4287,7 +4316,7 @@ class Ps1Page(QWidget):
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("psp preservation tool ~ quoije")
self.setWindowTitle("psppt ~ quoije")
self.resize(1240, 780)
self._rows = []
self._renames = []
@@ -4310,7 +4339,7 @@ class MainWindow(QMainWindow):
self._library_workers = []
self._library_loading = False
self.settings = QSettings("PSPPreserve", "PSPPreserve")
self.settings = QSettings("psppt", "psppt")
self._games_dir = self._first_existing(config.GAMES_ROOTS) or self.settings.value("games_dir", "") or ""
self._videos_dir = self._first_existing(config.VIDEO_ROOTS) or self.settings.value("videos_dir", "") or ""
self._store_dir = str(self.settings.value("store_dir", str(config.STORE_ROOT)) or "")
@@ -4353,7 +4382,7 @@ class MainWindow(QMainWindow):
pm = QPixmap(str(icon_path))
pm = pm.scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
logo.setPixmap(pm)
logo.setToolTip("PSP Preservation Tool")
logo.setToolTip("psppt")
self.tabs.setCornerWidget(logo, Qt.TopRightCorner)
self.queue = queue.TransferQueue(self)
@@ -5676,11 +5705,11 @@ def main():
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 psppt (db: %s)", config.DB_PATH)
app = QApplication(sys.argv)
style.apply_theme(app)
app.setApplicationName("PSP Preservation Tool")
app.setDesktopFileName("psp-preserve")
app.setApplicationName("psppt")
app.setDesktopFileName("psppt")
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path)))
+1 -1
View File
@@ -1,4 +1,4 @@
"""PSP Preservation Tool entry point (native GUI).
"""psppt entry point (native GUI).
Run with::
+1 -1
View File
@@ -151,7 +151,7 @@ def fetch_items(content_type: str, url: str = "", timeout: int = 60, force: bool
return items
try:
req = urllib.request.Request(url, headers={"User-Agent": "psp-preserve/0.1"})
req = urllib.request.Request(url, headers={"User-Agent": "psppt/0.1"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read()
text = data.decode("utf-8", errors="replace")
+1 -1
View File
@@ -155,7 +155,7 @@ def scrape(limit=None, workers=8, rate=3.0):
log.info("downloading OpenVGDB dump (%s)…", OVGDB_ZIP_URL)
tmpdir = Path(tempfile.mkdtemp(prefix="openvgdb-"))
dump_zip = tmpdir / "openvgdb.zip"
req = urllib.request.Request(OVGDB_ZIP_URL, headers={"User-Agent": "psp-preserve/0.1"})
req = urllib.request.Request(OVGDB_ZIP_URL, headers={"User-Agent": "psppt/0.1"})
with urllib.request.urlopen(req, timeout=60) as r, open(dump_zip, "wb") as f:
f.write(r.read())
+2 -2
View File
@@ -284,7 +284,7 @@ class RemoteStoreSource(Source):
self.token = token or ""
def _headers(self):
headers = {"User-Agent": "psp-preserve/0.1"}
headers = {"User-Agent": "psppt/0.1"}
if self.token:
headers["Authorization"] = f"Bearer {self.token}"
return headers
@@ -347,7 +347,7 @@ class ArchiveOrgSource(Source):
return f"https://archive.org/download/{self.item}"
def _headers(self):
headers = {"User-Agent": "psp-preserve/0.1"}
headers = {"User-Agent": "psppt/0.1"}
cookie = "; ".join(
p for p in (
f"logged-in-sig={self.sig}" if self.sig else "",
+2 -2
View File
@@ -1,7 +1,7 @@
services:
psp-preserve:
psppt:
build: .
container_name: psp-preserve
container_name: psppt
# Attach to Unraid's macvlan bridge so the API gets its own LAN IP.
# Plain HTTP for now; see Caddyfile to add Let's Encrypt TLS later.
networks:
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
# Install psppt to a system prefix.
#
# FHS-friendly layout:
# $PREFIX/bin/psppt symlink -> payload binary
# $PREFIX/lib/psppt/ app payload (binary + lib/)
# $PREFIX/share/applications/ desktop entry
# $PREFIX/share/icons/hicolor/256x256/apps/ app icon
#
# The PyInstaller onedir build keeps the executable and its `lib/` contents
# directory side by side, so both live under $PREFIX/lib/psppt/ and a
# symlink exposes the binary at $PREFIX/bin/psppt.
#
# Usage (run from inside the extracted tarball):
# sudo ./install.sh # install to /usr/local (default)
# sudo ./install.sh --prefix=/usr # install to /usr
# sudo ./install.sh --uninstall # remove from the default prefix
set -eu
PREFIX=/usr/local
MODE=install
for arg in "$@"; do
case "$arg" in
--prefix=*) PREFIX=${arg#--prefix=} ;;
--uninstall) MODE=uninstall ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
SRC_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
LIBDIR="$PREFIX/lib/psppt"
BINDIR="$PREFIX/bin"
APPDIR="$PREFIX/share/applications"
ICONDIR="$PREFIX/share/icons/hicolor/256x256/apps"
APP_BIN="$LIBDIR/psppt"
require_root() {
if [ "$(id -u)" -ne 0 ]; then
echo "run as root (e.g. sudo)" >&2
exit 1
fi
}
uninstall() {
rm -f "$BINDIR/psppt"
rm -f "$APPDIR/psppt.desktop"
rm -f "$ICONDIR/psppt.png"
rm -rf "$LIBDIR"
echo "Uninstalled from $PREFIX"
}
if [ "$MODE" = uninstall ]; then
require_root
uninstall
exit 0
fi
require_root
# The tarball ships these next to this script.
for f in psppt lib psppt.desktop app-icon.png; do
if [ ! -e "$SRC_DIR/$f" ]; then
echo "missing $f (run install.sh from inside the extracted tarball)" >&2
exit 1
fi
done
mkdir -p "$LIBDIR" "$BINDIR" "$APPDIR" "$ICONDIR"
rm -rf "$LIBDIR"
mkdir -p "$LIBDIR"
cp -a "$SRC_DIR/psppt" "$LIBDIR/psppt"
cp -a "$SRC_DIR/lib" "$LIBDIR/lib"
install -m 0644 "$SRC_DIR/psppt.desktop" "$APPDIR/psppt.desktop"
install -m 0644 "$SRC_DIR/app-icon.png" "$ICONDIR/psppt.png"
ln -sfn "$APP_BIN" "$BINDIR/psppt"
echo "Installed to $PREFIX"
echo " binary: $BINDIR/psppt -> $APP_BIN"
echo " payload: $LIBDIR"
echo " desktop: $APPDIR/psppt.desktop"
@@ -1,9 +1,9 @@
[Desktop Entry]
Type=Application
Name=PSP Preservation Tool
Name=psppt
Comment=Preserve and manage your PSP library
Exec=psp-preserve
Icon=psp-preserve
Exec=psppt
Icon=psppt
Terminal=false
Categories=Utility;AudioVideo;
StartupWMClass=psp-preserve
StartupWMClass=psppt
+8 -7
View File
@@ -1,13 +1,13 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec: build the PSP Preservation Tool (onedir).
"""PyInstaller spec: build psppt (onedir).
Bundles the read-only resources (DATs, PS1 loader, offline cover bundles,
icons) under ``_internal/`` next to the executable. User-writable data
icons) under ``lib/`` next to the executable. User-writable data
(credentials, manual covers, PS1 templates, NPS cache, library DB, cover
cache) lives in the OS state dir at runtime — see ``app/config.py``.
Usage: pyinstaller --clean --noconfirm psp-preserve.spec
Output: dist/psp-preserve/
Usage: pyinstaller --clean --noconfirm psppt.spec
Output: dist/psppt/
"""
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules
@@ -24,7 +24,7 @@ datas = [
("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"),
("img/app-icon.png", "img"),
("img/chevron.png", "img"),
("packaging/psp-preserve.desktop", "."),
("packaging/psppt.desktop", "."),
]
a = Analysis(
@@ -47,13 +47,14 @@ exe = EXE(
a.scripts,
[],
exclude_binaries=True,
name="psp-preserve",
name="psppt",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
contents_directory="lib",
)
coll = COLLECT(
@@ -62,5 +63,5 @@ coll = COLLECT(
a.datas,
strip=False,
upx=False,
name="psp-preserve",
name="psppt",
)