Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
733e3c9089 | ||
|
|
dc7d6c9720 | ||
|
|
5c5bb6e5d6 | ||
|
|
35db22e3c5 | ||
|
|
b0cdac5483 | ||
|
|
f367bbe298 | ||
|
|
c61d8e9d3f | ||
|
|
78f6d44c73 | ||
|
|
a7f90cd5a7 | ||
|
|
c9575817fe | ||
|
|
b576ff5747 | ||
|
|
6510229b07 | ||
|
|
e61d91418e | ||
|
|
a1c461f6aa | ||
|
|
985c8df2cc |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# PSP Preservation Tool
|
# psppt
|
||||||
|
|
||||||
|
|
||||||
the name is terrible, i know.
|
the name is terrible, i know.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
* 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
@@ -1 +1 @@
|
|||||||
"""PSP Preservation Tool application package."""
|
"""psppt application package."""
|
||||||
|
|||||||
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,4 +1,4 @@
|
|||||||
"""PSP Preservation Tool entry point (native GUI).
|
"""psppt entry point (native GUI).
|
||||||
|
|
||||||
Run with::
|
Run with::
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Executable
+81
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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 \
|
||||||
|
|||||||
Reference in New Issue
Block a user