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
|
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).
|
`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
|
- **Source formats** — `.iso`, `.img`, `.bin` (with a matching `.cue` for
|
||||||
multi-track discs).
|
multi-track discs).
|
||||||
|
|||||||
+95
-68
@@ -24,16 +24,31 @@ from .db import get_connection
|
|||||||
|
|
||||||
log = logging.getLogger("covers")
|
log = logging.getLogger("covers")
|
||||||
|
|
||||||
_LIBRETRO_BASE = "https://raw.githubusercontent.com/libretro-thumbnails/Sony_-_PlayStation_Portable/master"
|
|
||||||
_LIBRETRO_KINDS = ("Named_Boxarts",)
|
_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_API = "https://api.screenscraper.fr/api2/jeuInfos.php"
|
||||||
_SS_PSP_ID = "61" # Sony PSP
|
|
||||||
_IGDB_TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
_IGDB_TOKEN_URL = "https://id.twitch.tv/oauth2/token"
|
||||||
_IGDB_API = "https://api.igdb.com/v4/games"
|
_IGDB_API = "https://api.igdb.com/v4/games"
|
||||||
_IGDB_IMAGE = "https://images.igdb.com/igdb/image/upload/"
|
_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_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"
|
||||||
@@ -52,7 +67,7 @@ _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
|
||||||
|
|
||||||
_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
|
_INDEX_TTL = 86400.0 # refresh the libretro file index once a day
|
||||||
|
|
||||||
_neg_cache = {} # cache key -> time.time() of the last failed fetch
|
_neg_cache = {} # cache key -> time.time() of the last failed fetch
|
||||||
@@ -187,9 +202,9 @@ def ensure_cover_bundles(server_url=""):
|
|||||||
return downloaded
|
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."""
|
"""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):
|
def cover_path(release_id, system):
|
||||||
@@ -261,7 +276,7 @@ def _candidate_names(name):
|
|||||||
yield s
|
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.
|
"""Fetch a real image from libretro, following symlink text bodies.
|
||||||
|
|
||||||
GitHub serves a repo symlink as a tiny text body containing the target
|
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
|
target = name
|
||||||
for _ in range(_MAX_HOPS + 1):
|
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:
|
if data is None:
|
||||||
return None
|
return None
|
||||||
if _is_image(data):
|
if _is_image(data):
|
||||||
@@ -284,26 +299,29 @@ def _fetch_png(name, kind, timeout):
|
|||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# libretro filename index + fuzzy matching
|
# libretro filename index + fuzzy matching
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
def _index_path():
|
def _index_path(system="games"):
|
||||||
return config.COVERS_DIR / "libretro-index.json"
|
if system == "games":
|
||||||
|
return config.COVERS_DIR / "libretro-index.json"
|
||||||
|
return config.COVERS_DIR / f"libretro-index-{system}.json"
|
||||||
|
|
||||||
|
|
||||||
def _load_index(force=False):
|
def _load_index(force=False, system="games"):
|
||||||
st = _index_state
|
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:
|
if not force and st["names"] and time.time() - st["ts"] < _INDEX_TTL:
|
||||||
return st["names"]
|
return st["names"]
|
||||||
path = _index_path()
|
path = _index_path(system)
|
||||||
if not force and path.exists():
|
if not force and path.exists():
|
||||||
try:
|
try:
|
||||||
# Load the on-disk index so it doubles as a fallback if the network
|
# 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.
|
# is down, but only return it immediately while it's still fresh.
|
||||||
names = json.loads(path.read_text(encoding="utf-8"))
|
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:
|
if time.time() - path.stat().st_mtime < _INDEX_TTL:
|
||||||
return names
|
return names
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
data = _http_get(_TREE_URL, timeout=30)
|
data = _http_get(_libretro_tree_url(system), timeout=30)
|
||||||
if data:
|
if data:
|
||||||
try:
|
try:
|
||||||
tree = json.loads(data).get("tree", [])
|
tree = json.loads(data).get("tree", [])
|
||||||
@@ -314,7 +332,7 @@ def _load_index(force=False):
|
|||||||
prefix = kind + "/"
|
prefix = kind + "/"
|
||||||
if p.startswith(prefix) and p.endswith(".png"):
|
if p.startswith(prefix) and p.endswith(".png"):
|
||||||
names[kind].append(p[len(prefix):-4])
|
names[kind].append(p[len(prefix):-4])
|
||||||
_set_index(names)
|
_set_index(names, system)
|
||||||
try:
|
try:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
path.write_text(json.dumps(names), encoding="utf-8")
|
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}
|
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."""
|
"""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["names"] = names
|
||||||
st["norm"] = {k: [(_norm(n), n) for n in v] for k, v in names.items()}
|
st["norm"] = {k: [(_norm(n), n) for n in v] for k, v in names.items()}
|
||||||
st["ts"] = time.time()
|
st["ts"] = time.time()
|
||||||
@@ -344,11 +363,12 @@ def _plain(s):
|
|||||||
return re.sub(r"\s*\([^()]*\)", "", s).strip()
|
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."""
|
"""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"]:
|
if not st["norm"]:
|
||||||
_load_index()
|
_load_index(system=system)
|
||||||
|
st = _index_state.get(system) or st
|
||||||
pairs = st["norm"].get(kind, [])
|
pairs = st["norm"].get(kind, [])
|
||||||
db = _norm(name)
|
db = _norm(name)
|
||||||
if not pairs or len(db) < 5:
|
if not pairs or len(db) < 5:
|
||||||
@@ -385,24 +405,25 @@ def _index_best(name, kind):
|
|||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def _fetch_libretro(name, timeout, indexed_only=False):
|
def _fetch_libretro(name, timeout, system="games", indexed_only=False):
|
||||||
# 0. Shipped offline bundle first (fast, no network).
|
# 0. Shipped offline bundle first (fast, no network) — PSP only for now.
|
||||||
data = fetch_libretro_local(name)
|
if system == "games":
|
||||||
if data is not None:
|
data = fetch_libretro_local(name)
|
||||||
return data
|
if data is not None:
|
||||||
|
return data
|
||||||
if not indexed_only:
|
if not indexed_only:
|
||||||
# 1. Cheap: exact + progressively stripped names (no index needed).
|
# 1. Cheap: exact + progressively stripped names (no index needed).
|
||||||
for kind in _LIBRETRO_KINDS:
|
for kind in _LIBRETRO_KINDS:
|
||||||
for cand in _candidate_names(name):
|
for cand in _candidate_names(name):
|
||||||
data = _fetch_png(cand, kind, timeout)
|
data = _fetch_png(cand, kind, timeout, system)
|
||||||
if data is not None:
|
if data is not None:
|
||||||
return data
|
return data
|
||||||
# 2. Fuzzy/exact via the precomputed index (covers exact names too, so the
|
# 2. Fuzzy/exact via the precomputed index (covers exact names too, so the
|
||||||
# bulk loader skips the wasteful exact pass entirely).
|
# bulk loader skips the wasteful exact pass entirely).
|
||||||
for kind in _LIBRETRO_KINDS:
|
for kind in _LIBRETRO_KINDS:
|
||||||
best = _index_best(name, kind)
|
best = _index_best(name, kind, system)
|
||||||
if best:
|
if best:
|
||||||
data = _fetch_png(best, kind, timeout)
|
data = _fetch_png(best, kind, timeout, system)
|
||||||
if data is not None:
|
if data is not None:
|
||||||
return data
|
return data
|
||||||
return None
|
return None
|
||||||
@@ -577,7 +598,7 @@ def _screenscraper_box_urls(jeu):
|
|||||||
return urls
|
return urls
|
||||||
|
|
||||||
|
|
||||||
def _fetch_screenscraper(name, timeout):
|
def _fetch_screenscraper(name, timeout, system="games"):
|
||||||
creds = sources.load()
|
creds = sources.load()
|
||||||
devid = creds.get("screenscraper_devid")
|
devid = creds.get("screenscraper_devid")
|
||||||
devpassword = creds.get("screenscraper_devpassword")
|
devpassword = creds.get("screenscraper_devpassword")
|
||||||
@@ -588,7 +609,7 @@ def _fetch_screenscraper(name, timeout):
|
|||||||
"devpassword": devpassword,
|
"devpassword": devpassword,
|
||||||
"softname": creds.get("screenscraper_softname") or "psp-preserve",
|
"softname": creds.get("screenscraper_softname") or "psp-preserve",
|
||||||
"output": "json",
|
"output": "json",
|
||||||
"systemeid": _SS_PSP_ID,
|
"systemeid": _platform(system)["ss_id"],
|
||||||
"romtype": "rom",
|
"romtype": "rom",
|
||||||
"romnom": name,
|
"romnom": name,
|
||||||
}
|
}
|
||||||
@@ -834,19 +855,20 @@ def fetch_cover(name, release_id, system, timeout=10, quick=False):
|
|||||||
if data is not None:
|
if data is not None:
|
||||||
log.info("cover %r <- manual", name)
|
log.info("cover %r <- manual", name)
|
||||||
elif system != "videos":
|
elif system != "videos":
|
||||||
# 2. Games: libretro (full box art) first, then the shipped OpenVGDB
|
# 2. Games/PS1: libretro (full box art) first, then the shipped
|
||||||
# bundle (offline), then ScreenScraper (full box art, non-quick),
|
# OpenVGDB bundle (PSP only, offline), then ScreenScraper (non-quick),
|
||||||
# then Wikipedia (keyless, non-quick), then IGDB last — IGDB only
|
# then Wikipedia (keyless, non-quick), then IGDB last.
|
||||||
# stores cropped 3:4 key art.
|
data = _fetch_libretro(name, t, system=system, indexed_only=quick)
|
||||||
data = _fetch_libretro(name, t, indexed_only=quick)
|
|
||||||
if data is not None:
|
if data is not None:
|
||||||
log.info("cover %r <- libretro", name)
|
log.info("cover %r <- libretro", name)
|
||||||
else:
|
else:
|
||||||
data = fetch_openvgdb_local(name)
|
data = None
|
||||||
if data is not None:
|
if system == "games":
|
||||||
log.info("cover %r <- openvgdb", name)
|
data = fetch_openvgdb_local(name)
|
||||||
elif not quick and time.time() < deadline:
|
if data is not None:
|
||||||
data = _fetch_screenscraper(name, t)
|
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:
|
if data is not None:
|
||||||
log.info("cover %r <- screenscraper", name)
|
log.info("cover %r <- screenscraper", name)
|
||||||
if data is None and not quick and time.time() < deadline:
|
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:
|
if data is not None:
|
||||||
log.info("cover %r <- wikipedia", name)
|
log.info("cover %r <- wikipedia", name)
|
||||||
if data is None and time.time() < deadline:
|
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:
|
if data is not None:
|
||||||
# IGDB only has cropped 3:4 key art; composite it into the
|
if system == "games":
|
||||||
# full PSP cover template (header bar + artwork).
|
# IGDB only has cropped 3:4 PSP key art; composite it
|
||||||
data = compose_game_cover(data)
|
# into the full PSP cover template (header + artwork).
|
||||||
|
data = compose_game_cover(data)
|
||||||
log.info("cover %r <- igdb", name)
|
log.info("cover %r <- igdb", name)
|
||||||
|
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -1063,8 +1086,8 @@ def _wiki_file_search(query, limit, timeout):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _search_libretro(query, limit):
|
def _search_libretro(query, limit, system="games"):
|
||||||
names = _load_index().get("Named_Boxarts", [])
|
names = _load_index(system=system).get("Named_Boxarts", [])
|
||||||
db = _norm(query)
|
db = _norm(query)
|
||||||
results = []
|
results = []
|
||||||
if not db:
|
if not db:
|
||||||
@@ -1090,8 +1113,8 @@ def _search_libretro(query, limit):
|
|||||||
results.append({
|
results.append({
|
||||||
"title": n,
|
"title": n,
|
||||||
"source": "libretro",
|
"source": "libretro",
|
||||||
"thumb_url": cover_url(n),
|
"thumb_url": cover_url(n, system=system),
|
||||||
"image_url": cover_url(n),
|
"image_url": cover_url(n, system=system),
|
||||||
})
|
})
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@@ -1141,9 +1164,9 @@ def _search_openvgdb(query, limit):
|
|||||||
_META_TTL = 86400.0
|
_META_TTL = 86400.0
|
||||||
|
|
||||||
|
|
||||||
def get_game_metadata(title, timeout=10):
|
def get_game_metadata(title, timeout=10, system="games"):
|
||||||
"""Return game metadata from IGDB."""
|
"""Return game metadata from IGDB (system picks the platform)."""
|
||||||
return get_game_metadata_igdb(title, timeout)
|
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()
|
cid, _ = _igdb_creds()
|
||||||
if not cid:
|
if not cid:
|
||||||
return []
|
return []
|
||||||
term = plain_title(query) or query
|
term = plain_title(query) or query
|
||||||
|
platform_id = _platform(system)["igdb_id"]
|
||||||
q = (
|
q = (
|
||||||
f'search "{term}"; '
|
f'search "{term}"; '
|
||||||
"fields name, cover.image_id, first_release_date, summary, genres.name, "
|
"fields name, cover.image_id, first_release_date, summary, genres.name, "
|
||||||
"involved_companies.company.name, involved_companies.developer, involved_companies.publisher, "
|
"involved_companies.company.name, involved_companies.developer, involved_companies.publisher, "
|
||||||
"rating, videos.video_id; "
|
"rating, videos.video_id; "
|
||||||
f"where platforms = ({_IGDB_PSP_ID}); "
|
f"where platforms = ({platform_id}); "
|
||||||
f"limit {max(limit, 1)};"
|
f"limit {max(limit, 1)};"
|
||||||
)
|
)
|
||||||
return _igdb_post(q, timeout) or []
|
return _igdb_post(q, timeout) or []
|
||||||
|
|
||||||
|
|
||||||
def _fetch_igdb_cover(name, timeout):
|
def _fetch_igdb_cover(name, timeout, system="games"):
|
||||||
cid, _ = _igdb_creds()
|
cid, _ = _igdb_creds()
|
||||||
if not cid:
|
if not cid:
|
||||||
return None
|
return None
|
||||||
term = plain_title(name) or name
|
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 []
|
games = _igdb_post(q, timeout) or []
|
||||||
tried = set()
|
tried = set()
|
||||||
for g in games:
|
for g in games:
|
||||||
@@ -1303,9 +1328,9 @@ def _fetch_igdb_cover(name, timeout):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def search_games_igdb(query, limit=20, timeout=10):
|
def search_games_igdb(query, limit=20, timeout=10, system="games"):
|
||||||
out = []
|
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 ""
|
cover = (g.get("cover") or {}).get("image_id") or ""
|
||||||
out.append({
|
out.append({
|
||||||
"id": g.get("id"),
|
"id": g.get("id"),
|
||||||
@@ -1320,8 +1345,8 @@ def search_games_igdb(query, limit=20, timeout=10):
|
|||||||
return out[:limit]
|
return out[:limit]
|
||||||
|
|
||||||
|
|
||||||
def get_game_metadata_igdb(title, timeout=10):
|
def get_game_metadata_igdb(title, timeout=10, system="games"):
|
||||||
games = _igdb_search(title, limit=10, timeout=timeout)
|
games = _igdb_search(title, limit=10, timeout=timeout, system=system)
|
||||||
if not games:
|
if not games:
|
||||||
return None
|
return None
|
||||||
games.sort(key=lambda g: -_title_score(title, g.get("name") or ""))
|
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):
|
def search_metadata_candidates(system, query, limit=20, timeout=10):
|
||||||
"""Search candidates the user can link an item to.
|
"""Search candidates the user can link an item to.
|
||||||
|
|
||||||
Games: IGDB (rich metadata) first, then libretro box art and OpenVGDB
|
Games/PS1: IGDB (rich metadata) first, then libretro box art (and the
|
||||||
(GameFAQs) box art so covers can be scraped even without an IGDB key.
|
PSP-only OpenVGDB bundle) so covers can be scraped even without an IGDB key.
|
||||||
"""
|
"""
|
||||||
if system == "videos":
|
if system == "videos":
|
||||||
return search_movies(query, limit, timeout)
|
return search_movies(query, limit, timeout)
|
||||||
@@ -1559,13 +1584,15 @@ def search_metadata_candidates(system, query, limit=20, timeout=10):
|
|||||||
seen.add(key)
|
seen.add(key)
|
||||||
results.append(r)
|
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)
|
add(r)
|
||||||
for r in _search_libretro(query, limit):
|
for r in _search_libretro(query, limit, system):
|
||||||
r.setdefault("id", None)
|
r.setdefault("id", None)
|
||||||
add(r)
|
add(r)
|
||||||
for r in _search_openvgdb(query, limit):
|
if system == "games":
|
||||||
add(r)
|
for r in _search_openvgdb(query, limit):
|
||||||
|
add(r)
|
||||||
return results[:limit]
|
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.
|
# user still gets synopsis/genre/etc. when a key is configured.
|
||||||
title = candidate.get("title") or ""
|
title = candidate.get("title") or ""
|
||||||
if title:
|
if title:
|
||||||
meta = get_game_metadata_igdb(title, timeout)
|
meta = get_game_metadata_igdb(title, timeout, system)
|
||||||
if meta:
|
if meta:
|
||||||
return meta
|
return meta
|
||||||
return _empty_meta(title)
|
return _empty_meta(title)
|
||||||
|
|||||||
+103
-9
@@ -559,6 +559,7 @@ class MetadataWorker(QObject):
|
|||||||
class GameDetailsPanel(QWidget):
|
class GameDetailsPanel(QWidget):
|
||||||
edit_requested = Signal() # emitted when the user wants to hand-tag the item
|
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
|
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
|
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
|
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
|
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.setFixedWidth(300)
|
||||||
self._youtube_url = ""
|
self._youtube_url = ""
|
||||||
self._meta = None
|
self._meta = None
|
||||||
|
self._copy_available = False
|
||||||
|
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(12, 12, 0, 12)
|
outer.setContentsMargins(12, 12, 0, 12)
|
||||||
@@ -627,6 +629,12 @@ class GameDetailsPanel(QWidget):
|
|||||||
self.play_btn.setToolTip("Launch this game in PPSSPP")
|
self.play_btn.setToolTip("Launch this game in PPSSPP")
|
||||||
self.layout.addWidget(self.play_btn)
|
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 = QGridLayout()
|
||||||
btn_grid.setHorizontalSpacing(6)
|
btn_grid.setHorizontalSpacing(6)
|
||||||
btn_grid.setVerticalSpacing(6)
|
btn_grid.setVerticalSpacing(6)
|
||||||
@@ -658,14 +666,28 @@ class GameDetailsPanel(QWidget):
|
|||||||
|
|
||||||
def set_item(self, item):
|
def set_item(self, item):
|
||||||
self.system = item.get("system")
|
self.system = item.get("system")
|
||||||
|
self._ps1_kind = item.get("ps1_kind")
|
||||||
path = item.get("path") or ""
|
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.del_psp_btn.setEnabled(bool(item.get("on_psp")))
|
||||||
self.title.setText(item.get("title_override") or item.get("name") or "Unknown")
|
self.title.setText(item.get("title_override") or item.get("name") or "Unknown")
|
||||||
parts = []
|
parts = []
|
||||||
if item.get("serial"):
|
if item.get("serial"):
|
||||||
parts.append(item["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"])
|
parts.append(item["region"])
|
||||||
self.subtitle.setText(" · ".join(parts))
|
self.subtitle.setText(" · ".join(parts))
|
||||||
self._clear_meta()
|
self._clear_meta()
|
||||||
@@ -676,7 +698,8 @@ class GameDetailsPanel(QWidget):
|
|||||||
self._set_cover(item.get("release_id"), item.get("system"))
|
self._set_cover(item.get("release_id"), item.get("system"))
|
||||||
|
|
||||||
def set_copy_available(self, available):
|
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):
|
def set_psp_installed(self, on_psp):
|
||||||
self.del_psp_btn.setEnabled(bool(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 data:
|
||||||
if system == "videos":
|
if system == "videos":
|
||||||
return compose_umd_cover(data)
|
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 covers.compose_game_cover(data)
|
||||||
return data
|
return data
|
||||||
if title:
|
if title:
|
||||||
@@ -3024,7 +3047,7 @@ class PspSyncPage(QWidget):
|
|||||||
self._workers = []
|
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."""
|
"""Build the library item list from the DB with a single joined query."""
|
||||||
items = []
|
items = []
|
||||||
seen = set()
|
seen = set()
|
||||||
@@ -3093,16 +3116,59 @@ def build_library_items():
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
items.extend(_ps1_library_items(ps1_dir, output_dir, overrides, dates))
|
||||||
return items
|
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):
|
class LibraryWorker(QObject):
|
||||||
result = Signal(list)
|
result = Signal(list)
|
||||||
finished = Signal()
|
finished = Signal()
|
||||||
|
|
||||||
|
def __init__(self, ps1_dir="", output_dir=""):
|
||||||
|
super().__init__()
|
||||||
|
self.ps1_dir = ps1_dir
|
||||||
|
self.output_dir = output_dir
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
items = build_library_items()
|
items = build_library_items(self.ps1_dir, self.output_dir)
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
items = []
|
items = []
|
||||||
self.result.emit(items)
|
self.result.emit(items)
|
||||||
@@ -3470,7 +3536,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.library_search.textChanged.connect(self._apply_library_filter)
|
self.library_search.textChanged.connect(self._apply_library_filter)
|
||||||
bar.addWidget(self.library_search, 1)
|
bar.addWidget(self.library_search, 1)
|
||||||
self.library_type = QComboBox()
|
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)
|
self.library_type.currentTextChanged.connect(self._apply_library_filter)
|
||||||
bar.addWidget(self.library_type)
|
bar.addWidget(self.library_type)
|
||||||
self.library_psp = QComboBox()
|
self.library_psp = QComboBox()
|
||||||
@@ -3520,6 +3586,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.cover_grid.scrape_requested.connect(self._scrape_grid_item)
|
self.cover_grid.scrape_requested.connect(self._scrape_grid_item)
|
||||||
self.details_panel.edit_requested.connect(self._edit_details)
|
self.details_panel.edit_requested.connect(self._edit_details)
|
||||||
self.details_panel.link_requested.connect(self._scrape_item)
|
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.copy_requested.connect(self._copy_current_to_psp)
|
||||||
self.details_panel.delete_requested.connect(self._delete_current)
|
self.details_panel.delete_requested.connect(self._delete_current)
|
||||||
self.details_panel.delete_psp_requested.connect(self._delete_current_from_psp)
|
self.details_panel.delete_psp_requested.connect(self._delete_current_from_psp)
|
||||||
@@ -3688,7 +3755,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.statusBar().showMessage("Loading library…")
|
self.statusBar().showMessage("Loading library…")
|
||||||
log.info("loading library…")
|
log.info("loading library…")
|
||||||
thread = QThread(self)
|
thread = QThread(self)
|
||||||
worker = LibraryWorker()
|
worker = LibraryWorker(self._ps1_dir, self._ps1_output_dir)
|
||||||
worker.moveToThread(thread)
|
worker.moveToThread(thread)
|
||||||
thread.started.connect(worker.run)
|
thread.started.connect(worker.run)
|
||||||
worker.result.connect(self._on_library_loaded)
|
worker.result.connect(self._on_library_loaded)
|
||||||
@@ -3726,6 +3793,8 @@ class MainWindow(QMainWindow):
|
|||||||
continue
|
continue
|
||||||
if typ == "Videos" and it["system"] != "videos":
|
if typ == "Videos" and it["system"] != "videos":
|
||||||
continue
|
continue
|
||||||
|
if typ == "PS1" and it["system"] != "ps1":
|
||||||
|
continue
|
||||||
if q and q not in (it["canonical"] or it["name"]).lower():
|
if q and q not in (it["canonical"] or it["name"]).lower():
|
||||||
continue
|
continue
|
||||||
if psp_f == "On PSP" and not it.get("on_psp"):
|
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.setIcon(self._psp_dot_icon())
|
||||||
title.setToolTip("Installed on PSP")
|
title.setToolTip("Installed on PSP")
|
||||||
self.library_table.setItem(row, 0, title)
|
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, 2, QTableWidgetItem(it.get("region") or ""))
|
||||||
self.library_table.setItem(row, 3, QTableWidgetItem(it.get("serial") or ""))
|
self.library_table.setItem(row, 3, QTableWidgetItem(it.get("serial") or ""))
|
||||||
size = it.get("size")
|
size = it.get("size")
|
||||||
@@ -3886,6 +3956,18 @@ class MainWindow(QMainWindow):
|
|||||||
self.details_panel.set_item(it)
|
self.details_panel.set_item(it)
|
||||||
self._scrape_item()
|
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):
|
def _on_store_download(self, items):
|
||||||
if not items:
|
if not items:
|
||||||
return
|
return
|
||||||
@@ -3967,6 +4049,8 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
if item.get("system") == "videos":
|
if item.get("system") == "videos":
|
||||||
return # UMD Video discs can't be played in PPSSPP
|
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")
|
path = item.get("path")
|
||||||
if not path or not Path(path).exists():
|
if not path or not Path(path).exists():
|
||||||
QMessageBox.warning(self, "Missing file", f"File not found:\n{path}")
|
QMessageBox.warning(self, "Missing file", f"File not found:\n{path}")
|
||||||
@@ -4039,6 +4123,15 @@ class MainWindow(QMainWindow):
|
|||||||
psp_root = Path(self._psp_root)
|
psp_root = Path(self._psp_root)
|
||||||
if system == "videos":
|
if system == "videos":
|
||||||
dest_dir = psp_root / "ISO" / "VIDEO"
|
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:
|
else:
|
||||||
cat = "".join(c for c in (self._iso_category or "").strip() if c not in "/\\")
|
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")
|
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)
|
QTimer.singleShot(0, self._refresh_saves)
|
||||||
if kind == "ps1-convert":
|
if kind == "ps1-convert":
|
||||||
QTimer.singleShot(0, self.ps1_page.refresh)
|
QTimer.singleShot(0, self.ps1_page.refresh)
|
||||||
|
QTimer.singleShot(0, self.load_library)
|
||||||
|
|
||||||
# -- Common ------------------------------------------------------------ #
|
# -- Common ------------------------------------------------------------ #
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
+1
-1
@@ -197,7 +197,7 @@ def get_or_fetch(release_id, system, fetch_title):
|
|||||||
if system == "videos":
|
if system == "videos":
|
||||||
meta = covers.get_movie_metadata(fetch_title)
|
meta = covers.get_movie_metadata(fetch_title)
|
||||||
else:
|
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
|
except Exception: # noqa: BLE001 - network/API failures must not crash the UI
|
||||||
meta = None
|
meta = None
|
||||||
if meta:
|
if meta:
|
||||||
|
|||||||
Reference in New Issue
Block a user