Add PS1 games to the Library with covers, metadata, and EBOOT actions
This commit is contained in:
@@ -180,6 +180,8 @@ python3 -m app.openvgdb # OpenVGDB/GameFAQs -> openvgdb-covers.zip
|
||||
|
||||
The **PS1** tab scans a folder of PlayStation 1 rips and converts them into PSP
|
||||
`EBOOT.PBP` files with a pure-Python popstation converter (no external tools).
|
||||
PS1 discs (pending) and converted EBOOTs also show up in the **Library** tab
|
||||
(filter by **PS1**), where you can convert a disc or copy an EBOOT to the PSP.
|
||||
|
||||
- **Source formats** — `.iso`, `.img`, `.bin` (with a matching `.cue` for
|
||||
multi-track discs).
|
||||
|
||||
+95
-68
@@ -24,16 +24,31 @@ from .db import get_connection
|
||||
|
||||
log = logging.getLogger("covers")
|
||||
|
||||
_LIBRETRO_BASE = "https://raw.githubusercontent.com/libretro-thumbnails/Sony_-_PlayStation_Portable/master"
|
||||
_LIBRETRO_KINDS = ("Named_Boxarts",)
|
||||
_TREE_URL = "https://api.github.com/repos/libretro-thumbnails/Sony_-_PlayStation_Portable/git/trees/master?recursive=1"
|
||||
_PLATFORMS = {
|
||||
"games": {"libretro_repo": "Sony_-_PlayStation_Portable", "igdb_id": 38, "ss_id": "61"},
|
||||
"ps1": {"libretro_repo": "Sony_-_PlayStation", "igdb_id": 7, "ss_id": "57"},
|
||||
}
|
||||
|
||||
|
||||
def _platform(system):
|
||||
"""Platform parameters (libretro repo, IGDB id, ScreenScraper id)."""
|
||||
return _PLATFORMS.get(system) or _PLATFORMS["games"]
|
||||
|
||||
|
||||
def _libretro_base(system="games"):
|
||||
return ("https://raw.githubusercontent.com/libretro-thumbnails/"
|
||||
f"{_platform(system)['libretro_repo']}/master")
|
||||
|
||||
|
||||
def _libretro_tree_url(system="games"):
|
||||
return ("https://api.github.com/repos/libretro-thumbnails/"
|
||||
f"{_platform(system)['libretro_repo']}/git/trees/master?recursive=1")
|
||||
|
||||
_SS_API = "https://api.screenscraper.fr/api2/jeuInfos.php"
|
||||
_SS_PSP_ID = "61" # Sony PSP
|
||||
_IGDB_TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
||||
_IGDB_API = "https://api.igdb.com/v4/games"
|
||||
_IGDB_IMAGE = "https://images.igdb.com/igdb/image/upload/"
|
||||
_IGDB_PSP_ID = 38 # Sony PSP
|
||||
_TMDB_SEARCH = "https://api.themoviedb.org/3/search/movie"
|
||||
_TMDB_IMAGE = "https://image.tmdb.org/t/p/"
|
||||
_GOOGLE_CSE_API = "https://www.googleapis.com/customsearch/v1"
|
||||
@@ -52,7 +67,7 @@ _igdb_token_state = {"token": "", "expires": 0.0} # IGDB OAuth2 token cache
|
||||
_igdb_lock = threading.Lock()
|
||||
_igdb_last = 0.0 # monotonic clock of the last IGDB API call
|
||||
|
||||
_index_state = {"ts": 0.0, "names": {}, "norm": {}}
|
||||
_index_state = {} # system -> {"ts", "names", "norm"}
|
||||
_INDEX_TTL = 86400.0 # refresh the libretro file index once a day
|
||||
|
||||
_neg_cache = {} # cache key -> time.time() of the last failed fetch
|
||||
@@ -187,9 +202,9 @@ def ensure_cover_bundles(server_url=""):
|
||||
return downloaded
|
||||
|
||||
|
||||
def cover_url(name, kind="Named_Boxarts"):
|
||||
def cover_url(name, kind="Named_Boxarts", system="games"):
|
||||
"""Public libretro URL of an image for a catalog name."""
|
||||
return f"{_LIBRETRO_BASE}/{kind}/{urllib.parse.quote(name)}.png"
|
||||
return f"{_libretro_base(system)}/{kind}/{urllib.parse.quote(name)}.png"
|
||||
|
||||
|
||||
def cover_path(release_id, system):
|
||||
@@ -261,7 +276,7 @@ def _candidate_names(name):
|
||||
yield s
|
||||
|
||||
|
||||
def _fetch_png(name, kind, timeout):
|
||||
def _fetch_png(name, kind, timeout, system="games"):
|
||||
"""Fetch a real image from libretro, following symlink text bodies.
|
||||
|
||||
GitHub serves a repo symlink as a tiny text body containing the target
|
||||
@@ -269,7 +284,7 @@ def _fetch_png(name, kind, timeout):
|
||||
"""
|
||||
target = name
|
||||
for _ in range(_MAX_HOPS + 1):
|
||||
data = _http_get(cover_url(target, kind), timeout)
|
||||
data = _http_get(cover_url(target, kind, system), timeout)
|
||||
if data is None:
|
||||
return None
|
||||
if _is_image(data):
|
||||
@@ -284,26 +299,29 @@ def _fetch_png(name, kind, timeout):
|
||||
# --------------------------------------------------------------------------- #
|
||||
# libretro filename index + fuzzy matching
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _index_path():
|
||||
return config.COVERS_DIR / "libretro-index.json"
|
||||
def _index_path(system="games"):
|
||||
if system == "games":
|
||||
return config.COVERS_DIR / "libretro-index.json"
|
||||
return config.COVERS_DIR / f"libretro-index-{system}.json"
|
||||
|
||||
|
||||
def _load_index(force=False):
|
||||
st = _index_state
|
||||
def _load_index(force=False, system="games"):
|
||||
st = _index_state.get(system) or {"ts": 0.0, "names": {}, "norm": {}}
|
||||
_index_state[system] = st
|
||||
if not force and st["names"] and time.time() - st["ts"] < _INDEX_TTL:
|
||||
return st["names"]
|
||||
path = _index_path()
|
||||
path = _index_path(system)
|
||||
if not force and path.exists():
|
||||
try:
|
||||
# Load the on-disk index so it doubles as a fallback if the network
|
||||
# is down, but only return it immediately while it's still fresh.
|
||||
names = json.loads(path.read_text(encoding="utf-8"))
|
||||
_set_index(names)
|
||||
_set_index(names, system)
|
||||
if time.time() - path.stat().st_mtime < _INDEX_TTL:
|
||||
return names
|
||||
except Exception:
|
||||
pass
|
||||
data = _http_get(_TREE_URL, timeout=30)
|
||||
data = _http_get(_libretro_tree_url(system), timeout=30)
|
||||
if data:
|
||||
try:
|
||||
tree = json.loads(data).get("tree", [])
|
||||
@@ -314,7 +332,7 @@ def _load_index(force=False):
|
||||
prefix = kind + "/"
|
||||
if p.startswith(prefix) and p.endswith(".png"):
|
||||
names[kind].append(p[len(prefix):-4])
|
||||
_set_index(names)
|
||||
_set_index(names, system)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(names), encoding="utf-8")
|
||||
@@ -326,9 +344,10 @@ def _load_index(force=False):
|
||||
return st["names"] or {kind: [] for kind in _LIBRETRO_KINDS}
|
||||
|
||||
|
||||
def _set_index(names):
|
||||
def _set_index(names, system="games"):
|
||||
"""Store the raw filename index plus a pre-normalized copy for fast lookup."""
|
||||
st = _index_state
|
||||
st = _index_state.get(system) or {"ts": 0.0, "names": {}, "norm": {}}
|
||||
_index_state[system] = st
|
||||
st["names"] = names
|
||||
st["norm"] = {k: [(_norm(n), n) for n in v] for k, v in names.items()}
|
||||
st["ts"] = time.time()
|
||||
@@ -344,11 +363,12 @@ def _plain(s):
|
||||
return re.sub(r"\s*\([^()]*\)", "", s).strip()
|
||||
|
||||
|
||||
def _index_best(name, kind):
|
||||
def _index_best(name, kind, system="games"):
|
||||
"""Return the best-matching repo filename for `name`, or None."""
|
||||
st = _index_state
|
||||
st = _index_state.get(system) or {"ts": 0.0, "names": {}, "norm": {}}
|
||||
if not st["norm"]:
|
||||
_load_index()
|
||||
_load_index(system=system)
|
||||
st = _index_state.get(system) or st
|
||||
pairs = st["norm"].get(kind, [])
|
||||
db = _norm(name)
|
||||
if not pairs or len(db) < 5:
|
||||
@@ -385,24 +405,25 @@ def _index_best(name, kind):
|
||||
return best
|
||||
|
||||
|
||||
def _fetch_libretro(name, timeout, indexed_only=False):
|
||||
# 0. Shipped offline bundle first (fast, no network).
|
||||
data = fetch_libretro_local(name)
|
||||
if data is not None:
|
||||
return data
|
||||
def _fetch_libretro(name, timeout, system="games", indexed_only=False):
|
||||
# 0. Shipped offline bundle first (fast, no network) — PSP only for now.
|
||||
if system == "games":
|
||||
data = fetch_libretro_local(name)
|
||||
if data is not None:
|
||||
return data
|
||||
if not indexed_only:
|
||||
# 1. Cheap: exact + progressively stripped names (no index needed).
|
||||
for kind in _LIBRETRO_KINDS:
|
||||
for cand in _candidate_names(name):
|
||||
data = _fetch_png(cand, kind, timeout)
|
||||
data = _fetch_png(cand, kind, timeout, system)
|
||||
if data is not None:
|
||||
return data
|
||||
# 2. Fuzzy/exact via the precomputed index (covers exact names too, so the
|
||||
# bulk loader skips the wasteful exact pass entirely).
|
||||
for kind in _LIBRETRO_KINDS:
|
||||
best = _index_best(name, kind)
|
||||
best = _index_best(name, kind, system)
|
||||
if best:
|
||||
data = _fetch_png(best, kind, timeout)
|
||||
data = _fetch_png(best, kind, timeout, system)
|
||||
if data is not None:
|
||||
return data
|
||||
return None
|
||||
@@ -577,7 +598,7 @@ def _screenscraper_box_urls(jeu):
|
||||
return urls
|
||||
|
||||
|
||||
def _fetch_screenscraper(name, timeout):
|
||||
def _fetch_screenscraper(name, timeout, system="games"):
|
||||
creds = sources.load()
|
||||
devid = creds.get("screenscraper_devid")
|
||||
devpassword = creds.get("screenscraper_devpassword")
|
||||
@@ -588,7 +609,7 @@ def _fetch_screenscraper(name, timeout):
|
||||
"devpassword": devpassword,
|
||||
"softname": creds.get("screenscraper_softname") or "psp-preserve",
|
||||
"output": "json",
|
||||
"systemeid": _SS_PSP_ID,
|
||||
"systemeid": _platform(system)["ss_id"],
|
||||
"romtype": "rom",
|
||||
"romnom": name,
|
||||
}
|
||||
@@ -834,19 +855,20 @@ def fetch_cover(name, release_id, system, timeout=10, quick=False):
|
||||
if data is not None:
|
||||
log.info("cover %r <- manual", name)
|
||||
elif system != "videos":
|
||||
# 2. Games: libretro (full box art) first, then the shipped OpenVGDB
|
||||
# bundle (offline), then ScreenScraper (full box art, non-quick),
|
||||
# then Wikipedia (keyless, non-quick), then IGDB last — IGDB only
|
||||
# stores cropped 3:4 key art.
|
||||
data = _fetch_libretro(name, t, indexed_only=quick)
|
||||
# 2. Games/PS1: libretro (full box art) first, then the shipped
|
||||
# OpenVGDB bundle (PSP only, offline), then ScreenScraper (non-quick),
|
||||
# then Wikipedia (keyless, non-quick), then IGDB last.
|
||||
data = _fetch_libretro(name, t, system=system, indexed_only=quick)
|
||||
if data is not None:
|
||||
log.info("cover %r <- libretro", name)
|
||||
else:
|
||||
data = fetch_openvgdb_local(name)
|
||||
if data is not None:
|
||||
log.info("cover %r <- openvgdb", name)
|
||||
elif not quick and time.time() < deadline:
|
||||
data = _fetch_screenscraper(name, t)
|
||||
data = None
|
||||
if system == "games":
|
||||
data = fetch_openvgdb_local(name)
|
||||
if data is not None:
|
||||
log.info("cover %r <- openvgdb", name)
|
||||
if data is None and not quick and time.time() < deadline:
|
||||
data = _fetch_screenscraper(name, t, system=system)
|
||||
if data is not None:
|
||||
log.info("cover %r <- screenscraper", name)
|
||||
if data is None and not quick and time.time() < deadline:
|
||||
@@ -854,11 +876,12 @@ def fetch_cover(name, release_id, system, timeout=10, quick=False):
|
||||
if data is not None:
|
||||
log.info("cover %r <- wikipedia", name)
|
||||
if data is None and time.time() < deadline:
|
||||
data = _fetch_igdb_cover(name, t)
|
||||
data = _fetch_igdb_cover(name, t, system=system)
|
||||
if data is not None:
|
||||
# IGDB only has cropped 3:4 key art; composite it into the
|
||||
# full PSP cover template (header bar + artwork).
|
||||
data = compose_game_cover(data)
|
||||
if system == "games":
|
||||
# IGDB only has cropped 3:4 PSP key art; composite it
|
||||
# into the full PSP cover template (header + artwork).
|
||||
data = compose_game_cover(data)
|
||||
log.info("cover %r <- igdb", name)
|
||||
|
||||
if data is None:
|
||||
@@ -1063,8 +1086,8 @@ def _wiki_file_search(query, limit, timeout):
|
||||
return out
|
||||
|
||||
|
||||
def _search_libretro(query, limit):
|
||||
names = _load_index().get("Named_Boxarts", [])
|
||||
def _search_libretro(query, limit, system="games"):
|
||||
names = _load_index(system=system).get("Named_Boxarts", [])
|
||||
db = _norm(query)
|
||||
results = []
|
||||
if not db:
|
||||
@@ -1090,8 +1113,8 @@ def _search_libretro(query, limit):
|
||||
results.append({
|
||||
"title": n,
|
||||
"source": "libretro",
|
||||
"thumb_url": cover_url(n),
|
||||
"image_url": cover_url(n),
|
||||
"thumb_url": cover_url(n, system=system),
|
||||
"image_url": cover_url(n, system=system),
|
||||
})
|
||||
return results
|
||||
|
||||
@@ -1141,9 +1164,9 @@ def _search_openvgdb(query, limit):
|
||||
_META_TTL = 86400.0
|
||||
|
||||
|
||||
def get_game_metadata(title, timeout=10):
|
||||
"""Return game metadata from IGDB."""
|
||||
return get_game_metadata_igdb(title, timeout)
|
||||
def get_game_metadata(title, timeout=10, system="games"):
|
||||
"""Return game metadata from IGDB (system picks the platform)."""
|
||||
return get_game_metadata_igdb(title, timeout, system)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1268,28 +1291,30 @@ def _igdb_game_meta(g):
|
||||
}
|
||||
|
||||
|
||||
def _igdb_search(query, limit=20, timeout=10):
|
||||
def _igdb_search(query, limit=20, timeout=10, system="games"):
|
||||
cid, _ = _igdb_creds()
|
||||
if not cid:
|
||||
return []
|
||||
term = plain_title(query) or query
|
||||
platform_id = _platform(system)["igdb_id"]
|
||||
q = (
|
||||
f'search "{term}"; '
|
||||
"fields name, cover.image_id, first_release_date, summary, genres.name, "
|
||||
"involved_companies.company.name, involved_companies.developer, involved_companies.publisher, "
|
||||
"rating, videos.video_id; "
|
||||
f"where platforms = ({_IGDB_PSP_ID}); "
|
||||
f"where platforms = ({platform_id}); "
|
||||
f"limit {max(limit, 1)};"
|
||||
)
|
||||
return _igdb_post(q, timeout) or []
|
||||
|
||||
|
||||
def _fetch_igdb_cover(name, timeout):
|
||||
def _fetch_igdb_cover(name, timeout, system="games"):
|
||||
cid, _ = _igdb_creds()
|
||||
if not cid:
|
||||
return None
|
||||
term = plain_title(name) or name
|
||||
q = f'search "{term}"; fields cover.image_id; where platforms = ({_IGDB_PSP_ID}); limit 10;'
|
||||
platform_id = _platform(system)["igdb_id"]
|
||||
q = f'search "{term}"; fields cover.image_id; where platforms = ({platform_id}); limit 10;'
|
||||
games = _igdb_post(q, timeout) or []
|
||||
tried = set()
|
||||
for g in games:
|
||||
@@ -1303,9 +1328,9 @@ def _fetch_igdb_cover(name, timeout):
|
||||
return None
|
||||
|
||||
|
||||
def search_games_igdb(query, limit=20, timeout=10):
|
||||
def search_games_igdb(query, limit=20, timeout=10, system="games"):
|
||||
out = []
|
||||
for g in _igdb_search(query, limit, timeout):
|
||||
for g in _igdb_search(query, limit, timeout, system):
|
||||
cover = (g.get("cover") or {}).get("image_id") or ""
|
||||
out.append({
|
||||
"id": g.get("id"),
|
||||
@@ -1320,8 +1345,8 @@ def search_games_igdb(query, limit=20, timeout=10):
|
||||
return out[:limit]
|
||||
|
||||
|
||||
def get_game_metadata_igdb(title, timeout=10):
|
||||
games = _igdb_search(title, limit=10, timeout=timeout)
|
||||
def get_game_metadata_igdb(title, timeout=10, system="games"):
|
||||
games = _igdb_search(title, limit=10, timeout=timeout, system=system)
|
||||
if not games:
|
||||
return None
|
||||
games.sort(key=lambda g: -_title_score(title, g.get("name") or ""))
|
||||
@@ -1545,8 +1570,8 @@ def movie_metadata_from_wikipedia(page_title, timeout=10):
|
||||
def search_metadata_candidates(system, query, limit=20, timeout=10):
|
||||
"""Search candidates the user can link an item to.
|
||||
|
||||
Games: IGDB (rich metadata) first, then libretro box art and OpenVGDB
|
||||
(GameFAQs) box art so covers can be scraped even without an IGDB key.
|
||||
Games/PS1: IGDB (rich metadata) first, then libretro box art (and the
|
||||
PSP-only OpenVGDB bundle) so covers can be scraped even without an IGDB key.
|
||||
"""
|
||||
if system == "videos":
|
||||
return search_movies(query, limit, timeout)
|
||||
@@ -1559,13 +1584,15 @@ def search_metadata_candidates(system, query, limit=20, timeout=10):
|
||||
seen.add(key)
|
||||
results.append(r)
|
||||
|
||||
for r in search_games_igdb(query, limit, timeout):
|
||||
# Cap IGDB so libretro/OpenVGDB cover picks still fit in the list.
|
||||
for r in search_games_igdb(query, max(1, min(limit, 6)), timeout, system):
|
||||
add(r)
|
||||
for r in _search_libretro(query, limit):
|
||||
for r in _search_libretro(query, limit, system):
|
||||
r.setdefault("id", None)
|
||||
add(r)
|
||||
for r in _search_openvgdb(query, limit):
|
||||
add(r)
|
||||
if system == "games":
|
||||
for r in _search_openvgdb(query, limit):
|
||||
add(r)
|
||||
return results[:limit]
|
||||
|
||||
|
||||
@@ -1596,7 +1623,7 @@ def link_metadata(system, candidate, timeout=10):
|
||||
# user still gets synopsis/genre/etc. when a key is configured.
|
||||
title = candidate.get("title") or ""
|
||||
if title:
|
||||
meta = get_game_metadata_igdb(title, timeout)
|
||||
meta = get_game_metadata_igdb(title, timeout, system)
|
||||
if meta:
|
||||
return meta
|
||||
return _empty_meta(title)
|
||||
|
||||
+103
-9
@@ -559,6 +559,7 @@ class MetadataWorker(QObject):
|
||||
class GameDetailsPanel(QWidget):
|
||||
edit_requested = Signal() # emitted when the user wants to hand-tag the item
|
||||
link_requested = Signal() # emitted when the user wants to pick a movie/game to link
|
||||
convert_requested = Signal() # emitted when the user wants to convert a PS1 disc to EBOOT
|
||||
copy_requested = Signal() # emitted when the user wants to copy the current item to the PSP
|
||||
delete_requested = Signal() # emitted when the user wants to delete the current item
|
||||
play_requested = Signal() # emitted when the user wants to launch the item in PPSSPP
|
||||
@@ -569,6 +570,7 @@ class GameDetailsPanel(QWidget):
|
||||
self.setFixedWidth(300)
|
||||
self._youtube_url = ""
|
||||
self._meta = None
|
||||
self._copy_available = False
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(12, 12, 0, 12)
|
||||
@@ -627,6 +629,12 @@ class GameDetailsPanel(QWidget):
|
||||
self.play_btn.setToolTip("Launch this game in PPSSPP")
|
||||
self.layout.addWidget(self.play_btn)
|
||||
|
||||
self.convert_btn = QPushButton("\u2699 Convert to EBOOT")
|
||||
self.convert_btn.clicked.connect(self.convert_requested.emit)
|
||||
self.convert_btn.setVisible(False)
|
||||
self.convert_btn.setToolTip("Convert this PS1 disc to an EBOOT.PBP")
|
||||
self.layout.addWidget(self.convert_btn)
|
||||
|
||||
btn_grid = QGridLayout()
|
||||
btn_grid.setHorizontalSpacing(6)
|
||||
btn_grid.setVerticalSpacing(6)
|
||||
@@ -658,14 +666,28 @@ class GameDetailsPanel(QWidget):
|
||||
|
||||
def set_item(self, item):
|
||||
self.system = item.get("system")
|
||||
self._ps1_kind = item.get("ps1_kind")
|
||||
path = item.get("path") or ""
|
||||
self.play_btn.setEnabled(item.get("system") == "games" and bool(path) and Path(path).suffix.lower() == ".iso")
|
||||
is_ps1 = item.get("system") == "ps1"
|
||||
is_ps1_disc = is_ps1 and item.get("ps1_kind") == "disc"
|
||||
self.play_btn.setEnabled(
|
||||
(item.get("system") == "games" and bool(path) and Path(path).suffix.lower() == ".iso")
|
||||
or (is_ps1 and item.get("ps1_kind") == "eboot" and bool(path))
|
||||
)
|
||||
self.play_btn.setVisible(not is_ps1_disc)
|
||||
self.convert_btn.setVisible(is_ps1_disc)
|
||||
self.copy_btn.setEnabled(self._copy_available and not is_ps1_disc)
|
||||
self.copy_btn.setToolTip(
|
||||
"Copy this EBOOT to /PSP/GAME" if is_ps1 else "Copy this disc to the connected PSP"
|
||||
)
|
||||
self.del_psp_btn.setEnabled(bool(item.get("on_psp")))
|
||||
self.title.setText(item.get("title_override") or item.get("name") or "Unknown")
|
||||
parts = []
|
||||
if item.get("serial"):
|
||||
parts.append(item["serial"])
|
||||
if item.get("region"):
|
||||
if is_ps1 and item.get("ps1_status"):
|
||||
parts.append(item["ps1_status"])
|
||||
elif item.get("region"):
|
||||
parts.append(item["region"])
|
||||
self.subtitle.setText(" · ".join(parts))
|
||||
self._clear_meta()
|
||||
@@ -676,7 +698,8 @@ class GameDetailsPanel(QWidget):
|
||||
self._set_cover(item.get("release_id"), item.get("system"))
|
||||
|
||||
def set_copy_available(self, available):
|
||||
self.copy_btn.setEnabled(bool(available))
|
||||
self._copy_available = bool(available)
|
||||
self.copy_btn.setEnabled(self._copy_available and getattr(self, "_ps1_kind", None) != "disc")
|
||||
|
||||
def set_psp_installed(self, on_psp):
|
||||
self.del_psp_btn.setEnabled(bool(on_psp))
|
||||
@@ -875,7 +898,7 @@ def _scrape_cover(system, candidate, rid, timeout=20):
|
||||
if data:
|
||||
if system == "videos":
|
||||
return compose_umd_cover(data)
|
||||
if candidate.get("source") == "igdb":
|
||||
if candidate.get("source") == "igdb" and system == "games":
|
||||
return covers.compose_game_cover(data)
|
||||
return data
|
||||
if title:
|
||||
@@ -3024,7 +3047,7 @@ class PspSyncPage(QWidget):
|
||||
self._workers = []
|
||||
|
||||
|
||||
def build_library_items():
|
||||
def build_library_items(ps1_dir="", output_dir=""):
|
||||
"""Build the library item list from the DB with a single joined query."""
|
||||
items = []
|
||||
seen = set()
|
||||
@@ -3093,16 +3116,59 @@ def build_library_items():
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
items.extend(_ps1_library_items(ps1_dir, output_dir, overrides, dates))
|
||||
return items
|
||||
|
||||
|
||||
def _ps1_library_items(ps1_dir, output_dir, overrides, dates):
|
||||
"""PS1 discs (pending) + converted EBOOTs, merged into the library list."""
|
||||
if not ps1_dir:
|
||||
return []
|
||||
try:
|
||||
entries = ps1.scan_library(ps1_dir, output_dir)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
out = []
|
||||
for e in entries:
|
||||
# A converted disc is represented by its EBOOT entry (one row per game).
|
||||
if e["kind"] == "disc" and e.get("status") == "converted":
|
||||
continue
|
||||
name = e["name"]
|
||||
rid = covers.stable_key(name)
|
||||
path = Path(e["path"])
|
||||
try:
|
||||
size = path.stat().st_size if path.exists() else 0
|
||||
except OSError:
|
||||
size = 0
|
||||
out.append({
|
||||
"name": name,
|
||||
"release_id": rid,
|
||||
"system": "ps1",
|
||||
"canonical": name,
|
||||
"serial": e.get("serial") or "",
|
||||
"region": None,
|
||||
"title_override": overrides.get(("ps1", str(rid))),
|
||||
"release_date": dates.get((str(rid), "ps1")) or "",
|
||||
"path": str(path),
|
||||
"size": size,
|
||||
"ps1_kind": e["kind"],
|
||||
"ps1_status": e.get("status") or "pending",
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
class LibraryWorker(QObject):
|
||||
result = Signal(list)
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, ps1_dir="", output_dir=""):
|
||||
super().__init__()
|
||||
self.ps1_dir = ps1_dir
|
||||
self.output_dir = output_dir
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
items = build_library_items()
|
||||
items = build_library_items(self.ps1_dir, self.output_dir)
|
||||
except Exception: # noqa: BLE001
|
||||
items = []
|
||||
self.result.emit(items)
|
||||
@@ -3470,7 +3536,7 @@ class MainWindow(QMainWindow):
|
||||
self.library_search.textChanged.connect(self._apply_library_filter)
|
||||
bar.addWidget(self.library_search, 1)
|
||||
self.library_type = QComboBox()
|
||||
self.library_type.addItems(["All types", "Games", "Videos"])
|
||||
self.library_type.addItems(["All types", "Games", "Videos", "PS1"])
|
||||
self.library_type.currentTextChanged.connect(self._apply_library_filter)
|
||||
bar.addWidget(self.library_type)
|
||||
self.library_psp = QComboBox()
|
||||
@@ -3520,6 +3586,7 @@ class MainWindow(QMainWindow):
|
||||
self.cover_grid.scrape_requested.connect(self._scrape_grid_item)
|
||||
self.details_panel.edit_requested.connect(self._edit_details)
|
||||
self.details_panel.link_requested.connect(self._scrape_item)
|
||||
self.details_panel.convert_requested.connect(self._convert_current_item)
|
||||
self.details_panel.copy_requested.connect(self._copy_current_to_psp)
|
||||
self.details_panel.delete_requested.connect(self._delete_current)
|
||||
self.details_panel.delete_psp_requested.connect(self._delete_current_from_psp)
|
||||
@@ -3688,7 +3755,7 @@ class MainWindow(QMainWindow):
|
||||
self.statusBar().showMessage("Loading library…")
|
||||
log.info("loading library…")
|
||||
thread = QThread(self)
|
||||
worker = LibraryWorker()
|
||||
worker = LibraryWorker(self._ps1_dir, self._ps1_output_dir)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.result.connect(self._on_library_loaded)
|
||||
@@ -3726,6 +3793,8 @@ class MainWindow(QMainWindow):
|
||||
continue
|
||||
if typ == "Videos" and it["system"] != "videos":
|
||||
continue
|
||||
if typ == "PS1" and it["system"] != "ps1":
|
||||
continue
|
||||
if q and q not in (it["canonical"] or it["name"]).lower():
|
||||
continue
|
||||
if psp_f == "On PSP" and not it.get("on_psp"):
|
||||
@@ -3751,7 +3820,8 @@ class MainWindow(QMainWindow):
|
||||
title.setIcon(self._psp_dot_icon())
|
||||
title.setToolTip("Installed on PSP")
|
||||
self.library_table.setItem(row, 0, title)
|
||||
self.library_table.setItem(row, 1, QTableWidgetItem("Game" if it["system"] == "games" else "Video"))
|
||||
kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1"}.get(it["system"], "Game")
|
||||
self.library_table.setItem(row, 1, QTableWidgetItem(kind_label))
|
||||
self.library_table.setItem(row, 2, QTableWidgetItem(it.get("region") or ""))
|
||||
self.library_table.setItem(row, 3, QTableWidgetItem(it.get("serial") or ""))
|
||||
size = it.get("size")
|
||||
@@ -3886,6 +3956,18 @@ class MainWindow(QMainWindow):
|
||||
self.details_panel.set_item(it)
|
||||
self._scrape_item()
|
||||
|
||||
def _convert_current_item(self):
|
||||
item = getattr(self, "_current_item", None)
|
||||
if item is None or item.get("system") != "ps1" or item.get("ps1_kind") != "disc":
|
||||
return
|
||||
game_id = item.get("serial") or ""
|
||||
if not game_id:
|
||||
text, ok = QInputDialog.getText(self, "PS1", "Game ID (e.g. SLUS00594):")
|
||||
if not ok or not text.strip():
|
||||
return
|
||||
game_id = text.strip()
|
||||
self._on_ps1_convert({"source": item.get("path"), "title": item.get("name"), "game_id": game_id})
|
||||
|
||||
def _on_store_download(self, items):
|
||||
if not items:
|
||||
return
|
||||
@@ -3967,6 +4049,8 @@ class MainWindow(QMainWindow):
|
||||
return
|
||||
if item.get("system") == "videos":
|
||||
return # UMD Video discs can't be played in PPSSPP
|
||||
if item.get("system") == "ps1" and item.get("ps1_kind") != "eboot":
|
||||
return # raw PS1 discs are converted, not played
|
||||
path = item.get("path")
|
||||
if not path or not Path(path).exists():
|
||||
QMessageBox.warning(self, "Missing file", f"File not found:\n{path}")
|
||||
@@ -4039,6 +4123,15 @@ class MainWindow(QMainWindow):
|
||||
psp_root = Path(self._psp_root)
|
||||
if system == "videos":
|
||||
dest_dir = psp_root / "ISO" / "VIDEO"
|
||||
elif system == "ps1":
|
||||
# PS1 EBOOTs install to /PSP/GAME/<folder>/EBOOT.PBP.
|
||||
folder = item.get("serial") or item.get("name") or "PS1"
|
||||
folder = "".join(c for c in folder if c not in '/\\:*?"<>|').strip() or "PS1"
|
||||
dest_dir = psp_root / "PSP" / "GAME" / folder
|
||||
log.info("copying ps1 eboot to psp: %s -> %s", item["path"], dest_dir / "EBOOT.PBP")
|
||||
self.queue.enqueue(transfers.FileCopySource(item["path"]), dest_dir,
|
||||
{"dest_name": "EBOOT.PBP"}, item.get("name") or folder, kind="psp-install")
|
||||
return True
|
||||
else:
|
||||
cat = "".join(c for c in (self._iso_category or "").strip() if c not in "/\\")
|
||||
dest_dir = (psp_root / "ISO" / cat) if cat else (psp_root / "ISO")
|
||||
@@ -4179,6 +4272,7 @@ class MainWindow(QMainWindow):
|
||||
QTimer.singleShot(0, self._refresh_saves)
|
||||
if kind == "ps1-convert":
|
||||
QTimer.singleShot(0, self.ps1_page.refresh)
|
||||
QTimer.singleShot(0, self.load_library)
|
||||
|
||||
# -- Common ------------------------------------------------------------ #
|
||||
@staticmethod
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ def get_or_fetch(release_id, system, fetch_title):
|
||||
if system == "videos":
|
||||
meta = covers.get_movie_metadata(fetch_title)
|
||||
else:
|
||||
meta = covers.get_game_metadata(fetch_title)
|
||||
meta = covers.get_game_metadata(fetch_title, system=system)
|
||||
except Exception: # noqa: BLE001 - network/API failures must not crash the UI
|
||||
meta = None
|
||||
if meta:
|
||||
|
||||
Reference in New Issue
Block a user