Compare commits
14
Commits
02ed99c9c7
...
4b6e407b3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b6e407b3f | ||
|
|
196e3ac82f | ||
|
|
2c6b07e286 | ||
|
|
0ebd173af7 | ||
|
|
c0c0f841d6 | ||
|
|
78b57af79e | ||
|
|
43263aa644 | ||
|
|
f6dccbec24 | ||
|
|
2433240628 | ||
|
|
c1fcf6176d | ||
|
|
fb2a98dd21 | ||
|
|
f9d9d639ed | ||
|
|
abd201396c | ||
|
|
e179bf2f2e |
@@ -10,3 +10,7 @@ session-transcript-2026-09-01.md
|
||||
# Copyrighted PSN EBOOTs (base.pbp) used only for local PS1 conversion testing
|
||||
data/*.PBP
|
||||
data/*.pbp
|
||||
|
||||
# User-supplied PS1 Classics art templates (extracted from copyrighted PSN
|
||||
# EBOOTs) — kept local only; the app falls back to drawn art when absent.
|
||||
data/ps1_templates/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# PSP Preservation Tool
|
||||
|
||||
the name is terrible, i know.
|
||||
|
||||

|
||||
|
||||
Native Linux GUI (PySide6) for preserving PSP UMD **games** and **videos** as
|
||||
@@ -97,6 +98,9 @@ Discs are matched by hash first (most precise), then serial, then filename.
|
||||
+ a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted).
|
||||
- `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS
|
||||
loader and NPDRM version key (from iPoPS, MIT).
|
||||
- `data/ps1_templates/` — optional PS1 Classics art templates
|
||||
(`ICON0.PNG` case frame, `PIC1.PNG` launch background); the app falls back
|
||||
to drawn art when they're absent.
|
||||
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`):
|
||||
- `library.sqlite` — the catalog + library database.
|
||||
- `covers/` — downloaded cover cache.
|
||||
@@ -202,6 +206,17 @@ PS1 discs (pending) and converted EBOOTs also show up in the **Library** tab
|
||||
compressed PSAR) plus a `KEYS.BIN` (NPDRM version key used to decrypt the
|
||||
`STARTDAT` PGD), ready to copy to `<PSP>/PSP/GAME/<Game ID>/`. Compatible
|
||||
with CFW POPS (ARK-4/ARK-5).
|
||||
- **Artwork** — the converter fetches the game's real cover art (libretro /
|
||||
ScreenScraper / IGDB / Wikipedia, reusing the local cover cache) and builds
|
||||
official-style PS1 Classics art:
|
||||
- `ICON0` (80×80) — the cover composited into the bundled case frame
|
||||
(`data/ps1_templates/ICON0.PNG`) with the PlayStation logo + wordmark;
|
||||
- `PIC0` (310×180) — a PSN-style info board with the game title, a
|
||||
description from IGDB, an ESRB rating badge and PS3/PSP platform badges;
|
||||
- `PIC1` (480×272) — the bundled launch background
|
||||
(`data/ps1_templates/PIC1.PNG`).
|
||||
If the template images are missing the app falls back to drawn art, and to
|
||||
the base icon when no cover is found.
|
||||
|
||||
Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` /
|
||||
`PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc
|
||||
|
||||
+343
-2
@@ -808,6 +808,331 @@ def compose_game_cover(key_art_bytes):
|
||||
return bytes(ba)
|
||||
|
||||
|
||||
_PS1_TEMPLATE_DIR = config.DATA_DIR / "ps1_templates"
|
||||
|
||||
|
||||
def _ps1_template_bytes(name):
|
||||
"""Read a bundled PS1 art template (``ICON0.PNG`` / ``PIC1.PNG``)."""
|
||||
try:
|
||||
p = _PS1_TEMPLATE_DIR / name
|
||||
if p.is_file():
|
||||
return p.read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _slot_rect(img):
|
||||
"""Find the fully-transparent cover slot in an icon template image.
|
||||
|
||||
Returns ``(x, y, w, h)`` or ``None`` when the image has no transparent
|
||||
region (e.g. a flattened template)."""
|
||||
if img.isNull():
|
||||
return None
|
||||
w, h = img.width(), img.height()
|
||||
min_x = min_y = 1 << 30
|
||||
max_x = max_y = -1
|
||||
for y in range(h):
|
||||
for x in range(w):
|
||||
if img.pixelColor(x, y).alpha() < 10:
|
||||
min_x, max_x = min(min_x, x), max(max_x, x)
|
||||
min_y, max_y = min(min_y, y), max(max_y, y)
|
||||
if max_x < min_x or max_y < min_y:
|
||||
return None
|
||||
return min_x, min_y, max_x - min_x + 1, max_y - min_y + 1
|
||||
|
||||
|
||||
def make_ps1_assets(cover_bytes, title="", description="", esrb=None):
|
||||
"""Build official-style PS1 Classics art from the cover: (icon0, pic0, pic1).
|
||||
|
||||
- ``ICON0`` (80x80): the cover composited into the bundled case template
|
||||
(PS button logo, ``PlayStation.`` wordmark, gradient frame).
|
||||
- ``PIC0`` (310x180): the PSN-style info board — title, description,
|
||||
ESRB rating badge and PS3/PSP platform badges.
|
||||
- ``PIC1`` (480x272): the bundled launch background (gradient + curve).
|
||||
|
||||
Returns ``(None, None, None)`` when the art can't be decoded or Qt is
|
||||
unavailable.
|
||||
"""
|
||||
try:
|
||||
from PySide6.QtCore import QBuffer, QByteArray, QPointF, QRectF, Qt
|
||||
from PySide6.QtGui import (QColor, QFont, QFontMetrics, QImage,
|
||||
QLinearGradient, QPainter, QPainterPath,
|
||||
QPen, QPolygonF)
|
||||
except Exception: # noqa: BLE001 - headless API may lack PySide6
|
||||
return None, None, None
|
||||
|
||||
art = QImage.fromData(cover_bytes)
|
||||
if art.isNull():
|
||||
return None, None, None
|
||||
art = art.convertToFormat(QImage.Format_ARGB32)
|
||||
|
||||
def _save(img):
|
||||
ba = QByteArray()
|
||||
buf = QBuffer(ba)
|
||||
buf.open(QBuffer.WriteOnly)
|
||||
img.save(buf, "PNG")
|
||||
buf.close()
|
||||
return bytes(ba)
|
||||
|
||||
# --- ICON0: cover art inside the case template ---------------------------
|
||||
icon = QImage(80, 80, QImage.Format_ARGB32)
|
||||
icon.fill(QColor(0, 0, 0))
|
||||
p = QPainter(icon)
|
||||
p.setRenderHint(QPainter.SmoothPixmapTransform, True)
|
||||
tmpl = QImage.fromData(_ps1_template_bytes("ICON0.PNG") or b"")
|
||||
if not tmpl.isNull():
|
||||
tmpl = tmpl.convertToFormat(QImage.Format_ARGB32)
|
||||
slot = _slot_rect(tmpl) or (0, 0, 80, 80)
|
||||
sx, sy, sw, sh = slot
|
||||
# Fit the whole cover inside the slot (no cropping), centred with the
|
||||
# frame's dark background showing through the letterbox bars.
|
||||
cover = art.scaled(sw, sh, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
p.drawImage(sx + (sw - cover.width()) // 2, sy + (sh - cover.height()) // 2, cover)
|
||||
p.drawImage(0, 0, tmpl)
|
||||
else:
|
||||
cover = art.scaled(80, 80, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
||||
p.drawImage((80 - cover.width()) // 2, (80 - cover.height()) // 2, cover)
|
||||
p.end()
|
||||
icon0 = _save(icon)
|
||||
|
||||
# --- PIC0: PSN-style info board -----------------------------------------
|
||||
pic0 = _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath,
|
||||
QColor, QFont, QFontMetrics, QLinearGradient, QPen,
|
||||
QPolygonF, QPointF, QRectF, Qt, _save)
|
||||
|
||||
# --- PIC1: bundled launch background ------------------------------------
|
||||
pic1 = _make_pic1(QImage, QPainter, QColor, QLinearGradient, Qt, _save)
|
||||
|
||||
return icon0, pic0, pic1
|
||||
|
||||
|
||||
def _make_pic1(QImage, QPainter, QColor, QLinearGradient, Qt, _save):
|
||||
pic = QImage(480, 272, QImage.Format_ARGB32)
|
||||
raw = _ps1_template_bytes("PIC1.PNG")
|
||||
tmpl = QImage.fromData(raw) if raw else QImage()
|
||||
if not tmpl.isNull():
|
||||
return _save(tmpl.convertToFormat(QImage.Format_ARGB32))
|
||||
# Fallback: a light lavender gradient with a subtle curve (same vibe as
|
||||
# the official background) for when the template image is missing.
|
||||
grad = QLinearGradient(0, 0, 0, 272)
|
||||
grad.setColorAt(0.0, QColor(235, 232, 238))
|
||||
grad.setColorAt(1.0, QColor(205, 200, 215))
|
||||
p = QPainter(pic)
|
||||
p.fillRect(0, 0, 480, 272, grad)
|
||||
p.setPen(QPen(QColor(170, 160, 185, 120), 3))
|
||||
path = QPainterPath()
|
||||
path.moveTo(-40, 300)
|
||||
path.quadTo(240, 60, 520, 300)
|
||||
p.drawPath(path)
|
||||
p.end()
|
||||
return _save(pic)
|
||||
|
||||
|
||||
def _make_pic0(title, description, esrb, QImage, QPainter, QPainterPath, QColor,
|
||||
QFont, QFontMetrics, QLinearGradient, QPen, QPolygonF, QPointF,
|
||||
QRectF, Qt, _save):
|
||||
W, H = 310, 180
|
||||
img = QImage(W, H, QImage.Format_ARGB32)
|
||||
|
||||
# Dark gradient backdrop.
|
||||
grad = QLinearGradient(0, 0, 0, H)
|
||||
grad.setColorAt(0.0, QColor(38, 38, 40))
|
||||
grad.setColorAt(1.0, QColor(12, 12, 14))
|
||||
|
||||
p = QPainter(img)
|
||||
p.setRenderHint(QPainter.Antialiasing, True)
|
||||
p.setRenderHint(QPainter.TextAntialiasing, True)
|
||||
p.fillRect(0, 0, W, H, grad)
|
||||
|
||||
# Rounded border.
|
||||
p.setPen(QPen(QColor(110, 110, 115), 1))
|
||||
p.setBrush(Qt.NoBrush)
|
||||
p.drawRoundedRect(QRectF(4, 4, W - 8, H - 8), 9, 9)
|
||||
|
||||
# PlayStation button glyphs, top-right.
|
||||
_draw_ps_glyphs(p, W - 34, 12, 22, QColor(200, 200, 205), QRectF, QPointF,
|
||||
QPolygonF, QPen, Qt)
|
||||
|
||||
# Title (bold, upper-left). Shrink the font until the whole title fits, so
|
||||
# long names are shown in full instead of being cut with an ellipsis.
|
||||
f_title = QFont("DejaVu Sans")
|
||||
f_title.setBold(True)
|
||||
p.setPen(QColor(255, 255, 255))
|
||||
title_width = W - 70
|
||||
fm_title = QFontMetrics(f_title)
|
||||
full = (title or "").strip()
|
||||
size = 19
|
||||
while size > 10 and fm_title.horizontalAdvance(full) > title_width:
|
||||
size -= 1
|
||||
f_title.setPixelSize(size)
|
||||
fm_title = QFontMetrics(f_title)
|
||||
title_text = full
|
||||
if fm_title.horizontalAdvance(full) > title_width:
|
||||
title_text = fm_title.elidedText(full, Qt.ElideRight, title_width)
|
||||
p.setFont(f_title)
|
||||
p.drawText(QRectF(18, 16, title_width, 28), Qt.AlignLeft | Qt.AlignVCenter,
|
||||
title_text)
|
||||
|
||||
# Description (wrapped to at most 3 lines, last line elided).
|
||||
if description:
|
||||
f_desc = QFont("DejaVu Sans")
|
||||
f_desc.setPixelSize(12)
|
||||
p.setFont(f_desc)
|
||||
p.setPen(QColor(215, 215, 215))
|
||||
fm = QFontMetrics(f_desc)
|
||||
lines = _wrap_elided(fm, description.strip(), W - 36, 3, Qt)
|
||||
y = 46
|
||||
for line in lines:
|
||||
p.drawText(QRectF(18, y, W - 36, 16), Qt.AlignLeft | Qt.AlignVCenter, line)
|
||||
y += 16
|
||||
|
||||
# ESRB badge (bottom-left) + caption.
|
||||
if esrb:
|
||||
badge = esrb.strip()
|
||||
if badge:
|
||||
_draw_esrb(p, badge, 18, 140, QRectF, QColor, QFont, QFontMetrics, QPen, Qt)
|
||||
|
||||
# Platform badge (bottom-right). Only PSP for now; PS3/PS4 packaging is a
|
||||
# separate future feature.
|
||||
_draw_platform_badge(p, "PSP", W - 14 - 30, 146, QColor(212, 175, 55),
|
||||
QRectF, QColor, QFont, QFontMetrics, Qt)
|
||||
|
||||
p.end()
|
||||
return _save(img)
|
||||
|
||||
|
||||
def _wrap_elided(fm, text, width, max_lines, Qt):
|
||||
"""Word-wrap ``text`` to ``width`` px, eliding the final line when it runs
|
||||
past ``max_lines``. Returns a list of up to ``max_lines`` strings."""
|
||||
words = text.split()
|
||||
lines = []
|
||||
cur = ""
|
||||
for i, w in enumerate(words):
|
||||
cand = (cur + " " + w).strip() if cur else w
|
||||
if fm.horizontalAdvance(cand) <= width:
|
||||
cur = cand
|
||||
continue
|
||||
if len(lines) == max_lines - 1:
|
||||
rest = " ".join([cur] + words[i:]) if cur else " ".join(words[i:])
|
||||
lines.append(fm.elidedText(rest, Qt.ElideRight, width))
|
||||
return lines
|
||||
lines.append(cur)
|
||||
cur = w
|
||||
if cur:
|
||||
lines.append(cur)
|
||||
return lines[:max_lines]
|
||||
|
||||
|
||||
def _draw_ps_glyphs(p, x, y, size, color, QRectF, QPointF, QPolygonF, QPen, Qt):
|
||||
"""Draw the four PlayStation face-button glyphs in a 2x2 grid."""
|
||||
cell = size / 2.0
|
||||
pen = QPen(color, max(1.0, size / 14.0))
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
pen.setJoinStyle(Qt.RoundJoin)
|
||||
p.setPen(pen)
|
||||
p.setBrush(Qt.NoBrush)
|
||||
# Triangle (top-left).
|
||||
tri = QPolygonF([QPointF(x, y + cell), QPointF(x + cell, y), QPointF(x + cell, y + cell)])
|
||||
p.drawPolygon(tri)
|
||||
# Circle (top-right).
|
||||
p.drawEllipse(QRectF(x + cell, y, cell, cell))
|
||||
# Cross (bottom-left).
|
||||
p.drawLine(QPointF(x + 1, y + cell + 1), QPointF(x + cell - 1, y + size - 1))
|
||||
p.drawLine(QPointF(x + cell - 1, y + cell + 1), QPointF(x + 1, y + size - 1))
|
||||
# Square (bottom-right).
|
||||
p.drawRect(QRectF(x + cell, y + cell, cell, cell))
|
||||
|
||||
|
||||
def _draw_esrb(p, badge, x, y, QRectF, QColor, QFont, QFontMetrics, QPen, Qt):
|
||||
f = QFont("DejaVu Sans")
|
||||
f.setPixelSize(9)
|
||||
f.setBold(True)
|
||||
fm = QFontMetrics(f)
|
||||
text = badge if badge != "EVERYONE" else "EVERYONE"
|
||||
# Shrink until it fits the box (the official badge shows the word).
|
||||
size = 9
|
||||
while size > 5 and fm.horizontalAdvance(text) > 46:
|
||||
size -= 1
|
||||
f.setPixelSize(size)
|
||||
fm = QFontMetrics(f)
|
||||
rect = QRectF(x, y, 50, 24)
|
||||
p.setPen(QPen(QColor(20, 20, 20), 1))
|
||||
p.setBrush(QColor(255, 255, 255))
|
||||
p.drawRoundedRect(rect, 3, 3)
|
||||
p.setPen(QColor(20, 20, 20))
|
||||
p.setFont(f)
|
||||
p.drawText(rect, Qt.AlignCenter, text)
|
||||
# Caption.
|
||||
fc = QFont("DejaVu Sans")
|
||||
fc.setPixelSize(7)
|
||||
p.setFont(fc)
|
||||
p.setPen(QColor(150, 150, 155))
|
||||
p.drawText(QRectF(x + 56, y + 6, 120, 14),
|
||||
Qt.AlignLeft | Qt.AlignVCenter, "ESRB CONTENT RATING")
|
||||
|
||||
|
||||
def _draw_platform_badge(p, label, x, y, color, QRectF, QColor, QFont,
|
||||
QFontMetrics, Qt):
|
||||
f = QFont("DejaVu Sans")
|
||||
f.setPixelSize(10)
|
||||
f.setBold(True)
|
||||
rect = QRectF(x, y, 30, 16)
|
||||
p.setPen(Qt.NoPen)
|
||||
p.setBrush(QColor(26, 26, 28))
|
||||
p.drawRoundedRect(rect, 4, 4)
|
||||
p.setPen(color)
|
||||
p.setFont(f)
|
||||
p.drawText(rect, Qt.AlignCenter, label)
|
||||
|
||||
|
||||
def ps1_art(title, release_id, timeout=10):
|
||||
"""Fetch a PS1 game's cover and build ``(icon0, pic0, pic1)`` PNG bytes.
|
||||
|
||||
The EBOOT icon prefers IGDB first (clean box art without the PlayStation
|
||||
banner that libretro/ScreenScraper thumbnails bake in); if IGDB has
|
||||
nothing it falls back to the normal cover pipeline (libretro, OpenVGDB,
|
||||
ScreenScraper, Wikipedia, IGDB). The description and ESRB rating for the
|
||||
PIC0 info board come from IGDB (best effort).
|
||||
Returns ``(None, None, None)`` when no art can be produced.
|
||||
"""
|
||||
key = release_id or stable_key(title)
|
||||
path = None
|
||||
try:
|
||||
# IGDB first for the icon: clean box art without the banner.
|
||||
data = _fetch_igdb_cover(title, min(timeout, 6), system="ps1")
|
||||
if data is not None:
|
||||
dest = cover_path(key, "ps1")
|
||||
_write_atomic(dest, data)
|
||||
path = dest
|
||||
log.info("ps1 icon cover %r <- igdb", title)
|
||||
except Exception: # noqa: BLE001
|
||||
path = None
|
||||
if path is None:
|
||||
try:
|
||||
path = fetch_cover(title, key, "ps1", timeout=timeout)
|
||||
except Exception: # noqa: BLE001
|
||||
return None, None, None
|
||||
if path is None:
|
||||
return None, None, None
|
||||
|
||||
description = ""
|
||||
esrb = None
|
||||
try:
|
||||
meta = get_game_metadata_igdb(title, timeout=timeout, system="ps1")
|
||||
if meta:
|
||||
description = meta.get("overview") or ""
|
||||
esrb = meta.get("esrb")
|
||||
except Exception: # noqa: BLE001 - metadata is best effort
|
||||
pass
|
||||
|
||||
try:
|
||||
return make_ps1_assets(path.read_bytes(), title=title,
|
||||
description=description, esrb=esrb)
|
||||
except OSError:
|
||||
return None, None, None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Public entry point
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1255,6 +1580,21 @@ def _igdb_date(ts):
|
||||
return ""
|
||||
|
||||
|
||||
# IGDB ``age_ratings.rating`` values for ESRB (category 1). 6=RP, 7=EC, 8=E,
|
||||
# 9=E10+, 10=T, 11=M, 12=AO. The badge uses the short letter form.
|
||||
_ESRB_BADGE = {6: "RP", 7: "EC", 8: "EVERYONE", 9: "EVERYONE 10+",
|
||||
10: "TEEN", 11: "MATURE", 12: "ADULTS ONLY"}
|
||||
|
||||
|
||||
def _igdb_esrb(g):
|
||||
for ar in g.get("age_ratings") or []:
|
||||
if not isinstance(ar, dict):
|
||||
continue
|
||||
if ar.get("category") == 1: # ESRB
|
||||
return _ESRB_BADGE.get(ar.get("rating"))
|
||||
return None
|
||||
|
||||
|
||||
def _igdb_game_meta(g):
|
||||
genres = [x.get("name") for x in (g.get("genres") or []) if isinstance(x, dict) and x.get("name")]
|
||||
devs, pubs = [], []
|
||||
@@ -1287,6 +1627,7 @@ def _igdb_game_meta(g):
|
||||
"genres": list(dict.fromkeys(genres)),
|
||||
"players": None,
|
||||
"rating": rating,
|
||||
"esrb": _igdb_esrb(g),
|
||||
"youtube": yt,
|
||||
}
|
||||
|
||||
@@ -1301,7 +1642,7 @@ def _igdb_search(query, limit=20, timeout=10, system="games"):
|
||||
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; "
|
||||
"rating, videos.video_id, age_ratings.category, age_ratings.rating; "
|
||||
f"where platforms = ({platform_id}); "
|
||||
f"limit {max(limit, 1)};"
|
||||
)
|
||||
@@ -1360,7 +1701,7 @@ def game_metadata_by_id_igdb(game_id, timeout=10):
|
||||
q = (
|
||||
"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; "
|
||||
"rating, videos.video_id, age_ratings.category, age_ratings.rating; "
|
||||
f"where id = {int(game_id)}; limit 1;"
|
||||
)
|
||||
games = _igdb_post(q, timeout) or []
|
||||
|
||||
+237
-22
@@ -494,9 +494,17 @@ class CoverShowcase(QWidget):
|
||||
pm = QPixmap(str(path))
|
||||
if pm.isNull():
|
||||
return
|
||||
# Match by identity when possible, falling back to release_id/system
|
||||
# equality (the library may have been rebuilt while the scrape dialog
|
||||
# was open, replacing the item objects).
|
||||
for i in range(self.list.count()):
|
||||
item = self.list.item(i)
|
||||
if item.data(Qt.UserRole) is it:
|
||||
data = item.data(Qt.UserRole)
|
||||
if data is it or (
|
||||
data is not None
|
||||
and data.get("release_id") == it.get("release_id")
|
||||
and (data.get("system") or "games") == system
|
||||
):
|
||||
item.setIcon(self._fit_icon(pm))
|
||||
if i in self.missing:
|
||||
self.missing.remove(i)
|
||||
@@ -603,6 +611,12 @@ class GameDetailsPanel(QWidget):
|
||||
self.subtitle.setStyleSheet("color: #999;")
|
||||
self.layout.addWidget(self.subtitle)
|
||||
|
||||
self.filename = QLabel("")
|
||||
self.filename.setStyleSheet("color: #777;")
|
||||
self.filename.setWordWrap(True)
|
||||
self.filename.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.layout.addWidget(self.filename)
|
||||
|
||||
self.layout.addSpacing(8)
|
||||
self.meta_grid = QGridLayout()
|
||||
self.meta_grid.setHorizontalSpacing(14)
|
||||
@@ -668,6 +682,9 @@ class GameDetailsPanel(QWidget):
|
||||
self.system = item.get("system")
|
||||
self._ps1_kind = item.get("ps1_kind")
|
||||
path = item.get("path") or ""
|
||||
self.filename.setText(Path(path).name if path else "")
|
||||
self.filename.setToolTip(path)
|
||||
self.filename.setVisible(bool(path))
|
||||
is_ps1 = item.get("system") == "ps1"
|
||||
is_ps1_disc = is_ps1 and item.get("ps1_kind") == "disc"
|
||||
self.play_btn.setEnabled(
|
||||
@@ -1404,6 +1421,12 @@ class StoreDetailsPanel(QWidget):
|
||||
self.subtitle.setStyleSheet("color: #999;")
|
||||
self.layout.addWidget(self.subtitle)
|
||||
|
||||
self.filename = QLabel("")
|
||||
self.filename.setStyleSheet("color: #777;")
|
||||
self.filename.setWordWrap(True)
|
||||
self.filename.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.layout.addWidget(self.filename)
|
||||
|
||||
self.layout.addSpacing(8)
|
||||
self.meta_grid = QGridLayout()
|
||||
self.meta_grid.setHorizontalSpacing(14)
|
||||
@@ -1437,6 +1460,11 @@ class StoreDetailsPanel(QWidget):
|
||||
|
||||
def set_item(self, item):
|
||||
self._item = item
|
||||
fpath = item.get("path") or ""
|
||||
fname = Path(fpath).name if fpath else (item.get("name") or "")
|
||||
self.filename.setText(fname)
|
||||
self.filename.setToolTip(fpath or fname)
|
||||
self.filename.setVisible(bool(fname))
|
||||
self.title.setText(item.get("title_override") or store.display_name(item["name"]))
|
||||
parts = []
|
||||
size = item.get("size")
|
||||
@@ -2311,6 +2339,14 @@ class StorePage(QWidget):
|
||||
class QueuePage(QWidget):
|
||||
"""Live view of transfer jobs with progress, cancel, and delete."""
|
||||
|
||||
_KIND_LABELS = {
|
||||
"download": "Download",
|
||||
"psp-install": "Copy to PSP",
|
||||
"ps1-convert": "PS1 convert",
|
||||
"umd-dump": "UMD dump",
|
||||
"save-sync": "Save sync",
|
||||
}
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.queue = None
|
||||
@@ -2323,11 +2359,11 @@ class QueuePage(QWidget):
|
||||
self.clear_finished_btn.clicked.connect(self._clear_finished)
|
||||
header.addWidget(self.clear_finished_btn)
|
||||
layout.addLayout(header)
|
||||
self.table = QTableWidget(0, 6)
|
||||
self.table.setHorizontalHeaderLabels(["Item", "Status", "Progress", "Destination", "", ""])
|
||||
self.table = QTableWidget(0, 7)
|
||||
self.table.setHorizontalHeaderLabels(["Item", "Type", "Status", "Progress", "Destination", "", ""])
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
|
||||
self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||
self.table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeToContents)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
layout.addWidget(self.table)
|
||||
|
||||
@@ -2338,44 +2374,49 @@ class QueuePage(QWidget):
|
||||
q.job_finished.connect(self._on_updated)
|
||||
q.job_removed.connect(self._on_removed)
|
||||
|
||||
@classmethod
|
||||
def _kind_label(cls, kind):
|
||||
return cls._KIND_LABELS.get(kind, str(kind or ""))
|
||||
|
||||
def _on_added(self, job):
|
||||
row = self.table.rowCount()
|
||||
self.table.insertRow(row)
|
||||
self.table.setItem(row, 0, QTableWidgetItem(job["title"]))
|
||||
self.table.setItem(row, 1, QTableWidgetItem(self._status_text(job)))
|
||||
self.table.setItem(row, 1, QTableWidgetItem(self._kind_label(job.get("kind"))))
|
||||
self.table.setItem(row, 2, QTableWidgetItem(self._status_text(job)))
|
||||
bar = QProgressBar()
|
||||
bar.setRange(0, 1000)
|
||||
bar.setValue(int(job["progress"] * 1000))
|
||||
self.table.setCellWidget(row, 2, bar)
|
||||
self.table.setItem(row, 3, QTableWidgetItem(job["dest"]))
|
||||
self.table.setCellWidget(row, 3, bar)
|
||||
self.table.setItem(row, 4, QTableWidgetItem(job["dest"]))
|
||||
cancel = QPushButton("Cancel")
|
||||
cancel.clicked.connect(lambda _=False, jid=job["id"]: self.queue.cancel(jid))
|
||||
self.table.setCellWidget(row, 4, cancel)
|
||||
self.table.setCellWidget(row, 5, cancel)
|
||||
delete = QPushButton("✕")
|
||||
delete.setToolTip("Remove this entry from the queue history")
|
||||
delete.setFixedWidth(28)
|
||||
delete.clicked.connect(lambda _=False, jid=job["id"]: self.queue.remove(jid))
|
||||
self.table.setCellWidget(row, 5, delete)
|
||||
self.table.setCellWidget(row, 6, delete)
|
||||
self._rows[job["id"]] = row
|
||||
|
||||
def _on_updated(self, job):
|
||||
row = self._rows.get(job["id"])
|
||||
if row is None:
|
||||
return
|
||||
status_item = self.table.item(row, 1)
|
||||
status_item = self.table.item(row, 2)
|
||||
if status_item is not None:
|
||||
status_item.setText(self._status_text(job))
|
||||
bar = self.table.cellWidget(row, 2)
|
||||
bar = self.table.cellWidget(row, 3)
|
||||
if isinstance(bar, QProgressBar):
|
||||
if job["progress"] < 0:
|
||||
bar.setRange(0, 0)
|
||||
else:
|
||||
bar.setRange(0, 1000)
|
||||
bar.setValue(int(job["progress"] * 1000))
|
||||
cancel = self.table.cellWidget(row, 4)
|
||||
cancel = self.table.cellWidget(row, 5)
|
||||
if isinstance(cancel, QPushButton):
|
||||
cancel.setEnabled(job["status"] in ("queued", "running"))
|
||||
delete = self.table.cellWidget(row, 5)
|
||||
delete = self.table.cellWidget(row, 6)
|
||||
if isinstance(delete, QPushButton):
|
||||
delete.setEnabled(job["status"] not in ("running", "cancelling"))
|
||||
|
||||
@@ -2783,7 +2824,8 @@ class PspSyncPage(QWidget):
|
||||
title = QTableWidgetItem(label)
|
||||
title.setData(Qt.UserRole, it)
|
||||
self.sync_table.setItem(row, 1, title)
|
||||
self.sync_table.setItem(row, 2, QTableWidgetItem("Game" if it.get("system") == "games" else "Video"))
|
||||
kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1"}.get(it.get("system"), "Game")
|
||||
self.sync_table.setItem(row, 2, QTableWidgetItem(kind_label))
|
||||
size = it.get("size")
|
||||
self.sync_table.setItem(row, 3, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size or 0))
|
||||
self.sync_table.setSortingEnabled(True)
|
||||
@@ -3344,8 +3386,29 @@ class Ps1ScanWorker(QObject):
|
||||
self.failed.emit(str(exc))
|
||||
|
||||
|
||||
class Ps1PreviewWorker(QObject):
|
||||
"""Fetch a disc's cover art and build the EBOOT art preview."""
|
||||
|
||||
result = Signal(int, str, object, object, object) # gen, game_id, icon0, pic0, pic1
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, gen, title, game_id):
|
||||
super().__init__()
|
||||
self.gen = gen
|
||||
self.title = title
|
||||
self.game_id = game_id
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
icon0, pic0, pic1 = covers.ps1_art(self.title, self.game_id)
|
||||
except Exception: # noqa: BLE001
|
||||
icon0, pic0, pic1 = None, None, None
|
||||
self.result.emit(self.gen, self.game_id, icon0, pic0, pic1)
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
class Ps1Page(QWidget):
|
||||
"""PS1 tab: list raw source discs, converted EBOOTs and PSN games."""
|
||||
"""PS1 tab: list raw source discs that still need converting."""
|
||||
|
||||
convert_requested = Signal(dict)
|
||||
|
||||
@@ -3356,6 +3419,9 @@ class Ps1Page(QWidget):
|
||||
self._entries = []
|
||||
self._thread = None
|
||||
self._worker = None
|
||||
self._preview_gen = 0
|
||||
self._preview_threads = []
|
||||
self._preview_workers = []
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
bar = QHBoxLayout()
|
||||
@@ -3375,7 +3441,108 @@ class Ps1Page(QWidget):
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
layout.addWidget(self.table, 1)
|
||||
self.table.itemSelectionChanged.connect(self._on_preview_changed)
|
||||
|
||||
self.preview_title = QLabel("Select a disc to preview its XMB look.")
|
||||
self.preview_title.setWordWrap(True)
|
||||
self.preview_title.setStyleSheet("font-weight: bold;")
|
||||
self.preview_icon0 = QLabel()
|
||||
self.preview_icon0.setAlignment(Qt.AlignCenter)
|
||||
self.preview_icon0.setFixedSize(160, 160)
|
||||
self.preview_icon0.setStyleSheet("background: #14151a; border: 1px solid #333; color: #888;")
|
||||
self.preview_pic0 = QLabel()
|
||||
self.preview_pic0.setAlignment(Qt.AlignCenter)
|
||||
self.preview_pic0.setFixedSize(310, 180)
|
||||
self.preview_pic0.setStyleSheet("background: #14151a; border: 1px solid #333; color: #888;")
|
||||
self.preview_pic1 = QLabel()
|
||||
self.preview_pic1.setAlignment(Qt.AlignCenter)
|
||||
self.preview_pic1.setFixedSize(480, 272)
|
||||
self.preview_pic1.setStyleSheet("background: #14151a; border: 1px solid #333; color: #888;")
|
||||
|
||||
preview = QWidget()
|
||||
pv = QVBoxLayout(preview)
|
||||
pv.setContentsMargins(8, 0, 0, 0)
|
||||
pv.addWidget(QLabel("<b>Preview</b>"))
|
||||
pv.addWidget(self.preview_title)
|
||||
pv.addWidget(QLabel("ICON0 — XMB icon (80×80)"))
|
||||
pv.addWidget(self.preview_icon0)
|
||||
pv.addWidget(QLabel("PIC0 — info board (310×180)"))
|
||||
pv.addWidget(self.preview_pic0)
|
||||
pv.addWidget(QLabel("PIC1 — launch background (480×272)"))
|
||||
pv.addWidget(self.preview_pic1)
|
||||
pv.addStretch(1)
|
||||
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setWidget(preview)
|
||||
scroll.setFrameShape(QScrollArea.NoFrame)
|
||||
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
splitter.addWidget(self.table)
|
||||
splitter.addWidget(scroll)
|
||||
splitter.setStretchFactor(0, 1)
|
||||
splitter.setSizes([640, 500])
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
def _on_preview_changed(self):
|
||||
row = self.table.currentRow()
|
||||
if row < 0 or row >= len(self._entries):
|
||||
self._reset_preview("Select a disc to preview its XMB look.")
|
||||
return
|
||||
e = self._entries[row]
|
||||
if e["kind"] != "disc":
|
||||
self._reset_preview("This game is already converted.")
|
||||
return
|
||||
if e.get("status") == "converted":
|
||||
self._reset_preview("This disc has already been converted.")
|
||||
return
|
||||
self.preview_title.setText(e["name"])
|
||||
self.preview_icon0.setPixmap(QPixmap())
|
||||
self.preview_icon0.setText("Loading…")
|
||||
self.preview_pic0.setPixmap(QPixmap())
|
||||
self.preview_pic0.setText("Loading…")
|
||||
self.preview_pic1.setPixmap(QPixmap())
|
||||
self.preview_pic1.setText("Loading…")
|
||||
self._preview_gen += 1
|
||||
self._start_preview(self._preview_gen, e["name"], e.get("serial") or "")
|
||||
|
||||
def _start_preview(self, gen, title, game_id):
|
||||
worker = Ps1PreviewWorker(gen, title, game_id)
|
||||
thread = QThread(self)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.result.connect(self._on_preview_result)
|
||||
worker.finished.connect(thread.quit)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
thread.start()
|
||||
self._preview_threads.append(thread)
|
||||
self._preview_workers.append(worker)
|
||||
|
||||
def _on_preview_result(self, gen, game_id, icon0, pic0, pic1):
|
||||
if gen != self._preview_gen:
|
||||
return
|
||||
self.preview_icon0.setText("")
|
||||
self.preview_pic0.setText("")
|
||||
self.preview_pic1.setText("")
|
||||
for label, data, fallback in (
|
||||
(self.preview_icon0, icon0, "No art found"),
|
||||
(self.preview_pic0, pic0, "No art found"),
|
||||
(self.preview_pic1, pic1, "No art found — the default icon will be used"),
|
||||
):
|
||||
if data:
|
||||
pm = QPixmap()
|
||||
pm.loadFromData(data)
|
||||
label.setPixmap(pm.scaled(label.width(), label.height(),
|
||||
Qt.KeepAspectRatio, Qt.SmoothTransformation))
|
||||
else:
|
||||
label.setText(fallback)
|
||||
|
||||
def _reset_preview(self, message):
|
||||
self.preview_title.setText(message)
|
||||
for label in (self.preview_icon0, self.preview_pic0, self.preview_pic1):
|
||||
label.setPixmap(QPixmap())
|
||||
label.setText("")
|
||||
|
||||
def set_dirs(self, ps1_dir, output_dir):
|
||||
self._ps1_dir = ps1_dir
|
||||
@@ -3401,16 +3568,19 @@ class Ps1Page(QWidget):
|
||||
self._thread.start()
|
||||
|
||||
def _on_results(self, entries):
|
||||
# Only show discs that still need converting; converted games already
|
||||
# appear in the Library tab.
|
||||
entries = [e for e in entries if e["kind"] == "disc" and e.get("status") != "converted"]
|
||||
self._entries = entries
|
||||
self.table.setRowCount(len(entries))
|
||||
for row, e in enumerate(entries):
|
||||
kind = "Disc" if e["kind"] == "disc" else "EBOOT"
|
||||
self.table.setItem(row, 0, QTableWidgetItem(kind))
|
||||
self.table.setItem(row, 0, QTableWidgetItem("Disc"))
|
||||
self.table.setItem(row, 1, QTableWidgetItem(e["name"]))
|
||||
self.table.setItem(row, 2, QTableWidgetItem(e.get("serial") or ""))
|
||||
self.table.setItem(row, 3, QTableWidgetItem(e.get("status") or "pending"))
|
||||
pending = sum(1 for e in entries if e["kind"] == "disc" and e["status"] != "converted")
|
||||
self.status.setText(f"{len(entries)} item(s) — {pending} ready to convert")
|
||||
self.table.setItem(row, 3, QTableWidgetItem("pending"))
|
||||
self.status.setText(f"{len(entries)} disc(s) ready to convert")
|
||||
if entries:
|
||||
self.table.selectRow(0)
|
||||
|
||||
def _on_failed(self, message):
|
||||
self.status.setText(f"PS1 scan failed: {message}")
|
||||
@@ -3454,6 +3624,7 @@ class MainWindow(QMainWindow):
|
||||
self._psp_root = ""
|
||||
self._psp_index = {}
|
||||
self._psp_paths = {}
|
||||
self._psp_homebrew = []
|
||||
self._pending_umd_dump = None
|
||||
self._auto_rename_umd = None
|
||||
self._library_threads = []
|
||||
@@ -3999,6 +4170,7 @@ class MainWindow(QMainWindow):
|
||||
inventory = self.psp_page.inventory() or {}
|
||||
self._psp_index = psp.build_index(inventory)
|
||||
self._psp_paths = {"raw": {}, "norm": {}}
|
||||
self._psp_homebrew = list(inventory.get("homebrew", []))
|
||||
for group in ("games", "videos"):
|
||||
for it in inventory.get(group, []):
|
||||
stem = Path(it["name"]).stem
|
||||
@@ -4012,6 +4184,7 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self._psp_index = {}
|
||||
self._psp_paths = {}
|
||||
self._psp_homebrew = []
|
||||
if hasattr(self, "details_panel"):
|
||||
self.details_panel.set_copy_available(bool(self._psp_root))
|
||||
current = getattr(self, "_current_item", None)
|
||||
@@ -4030,7 +4203,20 @@ class MainWindow(QMainWindow):
|
||||
if stem.lower() in self._psp_index.get("raw", set()):
|
||||
return True
|
||||
norm = matcher.normalize_name(stem)
|
||||
return bool(norm) and norm in self._psp_index.get("names", set())
|
||||
if norm and norm in self._psp_index.get("names", set()):
|
||||
return True
|
||||
if item.get("system") == "ps1":
|
||||
# PS1 EBOOTs install as PSP/GAME/{category}/{GAME_ID}/EBOOT.PBP, so
|
||||
# match the source game-folder id (or the embedded title) against
|
||||
# the homebrew index (which includes Category Lite subfolders).
|
||||
game_id = Path(item.get("path") or "").parent.name.strip().lower()
|
||||
if game_id and game_id in self._psp_index.get("homebrew_ids", set()):
|
||||
return True
|
||||
if stem.lower() in self._psp_index.get("homebrew_names", set()):
|
||||
return True
|
||||
if norm and norm in self._psp_index.get("homebrew_names", set()):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _psp_dot_icon(self):
|
||||
icon = getattr(self, "_psp_icon", None)
|
||||
@@ -4221,6 +4407,35 @@ class MainWindow(QMainWindow):
|
||||
QMessageBox.information(self, "Not on PSP", "That disc is not installed on the PSP.")
|
||||
return
|
||||
stem = Path(item.get("canonical") or item.get("name") or "").stem
|
||||
if item.get("system") == "ps1":
|
||||
# PS1 EBOOTs live as PSP/GAME/{category}/{GAME_ID}/EBOOT.PBP; delete
|
||||
# the whole game folder, matched by the source folder id (or title).
|
||||
game_id = Path(item.get("path") or "").parent.name.strip().lower()
|
||||
hb = self._psp_homebrew or []
|
||||
if game_id:
|
||||
matches = [h for h in hb if (h.get("id") or "").strip().lower() == game_id]
|
||||
else:
|
||||
matches = [h for h in hb if Path(h.get("name") or "").stem.lower() == stem.lower()]
|
||||
if not matches:
|
||||
QMessageBox.warning(self, "Not found", f"Could not find the installed copy on the PSP:\n{stem}")
|
||||
return
|
||||
name = item.get("title_override") or item.get("canonical") or item.get("name") or stem
|
||||
preview = "\n".join(str(Path(m["path"]).parent) for m in matches[:5])
|
||||
res = QMessageBox.question(
|
||||
self, "Delete from PSP",
|
||||
f"Delete this game folder from the PSP?\n\n{name}\n\n{preview}",
|
||||
QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel,
|
||||
)
|
||||
if res != QMessageBox.Yes:
|
||||
return
|
||||
failed = 0
|
||||
for m in matches:
|
||||
if not transfers.remove_path(Path(m["path"]).parent):
|
||||
failed += 1
|
||||
if failed:
|
||||
QMessageBox.warning(self, "Delete incomplete", f"{failed} folder(s) could not be deleted.")
|
||||
self._refresh_psp()
|
||||
return
|
||||
matches = list(self._psp_paths.get("raw", {}).get(stem.lower(), []))
|
||||
if not matches:
|
||||
n = matcher.normalize_name(stem)
|
||||
|
||||
+24
-7
@@ -38,7 +38,11 @@ def clear_cache():
|
||||
|
||||
|
||||
def _all_release_candidates(conn):
|
||||
"""Catalog releases + supplement entries with pre-normalized names."""
|
||||
"""Catalog releases + supplement entries with pre-normalized names.
|
||||
|
||||
Each entry is ``(normalized_name, significant_word_set, candidate)`` so the
|
||||
fuzzy step can cheaply skip candidates that share no significant word.
|
||||
"""
|
||||
key = id(conn)
|
||||
cands = _releases_cache.get(key)
|
||||
if cands is not None:
|
||||
@@ -53,7 +57,11 @@ def _all_release_candidates(conn):
|
||||
)
|
||||
]
|
||||
cands.extend(_supplement_release(e) for e in _load_supplement())
|
||||
cands = [(_normalize(c["name"]), c) for c in cands]
|
||||
cands = [
|
||||
(norm, frozenset(w for w in norm.split() if len(w) >= 3), c)
|
||||
for c in cands
|
||||
if (norm := _normalize(c["name"] or ""))
|
||||
]
|
||||
_releases_cache[key] = cands
|
||||
return cands
|
||||
|
||||
@@ -169,12 +177,21 @@ def match(conn, record):
|
||||
disk_norm = _normalize(record["name"])
|
||||
title_norm = _normalize(record.get("title") or "")
|
||||
|
||||
# Cheap pre-filter: only run the expensive difflib comparison against
|
||||
# candidates sharing at least one significant (>=3 char) word with the
|
||||
# disc's filename or PARAM.SFO title.
|
||||
target_words = frozenset(w for w in disk_norm.split() if len(w) >= 3) | \
|
||||
frozenset(w for w in title_norm.split() if len(w) >= 3)
|
||||
if not target_words:
|
||||
return "unknown", None
|
||||
|
||||
scored = []
|
||||
for cand_norm, cand in _all_release_candidates(conn):
|
||||
ratio = max(
|
||||
SequenceMatcher(None, disk_norm, cand_norm).ratio(),
|
||||
SequenceMatcher(None, title_norm, cand_norm).ratio(),
|
||||
)
|
||||
for cand_norm, cand_words, cand in _all_release_candidates(conn):
|
||||
if not (target_words & cand_words):
|
||||
continue
|
||||
ratio = SequenceMatcher(None, disk_norm, cand_norm).ratio()
|
||||
if title_norm:
|
||||
ratio = max(ratio, SequenceMatcher(None, title_norm, cand_norm).ratio())
|
||||
scored.append((ratio, cand))
|
||||
best_ratio = max((r for r, _ in scored), default=0.0)
|
||||
if best_ratio < 0.9:
|
||||
|
||||
+17
-8
@@ -871,6 +871,9 @@ def convert(
|
||||
progress_cb=None,
|
||||
cancel_event=None,
|
||||
complevel: int = 1,
|
||||
icon0: Optional[bytes] = None,
|
||||
pic0: Optional[bytes] = None,
|
||||
pic1: Optional[bytes] = None,
|
||||
) -> Path:
|
||||
"""Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``).
|
||||
|
||||
@@ -878,6 +881,9 @@ def convert(
|
||||
``.iso``/``.img``/``.bin``. ``base_pbp`` must be a PSN PS1 classic EBOOT.
|
||||
``complevel`` is the DEFLATE level for the disc data (1-9; 0 = raw, which
|
||||
does not boot on real hardware and is only useful for diagnostics).
|
||||
``icon0`` (80x80 PNG), ``pic0`` (310x180 PNG) and ``pic1`` (480x272 PNG)
|
||||
override the generated art; when absent the base PBP's ICON0 is reused
|
||||
and PIC0/PIC1 are left empty.
|
||||
"""
|
||||
if not base_pbp.is_file():
|
||||
raise ConversionError(f"base.pbp not found: {base_pbp}")
|
||||
@@ -901,19 +907,18 @@ def convert(
|
||||
# Reuse the base PBP's SFO, patched with our title + game id (the
|
||||
# popstation approach), rather than rebuilding it from scratch.
|
||||
sfo = patch_sfo(extract_sfo(base_pbp), title, game_id)
|
||||
# Reuse the base PBP's ICON0 too. The XMB icon parser is picky about PNG
|
||||
# variants, and the base icon is guaranteed to decode; the other image
|
||||
# sections are left empty exactly like popstation when no assets are
|
||||
# supplied alongside the conversion.
|
||||
icon0 = extract_icon0(base_pbp)
|
||||
# Reuse the base PBP's ICON0 when no custom icon was supplied (the XMB
|
||||
# icon parser is picky about PNG variants, and the base icon is guaranteed
|
||||
# to decode). PIC1 is the launch background; empty when no art is given.
|
||||
icon0 = icon0 or extract_icon0(base_pbp)
|
||||
|
||||
# Sections in header order; empty sections keep the previous offset.
|
||||
sections = {
|
||||
"sfo": sfo,
|
||||
"icon0": icon0,
|
||||
"icon1": b"",
|
||||
"pic0": b"",
|
||||
"pic1": b"",
|
||||
"pic0": pic0 or b"",
|
||||
"pic1": pic1 or b"",
|
||||
"snd0": b"",
|
||||
"data_psp": data_psp,
|
||||
# psar written separately, sized after everything else.
|
||||
@@ -981,6 +986,9 @@ def convert_to_folder(
|
||||
progress_cb=None,
|
||||
cancel_event=None,
|
||||
complevel: int = 1,
|
||||
icon0: Optional[bytes] = None,
|
||||
pic0: Optional[bytes] = None,
|
||||
pic1: Optional[bytes] = None,
|
||||
) -> Path:
|
||||
"""Convert a PS1 disc into a game folder ready for /PSP/GAME.
|
||||
|
||||
@@ -993,7 +1001,8 @@ def convert_to_folder(
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
convert(source, title, game_id, base_pbp, folder / "EBOOT.PBP",
|
||||
progress_cb=progress_cb, cancel_event=cancel_event, complevel=complevel)
|
||||
progress_cb=progress_cb, cancel_event=cancel_event, complevel=complevel,
|
||||
icon0=icon0, pic0=pic0, pic1=pic1)
|
||||
return folder
|
||||
|
||||
|
||||
|
||||
+19
-3
@@ -236,8 +236,11 @@ def list_installed(root):
|
||||
def build_index(inventory):
|
||||
"""Build a lookup index for matching library discs against the PSP.
|
||||
|
||||
Returns ``{"raw": set, "names": set}`` where ``raw`` holds lowercase ISO
|
||||
stems (exact filename match) and ``names`` holds fuzzy-normalized stems.
|
||||
Returns ``{"raw", "names", "homebrew_ids", "homebrew_names"}`` where
|
||||
``raw`` holds lowercase ISO stems (exact filename match), ``names`` holds
|
||||
fuzzy-normalized stems, and the homebrew keys index ``PSP/GAME/**/EBOOT.PBP``
|
||||
(homebrew + PS1 Classics, including Category Lite subfolders) by folder id
|
||||
and embedded title.
|
||||
"""
|
||||
raw, names = set(), set()
|
||||
for group in ("games", "videos"):
|
||||
@@ -247,7 +250,20 @@ def build_index(inventory):
|
||||
norm = matcher.normalize_name(stem)
|
||||
if norm:
|
||||
names.add(norm)
|
||||
return {"raw": raw, "names": names}
|
||||
|
||||
hb_ids, hb_names = set(), set()
|
||||
for it in (inventory or {}).get("homebrew", []):
|
||||
ident = str(it.get("id") or "").strip().lower()
|
||||
if ident:
|
||||
hb_ids.add(ident)
|
||||
stem = Path(it.get("name") or "").stem.strip().lower()
|
||||
if stem:
|
||||
hb_names.add(stem)
|
||||
norm = matcher.normalize_name(Path(it.get("name") or "").stem)
|
||||
if norm:
|
||||
hb_names.add(norm)
|
||||
return {"raw": raw, "names": names,
|
||||
"homebrew_ids": hb_ids, "homebrew_names": hb_names}
|
||||
|
||||
|
||||
def find_umd_iso(root):
|
||||
|
||||
+9
-1
@@ -237,12 +237,20 @@ class Ps1ConvertSource(Source):
|
||||
if progress_cb is not None and total:
|
||||
progress_cb("Converting", min(done / total, 1.0), "")
|
||||
|
||||
from . import ps1_convert
|
||||
from . import covers, ps1_convert
|
||||
|
||||
# Pull the game's real cover art (libretro / ScreenScraper / IGDB /
|
||||
# Wikipedia) and bake it into ICON0 + PIC0 + PIC1 so the XMB shows the
|
||||
# official-style art, not the default PS1 icon. Best effort.
|
||||
if progress_cb is not None:
|
||||
progress_cb("Artwork", 0.0, "")
|
||||
icon0, pic0, pic1 = covers.ps1_art(title, game_id)
|
||||
|
||||
try:
|
||||
ps1_convert.convert_to_folder(
|
||||
source, title, game_id, self.base_pbp, dest_dir,
|
||||
progress_cb=cb, cancel_event=cancel_event,
|
||||
icon0=icon0, pic0=pic0, pic1=pic1,
|
||||
)
|
||||
except ps1_convert.Cancelled:
|
||||
raise TransferCancelled() from None
|
||||
|
||||
Reference in New Issue
Block a user