Compare commits

...
14 Commits
Author SHA1 Message Date
quoije 4b6e407b3f modify readme.md 2026-09-07 20:27:25 -04:00
quoije 196e3ac82f gui: show the source filename in Library and Store details
Add a muted, selectable filename line under the title in both details
panels, with the full path as its tooltip (archive name in the Store,
disc/ISO filename in the Library).
2026-09-07 20:05:31 -04:00
quoije 2c6b07e286 psp: detect installed PS1 games (incl. Category Lite folders)
PS1 EBOOTs install as PSP/GAME/{category}/{GAME_ID}/EBOOT.PBP, which the
PSP index ignored (it only indexed ISO games/videos), so the app never
marked them 'on PSP'. Index homebrew EBOOTs by folder id + embedded title,
match PS1 library items against them, and make 'Delete from PSP' remove the
whole game folder.
2026-09-07 19:59:30 -04:00
quoije 0ebd173af7 ps1: IGDB-first only for EBOOT icon, restore usual library order
Library covers go back to libretro -> OpenVGDB -> ScreenScraper ->
Wikipedia -> IGDB (last resort) for all systems, including PS1. The EBOOT
ICON0 (ps1_art) alone now tries IGDB first for clean banner-less box art,
falling back to the normal pipeline.
2026-09-07 19:46:50 -04:00
quoije c0c0f841d6 gui: label PS1 games as 'PS1' in the PSP sync table
The sync table reused a games/videos check, so PS1 entries showed as
'Video'. Use the same system->label map as the library table.
2026-09-07 19:39:14 -04:00
quoije 78b57af79e ps1: fit whole cover in ICON0 frame, show full PIC0 title
- ICON0: scale the cover to fit the case slot (no cropping), centred with
  the frame's dark background behind the letterbox bars.
- PIC0: shrink the title font until the full name fits instead of cutting
  it with an ellipsis (falls back to eliding only below 10px).
2026-09-07 19:33:21 -04:00
quoije 43263aa644 ps1: IGDB-first covers, elided PIC0 text, drop PS3 badge
- Cover art: try IGDB first for PS1 so the cover is clean box art without
  the PlayStation banner that libretro/ScreenScraper thumbnails bake in
  (redundant with the ICON0 case frame).
- PIC0: elide long titles with an ellipsis and word-wrap the description to
  3 lines with a trailing ellipsis instead of clipping/overflowing.
- PIC0: remove the PS3 badge; PSP only for now (PS3/PS4 packaging is future
  work).
2026-09-07 19:29:19 -04:00
quoije f6dccbec24 ps1: official-style ICON0/PIC0/PIC1 artwork with PSN templates
Replaces the letterboxed 144x80 icon + cropped PIC1 with official-looking
PS1 Classics art:
- ICON0 (80x80): cover art composited into the bundled case frame (PS logo
  + 'PlayStation.' wordmark) from data/ps1_templates/ICON0.PNG.
- PIC0 (310x180): a PSN-style info board drawn in code — title, IGDB
  description, ESRB badge (from IGDB age_ratings) and PS3/PSP badges.
- PIC1 (480x272): the bundled launch background template.

The converter now embeds PIC0, the PS1 tab previews all three images, and
ps1_art pulls IGDB description/ESRB as best effort. Templates are git-ignored
(user-supplied, like base.PBP) with a drawn-art fallback when missing.
2026-09-07 19:23:38 -04:00
quoije 2433240628 ps1: show only pending discs in the PS1 tab
Converted games already appear in the Library tab, so the PS1 tab now
lists only raw discs that still need converting. Auto-selects the first
pending disc so the preview loads immediately.
2026-09-07 18:45:55 -04:00
quoije c1fcf6176d ps1: preview EBOOT icon/background in the PS1 tab before converting
Selecting a pending disc in the PS1 tab now shows a live preview of the
ICON0 (144x80 XMB icon) and PIC1 (480x272 launch background) that the
converter would embed, fetched asynchronously so the UI stays responsive.
Converted discs/EBOOTs show an 'already converted' notice, and ps1_art now
falls back to a name-derived cache key when a disc has no serial.
2026-09-07 18:42:46 -04:00
quoije fb2a98dd21 ps1: embed real game art (ICON0 + PIC1) in converted EBOOTs
Fetch the game's cover art (libretro / ScreenScraper / IGDB / Wikipedia,
reusing the local cover cache) and bake it into the EBOOT's ICON0 (144x80
XMB icon) and PIC1 (480x272 launch background) instead of the default PS1
icon. Best-effort: falls back to the base icon/empty background when no art
is found. convert()/convert_to_folder() gain optional icon0/pic1 params.
2026-09-07 18:39:29 -04:00
quoije f9d9d639ed queue: add job Type column (Download/Copy to PSP/PS1 convert/etc.)
Add a 'Type' column to the Queue table mapping each job kind to a friendly
label, so downloads, copies, PS1 conversions, UMD dumps and save syncs are
distinguishable at a glance.
2026-09-07 18:27:43 -04:00
quoije abd201396c matcher: token pre-filter for fuzzy name matching (6x faster startup)
The fuzzy fallback ran difflib against every catalog release (~11k) for each
unmatched disc, dominating library load (2.7s) and startup scans. Pre-filter
candidates to those sharing a significant (>=3 char) word with the disc
filename or PARAM.SFO title before the expensive comparison, and cache the
pre-computed word sets. Library load: 2.7s -> 0.44s; scan: ~0.5s.
2026-09-07 18:23:28 -04:00
quoije e179bf2f2e library: refresh scraped cover in grid even when items were rebuilt
refresh_item matched the grid row by object identity, which silently fails
when the library list was rebuilt (background scan/store refresh) while the
scrape dialog was open. Match by release_id + system as a fallback, like
update_item_label already does, so the cover tile updates without a Reload.
2026-09-07 18:16:20 -04:00
8 changed files with 669 additions and 44 deletions
+4
View File
@@ -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/
+16 -1
View File
@@ -1,5 +1,6 @@
# PSP Preservation Tool
the name is terrible, i know.
![AI Generated](https://raw.githubusercontent.com/dotnetdave/ai-usage-badges/refs/heads/main/badges/png/1x/ai-generated.png)
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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