Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38a3859a21 | ||
|
|
96e876b8ec |
@@ -24,3 +24,7 @@ data/nps/*.tsv
|
|||||||
# PyInstaller output
|
# PyInstaller output
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
|
# Offline cover bundles (large; fetched from the project release on first run)
|
||||||
|
data/covers/*.zip
|
||||||
|
/psppt-covers.zip
|
||||||
@@ -79,8 +79,10 @@ pyinstaller --clean --noconfirm psppt.spec
|
|||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
|
|
||||||
- The build bundles the DATs, PS1 loader, and the offline cover bundles. The
|
- The build bundles the DATs, PS1 loader, cover templates, and index catalogs
|
||||||
cover zips are large (~850 MB), so builds are slow and the output is big.
|
(~80 MB total). The large offline cover zips (~850 MB) are **not** bundled —
|
||||||
|
the app fetches them on first run (see
|
||||||
|
[Offline bundles](#offline-bundles--auto-download)).
|
||||||
- `7z` (p7zip-full on Linux, 7-Zip on Windows) is a **runtime** dependency for
|
- `7z` (p7zip-full on Linux, 7-Zip on Windows) is a **runtime** dependency for
|
||||||
reading store archives — it is not bundled.
|
reading store archives — it is not bundled.
|
||||||
- User data (credentials, manual covers, PS1 templates, NPS cache, library DB,
|
- User data (credentials, manual covers, PS1 templates, NPS cache, library DB,
|
||||||
@@ -170,9 +172,10 @@ Discs are matched by hash first (most precise), then serial, then filename.
|
|||||||
- `data/credentials.json` — cover/metadata API keys (IGDB, TheMovieDB,
|
- `data/credentials.json` — cover/metadata API keys (IGDB, TheMovieDB,
|
||||||
ScreenScraper, Google Custom Search). Environment variables take precedence.
|
ScreenScraper, Google Custom Search). Environment variables take precedence.
|
||||||
- `data/manual_covers/games|videos/` — your own cover images.
|
- `data/manual_covers/games|videos/` — your own cover images.
|
||||||
- `data/covers/` — checked-in assets: the UMD video/game cover templates,
|
- `data/covers/` — checked-in assets: the UMD video/game cover templates and
|
||||||
`libretro-index.json` + `openvgdb-index.json` catalogs, `libretro-covers.zip` +
|
the `libretro-index.json` + `openvgdb-index.json` catalogs. The large
|
||||||
`openvgdb-covers.zip` offline cover bundles, and the legacy cover cache.
|
`libretro-covers.zip` + `openvgdb-covers.zip` bundles are git-ignored and
|
||||||
|
distributed as `psppt-covers.zip` (see above).
|
||||||
- `data/base.PBP` — a PSN PS1 classic EBOOT (any game) supplying `STARTDAT`
|
- `data/base.PBP` — a PSN PS1 classic EBOOT (any game) supplying `STARTDAT`
|
||||||
+ a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted).
|
+ a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted).
|
||||||
- `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS
|
- `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS
|
||||||
@@ -223,6 +226,7 @@ QSettings under `psppt`) taking precedence where noted.
|
|||||||
| `PSP_CREDENTIALS` | `$PSP_USER_DATA_DIR/credentials.json` | credentials file |
|
| `PSP_CREDENTIALS` | `$PSP_USER_DATA_DIR/credentials.json` | credentials file |
|
||||||
| `PSP_LIBRETRO_COVERS_URL` | *(empty)* | direct URL for `libretro-covers.zip` (else derived from store server) |
|
| `PSP_LIBRETRO_COVERS_URL` | *(empty)* | direct URL for `libretro-covers.zip` (else derived from store server) |
|
||||||
| `PSP_OVGDB_COVERS_URL` | *(empty)* | direct URL for `openvgdb-covers.zip` (else derived from store server) |
|
| `PSP_OVGDB_COVERS_URL` | *(empty)* | direct URL for `openvgdb-covers.zip` (else derived from store server) |
|
||||||
|
| `PSP_COVERS_BUNDLE_URL` | Gitea `covers` release asset | combined `psppt-covers.zip` download URL |
|
||||||
|
|
||||||
Cover/metadata keys can also be set as env vars: `IGDB_CLIENT_ID`,
|
Cover/metadata keys can also be set as env vars: `IGDB_CLIENT_ID`,
|
||||||
`IGDB_CLIENT_SECRET`, `TMDB_API_KEY`, `GOOGLE_API_KEY`, `GOOGLE_CX`,
|
`IGDB_CLIENT_SECRET`, `TMDB_API_KEY`, `GOOGLE_API_KEY`, `GOOGLE_CX`,
|
||||||
@@ -255,12 +259,23 @@ Manual covers in `data/manual_covers/videos/` always win.
|
|||||||
|
|
||||||
### Offline bundles & auto-download
|
### Offline bundles & auto-download
|
||||||
|
|
||||||
The two bundles (`libretro-covers.zip`, `openvgdb-covers.zip`) are shipped in
|
The two bundles (`libretro-covers.zip`, `openvgdb-covers.zip`) are packaged
|
||||||
`data/covers/`, so a fresh install works with **no internet and no API keys**.
|
into one `psppt-covers.zip` (~850 MB) and attached to the repo's `covers`
|
||||||
If either is missing at startup, the app downloads it from the store server's
|
release — **not** bundled into the app binary.
|
||||||
`/api/covers/<name>.zip` endpoint (see [Server setup](#server-setup)).
|
|
||||||
|
|
||||||
Rebuild them yourself:
|
On first launch, if the bundle isn't installed, the app asks whether to
|
||||||
|
download it (with a progress bar). Covers keep working online via libretro /
|
||||||
|
OpenVGDB / ScreenScraper / IGDB without it — the bundle just makes them instant
|
||||||
|
and offline. "Don't ask again" is remembered, and you can re-download from
|
||||||
|
**Settings → Cover sources → Download offline cover bundle…**.
|
||||||
|
|
||||||
|
Build the bundle yourself:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/make-covers-bundle.py # -> psppt-covers.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
Rebuild the inner zips:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m app.libretro # libretro-thumbnails -> libretro-covers.zip
|
python3 -m app.libretro # libretro-thumbnails -> libretro-covers.zip
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ STORE_TOKEN = os.environ.get("PSP_STORE_TOKEN", "")
|
|||||||
LIBRETRO_COVERS_URL = os.environ.get("PSP_LIBRETRO_COVERS_URL", "")
|
LIBRETRO_COVERS_URL = os.environ.get("PSP_LIBRETRO_COVERS_URL", "")
|
||||||
OVGDB_COVERS_URL = os.environ.get("PSP_OVGDB_COVERS_URL", "")
|
OVGDB_COVERS_URL = os.environ.get("PSP_OVGDB_COVERS_URL", "")
|
||||||
|
|
||||||
|
# Combined offline cover bundle (both inner zips) fetched on first run when the
|
||||||
|
# user opts in. Defaults to the project's Gitea release asset.
|
||||||
|
COVERS_BUNDLE_URL = os.environ.get(
|
||||||
|
"PSP_COVERS_BUNDLE_URL",
|
||||||
|
"https://git.ceaf.dev/quoije/psp-preservation-tool/releases/download/covers/psppt-covers.zip",
|
||||||
|
)
|
||||||
|
|
||||||
# archive.org item hosting the same Redump 7z set (identifier or full URL).
|
# archive.org item hosting the same Redump 7z set (identifier or full URL).
|
||||||
ARCHIVE_ITEM = os.environ.get("PSP_ARCHIVE_ITEM", "psp-redump-roms321.com")
|
ARCHIVE_ITEM = os.environ.get("PSP_ARCHIVE_ITEM", "psp-redump-roms321.com")
|
||||||
|
|
||||||
|
|||||||
+72
-32
@@ -53,8 +53,20 @@ _TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie"
|
|||||||
_TMDB_IMAGE = "https://image.tmdb.org/t/p/"
|
_TMDB_IMAGE = "https://image.tmdb.org/t/p/"
|
||||||
_GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1"
|
_GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1"
|
||||||
_MANUAL_DIR = config.USER_DATA_DIR / "manual_covers"
|
_MANUAL_DIR = config.USER_DATA_DIR / "manual_covers"
|
||||||
_OVGDB_ZIP = config.DATA_DIR / "covers" / "openvgdb-covers.zip"
|
# Offline cover bundles live in the writable cover dir (downloaded on demand),
|
||||||
_LIBRETRO_ZIP = config.DATA_DIR / "covers" / "libretro-covers.zip"
|
# not the read-only app bundle. A source checkout keeps them in data/covers/.
|
||||||
|
_OVGDB_ZIP = config.COVERS_DIR / "openvgdb-covers.zip"
|
||||||
|
_LIBRETRO_ZIP = config.COVERS_DIR / "libretro-covers.zip"
|
||||||
|
_BUNDLE_ZIP = config.COVERS_DIR / "psppt-covers.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_zip_path(name):
|
||||||
|
"""Resolve a cover bundle zip: writable dir first, legacy checkout second."""
|
||||||
|
for base in (config.COVERS_DIR, config.LEGACY_COVERS_DIR):
|
||||||
|
p = base / name
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
return config.COVERS_DIR / name
|
||||||
|
|
||||||
_WIKI_SEARCH = "https://en.wikipedia.org/w/api.php"
|
_WIKI_SEARCH = "https://en.wikipedia.org/w/api.php"
|
||||||
_WIKI_SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
|
_WIKI_SUMMARY = "https://en.wikipedia.org/api/rest_v1/page/summary/"
|
||||||
@@ -158,48 +170,74 @@ def clear_cover_cache():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _download_file(url, dest, timeout=600):
|
def _download_file(url, dest, timeout=600, progress_cb=None, cancel_event=None):
|
||||||
"""Stream `url` to `dest` (atomic); returns True on success."""
|
"""Stream `url` to `dest` (atomic); returns True on success.
|
||||||
|
|
||||||
|
``progress_cb(done, total)`` is called with bytes so far (total is 0 when
|
||||||
|
the server doesn't report a content length). ``cancel_event`` aborts the
|
||||||
|
download when set.
|
||||||
|
"""
|
||||||
req = urllib.request.Request(url, headers=_UA)
|
req = urllib.request.Request(url, headers=_UA)
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
return False
|
return False
|
||||||
|
total = int(resp.headers.get("Content-Length") or 0)
|
||||||
tmp = dest.with_name(dest.name + ".tmp")
|
tmp = dest.with_name(dest.name + ".tmp")
|
||||||
|
done = 0
|
||||||
with open(tmp, "wb") as f:
|
with open(tmp, "wb") as f:
|
||||||
while True:
|
while True:
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
raise InterruptedError("download cancelled")
|
||||||
chunk = resp.read(1 << 20)
|
chunk = resp.read(1 << 20)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
f.write(chunk)
|
f.write(chunk)
|
||||||
|
done += len(chunk)
|
||||||
|
if progress_cb is not None:
|
||||||
|
progress_cb(done, total)
|
||||||
os.replace(tmp, dest)
|
os.replace(tmp, dest)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def ensure_cover_bundles(server_url=""):
|
def covers_bundle_present():
|
||||||
"""Download missing offline cover bundle zips from the store server.
|
"""True when the offline cover bundle is installed (both inner zips)."""
|
||||||
|
return (_bundle_zip_path("libretro-covers.zip").exists()
|
||||||
|
and _bundle_zip_path("openvgdb-covers.zip").exists())
|
||||||
|
|
||||||
``server_url`` is the base URL (e.g. ``http://192.168.10.100:8000``); the
|
|
||||||
zips are fetched from ``<base>/api/covers/<name>.zip`` unless a direct URL
|
def download_covers_bundle(url=None, progress_cb=None, cancel_event=None):
|
||||||
is set in config. Returns the names of bundles downloaded.
|
"""Download and extract the combined cover bundle into the cover dir.
|
||||||
|
|
||||||
|
``url`` defaults to ``config.COVERS_BUNDLE_URL``. Raises on failure; the
|
||||||
|
caller decides whether to surface the error.
|
||||||
"""
|
"""
|
||||||
base = (server_url or config.STORE_SERVER_URL or "").rstrip("/")
|
url = (url or config.COVERS_BUNDLE_URL or "").strip()
|
||||||
downloaded = []
|
|
||||||
for name, path, direct in (
|
|
||||||
("libretro-covers.zip", _LIBRETRO_ZIP, config.LIBRETRO_COVERS_URL),
|
|
||||||
("openvgdb-covers.zip", _OVGDB_ZIP, config.OVGDB_COVERS_URL),
|
|
||||||
):
|
|
||||||
if path.exists():
|
|
||||||
continue
|
|
||||||
url = direct or (f"{base}/api/covers/{name}" if base else "")
|
|
||||||
if not url:
|
if not url:
|
||||||
continue
|
raise ValueError("no cover bundle URL configured")
|
||||||
try:
|
config.COVERS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
if _download_file(url, path):
|
_download_file(url, _BUNDLE_ZIP, timeout=3600,
|
||||||
downloaded.append(name)
|
progress_cb=progress_cb, cancel_event=cancel_event)
|
||||||
log.info("downloaded offline cover bundle %s", name)
|
inner = ("libretro-covers.zip", "openvgdb-covers.zip")
|
||||||
except Exception as exc: # noqa: BLE001
|
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
|
||||||
log.warning("could not download cover bundle %s: %s", name, exc)
|
missing = [n for n in inner if n not in z.namelist()]
|
||||||
return downloaded
|
if missing:
|
||||||
|
raise ValueError(f"cover bundle is missing: {', '.join(missing)}")
|
||||||
|
with zipfile.ZipFile(_BUNDLE_ZIP) as z:
|
||||||
|
for name in inner:
|
||||||
|
out = config.COVERS_DIR / name
|
||||||
|
tmp = out.with_name(out.name + ".tmp")
|
||||||
|
with z.open(name) as src, open(tmp, "wb") as dst:
|
||||||
|
while True:
|
||||||
|
chunk = src.read(1 << 20)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
dst.write(chunk)
|
||||||
|
os.replace(tmp, out)
|
||||||
|
# Invalidate cached namelists so lookups pick up the freshly extracted zips.
|
||||||
|
global _libretro_names, _ovgdb_names
|
||||||
|
_libretro_names = None
|
||||||
|
_ovgdb_names = None
|
||||||
|
return config.COVERS_DIR
|
||||||
|
|
||||||
|
|
||||||
def cover_url(name, kind="Named_Boxarts", system="games"):
|
def cover_url(name, kind="Named_Boxarts", system="games"):
|
||||||
@@ -482,9 +520,10 @@ def _ovgdb_name_list():
|
|||||||
global _ovgdb_names
|
global _ovgdb_names
|
||||||
if _ovgdb_names is None:
|
if _ovgdb_names is None:
|
||||||
_ovgdb_names = set()
|
_ovgdb_names = set()
|
||||||
if _OVGDB_ZIP.exists():
|
path = _bundle_zip_path("openvgdb-covers.zip")
|
||||||
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(_OVGDB_ZIP) as z:
|
with zipfile.ZipFile(path) as z:
|
||||||
_ovgdb_names = {n[:-4] for n in z.namelist() if n.endswith(".jpg")}
|
_ovgdb_names = {n[:-4] for n in z.namelist() if n.endswith(".jpg")}
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
@@ -535,7 +574,7 @@ def fetch_openvgdb_local(name):
|
|||||||
if entry is None:
|
if entry is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(_OVGDB_ZIP) as z:
|
with zipfile.ZipFile(_bundle_zip_path("openvgdb-covers.zip")) as z:
|
||||||
data = z.read(entry)
|
data = z.read(entry)
|
||||||
return data if _is_image(data) else None
|
return data if _is_image(data) else None
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -559,7 +598,7 @@ def fetch_libretro_local(name):
|
|||||||
if entry is None:
|
if entry is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(_LIBRETRO_ZIP) as z:
|
with zipfile.ZipFile(_bundle_zip_path("libretro-covers.zip")) as z:
|
||||||
data = z.read(entry)
|
data = z.read(entry)
|
||||||
return data if _is_image(data) else None
|
return data if _is_image(data) else None
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
@@ -570,9 +609,10 @@ def _libretro_name_list():
|
|||||||
global _libretro_names
|
global _libretro_names
|
||||||
if _libretro_names is None:
|
if _libretro_names is None:
|
||||||
_libretro_names = set()
|
_libretro_names = set()
|
||||||
if _LIBRETRO_ZIP.exists():
|
path = _bundle_zip_path("libretro-covers.zip")
|
||||||
|
if path.exists():
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(_LIBRETRO_ZIP) as z:
|
with zipfile.ZipFile(path) as z:
|
||||||
_libretro_names = {n[:-4] for n in z.namelist() if n.endswith(".png")}
|
_libretro_names = {n[:-4] for n in z.namelist() if n.endswith(".png")}
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|||||||
+164
-17
@@ -7,6 +7,7 @@ Run with::
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -158,7 +159,9 @@ class CoverLoader(QObject):
|
|||||||
else:
|
else:
|
||||||
self.failed.emit(self.gen, i, self.items[i]["name"])
|
self.failed.emit(self.gen, i, self.items[i]["name"])
|
||||||
finally:
|
finally:
|
||||||
pool.shutdown(wait=True, cancel_futures=True)
|
# Return promptly on cancel/exit instead of blocking the GUI thread
|
||||||
|
# on in-flight network timeouts during shutdown.
|
||||||
|
pool.shutdown(wait=not self._cancel.is_set(), cancel_futures=True)
|
||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
@@ -480,7 +483,7 @@ class CoverShowcase(QWidget):
|
|||||||
return QIcon(pm)
|
return QIcon(pm)
|
||||||
|
|
||||||
def _on_loaded(self, gen, index, image):
|
def _on_loaded(self, gen, index, image):
|
||||||
if gen != self._generation:
|
if self._closing or 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:
|
||||||
@@ -502,7 +505,7 @@ class CoverShowcase(QWidget):
|
|||||||
self.play_requested.emit(data)
|
self.play_requested.emit(data)
|
||||||
|
|
||||||
def _on_failed(self, gen, index, name):
|
def _on_failed(self, gen, index, name):
|
||||||
if gen != self._generation:
|
if self._closing or gen != self._generation:
|
||||||
return
|
return
|
||||||
if index not in self.missing:
|
if index not in self.missing:
|
||||||
self.missing.append(index)
|
self.missing.append(index)
|
||||||
@@ -510,6 +513,8 @@ class CoverShowcase(QWidget):
|
|||||||
self._update_status()
|
self._update_status()
|
||||||
|
|
||||||
def _on_loading_done(self):
|
def _on_loading_done(self):
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
sender = self.sender()
|
sender = self.sender()
|
||||||
if sender is not None and getattr(sender, "gen", None) != self._generation:
|
if sender is not None and getattr(sender, "gen", None) != self._generation:
|
||||||
return
|
return
|
||||||
@@ -517,6 +522,8 @@ class CoverShowcase(QWidget):
|
|||||||
self._update_status()
|
self._update_status()
|
||||||
|
|
||||||
def _update_status(self):
|
def _update_status(self):
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
if self._loading:
|
if self._loading:
|
||||||
self.status.setText("Loading covers…")
|
self.status.setText("Loading covers…")
|
||||||
self.missing_changed.emit(0)
|
self.missing_changed.emit(0)
|
||||||
@@ -1136,6 +1143,7 @@ class ScrapeDialog(QDialog):
|
|||||||
|
|
||||||
class SettingsPage(QWidget):
|
class SettingsPage(QWidget):
|
||||||
saved = Signal()
|
saved = Signal()
|
||||||
|
download_covers = Signal()
|
||||||
|
|
||||||
def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe,
|
def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe,
|
||||||
store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user,
|
store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user,
|
||||||
@@ -1303,6 +1311,9 @@ class SettingsPage(QWidget):
|
|||||||
self.clear_covers_btn = QPushButton("Clear cover cache (re-fetch all covers)")
|
self.clear_covers_btn = QPushButton("Clear cover cache (re-fetch all covers)")
|
||||||
self.clear_covers_btn.clicked.connect(self._clear_cover_cache)
|
self.clear_covers_btn.clicked.connect(self._clear_cover_cache)
|
||||||
form.addRow(self.clear_covers_btn)
|
form.addRow(self.clear_covers_btn)
|
||||||
|
self.download_bundle_btn = QPushButton("Download offline cover bundle…")
|
||||||
|
self.download_bundle_btn.clicked.connect(self.download_covers.emit)
|
||||||
|
form.addRow(self.download_bundle_btn)
|
||||||
col.addWidget(box)
|
col.addWidget(box)
|
||||||
|
|
||||||
# -- Storage ---------------------------------------------------------- #
|
# -- Storage ---------------------------------------------------------- #
|
||||||
@@ -1541,7 +1552,9 @@ class StoreCoverLoader(QObject):
|
|||||||
if not img.isNull():
|
if not img.isNull():
|
||||||
self.loaded.emit(self.gen, row, _fit_image(img, self.icon_w, self.icon_h))
|
self.loaded.emit(self.gen, row, _fit_image(img, self.icon_w, self.icon_h))
|
||||||
finally:
|
finally:
|
||||||
pool.shutdown(wait=True, cancel_futures=True)
|
# Return promptly on cancel/exit instead of blocking the GUI thread
|
||||||
|
# on in-flight network timeouts during shutdown.
|
||||||
|
pool.shutdown(wait=not self._cancel.is_set(), cancel_futures=True)
|
||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
@@ -2353,7 +2366,7 @@ class StorePage(QWidget):
|
|||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def _on_date_loaded(self, gen, row, date):
|
def _on_date_loaded(self, gen, row, date):
|
||||||
if gen != self._generation or not date:
|
if self._closing or gen != self._generation or not date:
|
||||||
return
|
return
|
||||||
if 0 <= row < len(self._filtered):
|
if 0 <= row < len(self._filtered):
|
||||||
self._filtered[row]["release_date"] = date
|
self._filtered[row]["release_date"] = date
|
||||||
@@ -2376,7 +2389,7 @@ class StorePage(QWidget):
|
|||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def _on_cover_loaded(self, gen, row, image):
|
def _on_cover_loaded(self, gen, row, image):
|
||||||
if gen != self._generation:
|
if self._closing or 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:
|
||||||
@@ -3748,6 +3761,35 @@ class LibraryWorker(QObject):
|
|||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
|
class CoversBundleWorker(QObject):
|
||||||
|
"""Download + extract the offline cover bundle off the GUI thread."""
|
||||||
|
progress = Signal(int, int) # done bytes, total bytes (0 = unknown)
|
||||||
|
succeeded = Signal()
|
||||||
|
failed = Signal(str)
|
||||||
|
finished = Signal()
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._cancel = threading.Event()
|
||||||
|
|
||||||
|
def cancel(self):
|
||||||
|
self._cancel.set()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
covers.download_covers_bundle(
|
||||||
|
progress_cb=lambda d, t: self.progress.emit(d, t),
|
||||||
|
cancel_event=self._cancel)
|
||||||
|
except InterruptedError:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self.failed.emit(str(exc))
|
||||||
|
else:
|
||||||
|
self.succeeded.emit()
|
||||||
|
finally:
|
||||||
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
class SaveSyncPlanWorker(QObject):
|
class SaveSyncPlanWorker(QObject):
|
||||||
"""Compute the save-sync plan off the GUI thread (content hashing is I/O-heavy)."""
|
"""Compute the save-sync plan off the GUI thread (content hashing is I/O-heavy)."""
|
||||||
plan = Signal(list, bool) # plan, switch_to_queue
|
plan = Signal(list, bool) # plan, switch_to_queue
|
||||||
@@ -4130,6 +4172,7 @@ class Ps1Page(QWidget):
|
|||||||
self._preview_gen = 0
|
self._preview_gen = 0
|
||||||
self._preview_threads = []
|
self._preview_threads = []
|
||||||
self._preview_workers = []
|
self._preview_workers = []
|
||||||
|
self._closing = False
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
bar = QHBoxLayout()
|
bar = QHBoxLayout()
|
||||||
@@ -4228,7 +4271,7 @@ class Ps1Page(QWidget):
|
|||||||
self._preview_workers.append(worker)
|
self._preview_workers.append(worker)
|
||||||
|
|
||||||
def _on_preview_result(self, gen, game_id, icon0, pic0, pic1):
|
def _on_preview_result(self, gen, game_id, icon0, pic0, pic1):
|
||||||
if gen != self._preview_gen:
|
if self._closing or gen != self._preview_gen:
|
||||||
return
|
return
|
||||||
self.preview_icon0.setText("")
|
self.preview_icon0.setText("")
|
||||||
self.preview_pic0.setText("")
|
self.preview_pic0.setText("")
|
||||||
@@ -4276,6 +4319,8 @@ class Ps1Page(QWidget):
|
|||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _on_results(self, entries):
|
def _on_results(self, entries):
|
||||||
|
if self._closing:
|
||||||
|
return
|
||||||
# Only show discs that still need converting; converted games already
|
# Only show discs that still need converting; converted games already
|
||||||
# appear in the Library tab.
|
# appear in the Library tab.
|
||||||
entries = [e for e in entries if e["kind"] == "disc" and e.get("status") != "converted"]
|
entries = [e for e in entries if e["kind"] == "disc" and e.get("status") != "converted"]
|
||||||
@@ -4291,8 +4336,23 @@ class Ps1Page(QWidget):
|
|||||||
self.table.selectRow(0)
|
self.table.selectRow(0)
|
||||||
|
|
||||||
def _on_failed(self, message):
|
def _on_failed(self, message):
|
||||||
|
if not self._closing:
|
||||||
self.status.setText(f"PS1 scan failed: {message}")
|
self.status.setText(f"PS1 scan failed: {message}")
|
||||||
|
|
||||||
|
def shutdown(self):
|
||||||
|
self._closing = True
|
||||||
|
if self._thread is not None and _thread_running(self._thread):
|
||||||
|
self._thread.quit()
|
||||||
|
self._thread.wait()
|
||||||
|
for t in self._preview_threads:
|
||||||
|
if _thread_running(t):
|
||||||
|
t.quit()
|
||||||
|
t.wait()
|
||||||
|
self._preview_threads = []
|
||||||
|
self._preview_workers = []
|
||||||
|
self._thread = None
|
||||||
|
self._worker = None
|
||||||
|
|
||||||
def _convert_selected(self):
|
def _convert_selected(self):
|
||||||
row = self.table.currentRow()
|
row = self.table.currentRow()
|
||||||
if row < 0 or row >= len(self._entries):
|
if row < 0 or row >= len(self._entries):
|
||||||
@@ -4588,6 +4648,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp,
|
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp,
|
||||||
self._nps_dir)
|
self._nps_dir)
|
||||||
self.settings_page.saved.connect(self._on_settings_saved)
|
self.settings_page.saved.connect(self._on_settings_saved)
|
||||||
|
self.settings_page.download_covers.connect(self._download_covers_bundle)
|
||||||
return self.settings_page
|
return self.settings_page
|
||||||
|
|
||||||
def _on_settings_saved(self):
|
def _on_settings_saved(self):
|
||||||
@@ -5660,6 +5721,79 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.information(self, "Done", f"Renamed {renamed} file(s).")
|
QMessageBox.information(self, "Done", f"Renamed {renamed} file(s).")
|
||||||
self.load_library()
|
self.load_library()
|
||||||
|
|
||||||
|
def maybe_offer_covers_bundle(self):
|
||||||
|
"""Offer to download the offline cover bundle once, if it's missing."""
|
||||||
|
if self._closing or covers.covers_bundle_present():
|
||||||
|
return
|
||||||
|
s = QSettings("psppt", "psppt")
|
||||||
|
if str(s.value("covers_bundle_skip", "false")).lower() == "true":
|
||||||
|
return
|
||||||
|
box = QMessageBox(self)
|
||||||
|
box.setWindowTitle("Offline covers")
|
||||||
|
box.setIcon(QMessageBox.Question)
|
||||||
|
box.setText(
|
||||||
|
"The offline cover bundle (~843 MB) isn't installed.\n\n"
|
||||||
|
"Download it now from the project repo? Covers keep working online "
|
||||||
|
"without it — the bundle just makes them instant and offline."
|
||||||
|
)
|
||||||
|
download = box.addButton("Download", QMessageBox.AcceptRole)
|
||||||
|
box.addButton("Not now", QMessageBox.RejectRole)
|
||||||
|
never = box.addButton("Don't ask again", QMessageBox.DestructiveRole)
|
||||||
|
box.setDefaultButton(download)
|
||||||
|
box.exec()
|
||||||
|
clicked = box.clickedButton()
|
||||||
|
if clicked is never:
|
||||||
|
s.setValue("covers_bundle_skip", "true")
|
||||||
|
elif clicked is download:
|
||||||
|
self._download_covers_bundle()
|
||||||
|
|
||||||
|
def _download_covers_bundle(self):
|
||||||
|
"""Download + extract the offline cover bundle with a progress dialog."""
|
||||||
|
if self._closing or getattr(self, "_bundle_worker", None) is not None:
|
||||||
|
return
|
||||||
|
prog = QProgressDialog("Downloading cover bundle…", "Cancel", 0, 0, self)
|
||||||
|
prog.setWindowTitle("Offline covers")
|
||||||
|
prog.setWindowModality(Qt.WindowModal)
|
||||||
|
prog.setMinimumDuration(0)
|
||||||
|
prog.setAutoClose(False)
|
||||||
|
prog.setAutoReset(False)
|
||||||
|
|
||||||
|
result = {"ok": False, "err": ""}
|
||||||
|
thread = QThread(self)
|
||||||
|
worker = CoversBundleWorker()
|
||||||
|
worker.moveToThread(thread)
|
||||||
|
thread.started.connect(worker.run)
|
||||||
|
worker.progress.connect(lambda d, t: self._on_bundle_progress(prog, d, t))
|
||||||
|
worker.succeeded.connect(lambda: result.update(ok=True))
|
||||||
|
worker.failed.connect(lambda e: result.update(err=str(e)))
|
||||||
|
prog.canceled.connect(worker.cancel)
|
||||||
|
worker.finished.connect(thread.quit)
|
||||||
|
thread.finished.connect(lambda: self._on_bundle_done(prog, result, worker, thread))
|
||||||
|
self._bundle_worker = worker
|
||||||
|
self._bundle_thread = thread
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _on_bundle_progress(prog, done, total):
|
||||||
|
if total > 0:
|
||||||
|
prog.setRange(0, total)
|
||||||
|
prog.setValue(done)
|
||||||
|
prog.setLabelText(
|
||||||
|
f"Downloading cover bundle… {done / (1 << 20):.0f} / {total / (1 << 20):.0f} MB")
|
||||||
|
else:
|
||||||
|
prog.setRange(0, 0)
|
||||||
|
|
||||||
|
def _on_bundle_done(self, prog, result, worker, thread):
|
||||||
|
prog.close()
|
||||||
|
self._bundle_worker = None
|
||||||
|
self._bundle_thread = None
|
||||||
|
if result["ok"]:
|
||||||
|
QMessageBox.information(self, "Offline covers", "Cover bundle installed.")
|
||||||
|
covers.clear_miss_cache()
|
||||||
|
self.load_library()
|
||||||
|
elif result["err"]:
|
||||||
|
QMessageBox.warning(self, "Offline covers", f"Download failed:\n{result['err']}")
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
self._closing = True
|
self._closing = True
|
||||||
self.settings.setValue("window_geometry", self.saveGeometry())
|
self.settings.setValue("window_geometry", self.saveGeometry())
|
||||||
@@ -5668,6 +5802,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.worker.choose(None)
|
self.worker.choose(None)
|
||||||
self.worker.cancel()
|
self.worker.cancel()
|
||||||
self._thread_cleanup()
|
self._thread_cleanup()
|
||||||
|
if getattr(self, "_bundle_worker", None) is not None:
|
||||||
|
self._bundle_worker.cancel()
|
||||||
|
if getattr(self, "_bundle_thread", None) is not None and _thread_running(self._bundle_thread):
|
||||||
|
self._bundle_thread.quit()
|
||||||
|
self._bundle_thread.wait()
|
||||||
self.cover_grid.shutdown()
|
self.cover_grid.shutdown()
|
||||||
for t in self._meta_threads:
|
for t in self._meta_threads:
|
||||||
if _thread_running(t):
|
if _thread_running(t):
|
||||||
@@ -5685,18 +5824,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.psp_page.shutdown()
|
self.psp_page.shutdown()
|
||||||
if hasattr(self, "saves_page"):
|
if hasattr(self, "saves_page"):
|
||||||
self.saves_page.shutdown()
|
self.saves_page.shutdown()
|
||||||
|
if hasattr(self, "ps1_page"):
|
||||||
|
self.ps1_page.shutdown()
|
||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
|
|
||||||
def _start_bundle_check(window):
|
|
||||||
"""Download any missing offline cover bundles in the background."""
|
|
||||||
server = getattr(window, "_store_server_url", "") or ""
|
|
||||||
try:
|
|
||||||
covers.ensure_cover_bundles(server)
|
|
||||||
except Exception: # noqa: BLE001 - background best-effort
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s")
|
||||||
init_db() # migrate legacy on-share DB/covers to local storage, then schema
|
init_db() # migrate legacy on-share DB/covers to local storage, then schema
|
||||||
@@ -5717,7 +5849,22 @@ def main():
|
|||||||
window.setWindowIcon(app.windowIcon())
|
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()
|
QTimer.singleShot(1200, window.maybe_offer_covers_bundle)
|
||||||
|
|
||||||
|
# Ctrl+C should close the window gracefully (running closeEvent's thread
|
||||||
|
# cleanup) rather than tearing Qt down mid-thread, which segfaults.
|
||||||
|
interrupt = {"pending": False}
|
||||||
|
|
||||||
|
def _sigint_handler(signum, frame):
|
||||||
|
interrupt["pending"] = True
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _sigint_handler)
|
||||||
|
sigint_timer = QTimer()
|
||||||
|
sigint_timer.setInterval(100)
|
||||||
|
sigint_timer.timeout.connect(
|
||||||
|
lambda: window.close() if interrupt["pending"] else None)
|
||||||
|
sigint_timer.start()
|
||||||
|
|
||||||
return app.exec()
|
return app.exec()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+2
-4
@@ -1,8 +1,8 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
"""PyInstaller spec: build psppt (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, cover templates, index
|
||||||
icons) under ``lib/`` next to the executable. User-writable data
|
catalogs, 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``.
|
||||||
|
|
||||||
@@ -15,8 +15,6 @@ datas = [
|
|||||||
("data/dats", "data/dats"),
|
("data/dats", "data/dats"),
|
||||||
("data/loader", "data/loader"),
|
("data/loader", "data/loader"),
|
||||||
("data/supplement.json", "data"),
|
("data/supplement.json", "data"),
|
||||||
("data/covers/libretro-covers.zip", "data/covers"),
|
|
||||||
("data/covers/openvgdb-covers.zip", "data/covers"),
|
|
||||||
("data/covers/libretro-index.json", "data/covers"),
|
("data/covers/libretro-index.json", "data/covers"),
|
||||||
("data/covers/openvgdb-index.json", "data/covers"),
|
("data/covers/openvgdb-index.json", "data/covers"),
|
||||||
("data/covers/UMD_COVER_TEMPLATE.png", "data/covers"),
|
("data/covers/UMD_COVER_TEMPLATE.png", "data/covers"),
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Package the offline cover zips into a single distributable bundle.
|
||||||
|
|
||||||
|
Outputs ``psppt-covers.zip`` containing ``libretro-covers.zip`` and
|
||||||
|
``openvgdb-covers.zip``. Attach it to the Gitea ``covers`` release; the app
|
||||||
|
downloads it on first run when the user opts in.
|
||||||
|
|
||||||
|
Usage: python3 scripts/make-covers-bundle.py [out-path]
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
COVERS = ROOT / "data" / "covers"
|
||||||
|
INNER = ("libretro-covers.zip", "openvgdb-covers.zip")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
out = Path(argv[1]) if len(argv) > 1 else ROOT / "psppt-covers.zip"
|
||||||
|
missing = [n for n in INNER if not (COVERS / n).exists()]
|
||||||
|
if missing:
|
||||||
|
print(f"missing: {', '.join(missing)} in {COVERS}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
# Inner zips are already compressed; store them to avoid recompressing.
|
||||||
|
with zipfile.ZipFile(out, "w", zipfile.ZIP_STORED) as z:
|
||||||
|
for n in INNER:
|
||||||
|
print(f"adding {n} …")
|
||||||
|
z.write(COVERS / n, arcname=n)
|
||||||
|
size = out.stat().st_size / (1 << 20)
|
||||||
|
print(f"wrote {out} ({size:.0f} MB)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main(sys.argv))
|
||||||
Reference in New Issue
Block a user