Add PS1 games to the Library with covers, metadata, and EBOOT actions

This commit is contained in:
2026-09-07 16:20:39 -04:00
parent 777d316c0f
commit 4d5c48cb94
4 changed files with 201 additions and 78 deletions
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: