Compare commits

...
15 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
quoije c61d8e9d3f Revert contents_directory (collides with exe name)
build / Linux x86_64 (push) Failing after 3m8s
build / Windows x64 (Wine) (push) Canceled after 0s
2026-09-13 21:43:28 -04:00
quoije 78f6d44c73 Fix frozen-build SSL certs, taskbar icon, and rename _internal to psp-preserve
build / Linux x86_64 (push) Failing after 7m17s
build / Windows x64 (Wine) (push) Canceled after 0s
- config: point SSL at certifi bundle when frozen (fixes store/NPS/TMDB SSL errors)
- gui: set app name + window icon for the taskbar
- spec: contents_directory=psp-preserve; bundle a .desktop file for Wayland launchers
2026-09-13 21:19:56 -04:00
quoije a7f90cd5a7 Runner image: use WineHQ wine-staging + embeddable Python (Wine 8.0 too old for Py3.12) 2026-09-13 21:13:30 -04:00
quoije c9575817fe Runner image: use full Python installer (bundles pip + OpenSSL) 2026-09-13 21:11:45 -04:00
quoije b576ff5747 Settings: add Storage section showing state dir, DB, cover cache, and user data paths 2026-09-13 21:07:23 -04:00
quoije 6510229b07 Add cover-grid sorting to Library and Store
Library: Title / Release date / Size / Region / Type / Serial
Store: Newest first / Title / Region / Size / In library first
2026-09-13 21:03:49 -04:00
quoije e61d91418e Runner image: add wine32 (winetricks regedit needs 32-bit Wine) 2026-09-13 20:52:42 -04:00
quoije a1c461f6aa Runner image: use embeddable Python (avoid 32-bit installer bootstrap) 2026-09-13 20:50:57 -04:00
quoije 985c8df2cc Runner image: add xauth (xvfb-run requirement) 2026-09-13 20:38:48 -04:00
18 changed files with 313 additions and 72 deletions
+12 -8
View File
@@ -22,15 +22,19 @@ jobs:
pip install -r requirements-build.txt pip install -r requirements-build.txt
- name: Build - name: Build
run: pyinstaller --clean --noconfirm psp-preserve.spec run: pyinstaller --clean --noconfirm psppt.spec
- name: Package tarball - 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 - uses: actions/upload-artifact@v3
with: with:
name: psp-preserve-linux-x86_64 name: psppt-linux-x86_64
path: psp-preserve-linux-x86_64.tar.gz path: psppt-linux-x86_64.tar.gz
windows: windows:
name: Windows x64 (Wine) name: Windows x64 (Wine)
@@ -44,13 +48,13 @@ jobs:
winpython -m pip install -r requirements-build.txt winpython -m pip install -r requirements-build.txt
- name: Build - name: Build
run: winpython -m PyInstaller --clean --noconfirm psp-preserve.spec run: winpython -m PyInstaller --clean --noconfirm psppt.spec
- name: Package zip - name: Package zip
run: | 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 - uses: actions/upload-artifact@v3
with: with:
name: psp-preserve-windows-x64 name: psppt-windows-x64
path: psp-preserve-windows-x64.zip path: psppt-windows-x64.zip
+1 -1
View File
@@ -3,5 +3,5 @@
# Port-forward 80 and 443 on your router to this host. # Port-forward 80 and 443 on your router to this host.
hostname.com { 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. 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) ![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 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 collection, downloads PSN content from NoPayStation, and converts PlayStation 1
ISO/CUE/BIN rips into PSP `EBOOT.PBP` homebrew. 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. * * Dark theme with a blue accent — the Library tab in cover view. *
## Features ## Features
@@ -69,8 +73,8 @@ see the note inside).
python3 -m venv .venv-build python3 -m venv .venv-build
. .venv-build/bin/activate . .venv-build/bin/activate
pip install -r requirements-build.txt pip install -r requirements-build.txt
pyinstaller --clean --noconfirm psp-preserve.spec pyinstaller --clean --noconfirm psppt.spec
# output: dist/psp-preserve/ → run dist/psp-preserve/psp-preserve # output: dist/psppt/ → run dist/psppt/psppt
``` ```
Notes: Notes:
@@ -178,7 +182,7 @@ 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).
- 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`): 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.
@@ -192,13 +196,13 @@ bundled with the app; user-writable data lives in the state dir.
## Configuration ## Configuration
Everything is driven by environment variables, with GUI settings (stored in 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 | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `PSP_DATA_DIR` | `./data` | data root (DATs, credentials, manual covers) | | `PSP_DATA_DIR` | `./data` | data root (DATs, credentials, manual covers) |
| `PSP_DATS_DIR` | `$PSP_DATA_DIR/dats` | DAT folder | | `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_DB_PATH` | `$PSP_STATE_DIR/library.sqlite` | SQLite library |
| `PSP_COVERS_DIR` | `$PSP_STATE_DIR/covers` | cover cache | | `PSP_COVERS_DIR` | `$PSP_STATE_DIR/covers` | cover cache |
| `PSP_GAMES_ROOTS` | `/data/umd_games` | comma-separated game disc roots | | `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): 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/ # in /path/to/psppt/
cp .env.example .env # then edit: LAN IP, share paths, API token cp .env.example .env # then edit: LAN IP, share paths, API token
docker compose up -d --build 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): class Handler(BaseHTTPRequestHandler):
server_version = "PSPPreserve/0.1" server_version = "psppt/0.1"
def do_GET(self): def do_GET(self):
parsed = urlparse(self.path) parsed = urlparse(self.path)
@@ -262,7 +262,7 @@ class Handler(BaseHTTPRequestHandler):
try: try:
if path == "/": if path == "/":
_json(self, { _json(self, {
"name": "PSP Preservation Tool API", "name": "psppt API",
"endpoints": [ "endpoints": [
"/api/summary", "/api/summary",
"/api/releases?system=games|videos&q=&limit=&offset=", "/api/releases?system=games|videos&q=&limit=&offset=",
+15 -2
View File
@@ -11,6 +11,19 @@ _BUNDLE_DIR = getattr(sys, "_MEIPASS", None)
PROJECT_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent
# Frozen bundles don't ship the system CA store, so Python's urllib would fail
# TLS verification against the store server / NoPayStation / TMDB / archive.org.
# Point OpenSSL at the certifi bundle (a curl_cffi dependency) when frozen only;
# source runs keep using the OS CA store.
if IS_FROZEN:
try:
import certifi
_CA_BUNDLE = certifi.where()
os.environ.setdefault("SSL_CERT_FILE", _CA_BUNDLE)
os.environ.setdefault("REQUESTS_CA_BUNDLE", _CA_BUNDLE)
except Exception: # noqa: BLE001
pass
def resource_path(*parts): def resource_path(*parts):
"""Absolute path to a read-only bundled resource (works when frozen).""" """Absolute path to a read-only bundled resource (works when frozen)."""
@@ -29,10 +42,10 @@ def _default_state_dir():
""" """
if os.name == "nt": if os.name == "nt":
base = os.environ.get("LOCALAPPDATA") or os.environ.get("APPDATA") or str(Path.home()) 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") 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 / "psppt"
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())))
+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" _PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
_JPEG_MAGIC = b"\xff\xd8\xff" _JPEG_MAGIC = b"\xff\xd8\xff"
_MAX_HOPS = 3 # follow libretro symlink text bodies at most this many times _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_token_state = {"token": "", "expires": 0.0} # IGDB OAuth2 token cache
_igdb_lock = threading.Lock() _igdb_lock = threading.Lock()
_igdb_last = 0.0 # monotonic clock of the last IGDB API call _igdb_last = 0.0 # monotonic clock of the last IGDB API call
@@ -610,7 +610,7 @@ def _fetch_screenscraper(name, timeout, system="games"):
params = { params = {
"devid": devid, "devid": devid,
"devpassword": devpassword, "devpassword": devpassword,
"softname": creds.get("screenscraper_softname") or "psp-preserve", "softname": creds.get("screenscraper_softname") or "psppt",
"output": "json", "output": "json",
"systemeid": _platform(system)["ss_id"], "systemeid": _platform(system)["ss_id"],
"romtype": "rom", "romtype": "rom",
+132 -20
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:: Run with::
@@ -117,14 +117,16 @@ def _thread_running(thread):
class CoverLoader(QObject): 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) failed = Signal(int, int, str) # gen, item index, name (no cover found)
finished = Signal() finished = Signal()
def __init__(self, items, gen): def __init__(self, items, gen, icon_w=150, icon_h=220):
super().__init__() super().__init__()
self.items = items self.items = items
self.gen = gen self.gen = gen
self.icon_w = icon_w
self.icon_h = icon_h
self._cancel = threading.Event() self._cancel = threading.Event()
def cancel(self): def cancel(self):
@@ -148,7 +150,11 @@ class CoverLoader(QObject):
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
path = None path = None
if path is not 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: else:
self.failed.emit(self.gen, i, self.items[i]["name"]) self.failed.emit(self.gen, i, self.items[i]["name"])
finally: finally:
@@ -276,6 +282,25 @@ def _fit_pixmap(pm, w, h):
return canvas 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): def _cover_pixmap(rid, system, max_w, max_h):
"""Return a scaled cover pixmap for an item, or None if unavailable.""" """Return a scaled cover pixmap for an item, or None if unavailable."""
if rid is None: if rid is None:
@@ -429,7 +454,7 @@ class CoverShowcase(QWidget):
for loader in self._loaders: for loader in self._loaders:
loader.cancel() loader.cancel()
self.thread = QThread(self) 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.loader.moveToThread(self.thread)
self.thread.started.connect(self.loader.run) self.thread.started.connect(self.loader.run)
self.loader.loaded.connect(self._on_loaded) self.loader.loaded.connect(self._on_loaded)
@@ -454,12 +479,12 @@ class CoverShowcase(QWidget):
painter.end() painter.end()
return QIcon(pm) return QIcon(pm)
def _on_loaded(self, gen, index, path): def _on_loaded(self, gen, index, image):
if gen != self._generation: if gen != self._generation:
return return
item = self.list.item(index) item = self.list.item(index)
if item is not None: if item is not None:
item.setIcon(self._fit_icon(QPixmap(path))) item.setIcon(QIcon(QPixmap.fromImage(image)))
if 0 <= index < len(self.items): if 0 <= index < len(self.items):
self.cover_loaded.emit(self.items[index]) 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, iso_category, ps1_category, psp_category, ps1_dir, ps1_output_dir, ps1_base_pbp,
nps_dir="", parent=None): nps_dir="", parent=None):
super().__init__(parent) super().__init__(parent)
s = QSettings("PSPPreserve", "PSPPreserve") s = QSettings("psppt", "psppt")
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16) outer.setContentsMargins(16, 16, 16, 16)
@@ -1280,6 +1305,22 @@ class SettingsPage(QWidget):
form.addRow(self.clear_covers_btn) form.addRow(self.clear_covers_btn)
col.addWidget(box) col.addWidget(box)
# -- Storage ---------------------------------------------------------- #
box, form = self._group("Storage")
self._path_row(form, "State directory", str(config.STATE_DIR))
self._path_row(form, "Library database", str(config.DB_PATH))
self._path_row(form, "Cover cache", str(config.COVERS_DIR))
self._path_row(form, "User data (credentials, NPS cache…)", str(config.USER_DATA_DIR))
note = QLabel(
"Your library, covers, and credentials live here — outside the app "
"bundle, so they survive upgrades. On Linux this is the "
"~/.local/state/psppt folder."
)
note.setWordWrap(True)
note.setStyleSheet("color: #9a9ba5;")
form.addRow(note)
col.addWidget(box)
col.addStretch(1) col.addStretch(1)
def _group(self, title): def _group(self, title):
@@ -1342,6 +1383,29 @@ class SettingsPage(QWidget):
form.addRow(label, edit) form.addRow(label, edit)
return edit return edit
def _path_row(self, form, label, path):
edit = QLineEdit(path)
edit.setReadOnly(True)
open_btn = QPushButton("Open")
open_btn.clicked.connect(lambda _=False, p=Path(path): self._open_path(p))
row = QWidget()
h = QHBoxLayout(row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(edit, 1)
h.addWidget(open_btn)
form.addRow(label, row)
return edit
@staticmethod
def _open_path(path):
path = Path(path)
target = path if path.is_dir() else path.parent
if target.is_dir():
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
else:
QMessageBox.information(None, "Not found",
f"Folder not created yet:\n{target}\n\nIt is created on first use.")
def games_dir(self): def games_dir(self):
return self.games_edit.text().strip() return self.games_edit.text().strip()
@@ -1405,7 +1469,7 @@ class SettingsPage(QWidget):
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()
s = QSettings("PSPPreserve", "PSPPreserve") s = QSettings("psppt", "psppt")
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())
@@ -1440,14 +1504,16 @@ class StoreScanWorker(QObject):
class StoreCoverLoader(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() finished = Signal()
def __init__(self, tasks, gen, quick=True): def __init__(self, tasks, gen, quick=True, icon_w=150, icon_h=220):
super().__init__() super().__init__()
self.tasks = tasks # list of (grid_row, item_dict) self.tasks = tasks # list of (grid_row, item_dict)
self.gen = gen self.gen = gen
self.quick = quick self.quick = quick
self.icon_w = icon_w
self.icon_h = icon_h
self._cancel = threading.Event() self._cancel = threading.Event()
def cancel(self): def cancel(self):
@@ -1471,7 +1537,9 @@ class StoreCoverLoader(QObject):
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
path = None path = None
if path is not 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: finally:
pool.shutdown(wait=True, cancel_futures=True) pool.shutdown(wait=True, cancel_futures=True)
self.finished.emit() self.finished.emit()
@@ -1789,6 +1857,11 @@ class StorePage(QWidget):
self.psp_filter.setToolTip("Filter by whether the archive matches a disc installed on the connected PSP") self.psp_filter.setToolTip("Filter by whether the archive matches a disc installed on the connected PSP")
self.psp_filter.currentIndexChanged.connect(self._apply_filter) self.psp_filter.currentIndexChanged.connect(self._apply_filter)
bar.addWidget(self.psp_filter) bar.addWidget(self.psp_filter)
self.store_sort = QComboBox()
self.store_sort.addItems(["Newest first", "Title (A–Z)", "Region (A–Z)", "Size (largest)", "In library first"])
self.store_sort.setToolTip("Sort the cover grid")
self.store_sort.currentTextChanged.connect(self._apply_filter)
bar.addWidget(self.store_sort)
refresh_btn = QPushButton("Refresh") refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(lambda: self.refresh(force=True)) refresh_btn.clicked.connect(lambda: self.refresh(force=True))
bar.addWidget(refresh_btn) bar.addWidget(refresh_btn)
@@ -2046,7 +2119,7 @@ class StorePage(QWidget):
if not it.get("release_date"): if not it.get("release_date"):
it["release_date"] = dates.get((str(rid), system)) or "" it["release_date"] = dates.get((str(rid), system)) or ""
filtered.append(it) filtered.append(it)
filtered.sort(key=self._store_sort_key) self._sort_store_items(filtered)
self._filtered = filtered self._filtered = filtered
if self.all_check.isChecked(): if self.all_check.isChecked():
show = len(filtered) show = len(filtered)
@@ -2054,6 +2127,19 @@ class StorePage(QWidget):
show = min(self.RECENT_LIMIT, len(filtered)) show = min(self.RECENT_LIMIT, len(filtered))
self._populate(filtered, show) self._populate(filtered, show)
def _sort_store_items(self, items):
mode = self.store_sort.currentText()
if mode == "Title (A–Z)":
items.sort(key=lambda it: store.display_name(it["name"]).lower())
elif mode == "Region (A–Z)":
items.sort(key=lambda it: (store.region(it["name"]) or "").lower())
elif mode == "Size (largest)":
items.sort(key=lambda it: it.get("size") or 0, reverse=True)
elif mode == "In library first":
items.sort(key=lambda it: (not it.get("in_library"), store.display_name(it["name"]).lower()))
else: # Newest first
items.sort(key=self._store_sort_key)
@staticmethod @staticmethod
def _store_sort_key(it): def _store_sort_key(it):
d = it.get("release_date") or "" d = it.get("release_date") or ""
@@ -2279,7 +2365,7 @@ class StorePage(QWidget):
if self._closing or not tasks: if self._closing or not tasks:
return return
thread = QThread(self) 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) loader.moveToThread(thread)
thread.started.connect(loader.run) thread.started.connect(loader.run)
loader.loaded.connect(self._on_cover_loaded) loader.loaded.connect(self._on_cover_loaded)
@@ -2289,12 +2375,12 @@ class StorePage(QWidget):
self._loaders.append(loader) self._loaders.append(loader)
thread.start() thread.start()
def _on_cover_loaded(self, gen, row, path): def _on_cover_loaded(self, gen, row, image):
if gen != self._generation: if gen != self._generation:
return return
item = self.grid.item(row) item = self.grid.item(row)
if item is not None: 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) it = item.data(Qt.UserRole)
shown = getattr(self.details, "_item", None) shown = getattr(self.details, "_item", None)
if it is not None and shown is not None and it.get("path") == shown.get("path"): if it is not None and shown is not None and it.get("path") == shown.get("path"):
@@ -4230,7 +4316,7 @@ class Ps1Page(QWidget):
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.setWindowTitle("psp preservation tool ~ quoije") self.setWindowTitle("psppt ~ quoije")
self.resize(1240, 780) self.resize(1240, 780)
self._rows = [] self._rows = []
self._renames = [] self._renames = []
@@ -4253,7 +4339,7 @@ class MainWindow(QMainWindow):
self._library_workers = [] self._library_workers = []
self._library_loading = False 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._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._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 "") self._store_dir = str(self.settings.value("store_dir", str(config.STORE_ROOT)) or "")
@@ -4296,7 +4382,7 @@ class MainWindow(QMainWindow):
pm = QPixmap(str(icon_path)) pm = QPixmap(str(icon_path))
pm = pm.scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation) pm = pm.scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
logo.setPixmap(pm) logo.setPixmap(pm)
logo.setToolTip("PSP Preservation Tool") logo.setToolTip("psppt")
self.tabs.setCornerWidget(logo, Qt.TopRightCorner) self.tabs.setCornerWidget(logo, Qt.TopRightCorner)
self.queue = queue.TransferQueue(self) self.queue = queue.TransferQueue(self)
@@ -4366,6 +4452,11 @@ class MainWindow(QMainWindow):
self.library_view = QComboBox() self.library_view = QComboBox()
self.library_view.addItems(["Cover view", "List view"]) self.library_view.addItems(["Cover view", "List view"])
bar.addWidget(self.library_view) bar.addWidget(self.library_view)
self.library_sort = QComboBox()
self.library_sort.addItems(["Title", "Release date", "Size", "Region", "Type", "Serial"])
self.library_sort.setToolTip("Sort the cover grid")
self.library_sort.currentTextChanged.connect(self._apply_library_filter)
bar.addWidget(self.library_sort)
reload_btn = QPushButton("Reload") reload_btn = QPushButton("Reload")
reload_btn.clicked.connect(self.load_library) reload_btn.clicked.connect(self.load_library)
bar.addWidget(reload_btn) bar.addWidget(reload_btn)
@@ -4633,12 +4724,30 @@ class MainWindow(QMainWindow):
if psp_f == "Not on PSP" and it.get("on_psp"): if psp_f == "Not on PSP" and it.get("on_psp"):
continue continue
items.append(it) items.append(it)
self._sort_library_items(items)
self._library_filtered = items self._library_filtered = items
self._populate_library_table(items) self._populate_library_table(items)
self.cover_grid.set_items(items) self.cover_grid.set_items(items)
if hasattr(self, "psp_page"): if hasattr(self, "psp_page"):
self.psp_page.set_library_items(self._library_cache) self.psp_page.set_library_items(self._library_cache)
def _sort_library_items(self, items):
mode = self.library_sort.currentText()
title = lambda it: (it.get("title_override") or it.get("canonical") or it["name"] or "").lower()
if mode == "Release date":
items.sort(key=lambda it: it.get("release_date") or "", reverse=True)
elif mode == "Size":
items.sort(key=lambda it: it.get("size") or 0, reverse=True)
elif mode == "Region":
items.sort(key=lambda it: (it.get("region") or "").lower())
elif mode == "Type":
order = {"games": 0, "videos": 1, "ps1": 2, "theme": 3, "dlc": 4}
items.sort(key=lambda it: (order.get(it.get("system"), 9), title(it)))
elif mode == "Serial":
items.sort(key=lambda it: (it.get("serial") or "").lower())
else: # Title
items.sort(key=title)
def _populate_library_table(self, items): def _populate_library_table(self, items):
self.library_table.setUpdatesEnabled(False) self.library_table.setUpdatesEnabled(False)
self.library_table.setSortingEnabled(False) self.library_table.setSortingEnabled(False)
@@ -5596,13 +5705,16 @@ def main():
n = ingest.ensure_catalog() n = ingest.ensure_catalog()
if n: if n:
log.info("ingested %d DAT(s) into the catalog", 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) app = QApplication(sys.argv)
style.apply_theme(app) style.apply_theme(app)
app.setApplicationName("psppt")
app.setDesktopFileName("psppt")
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)))
window = MainWindow() window = MainWindow()
window.setWindowIcon(app.windowIcon())
window.show() window.show()
log.info("main window ready") log.info("main window ready")
threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start() threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start()
+1 -1
View File
@@ -1,4 +1,4 @@
"""PSP Preservation Tool entry point (native GUI). """psppt entry point (native GUI).
Run with:: 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 return items
try: 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: with urllib.request.urlopen(req, timeout=timeout) as resp:
data = resp.read() data = resp.read()
text = data.decode("utf-8", errors="replace") 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) log.info("downloading OpenVGDB dump (%s)…", OVGDB_ZIP_URL)
tmpdir = Path(tempfile.mkdtemp(prefix="openvgdb-")) tmpdir = Path(tempfile.mkdtemp(prefix="openvgdb-"))
dump_zip = tmpdir / "openvgdb.zip" 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: with urllib.request.urlopen(req, timeout=60) as r, open(dump_zip, "wb") as f:
f.write(r.read()) f.write(r.read())
+2 -2
View File
@@ -284,7 +284,7 @@ class RemoteStoreSource(Source):
self.token = token or "" self.token = token or ""
def _headers(self): def _headers(self):
headers = {"User-Agent": "psp-preserve/0.1"} headers = {"User-Agent": "psppt/0.1"}
if self.token: if self.token:
headers["Authorization"] = f"Bearer {self.token}" headers["Authorization"] = f"Bearer {self.token}"
return headers return headers
@@ -347,7 +347,7 @@ class ArchiveOrgSource(Source):
return f"https://archive.org/download/{self.item}" return f"https://archive.org/download/{self.item}"
def _headers(self): def _headers(self):
headers = {"User-Agent": "psp-preserve/0.1"} headers = {"User-Agent": "psppt/0.1"}
cookie = "; ".join( cookie = "; ".join(
p for p in ( p for p in (
f"logged-in-sig={self.sig}" if self.sig else "", f"logged-in-sig={self.sig}" if self.sig else "",
+2 -2
View File
@@ -1,7 +1,7 @@
services: services:
psp-preserve: psppt:
build: . build: .
container_name: psp-preserve container_name: psppt
# Attach to Unraid's macvlan bridge so the API gets its own LAN IP. # 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. # Plain HTTP for now; see Caddyfile to add Let's Encrypt TLS later.
networks: 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"
+9
View File
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=psppt
Comment=Preserve and manage your PSP library
Exec=psppt
Icon=psppt
Terminal=false
Categories=Utility;AudioVideo;
StartupWMClass=psppt
+8 -6
View File
@@ -1,13 +1,13 @@
# -*- mode: python ; coding: utf-8 -*- # -*- 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, 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 (credentials, manual covers, PS1 templates, NPS cache, library DB, cover
cache) lives in the OS state dir at runtime — see ``app/config.py``. cache) lives in the OS state dir at runtime — see ``app/config.py``.
Usage: pyinstaller --clean --noconfirm psp-preserve.spec Usage: pyinstaller --clean --noconfirm psppt.spec
Output: dist/psp-preserve/ Output: dist/psppt/
""" """
from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules from PyInstaller.utils.hooks import collect_dynamic_libs, collect_submodules
@@ -24,6 +24,7 @@ datas = [
("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"), ("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"),
("img/app-icon.png", "img"), ("img/app-icon.png", "img"),
("img/chevron.png", "img"), ("img/chevron.png", "img"),
("packaging/psppt.desktop", "."),
] ]
a = Analysis( a = Analysis(
@@ -46,13 +47,14 @@ exe = EXE(
a.scripts, a.scripts,
[], [],
exclude_binaries=True, exclude_binaries=True,
name="psp-preserve", name="psppt",
debug=False, debug=False,
bootloader_ignore_signals=False, bootloader_ignore_signals=False,
strip=False, strip=False,
upx=False, upx=False,
console=False, console=False,
disable_windowed_traceback=False, disable_windowed_traceback=False,
contents_directory="lib",
) )
coll = COLLECT( coll = COLLECT(
@@ -61,5 +63,5 @@ coll = COLLECT(
a.datas, a.datas,
strip=False, strip=False,
upx=False, upx=False,
name="psp-preserve", name="psppt",
) )
+31 -15
View File
@@ -12,34 +12,50 @@ ENV DEBIAN_FRONTEND=noninteractive \
WINEDLLOVERRIDES="mscoree,mshtml=" \ WINEDLLOVERRIDES="mscoree,mshtml=" \
PYTHON_VERSION=3.12.8 PYTHON_VERSION=3.12.8
# Wine (64-bit), node + git for JS actions, and build/package tooling. # WineHQ wine-staging (newer than Debian's 8.0, needed for Python 3.12),
RUN apt-get update && apt-get install -y --no-install-recommends \ # node + git for JS actions, and build/package tooling.
ca-certificates curl wget unzip zip \ RUN dpkg --add-architecture i386 \
wine wine64 xvfb winetricks cabextract \ && apt-get update \
nodejs git \ && apt-get install -y --no-install-recommends ca-certificates curl wget gnupg2 \
&& mkdir -p /etc/apt/keyrings \
&& wget -qO- https://dl.winehq.org/wine-builds/winehq.key | gpg --dearmor -o /etc/apt/keyrings/winehq.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/winehq.gpg] https://dl.winehq.org/wine-builds/debian/ bookworm main" > /etc/apt/sources.list.d/winehq.list \
&& apt-get update \
&& apt-get install -y --install-recommends winehq-staging \
&& apt-get install -y --no-install-recommends unzip zip xvfb xauth cabextract nodejs git \
&& apt-get clean && rm -rf /var/lib/apt/lists/* && apt-get clean && rm -rf /var/lib/apt/lists/*
# winetricks (standalone script; not packaged in Debian main).
RUN wget -qO /usr/local/bin/winetricks \
"https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks" \
&& chmod +x /usr/local/bin/winetricks
# Gitea Actions runner (linux-amd64 binary). # Gitea Actions runner (linux-amd64 binary).
RUN wget -qO /usr/local/bin/act_runner \ RUN wget -qO /usr/local/bin/act_runner \
"https://gitea.com/gitea/act_runner/releases/download/v0.6.1/act_runner-0.6.1-linux-amd64" \ "https://gitea.com/gitea/act_runner/releases/download/v0.6.1/act_runner-0.6.1-linux-amd64" \
&& chmod +x /usr/local/bin/act_runner && chmod +x /usr/local/bin/act_runner
# Windows CPython 3.12, silent install into C:\Python312. # MSVC runtime required by PySide6's Qt DLLs (also initializes the Wine prefix).
RUN wget -qO /tmp/python-installer.exe \
"https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-amd64.exe" \
&& xvfb-run -a wine /tmp/python-installer.exe \
/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_launcher=0 Shortcuts=0 \
&& rm -f /tmp/python-installer.exe \
&& wine "C:\\Python312\\python.exe" --version
# MSVC runtime required by PySide6's Qt DLLs.
RUN xvfb-run -a winetricks --unattended vcrun2022 RUN xvfb-run -a winetricks --unattended vcrun2022
# Windows CPython 3.12 (embeddable zip) + pip.
RUN wget -qO /tmp/pyembed.zip \
"https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
&& mkdir -p /root/.wine/drive_c/Python312 \
&& cd /root/.wine/drive_c/Python312 \
&& unzip -q /tmp/pyembed.zip \
&& rm -f /tmp/pyembed.zip \
&& printf 'python312.zip\n.\nimport site\nLib\\site-packages\n' > python312._pth \
&& wine "C:\\Python312\\python.exe" --version
# Convenience wrapper: winpython -> wine C:\Python312\python.exe # Convenience wrapper: winpython -> wine C:\Python312\python.exe
RUN printf '#!/bin/sh\nexec wine "C:\\Python312\\python.exe" "$@"\n' > /usr/local/bin/winpython \ RUN printf '#!/bin/sh\nexec wine "C:\\Python312\\python.exe" "$@"\n' > /usr/local/bin/winpython \
&& chmod +x /usr/local/bin/winpython && chmod +x /usr/local/bin/winpython
# Pre-install the Windows build deps so CI jobs start fast. # Bootstrap pip, then pre-install the Windows build deps so CI jobs start fast.
RUN wget -qO /tmp/get-pip.py https://bootstrap.pypa.io/get-pip.py \
&& wine "C:\\Python312\\python.exe" /tmp/get-pip.py \
&& rm -f /tmp/get-pip.py
COPY requirements-build.txt /tmp/requirements-build.txt COPY requirements-build.txt /tmp/requirements-build.txt
RUN winpython -m pip install --upgrade pip \ RUN winpython -m pip install --upgrade pip \
&& winpython -m pip install -r /tmp/requirements-build.txt \ && winpython -m pip install -r /tmp/requirements-build.txt \