Files
psp-preservation-tool/app/gui.py
T

5088 lines
200 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Native Linux GUI for the PSP Preservation Tool (PySide6 / Qt).
Run with::
python3 -m app.main
"""
import datetime
import logging
import sys
import threading
from pathlib import Path
from PySide6.QtCore import QBuffer, QByteArray, QObject, QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import QColor, QDesktopServices, QFont, QIcon, QImage, QPainter, QPixmap
from PySide6.QtWidgets import (
QAbstractItemView, QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
QFileDialog, QFormLayout, QGridLayout, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit,
QProgressBar, QProgressDialog, QPushButton, QScrollArea, QSpinBox, QSplitter, QStackedWidget,
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from . import config, covers, info, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, transfers
from .db import get_connection, init_db
log = logging.getLogger("gui")
class ScanWorker(QObject):
record = Signal(dict)
prompt = Signal(dict, list)
done = Signal(int)
cancelled = Signal()
failed = Signal(str)
def __init__(self, roots, db_path, hash_files=True, workers=None):
super().__init__()
self.roots = roots
self.db_path = db_path
self.hash_files = hash_files
self.workers = workers
self._cancel = threading.Event()
self._reply = [None]
self._reply_event = threading.Event()
self._prompt_pending = False
def cancel(self):
self._cancel.set()
def choose(self, release):
self._reply[0] = release
self._reply_event.set()
def _ask(self, rec, candidates):
self._reply = [None]
self._reply_event.clear()
self._prompt_pending = True
self.prompt.emit(rec, candidates)
self._reply_event.wait()
self._prompt_pending = False
return self._reply[0]
def run(self):
conn = init_db(self.db_path)
count = 0
try:
for rec in scanner.scan(
self.roots, conn=conn, workers=self.workers or config.SCAN_WORKERS,
hash_files=self.hash_files, cancel=self._cancel,
):
ident = pipeline.identify(conn, rec)
if ident["method"] == "unknown" and rec.get("serial") and not self._cancel.is_set():
candidates = matcher.serial_candidates(conn, rec)
if len(candidates) > 1:
chosen = self._ask(rec, candidates)
if chosen is not None:
conn.execute(
"INSERT OR REPLACE INTO choices (serial, size, release_id) VALUES (?,?,?)",
(rec["serial"].upper(), rec["size"], chosen["id"]),
)
ident = pipeline.identify_with_release(rec, chosen, "choice")
rel = ident.get("release") or {}
conn.execute(
"UPDATE files SET release_id = ? WHERE path = ?",
(rel.get("id"), rec["path"]),
)
self.record.emit({**rec, **ident})
count += 1
conn.commit()
if self._cancel.is_set():
self.cancelled.emit()
else:
self.done.emit(count)
except Exception as exc: # noqa: BLE001
self.failed.emit(str(exc))
finally:
conn.close()
def _thread_running(thread):
try:
return thread is not None and thread.isRunning()
except RuntimeError:
return False
class CoverLoader(QObject):
loaded = Signal(int, int, str) # gen, item index, cached image path
failed = Signal(int, int, str) # gen, item index, name (no cover found)
finished = Signal()
def __init__(self, items, gen):
super().__init__()
self.items = items
self.gen = gen
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
from concurrent.futures import ThreadPoolExecutor, as_completed
pool = ThreadPoolExecutor(max_workers=8)
futures = {
pool.submit(load_cover, it): i
for i, it in enumerate(self.items)
}
try:
for fut in as_completed(futures):
if self._cancel.is_set():
break
i = futures[fut]
try:
path = fut.result()
except Exception: # noqa: BLE001
path = None
if path is not None:
self.loaded.emit(self.gen, i, str(path))
else:
self.failed.emit(self.gen, i, self.items[i]["name"])
finally:
pool.shutdown(wait=True, cancel_futures=True)
self.finished.emit()
_template_image = None
def _umd_template():
global _template_image
if _template_image is None:
img = QImage()
for path in (
config.COVERS_DIR / "UMD_COVER_TEMPLATE.png",
config.DATA_DIR / "covers" / "UMD_COVER_TEMPLATE.png",
):
if path.exists():
img.load(str(path))
if not img.isNull():
break
_template_image = img.convertToFormat(QImage.Format_ARGB32)
return _template_image
def _transparent_window(img):
minx, miny, maxx, maxy = img.width(), img.height(), -1, -1
for y in range(img.height()):
for x in range(img.width()):
if ((img.pixel(x, y) >> 24) & 0xFF) == 0:
minx, miny = min(minx, x), min(miny, y)
maxx, maxy = max(maxx, x), max(maxy, y)
if maxx < minx or maxy < miny:
return QRect()
return QRect(minx, miny, maxx - minx + 1, maxy - miny + 1)
def compose_umd_cover(poster_bytes):
"""Paste a movie poster into the UMD template's transparent window (PNG bytes)."""
template = _umd_template()
poster = QImage.fromData(poster_bytes)
if template.isNull() or poster.isNull():
return None
poster = poster.convertToFormat(QImage.Format_ARGB32)
out = QImage(template.size(), QImage.Format_ARGB32)
out.fill(Qt.transparent)
window = _transparent_window(template)
painter = QPainter(out)
if window.width() > 0 and window.height() > 0:
scaled = poster.scaled(window.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
ox = window.x() + (window.width() - scaled.width()) // 2
oy = window.y() + (window.height() - scaled.height()) // 2
painter.save()
painter.setClipRect(window)
painter.drawImage(ox, oy, scaled)
painter.restore()
painter.drawImage(0, 0, template)
painter.end()
ba = QByteArray()
buf = QBuffer(ba)
buf.open(QBuffer.WriteOnly)
out.save(buf, "PNG")
buf.close()
return bytes(ba)
def load_cover(item, timeout=8, quick=False):
name = item["name"]
rid = item["release_id"]
system = item["system"]
if system == "videos":
return _load_video_cover(name, rid, system, timeout)
return covers.fetch_cover(name, rid, system, timeout, quick=quick)
def _load_video_cover(name, rid, system, timeout):
dest = covers.cover_path(rid, system)
if dest.exists() and covers.file_is_image(dest):
return dest
data = covers.fetch_manual(name, system)
if data is not None:
logging.info("cover %r <- manual", name)
else:
poster = covers.fetch_poster(covers.movie_search_title(name), timeout)
if poster is not None:
data = compose_umd_cover(poster)
logging.info("cover %r <- poster+template", name)
else:
logging.warning("no cover found for %r", name)
if data is None:
return None
return covers.save_cover(rid, system, data)
def _placeholder_pixmap(w, h):
pm = QPixmap(w, h)
pm.fill(QColor(38, 40, 46))
painter = QPainter(pm)
painter.setPen(QColor(90, 95, 105))
painter.drawRect(1, 1, w - 2, h - 2)
font = QFont()
font.setPointSize(10)
painter.setFont(font)
painter.setPen(QColor(150, 155, 165))
painter.drawText(pm.rect(), Qt.AlignCenter, "No cover")
painter.end()
return pm
def _fit_pixmap(pm, w, h):
"""Scale a cover to fit inside ``w x h`` (letterboxed, full cover visible)."""
canvas = QPixmap(w, h)
canvas.fill(Qt.transparent)
if pm.isNull():
return canvas
scaled = pm.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
x = (w - scaled.width()) // 2
y = (h - scaled.height()) // 2
painter = QPainter(canvas)
painter.drawPixmap(x, y, scaled)
painter.end()
return canvas
def _cover_pixmap(rid, system, max_w, max_h):
"""Return a scaled cover pixmap for an item, or None if unavailable."""
if rid is None:
return None
path = covers.cover_path(rid, system)
if path is None or not path.exists() or not covers.file_is_image(path):
return None
return QPixmap(str(path)).scaled(max_w, max_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
class NumericItem(QTableWidgetItem):
"""Table item that sorts numerically by a stored value."""
def __init__(self, text, value):
super().__init__(text)
self._sort_value = value
def __lt__(self, other):
a = getattr(self, "_sort_value", None)
b = getattr(other, "_sort_value", None)
try:
return float(a) < float(b)
except (TypeError, ValueError):
return super().__lt__(other)
def _item_label(it):
"""Grid label: manual title override > canonical name > catalog name."""
label = it.get("title_override") or it["canonical"] or it["name"]
if label.endswith(".iso"):
label = label[:-4]
if len(label) > 44:
label = label[:43] + "…"
if it.get("on_psp"):
label = "\u25cf " + label
return label
def open_containing_folder(path):
"""Open the folder containing a disc/archive in the file manager."""
p = Path(path) if path else None
if p is not None and p.exists():
QDesktopServices.openUrl(QUrl.fromLocalFile(str(p.parent)))
else:
QMessageBox.warning(None, "Not found", f"File not found:\n{path}")
class CoverShowcase(QWidget):
selected = Signal(dict) # emitted when a cover is selected
cover_loaded = Signal(dict) # emitted when an item's cover finishes loading
copy_requested = Signal(dict) # emitted when the user wants to copy an item to the PSP
delete_requested = Signal(dict) # emitted when the user wants to delete an item
delete_psp_requested = Signal(dict) # emitted when the user wants to delete an item from the PSP
play_requested = Signal(dict) # emitted when the user wants to launch an item in PPSSPP
scrape_requested = Signal(dict) # emitted when the user wants to scrape metadata + cover
missing_changed = Signal(int) # emitted with the number of missing covers
ICON_W, ICON_H = 150, 220
GRID_W, GRID_H = 182, 262
def __init__(self, parent=None):
super().__init__(parent)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.list = QListWidget()
self.list.setViewMode(QListWidget.IconMode)
self.list.setIconSize(QSize(self.ICON_W, self.ICON_H))
self.list.setGridSize(QSize(self.GRID_W, self.GRID_H))
self.list.setResizeMode(QListWidget.Adjust)
self.list.setMovement(QListWidget.Static)
self.list.setUniformItemSizes(True)
self.list.setWordWrap(True)
self.list.setSpacing(6)
self.list.setContextMenuPolicy(Qt.CustomContextMenu)
self.list.customContextMenuRequested.connect(self._on_context_menu)
self.list.currentItemChanged.connect(self._on_current_changed)
self.list.itemDoubleClicked.connect(self._on_item_double_clicked)
layout.addWidget(self.list)
self.status = QLabel("")
layout.addWidget(self.status)
self.items = []
self.thread = None
self.loader = None
self._generation = 0
self._threads = []
self._loaders = []
self.missing = []
self._loading = False
self._closing = False
def set_items(self, items):
self._generation += 1
gen = self._generation
self.items = list(items)
self.missing = []
self._loading = True
self.status.setText("Loading covers…")
self.missing_changed.emit(0)
placeholder = self._placeholder_icon()
self.list.clear()
for it in self.items:
label = _item_label(it)
item = QListWidgetItem(placeholder, label)
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignTop)
item.setToolTip(it["name"])
item.setData(Qt.UserRole, it)
self.list.addItem(item)
self._start_loader(gen)
def _start_loader(self, gen):
if self._closing or not self.items:
return
# Cancel any previous loaders so reloads/filter changes don't pile up
# overlapping network fetches.
for loader in self._loaders:
loader.cancel()
self.thread = QThread(self)
self.loader = CoverLoader(self.items, gen)
self.loader.moveToThread(self.thread)
self.thread.started.connect(self.loader.run)
self.loader.loaded.connect(self._on_loaded)
self.loader.failed.connect(self._on_failed)
self.loader.finished.connect(self._on_loading_done)
self.loader.finished.connect(self.thread.quit)
self._threads.append(self.thread)
self._loaders.append(self.loader)
self.thread.start()
def _placeholder_icon(self):
pm = QPixmap(self.ICON_W, self.ICON_H)
pm.fill(QColor(38, 40, 46))
painter = QPainter(pm)
painter.setPen(QColor(90, 95, 105))
painter.drawRect(1, 1, self.ICON_W - 2, self.ICON_H - 2)
font = QFont()
font.setPointSize(10)
painter.setFont(font)
painter.setPen(QColor(150, 155, 165))
painter.drawText(pm.rect(), Qt.AlignCenter, "No cover")
painter.end()
return QIcon(pm)
def _on_loaded(self, gen, index, path):
if gen != self._generation:
return
item = self.list.item(index)
if item is not None:
item.setIcon(self._fit_icon(QPixmap(path)))
if 0 <= index < len(self.items):
self.cover_loaded.emit(self.items[index])
def _on_current_changed(self, current, previous):
if current is not None:
data = current.data(Qt.UserRole)
if data:
self.selected.emit(data)
def _on_item_double_clicked(self, item):
if item is None:
return
data = item.data(Qt.UserRole)
if data and data.get("system") != "videos":
self.play_requested.emit(data)
def _on_failed(self, gen, index, name):
if gen != self._generation:
return
if index not in self.missing:
self.missing.append(index)
logging.warning("no cover for %r", name)
self._update_status()
def _on_loading_done(self):
sender = self.sender()
if sender is not None and getattr(sender, "gen", None) != self._generation:
return
self._loading = False
self._update_status()
def _update_status(self):
if self._loading:
self.status.setText("Loading covers…")
self.missing_changed.emit(0)
return
total = len(self.items)
missing = len(self.missing)
self.status.setText(f"{total - missing}/{total} covers · {missing} missing")
self.missing_changed.emit(missing)
def _on_context_menu(self, pos):
item = self.list.itemAt(pos)
if item is None:
return
idx = self.list.row(item)
it = self.items[idx] if 0 <= idx < len(self.items) else None
menu = QMenu(self)
if it is not None and it.get("path"):
menu.addAction("Open containing folder", lambda: open_containing_folder(it.get("path")))
if it is not None:
if it.get("system") != "videos":
menu.addAction("Play on PPSSPP", lambda: self.play_requested.emit(it))
menu.addAction("Copy to PSP", lambda: self.copy_requested.emit(it))
if it.get("on_psp"):
menu.addAction("Delete from PSP", lambda: self.delete_psp_requested.emit(it))
menu.addAction("Delete…", lambda: self.delete_requested.emit(it))
if it is not None:
menu.addAction("Scrape…", lambda: self.scrape_requested.emit(it))
menu.exec(self.list.mapToGlobal(pos))
def search_missing(self):
"""Open the scrape dialog for the first missing cover (if any)."""
if self.missing:
idx = self.missing[0]
if 0 <= idx < len(self.items):
self.scrape_requested.emit(self.items[idx])
def refresh_item(self, it):
"""Re-read an item's cover from disk and update its icon."""
rid = it.get("release_id") or covers.stable_key(it.get("name") or "")
system = it.get("system") or "games"
path = covers.cover_path(rid, system)
if path is None or not path.exists():
return
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)
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)
self._update_status()
break
def update_item_label(self, it):
"""Refresh a single item's label after a manual retag."""
for i in range(self.list.count()):
list_item = self.list.item(i)
data = list_item.data(Qt.UserRole)
if data is not None and data.get("release_id") == it.get("release_id"):
list_item.setText(_item_label(it))
break
def _fit_icon(self, pm):
canvas = QPixmap(self.ICON_W, self.ICON_H)
canvas.fill(Qt.transparent)
if pm.isNull():
return QIcon(canvas)
scaled = pm.scaled(self.ICON_W, self.ICON_H, Qt.KeepAspectRatio, Qt.SmoothTransformation)
x = (self.ICON_W - scaled.width()) // 2
y = (self.ICON_H - scaled.height()) // 2
painter = QPainter(canvas)
painter.drawPixmap(x, y, scaled)
painter.end()
return QIcon(canvas)
def shutdown(self):
self._closing = True
for loader in self._loaders:
loader.cancel()
for t in self._threads:
if _thread_running(t):
t.quit()
t.wait()
self._threads = []
self._loaders = []
self.thread = None
self.loader = None
class MetadataWorker(QObject):
result = Signal(int, dict) # gen, meta
finished = Signal()
def __init__(self, title, gen, system, release_id):
super().__init__()
self.title = title
self.gen = gen
self.system = system
self.release_id = release_id
def run(self):
meta = info.get_or_fetch(self.release_id, self.system, self.title)
self.result.emit(self.gen, meta or {})
self.finished.emit()
class GameDetailsPanel(QWidget):
edit_requested = Signal() # emitted when the user wants to hand-tag the item
link_requested = Signal() # emitted when the user wants to pick a movie/game to link
convert_requested = Signal() # emitted when the user wants to convert a PS1 disc to EBOOT
copy_requested = Signal() # emitted when the user wants to copy the current item to the PSP
delete_requested = Signal() # emitted when the user wants to delete the current item
play_requested = Signal() # emitted when the user wants to launch the item in PPSSPP
delete_psp_requested = Signal() # emitted when the user wants to delete the current item from the PSP
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedWidth(300)
self._youtube_url = ""
self._meta = None
self._copy_available = False
outer = QVBoxLayout(self)
outer.setContentsMargins(12, 12, 0, 12)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
outer.addWidget(scroll)
content = QWidget()
self.layout = QVBoxLayout(content)
self.layout.setContentsMargins(6, 6, 6, 6)
self.layout.setSpacing(6)
scroll.setWidget(content)
self.cover = QLabel()
self.cover.setAlignment(Qt.AlignCenter)
self.cover.setVisible(False)
self.layout.addWidget(self.cover)
self.title = QLabel("Select a game")
title_font = QFont()
title_font.setPointSize(16)
title_font.setBold(True)
self.title.setFont(title_font)
self.title.setWordWrap(True)
self.layout.addWidget(self.title)
self.subtitle = QLabel("")
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)
self.meta_grid.setVerticalSpacing(6)
self.layout.addLayout(self.meta_grid)
self.layout.addSpacing(8)
self.synopsis_label = QLabel("<b>Synopsis</b>")
self.layout.addWidget(self.synopsis_label)
self.synopsis = QLabel("")
self.synopsis.setWordWrap(True)
self.layout.addWidget(self.synopsis)
self.layout.addSpacing(12)
self.youtube_btn = QPushButton("\u25b6 Watch trailer")
self.youtube_btn.setVisible(False)
self.youtube_btn.clicked.connect(self._open_youtube)
self.layout.addWidget(self.youtube_btn)
self.layout.addSpacing(8)
self.play_btn = QPushButton("\u25b6 Play on PPSSPP")
self.play_btn.clicked.connect(self.play_requested.emit)
self.play_btn.setEnabled(False)
self.play_btn.setToolTip("Launch this game in PPSSPP")
self.layout.addWidget(self.play_btn)
self.convert_btn = QPushButton("\u2699 Convert to EBOOT")
self.convert_btn.clicked.connect(self.convert_requested.emit)
self.convert_btn.setVisible(False)
self.convert_btn.setToolTip("Convert this PS1 disc to an EBOOT.PBP")
self.layout.addWidget(self.convert_btn)
btn_grid = QGridLayout()
btn_grid.setHorizontalSpacing(6)
btn_grid.setVerticalSpacing(6)
self.link_btn = QPushButton("Scrape…")
self.link_btn.clicked.connect(self.link_requested.emit)
btn_grid.addWidget(self.link_btn, 0, 0)
self.edit_btn = QPushButton("Edit details…")
self.edit_btn.clicked.connect(self.edit_requested.emit)
btn_grid.addWidget(self.edit_btn, 0, 1)
self.copy_btn = QPushButton("Copy to PSP")
self.copy_btn.clicked.connect(self.copy_requested.emit)
self.copy_btn.setEnabled(False)
self.copy_btn.setToolTip("Copy this disc to the connected PSP")
btn_grid.addWidget(self.copy_btn, 1, 0)
self.delete_btn = QPushButton("Delete")
self.delete_btn.clicked.connect(self.delete_requested.emit)
self.delete_btn.setStyleSheet("color: #e06c6c;")
self.delete_btn.setToolTip("Permanently delete this disc from disk")
btn_grid.addWidget(self.delete_btn, 1, 1)
self.del_psp_btn = QPushButton("Delete from PSP")
self.del_psp_btn.clicked.connect(self.delete_psp_requested.emit)
self.del_psp_btn.setEnabled(False)
self.del_psp_btn.setStyleSheet("color: #e06c6c;")
self.del_psp_btn.setToolTip("Delete this disc from the connected PSP")
btn_grid.addWidget(self.del_psp_btn, 2, 0)
self.layout.addLayout(btn_grid)
self.layout.addStretch(1)
def set_item(self, item):
self.system = item.get("system")
self._ps1_kind = item.get("ps1_kind")
path = item.get("path") or ""
is_ps1 = item.get("system") == "ps1"
if (is_ps1 and item.get("ps1_kind") == "eboot") or item.get("psp_kind") == "eboot":
self.filename.setText(Path(path).parent.name if path else "")
else:
self.filename.setText(Path(path).name if path else "")
self.filename.setToolTip(path)
self.filename.setVisible(bool(path))
is_ps1_disc = is_ps1 and item.get("ps1_kind") == "disc"
self.play_btn.setEnabled(
(item.get("system") == "games" and bool(path) and Path(path).suffix.lower() == ".iso")
or (is_ps1 and item.get("ps1_kind") == "eboot" and bool(path))
)
self.play_btn.setVisible(not is_ps1_disc)
self.convert_btn.setVisible(is_ps1_disc)
self.copy_btn.setEnabled(self._copy_available and not is_ps1_disc)
self.copy_btn.setToolTip(
"Copy this EBOOT to /PSP/GAME" if is_ps1 else "Copy this disc to the connected PSP"
)
self.del_psp_btn.setEnabled(bool(item.get("on_psp")))
self.title.setText(item.get("title_override") or item.get("name") or "Unknown")
parts = []
if item.get("serial"):
parts.append(item["serial"])
if is_ps1 and item.get("ps1_status"):
parts.append(item["ps1_status"])
elif item.get("region"):
parts.append(item["region"])
self.subtitle.setText(" · ".join(parts))
self._clear_meta()
self.synopsis.setText("")
self.youtube_btn.setVisible(False)
self._youtube_url = ""
self._meta = None
self._set_cover(item.get("release_id"), item.get("system"))
def set_copy_available(self, available):
self._copy_available = bool(available)
self.copy_btn.setEnabled(self._copy_available and getattr(self, "_ps1_kind", None) != "disc")
def set_psp_installed(self, on_psp):
self.del_psp_btn.setEnabled(bool(on_psp))
def _set_cover(self, rid, system):
pm = _cover_pixmap(rid, system, 180, 260)
if pm is not None:
self.cover.setPixmap(pm)
self.cover.setVisible(True)
else:
self.cover.clear()
self.cover.setVisible(False)
def set_metadata(self, meta):
self._meta = meta
self._clear_meta()
self.synopsis.setText("")
self.youtube_btn.setVisible(False)
self._youtube_url = ""
if not meta:
self.synopsis_label.setVisible(False)
self.synopsis.setVisible(False)
return
self.synopsis_label.setVisible(True)
self.synopsis.setVisible(True)
if getattr(self, "system", None) == "videos":
fields = (
("Released", meta.get("release_date")),
("Genre", ", ".join(meta.get("genres") or [])),
("Rating", meta.get("rating")),
)
else:
fields = (
("Released", meta.get("release_date")),
("Developer", ", ".join(meta.get("developers") or [])),
("Publisher", ", ".join(meta.get("publishers") or [])),
("Genre", ", ".join(meta.get("genres") or [])),
("Players", meta.get("players")),
("Rating", meta.get("rating")),
)
rows = []
for label, value in fields:
if value:
rows.append((label, str(value)))
for r, (label, value) in enumerate(rows):
lbl = QLabel(f"<b>{label}</b>")
val = QLabel(value)
val.setWordWrap(True)
self.meta_grid.addWidget(lbl, r, 0, Qt.AlignTop)
self.meta_grid.addWidget(val, r, 1)
overview = meta.get("overview")
if overview:
self.synopsis.setText(overview)
youtube = meta.get("youtube")
if youtube:
self._youtube_url = youtube
self.youtube_btn.setVisible(True)
def _clear_meta(self):
while self.meta_grid.count():
it = self.meta_grid.takeAt(0)
w = it.widget()
if w is not None:
w.deleteLater()
def _open_youtube(self):
if self._youtube_url:
QDesktopServices.openUrl(QUrl(self._youtube_url))
def current_meta(self):
return self._meta
class TagDialog(QDialog):
"""Hand-edit the title and metadata of a library item."""
def __init__(self, item, meta, parent=None):
super().__init__(parent)
self.setWindowTitle("Edit details")
self.resize(480, 540)
self.reset_pressed = False
meta = meta or {}
layout = QVBoxLayout(self)
grid = QGridLayout()
grid.setHorizontalSpacing(8)
grid.setVerticalSpacing(8)
self.title_edit = QLineEdit(item.get("title_override") or item.get("name") or "")
self.date_edit = QLineEdit(str(meta.get("release_date") or ""))
self.genre_edit = QLineEdit(", ".join(meta.get("genres") or []))
self.rating_edit = QLineEdit(str(meta.get("rating") or ""))
self.youtube_edit = QLineEdit(str(meta.get("youtube") or ""))
self.synopsis_edit = QPlainTextEdit(str(meta.get("overview") or ""))
for r, (label, widget) in enumerate((
("Title", self.title_edit),
("Released", self.date_edit),
("Genre", self.genre_edit),
("Rating", self.rating_edit),
("Trailer URL", self.youtube_edit),
)):
grid.addWidget(QLabel(f"<b>{label}</b>"), r, 0, Qt.AlignTop)
grid.addWidget(widget, r, 1)
layout.addLayout(grid)
layout.addWidget(QLabel("<b>Synopsis</b>"))
layout.addWidget(self.synopsis_edit, 1)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Reset | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Save).setText("Save")
buttons.button(QDialogButtonBox.Reset).setText("Reset to automatic")
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
buttons.button(QDialogButtonBox.Reset).clicked.connect(self._reset)
layout.addWidget(buttons)
def _reset(self):
self.reset_pressed = True
self.accept()
def result_meta(self):
genres = [g.strip() for g in self.genre_edit.text().split(",") if g.strip()]
return {
"title": self.title_edit.text().strip(),
"release_date": self.date_edit.text().strip(),
"genres": genres,
"rating": self.rating_edit.text().strip() or None,
"youtube": self.youtube_edit.text().strip(),
"overview": self.synopsis_edit.toPlainText().strip(),
}
class ScrapeSearchWorker(QObject):
results = Signal(list) # candidates with thumb_bytes added
finished = Signal()
def __init__(self, system, query):
super().__init__()
self.system = system
self.query = query
def run(self):
found = covers.search_metadata_candidates(self.system, self.query, limit=20, timeout=10)
for r in found:
src = r.get("source")
if src == "openvgdb":
r["thumb_bytes"] = covers.fetch_openvgdb_local(r.get("title") or "")
elif src == "libretro":
data = covers.fetch_libretro_local(r.get("title") or "")
r["thumb_bytes"] = data if data else (
covers.download_image(r.get("thumb_url"), timeout=8) if r.get("thumb_url") else None
)
else:
url = r.get("thumb_url")
r["thumb_bytes"] = covers.download_image(url, timeout=8) if url else None
self.results.emit(found)
self.finished.emit()
class ScrapeFetchWorker(QObject):
result = Signal(dict) # {"meta": {...}, "cover": bytes|None}
finished = Signal()
def __init__(self, system, rid, candidate):
super().__init__()
self.system = system
self.rid = rid
self.candidate = candidate
def run(self):
meta = covers.link_metadata(self.system, self.candidate, timeout=10) or {}
cover = _scrape_cover(self.system, self.candidate, self.rid, timeout=20)
self.result.emit({"meta": meta, "cover": cover})
self.finished.emit()
def _scrape_cover(system, candidate, rid, timeout=20):
"""Download the cover for a metadata candidate; returns bytes or None."""
src = candidate.get("source")
title = candidate.get("title") or ""
# Cover repositories ship offline bundles — read them directly (no network).
if src == "openvgdb" and title:
data = covers.fetch_openvgdb_local(title)
if data:
return data
if src == "libretro" and title:
data = covers.fetch_libretro_local(title)
if data:
return data
url = candidate.get("image_url")
if url:
data = covers.download_image(url, timeout)
if data:
if system == "videos":
return compose_umd_cover(data)
if candidate.get("source") == "igdb" and system == "games":
return covers.compose_game_cover(data)
return data
if title:
path = covers.fetch_cover(title, rid, system, timeout=timeout)
if path is not None:
try:
return path.read_bytes()
except OSError:
return None
return None
class ScrapeDialog(QDialog):
"""Scrape metadata + cover for an item in one go (Plex-style)."""
def __init__(self, item, parent=None, rid=None, system=None):
super().__init__(parent)
self.system = system or item.get("system") or "games"
self.rid = str(rid or item.get("release_id") or covers.stable_key(item.get("name") or ""))
self.setWindowTitle("Scrape metadata & cover")
self.resize(600, 580)
self.candidates = []
self.selected_meta = None
layout = QVBoxLayout(self)
top = QHBoxLayout()
top.addWidget(QLabel("Search:"))
if self.system == "videos":
hint = covers.movie_search_title(item.get("name") or "")
else:
hint = covers.plain_title(item.get("name") or "")
self.query_edit = QLineEdit(hint)
self.query_edit.returnPressed.connect(self._do_search)
top.addWidget(self.query_edit, 1)
self.search_btn = QPushButton("Search")
self.search_btn.clicked.connect(self._do_search)
top.addWidget(self.search_btn)
layout.addLayout(top)
self.list = QListWidget()
self.list.setViewMode(QListWidget.IconMode)
self.list.setIconSize(QSize(90, 120))
self.list.setGridSize(QSize(120, 170))
self.list.setResizeMode(QListWidget.Adjust)
self.list.setWordWrap(True)
self.list.setUniformItemSizes(True)
self.list.itemDoubleClicked.connect(self._scrape)
layout.addWidget(self.list)
self.status = QLabel("Searching…")
layout.addWidget(self.status)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.ok_btn = buttons.button(QDialogButtonBox.Ok)
self.ok_btn.setText("Scrape")
self.ok_btn.setEnabled(False)
buttons.accepted.connect(self._scrape)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
self._threads = []
self._workers = []
self._do_search()
def _do_search(self):
query = self.query_edit.text().strip()
if not query:
return
self.status.setText("Searching…")
self.search_btn.setEnabled(False)
self.ok_btn.setEnabled(False)
self.list.clear()
self.candidates = []
thread = QThread(self)
worker = ScrapeSearchWorker(self.system, query)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.results.connect(self._on_results)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_results(self, results):
self.search_btn.setEnabled(True)
self.candidates = results
self.list.clear()
if not results:
self.status.setText("No results. Try another term.")
return
for r in results:
icon = QIcon()
if r.get("thumb_bytes"):
pm = QPixmap()
pm.loadFromData(r["thumb_bytes"])
icon = QIcon(pm)
year = r.get("year") or (r.get("release_date") or "")[:4]
label = f"{r['title']} ({year})" if year else r["title"]
if r.get("source") not in (None, "igdb", "tmdb"):
label += f" [{r['source']}]"
item = QListWidgetItem(icon, label)
if r.get("overview"):
item.setToolTip(r["overview"])
item.setData(Qt.UserRole, r)
self.list.addItem(item)
self.list.setCurrentRow(0)
self.status.setText(f"{len(results)} result(s). Select one and press Scrape.")
self.ok_btn.setEnabled(True)
def _scrape(self):
item = self.list.currentItem()
if item is None:
return
candidate = item.data(Qt.UserRole)
self.ok_btn.setEnabled(False)
self.search_btn.setEnabled(False)
self.status.setText("Scraping…")
thread = QThread(self)
worker = ScrapeFetchWorker(self.system, self.rid, candidate)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_scraped)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_scraped(self, payload):
meta = payload.get("meta") or {}
cover = payload.get("cover")
if cover:
covers.save_cover(self.rid, self.system, cover)
if meta:
self.selected_meta = meta
self.accept()
elif cover:
self.accept() # a cover with no metadata is still a win
else:
self.ok_btn.setEnabled(True)
self.search_btn.setEnabled(True)
self.status.setText("Could not fetch anything for that selection.")
def closeEvent(self, event):
for t in self._threads:
if _thread_running(t):
t.quit()
t.wait()
self._workers = []
event.accept()
class SettingsPage(QWidget):
saved = Signal()
def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe,
store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user,
iso_category, ps1_category, psp_category, ps1_dir, ps1_output_dir, ps1_base_pbp,
nps_dir="", pkg2zip_path="", parent=None):
super().__init__(parent)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
self.tabs = QTabWidget()
outer.addWidget(self.tabs, 1)
s = QSettings("PSPPreserve", "PSPPreserve")
# -- General ----------------------------------------------------- #
page, form = self._new_form()
self.games_edit = self._dir_row(form, "Games directory:", games_dir)
self.videos_edit = self._dir_row(form, "Videos directory:", videos_dir)
self.ps1_edit = self._dir_row(form, "PS1 directory:", ps1_dir)
self.ps1_out_edit = self._dir_row(form, "PS1 output directory:", ps1_output_dir)
self.ps1_base_edit = self._file_row(form, "PS1 base.pbp (PSN classic):", ps1_base_pbp)
self.auto_scan = QCheckBox("Scan directories automatically on startup")
self.auto_scan.setChecked(str(s.value("auto_scan", False)).lower() == "true")
form.addRow(self.auto_scan)
self.workers_spin = QSpinBox()
self.workers_spin.setRange(1, 16)
self.workers_spin.setValue(int(s.value("scan_workers", 1)))
self.workers_spin.setToolTip("Raise on SSDs / fast network shares; keep 1 on spinning disks.")
form.addRow("Parallel scan workers:", self.workers_spin)
self.tabs.addTab(page, "General")
# -- Store ------------------------------------------------------- #
page, form = self._new_form()
self.store_server_edit = self._line_row(form, "Server URL:", store_server_url)
self.store_token_edit = self._secret_row(form, "API token:", store_token)
self.archive_edit = self._line_row(form, "Archive.org item:", store_archive_id)
self.archive_sig_edit = self._secret_row(form, "Archive.org logged-in-sig:", store_archive_sig)
self.archive_user_edit = self._secret_row(form, "Archive.org logged-in-user:", store_archive_user)
self.store_edit = self._dir_row(form, "Local store dir:", store_dir)
self.nps_edit = self._dir_row(form, "NoPayStation output dir:", nps_dir)
self.pkg2zip_edit = self._file_row(form, "pkg2zip executable (optional fallback):", pkg2zip_path)
self.tabs.addTab(page, "Store")
# -- Saves ------------------------------------------------------- #
page, form = self._new_form()
self.ppsspp_edit = self._dir_row(form, "PPSSPP saves dir:", ppsspp_dir)
self.cloud_edit = self._dir_row(form, "Cloud saves dir:", cloud_dir)
self.ppsspp_exe_edit = self._file_row(form, "PPSSPP executable:", ppsspp_exe)
self.auto_sync = QCheckBox("Auto-sync saves when the PSP is connected")
self.auto_sync.setChecked(str(s.value("auto_sync_saves", "false")).lower() == "true")
form.addRow(self.auto_sync)
self.tabs.addTab(page, "Saves")
# -- PSP --------------------------------------------------------- #
page, form = self._new_form()
self.iso_category_edit = QLineEdit(iso_category)
self.iso_category_edit.setPlaceholderText("e.g. Homebrew (blank = ISO root)")
form.addRow("ISO category (Category Lite):", self.iso_category_edit)
self.ps1_category_edit = QLineEdit(ps1_category)
self.ps1_category_edit.setPlaceholderText("e.g. PS1 (blank = PSP/GAME root)")
form.addRow("PS1 category (Category Lite):", self.ps1_category_edit)
self.psp_category_edit = QLineEdit(psp_category)
self.psp_category_edit.setPlaceholderText("e.g. psp (blank = PSP/GAME root)")
form.addRow("PSP game category (Category Lite):", self.psp_category_edit)
self.tabs.addTab(page, "PSP")
# -- Cover sources ------------------------------------------------ #
page, form = self._new_form()
creds = sources.load()
self.fields = {}
def add_field(label, key, password=False, placeholder="(optional)"):
edit = QLineEdit()
if password:
edit.setEchoMode(QLineEdit.Password)
edit.setText(creds.get(key, ""))
edit.setPlaceholderText(placeholder)
form.addRow(label, edit)
self.fields[key] = edit
note = QLabel(
"libretro-thumbnails is always on and needs no key — it's the default "
"game-cover source. The keys below are optional upgrades."
)
note.setWordWrap(True)
form.addRow(note)
form.addRow(QLabel("<b>Games — covers &amp; metadata</b>"))
add_field("IGDB Client ID:", "igdb_client_id")
add_field("IGDB Client Secret:", "igdb_client_secret", password=True)
add_field("ScreenScraper dev ID:", "screenscraper_devid")
add_field("ScreenScraper dev password:", "screenscraper_devpassword", password=True)
add_field("ScreenScraper user ID:", "screenscraper_ssid")
add_field("ScreenScraper user password:", "screenscraper_sspassword", password=True)
add_field("ScreenScraper software name:", "screenscraper_softname")
form.addRow(QLabel("<b>Videos — metadata &amp; posters</b>"))
add_field("TheMovieDB API key:", "tmdb_api_key", password=True)
add_field("Google API key:", "google_api_key", password=True)
add_field("Google Search Engine ID (cx):", "google_cx")
hint = QLabel(
"Manual UMD covers: drop images into "
f"<tt>{config.DATA_DIR / 'manual_covers'}</tt> "
"(subfolders <tt>games/</tt> and <tt>videos/</tt>)."
)
hint.setWordWrap(True)
form.addRow(hint)
self.clear_covers_btn = QPushButton("Clear cover cache (re-fetch all covers)")
self.clear_covers_btn.clicked.connect(self._clear_cover_cache)
form.addRow(self.clear_covers_btn)
self.tabs.addTab(page, "Cover sources")
self.save_btn = QPushButton("Save settings")
self.save_btn.clicked.connect(self._save)
outer.addWidget(self.save_btn)
def _new_form(self):
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
page = QWidget()
form = QFormLayout(page)
form.setContentsMargins(16, 16, 16, 16)
form.setHorizontalSpacing(16)
form.setVerticalSpacing(10)
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
scroll.setWidget(page)
return scroll, form
def _dir_row(self, form, label, value):
edit = QLineEdit(value)
edit.setPlaceholderText("Select a directory…")
browse = QPushButton("…")
browse.clicked.connect(lambda _=False, e=edit: self._browse(e))
row = QWidget()
h = QHBoxLayout(row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(edit, 1)
h.addWidget(browse)
form.addRow(label, row)
return edit
def _browse(self, edit):
path = QFileDialog.getExistingDirectory(self, "Select directory")
if path:
edit.setText(path)
def _file_row(self, form, label, value):
edit = QLineEdit(value)
edit.setPlaceholderText("Auto-detect (optional)")
browse = QPushButton("…")
browse.clicked.connect(lambda _=False, e=edit: self._browse_file(e))
row = QWidget()
h = QHBoxLayout(row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(edit, 1)
h.addWidget(browse)
form.addRow(label, row)
return edit
def _browse_file(self, edit):
path, _ = QFileDialog.getOpenFileName(self, "Select file")
if path:
edit.setText(path)
def _line_row(self, form, label, value):
edit = QLineEdit(value)
form.addRow(label, edit)
return edit
def _secret_row(self, form, label, value):
edit = QLineEdit(value)
edit.setEchoMode(QLineEdit.Password)
form.addRow(label, edit)
return edit
def games_dir(self):
return self.games_edit.text().strip()
def videos_dir(self):
return self.videos_edit.text().strip()
def store_dir(self):
return self.store_edit.text().strip()
def store_server_url(self):
return self.store_server_edit.text().strip()
def store_token(self):
return self.store_token_edit.text().strip()
def archive_item(self):
return self.archive_edit.text().strip()
def archive_sig(self):
return self.archive_sig_edit.text().strip()
def archive_user(self):
return self.archive_user_edit.text().strip()
def nps_dir(self):
return self.nps_edit.text().strip()
def pkg2zip_path(self):
return self.pkg2zip_edit.text().strip()
def ppsspp_saves_dir(self):
return self.ppsspp_edit.text().strip()
def cloud_saves_dir(self):
return self.cloud_edit.text().strip()
def auto_sync_saves(self):
return self.auto_sync.isChecked()
def ppsspp_exe(self):
return self.ppsspp_exe_edit.text().strip()
def iso_category(self):
return self.iso_category_edit.text().strip()
def ps1_category(self):
return self.ps1_category_edit.text().strip()
def psp_category(self):
return self.psp_category_edit.text().strip()
def ps1_dir(self):
return self.ps1_edit.text().strip()
def ps1_output_dir(self):
return self.ps1_out_edit.text().strip()
def ps1_base_pbp(self):
return self.ps1_base_edit.text().strip()
def _save(self):
sources.save({key: edit.text() for key, edit in self.fields.items()})
covers.clear_miss_cache()
s = QSettings("PSPPreserve", "PSPPreserve")
s.setValue("auto_scan", self.auto_scan.isChecked())
s.setValue("scan_workers", self.workers_spin.value())
s.setValue("auto_sync_saves", self.auto_sync.isChecked())
self.saved.emit()
def _clear_cover_cache(self):
covers.clear_cover_cache()
QMessageBox.information(self, "Cover cache cleared",
"Cached covers deleted.\nRefresh the Library or Store to re-fetch them.")
class StoreScanWorker(QObject):
results = Signal(list)
failed = Signal(str)
finished = Signal()
def __init__(self, root):
super().__init__()
self.root = root
def run(self):
try:
items = store.scan(self.root)
except Exception as exc: # noqa: BLE001
self.failed.emit(str(exc))
self.finished.emit()
return
self.results.emit(items)
self.finished.emit()
class StoreCoverLoader(QObject):
loaded = Signal(int, int, str) # gen, grid row, cached image path
finished = Signal()
def __init__(self, tasks, gen, quick=True):
super().__init__()
self.tasks = tasks # list of (grid_row, item_dict)
self.gen = gen
self.quick = quick
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
from concurrent.futures import ThreadPoolExecutor, as_completed
pool = ThreadPoolExecutor(max_workers=8)
futures = {
pool.submit(load_cover, it, 8, self.quick): row
for row, it in self.tasks
}
try:
for fut in as_completed(futures):
if self._cancel.is_set():
break
row = futures[fut]
try:
path = fut.result()
except Exception: # noqa: BLE001
path = None
if path is not None:
self.loaded.emit(self.gen, row, str(path))
finally:
pool.shutdown(wait=True, cancel_futures=True)
self.finished.emit()
class StoreMetaWorker(QObject):
result = Signal(int, dict) # gen, meta
finished = Signal()
def __init__(self, title, gen, rid, system="games"):
super().__init__()
self.title = title
self.gen = gen
self.rid = rid
self.system = system
def run(self):
meta = info.get_or_fetch(self.rid, self.system, self.title)
self.result.emit(self.gen, meta or {})
self.finished.emit()
class StoreDetailsPanel(QWidget):
"""Metadata sidebar for a selected store item."""
link_requested = Signal()
download_requested = Signal()
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedWidth(300)
self._youtube_url = ""
outer = QVBoxLayout(self)
outer.setContentsMargins(12, 12, 0, 12)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
outer.addWidget(scroll)
content = QWidget()
self.layout = QVBoxLayout(content)
self.layout.setContentsMargins(6, 6, 6, 6)
self.layout.setSpacing(6)
scroll.setWidget(content)
self.cover = QLabel()
self.cover.setAlignment(Qt.AlignCenter)
self.cover.setVisible(False)
self.layout.addWidget(self.cover)
self.title = QLabel("Select a game")
title_font = QFont()
title_font.setPointSize(16)
title_font.setBold(True)
self.title.setFont(title_font)
self.title.setWordWrap(True)
self.layout.addWidget(self.title)
self.subtitle = QLabel("")
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)
self.meta_grid.setVerticalSpacing(6)
self.layout.addLayout(self.meta_grid)
self.layout.addSpacing(8)
self.synopsis_label = QLabel("<b>Synopsis</b>")
self.layout.addWidget(self.synopsis_label)
self.synopsis = QLabel("")
self.synopsis.setWordWrap(True)
self.layout.addWidget(self.synopsis)
self.layout.addSpacing(12)
self.youtube_btn = QPushButton("\u25b6 Watch trailer")
self.youtube_btn.setVisible(False)
self.youtube_btn.clicked.connect(self._open_youtube)
self.layout.addWidget(self.youtube_btn)
self.layout.addSpacing(8)
self.download_btn = QPushButton("\u2b07 Download")
self.download_btn.clicked.connect(self.download_requested.emit)
self.download_btn.setToolTip("Download and extract this game into your library")
self.layout.addWidget(self.download_btn)
self.layout.addSpacing(6)
self.link_btn = QPushButton("Scrape…")
self.link_btn.clicked.connect(self.link_requested.emit)
self.link_btn.setToolTip("Scrape metadata and cover for this archive")
self.layout.addWidget(self.link_btn)
self.layout.addStretch(1)
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")
if size:
parts.append(f"{size / (1024 * 1024):.1f} MB")
if item.get("in_library"):
parts.append("\u2713 In library")
self.subtitle.setText(" · ".join(parts))
self._clear_meta()
self.synopsis.setText("")
self.youtube_btn.setVisible(False)
self._youtube_url = ""
lc = item.get("_lib_cover")
if lc is not None:
rid, system = lc
else:
rid, system = covers.stable_key(item["name"]), "games"
self._set_cover(rid, system)
def _set_cover(self, rid, system):
pm = _cover_pixmap(rid, system, 180, 260)
if pm is not None:
self.cover.setPixmap(pm)
self.cover.setVisible(True)
else:
self.cover.clear()
self.cover.setVisible(False)
def set_metadata(self, meta):
self._clear_meta()
self.synopsis.setText("")
self.youtube_btn.setVisible(False)
self._youtube_url = ""
if not meta:
self.synopsis_label.setVisible(False)
self.synopsis.setVisible(False)
return
self.synopsis_label.setVisible(True)
self.synopsis.setVisible(True)
fields = (
("Released", meta.get("release_date")),
("Developer", ", ".join(meta.get("developers") or [])),
("Publisher", ", ".join(meta.get("publishers") or [])),
("Genre", ", ".join(meta.get("genres") or [])),
("Players", meta.get("players")),
("Rating", meta.get("rating")),
)
rows = [(label, str(value)) for label, value in fields if value]
for r, (label, value) in enumerate(rows):
lbl = QLabel(f"<b>{label}</b>")
val = QLabel(value)
val.setWordWrap(True)
self.meta_grid.addWidget(lbl, r, 0, Qt.AlignTop)
self.meta_grid.addWidget(val, r, 1)
overview = meta.get("overview")
if overview:
self.synopsis.setText(overview)
youtube = meta.get("youtube")
if youtube:
self._youtube_url = youtube
self.youtube_btn.setVisible(True)
def _clear_meta(self):
while self.meta_grid.count():
it = self.meta_grid.takeAt(0)
w = it.widget()
if w is not None:
w.deleteLater()
def _open_youtube(self):
if self._youtube_url:
QDesktopServices.openUrl(QUrl(self._youtube_url))
class StoreDateLoader(QObject):
"""Background metadata fetch: fills in release dates for store rows."""
loaded = Signal(int, int, str) # gen, row, release_date
finished = Signal()
def __init__(self, tasks, gen):
super().__init__()
self.tasks = tasks # list of (row, release_id, system, title)
self.gen = gen
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
for row, rid, system, title in self.tasks:
if self._cancel.is_set():
break
try:
meta = info.get_or_fetch(rid, system, title)
date = (meta or {}).get("release_date") or ""
except Exception: # noqa: BLE001
date = ""
self.loaded.emit(self.gen, row, date)
self.finished.emit()
class StoreScrapeAllWorker(QObject):
"""Bulk scrape for the whole store (release dates, details, and covers)."""
progress = Signal(int, int) # done, total
finished = Signal()
def __init__(self, tasks):
super().__init__()
self.tasks = tasks # list of (rid, system, name, title)
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
total = len(self.tasks)
for i, (rid, system, name, title) in enumerate(self.tasks):
if self._cancel.is_set():
break
try:
info.get_or_fetch(rid, system, title)
covers.fetch_cover(name, rid, system)
except Exception: # noqa: BLE001
pass
self.progress.emit(i + 1, total)
self.finished.emit()
class RemoteStoreScanWorker(QObject):
results = Signal(list)
failed = Signal(str)
finished = Signal()
def __init__(self, source):
super().__init__()
self.source = source
def run(self):
try:
items = self.source.scan()
except Exception as exc: # noqa: BLE001
self.failed.emit(str(exc))
self.finished.emit()
return
self.results.emit(items)
self.finished.emit()
class StorePage(QWidget):
"""Browse the Redump 7z store and queue games for download."""
download_requested = Signal(list) # list of store item dicts
ICON_W, ICON_H = 150, 220
GRID_W, GRID_H = 182, 262
def __init__(self, parent=None):
super().__init__(parent)
self._store_dir = ""
self._server_url = ""
self._token = ""
self._archive_id = ""
self._archive_sig = ""
self._archive_user = ""
self._nps_dir = ""
self._pkg2zip = ""
self._items = []
self._filtered = []
self._lib_norm = []
self._lib_cover = {} # norm -> (release_id, system)
self._psp_norm = [] # norm of library items installed on the PSP
self._cover_attempted = set()
self._generation = 0
self._meta_gen = 0
self._closing = False
self._threads = []
self._workers = []
self._loaders = []
self._dirty = False
self._fill_timer = None
self._scraping = False
self._scrape_dialog = None
self._scrape_worker = None
self._scrape_thread = None
layout = QVBoxLayout(self)
m = layout.contentsMargins()
layout.setContentsMargins(m.left(), m.top(), 0, m.bottom())
bar = QHBoxLayout()
bar.addWidget(QLabel("Search:"))
self.search = QLineEdit()
self.search.setPlaceholderText("Filter by title…")
self.search.textChanged.connect(self._schedule_filter)
bar.addWidget(self.search, 1)
self.source_combo = QComboBox()
self.source_combo.addItems(["Server", "Archive.org", "Local folder", "NoPayStation"])
self.source_combo.currentIndexChanged.connect(self._on_source_changed)
bar.addWidget(self.source_combo)
self.nps_type = QComboBox()
self.nps_type.addItems(["PSP Games", "PS1 Classics"])
self.nps_type.setVisible(False)
self.nps_type.currentIndexChanged.connect(self._on_nps_type_changed)
bar.addWidget(self.nps_type)
self.view_combo = QComboBox()
self.view_combo.addItems(["Cover view", "List view"])
bar.addWidget(self.view_combo)
self.psp_filter = QComboBox()
self.psp_filter.addItems(["All PSP", "On PSP", "Not on PSP"])
self.psp_filter.setToolTip("Filter by whether the archive matches a disc installed on the connected PSP")
self.psp_filter.currentIndexChanged.connect(self._apply_filter)
bar.addWidget(self.psp_filter)
refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(self.refresh)
bar.addWidget(refresh_btn)
self.all_check = QCheckBox("All")
self.all_check.setChecked(False)
self.all_check.setToolTip("Show all games at once (unchecked loads more as you scroll)")
self.all_check.toggled.connect(self._apply_filter)
bar.addWidget(self.all_check)
self.download_btn = QPushButton("Download selected")
self.download_btn.setEnabled(False)
self.download_btn.clicked.connect(self._emit_download)
bar.addWidget(self.download_btn)
self.scrape_btn = QPushButton("Scrape all")
self.scrape_btn.setToolTip("Fetch metadata and covers for every game in the store")
self.scrape_btn.clicked.connect(self._on_scrape_all)
bar.addWidget(self.scrape_btn)
layout.addLayout(bar)
self._debounce = QTimer(self)
self._debounce.setSingleShot(True)
self._debounce.setInterval(250)
self._debounce.timeout.connect(self._apply_filter)
self.stack = QStackedWidget()
self.grid = QListWidget()
self.grid.setViewMode(QListWidget.IconMode)
self.grid.setIconSize(QSize(self.ICON_W, self.ICON_H))
self.grid.setGridSize(QSize(self.GRID_W, self.GRID_H))
self.grid.setResizeMode(QListWidget.Adjust)
self.grid.setWordWrap(True)
self.grid.setUniformItemSizes(True)
self.grid.setSpacing(6)
self.grid.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.grid.itemSelectionChanged.connect(self._on_selection_changed)
self.grid.setContextMenuPolicy(Qt.CustomContextMenu)
self.grid.customContextMenuRequested.connect(self._on_grid_menu)
self.grid.verticalScrollBar().valueChanged.connect(self._on_scroll)
self.stack.addWidget(self.grid)
self.table = QTableWidget(0, 5)
self.table.setHorizontalHeaderLabels(["In library", "Title", "Region", "Size", "Released"])
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
header = self.table.horizontalHeader()
header.resizeSection(0, 80)
header.resizeSection(2, 90)
header.resizeSection(3, 90)
header.resizeSection(4, 110)
self.table.verticalHeader().setVisible(False)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.table.itemSelectionChanged.connect(self._on_selection_changed)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._on_table_menu)
self.table.verticalScrollBar().valueChanged.connect(self._on_scroll)
self.table.setSortingEnabled(True)
self.stack.addWidget(self.table)
self.view_combo.currentIndexChanged.connect(self.stack.setCurrentIndex)
self.details = StoreDetailsPanel()
self.details.link_requested.connect(self._link_item)
self.details.download_requested.connect(self._download_current_item)
splitter = QSplitter(Qt.Horizontal)
splitter.addWidget(self.stack)
splitter.addWidget(self.details)
splitter.setStretchFactor(0, 1)
layout.addWidget(splitter, 1)
self.status = QLabel("")
layout.addWidget(self.status)
def set_store_config(self, local_dir, server_url, token, archive_id="", archive_sig="", archive_user="",
nps_dir="", pkg2zip_path=""):
self._store_dir = local_dir or ""
self._server_url = server_url or ""
self._token = token or ""
self._archive_id = archive_id or ""
self._archive_sig = archive_sig or ""
self._archive_user = archive_user or ""
self._nps_dir = nps_dir or ""
self._pkg2zip = pkg2zip_path or ""
if self._server_url:
self.status.setText(f"Store: {self._server_url}")
elif self._archive_id:
self.status.setText(f"Store: archive.org/{self._archive_id}")
elif self._store_dir:
self.status.setText(f"Store (local): {self._store_dir}")
else:
self.status.setText("Store not configured")
def download_source(self):
if self.source_combo.currentIndex() == 0:
return transfers.RemoteStoreSource(self._server_url, self._token)
if self.source_combo.currentIndex() == 1:
return transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user)
if self.source_combo.currentIndex() == 3:
ct = "psp" if self.nps_type.currentIndex() == 0 else "psx"
return transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip)
return transfers.LocalShareSource(self._store_dir)
def _on_nps_type_changed(self, _idx):
if self.source_combo.currentIndex() == 3:
self._items = []
self._filtered = []
self._dirty = False
self.refresh()
def _on_source_changed(self, _idx):
self.nps_type.setVisible(self.source_combo.currentIndex() == 3)
self._items = []
self._filtered = []
self._dirty = False
self.refresh()
def set_library_items(self, items):
norm, seen, cover = [], set(), {}
psp_norm, psp_seen = [], set()
for it in items or []:
# The store only contains PSP game discs, so ignore UMD videos when
# matching against the library (a game archive must never match a
# same-named movie and inherit its cover/metadata).
system = it.get("system")
if system != "games":
continue
rid = it.get("release_id")
for key in (it.get("canonical"), it.get("name")):
if not key:
continue
n = matcher.normalize_name(key)
if n and n not in seen:
seen.add(n)
norm.append(n)
if rid is not None:
cover[n] = (rid, system)
if it.get("on_psp") and n and n not in psp_seen:
psp_seen.add(n)
psp_norm.append(n)
self._lib_norm = norm
self._lib_cover = cover
self._psp_norm = psp_norm
if self._items:
self._apply_filter()
def refresh(self):
if self._closing:
return
if self.source_combo.currentIndex() == 0: # Server
if not self._server_url:
self.status.setText("Set the store server URL in Settings")
return
log.info("store: fetching from server %s", self._server_url)
self.status.setText("Fetching store from server…")
worker = RemoteStoreScanWorker(transfers.RemoteStoreSource(self._server_url, self._token))
elif self.source_combo.currentIndex() == 1: # Archive.org
if not self._archive_id:
self.status.setText("Set the Archive.org item in Settings")
return
log.info("store: fetching from archive.org %s", self._archive_id)
self.status.setText("Fetching store from archive.org…")
worker = RemoteStoreScanWorker(transfers.ArchiveOrgSource(self._archive_id, self._archive_sig, self._archive_user))
elif self.source_combo.currentIndex() == 3: # NoPayStation
ct = "psp" if self.nps_type.currentIndex() == 0 else "psx"
log.info("store: fetching NoPayStation %s list", ct)
self.status.setText(f"Fetching NoPayStation ({ct.upper()})…")
worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, pkg2zip_path=self._pkg2zip))
else:
if not self._store_dir:
self.status.setText("Set the local store folder in Settings (Advanced)")
return
log.info("store: scanning local folder %s", self._store_dir)
self.status.setText("Scanning local store…")
worker = StoreScanWorker(self._store_dir)
thread = QThread(self)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.results.connect(self._on_scan_results)
worker.failed.connect(self._on_scan_failed)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_scan_failed(self, message):
if self._closing:
return
self._items = []
self._filtered = []
self._dirty = False
log.warning("store unavailable: %s", message)
self.status.setText(f"Store unavailable: {message}")
def _on_scan_results(self, items):
if self._closing:
return
self._items = items
self._cover_attempted.clear()
log.info("store: %d archive(s)", len(items))
self.status.setText(f"{len(items)} archive(s) in store")
self._apply_filter()
def _schedule_filter(self, _text=None):
self._debounce.start()
def _apply_filter(self):
if not hasattr(self, "_items"):
return
q = self.search.text().strip().lower()
dates = info.cached_release_dates()
psp_f = self.psp_filter.currentText() if hasattr(self, "psp_filter") else "All PSP"
filtered = []
for it in self._items:
disp = store.display_name(it["name"]).lower()
if q and q not in disp and q not in it["name"].lower():
continue
n = matcher.normalize_name(it["name"])
it["in_library"] = False
it["on_psp"] = False
it["_lib_cover"] = None
if n:
# Exact normalized match only: region/language/version tags are
# already stripped, and substring matching would false-positive
# sequels (e.g. "Open Tee" matching "Open Tee 2").
for ln in self._lib_norm:
if n == ln:
it["in_library"] = True
it["_lib_cover"] = self._lib_cover.get(ln)
break
for pn in self._psp_norm:
if n == pn:
it["on_psp"] = True
break
if psp_f == "On PSP" and not it.get("on_psp"):
continue
if psp_f == "Not on PSP" and it.get("on_psp"):
continue
lc = it.get("_lib_cover")
if lc is not None:
rid, system = lc
else:
rid, system = covers.stable_key(it["name"]), it.get("system") or "games"
if not it.get("release_date"):
it["release_date"] = dates.get((str(rid), system)) or ""
filtered.append(it)
filtered.sort(key=self._store_sort_key)
self._filtered = filtered
if self.all_check.isChecked():
show = len(filtered)
else:
show = min(self.RECENT_LIMIT, len(filtered))
self._populate(filtered, show)
@staticmethod
def _store_sort_key(it):
d = it.get("release_date") or ""
mtime = it.get("mtime_ns") or 0
if d:
try:
date_int = int(d.replace("-", "")[:8])
except (ValueError, AttributeError):
date_int = 0
return (0, -date_int, -mtime)
return (1, 0, -mtime)
CHUNK = 150 # items filled per timer tick
COVER_BATCH = 200 # covers fetched for the initially-visible area
COVER_SCROLL_BATCH = 100
RECENT_LIMIT = 150 # default: show only the newest N store items
DATE_BATCH = 150 # release-date metadata fetched per batch
def _populate(self, items, show_count):
self._generation += 1
self._filtered = list(items)
self._show_count = show_count
self._fill_index = 0
self._cover_queue = []
self._cover_next = 0
self._date_queue = []
self._date_next = 0
self._cover_attempted.clear()
self._placeholder = QIcon(_placeholder_pixmap(self.ICON_W, self.ICON_H))
# Cancel any in-flight cover loaders (stale generation).
for loader in self._loaders:
loader.cancel()
self._loaders = []
self.grid.clear()
self.table.setUpdatesEnabled(False)
self.table.setSortingEnabled(False)
self.table.setRowCount(show_count)
if show_count <= 0:
self._finish_fill()
return
if self._fill_timer is None:
self._fill_timer = QTimer(self)
self._fill_timer.timeout.connect(self._fill_chunk)
self._fill_timer.start()
def _fill_chunk(self):
if self._closing:
return
items = self._filtered
if self._fill_index >= self._show_count:
self._finish_fill()
return
end = min(self._fill_index + self.CHUNK, self._show_count)
for i in range(self._fill_index, end):
self._add_grid_item(items[i], i)
self._add_table_row(items[i], i)
self._fill_index = end
self.status.setText(f"Loading… {self._fill_index}/{self._show_count}")
def _add_grid_item(self, it, row):
label = store.display_name(it["name"])
if len(label) > 44:
label = label[:43] + "…"
item = QListWidgetItem(self._placeholder, label)
item.setToolTip(it["name"])
item.setTextAlignment(Qt.AlignHCenter | Qt.AlignTop)
item.setData(Qt.UserRole, it)
self.grid.addItem(item)
lc = it.get("_lib_cover")
if lc is not None:
rid, system = lc
else:
rid, system = covers.stable_key(it["name"]), it.get("system") or "games"
p = covers.cover_path(rid, system)
if p.exists() and covers.file_is_image(p):
item.setIcon(QIcon(_fit_pixmap(QPixmap(str(p)), self.ICON_W, self.ICON_H)))
elif rid not in self._cover_attempted:
self._cover_attempted.add(rid)
self._cover_queue.append((row, {
"name": covers.plain_title(it["name"]) or it["name"],
"release_id": rid,
"system": system,
}))
def _add_table_row(self, it, row):
self.table.setItem(row, 0, QTableWidgetItem("✓" if it.get("in_library") else ""))
title = QTableWidgetItem(store.display_name(it["name"]))
title.setData(Qt.UserRole, it)
self.table.setItem(row, 1, title)
self.table.setItem(row, 2, QTableWidgetItem(store.region(it["name"])))
self.table.setItem(row, 3, NumericItem(f"{it['size'] / (1024 * 1024):.1f} MB", it["size"]))
lc = it.get("_lib_cover")
if lc is not None:
rid, system = lc
else:
rid, system = covers.stable_key(it["name"]), it.get("system") or "games"
rel = it.get("release_date") or ""
self.table.setItem(row, 4, QTableWidgetItem(rel))
if not rel and system == "games":
self._date_queue.append((row, str(rid), system,
covers.plain_title(store.display_name(it["name"])) or it["name"]))
def _finish_fill(self):
if self._fill_timer is not None:
self._fill_timer.stop()
self.table.setUpdatesEnabled(True)
self.table.setSortingEnabled(True)
total = len(self._filtered)
shown = self._show_count
if not self.all_check.isChecked() and total > shown:
self.status.setText(f"Latest {shown} of {total} — scroll for more")
else:
self.status.setText(f"{shown} archive(s) in store")
self._load_cover_batch(self.COVER_BATCH)
self._load_date_batch(self.DATE_BATCH)
def _load_cover_batch(self, n):
if self._closing or not self._cover_queue:
return
end = min(self._cover_next + n, len(self._cover_queue))
if self._cover_next >= end:
return
tasks = self._cover_queue[self._cover_next:end]
self._cover_next = end
self._start_cover_loader(self._generation, tasks)
def _active_scrollbar(self):
if self.view_combo.currentIndex() == 0:
return self.grid.verticalScrollBar()
return self.table.verticalScrollBar()
def _on_scroll(self, *_):
if self._closing:
return
bar = self._active_scrollbar()
if bar.maximum() - bar.value() >= 600:
return
self._load_cover_batch(self.COVER_SCROLL_BATCH)
self._load_date_batch(self.DATE_BATCH)
self._load_more()
def _load_more(self):
if self._closing or self.all_check.isChecked():
return
if self._show_count >= len(self._filtered):
return
self._show_count = min(self._show_count + self.RECENT_LIMIT, len(self._filtered))
self.table.setSortingEnabled(False)
self.table.setUpdatesEnabled(False)
self.table.setRowCount(self._show_count)
if self._fill_timer is not None:
self._fill_timer.start()
def _load_date_batch(self, n):
if self._closing or not self._date_queue:
return
end = min(self._date_next + n, len(self._date_queue))
if self._date_next >= end:
return
tasks = self._date_queue[self._date_next:end]
self._date_next = end
self._start_date_loader(self._generation, tasks)
def _start_date_loader(self, gen, tasks):
if self._closing or not tasks:
return
thread = QThread(self)
loader = StoreDateLoader(tasks, gen)
loader.moveToThread(thread)
thread.started.connect(loader.run)
loader.loaded.connect(self._on_date_loaded)
loader.finished.connect(thread.quit)
self._threads.append(thread)
self._loaders.append(loader)
thread.start()
def _on_date_loaded(self, gen, row, date):
if gen != self._generation or not date:
return
if 0 <= row < len(self._filtered):
self._filtered[row]["release_date"] = date
cell = self.table.item(row, 4)
if cell is not None:
cell.setText(date)
def _start_cover_loader(self, gen, tasks):
if self._closing or not tasks:
return
thread = QThread(self)
loader = StoreCoverLoader(tasks, gen, quick=True)
loader.moveToThread(thread)
thread.started.connect(loader.run)
loader.loaded.connect(self._on_cover_loaded)
loader.finished.connect(thread.quit)
self._threads.append(thread)
self._loaders.append(loader)
thread.start()
def _on_cover_loaded(self, gen, row, path):
if gen != self._generation:
return
item = self.grid.item(row)
if item is not None:
item.setIcon(QIcon(_fit_pixmap(QPixmap(path), self.ICON_W, self.ICON_H)))
it = item.data(Qt.UserRole)
shown = getattr(self.details, "_item", None)
if it is not None and shown is not None and it.get("path") == shown.get("path"):
lc = it.get("_lib_cover")
rid, system = (lc if lc is not None else (covers.stable_key(it["name"]), it.get("system") or "games"))
self.details._set_cover(rid, system)
def _selected_items(self):
if self.view_combo.currentIndex() == 0:
return [i.data(Qt.UserRole) for i in self.grid.selectedItems() if i.data(Qt.UserRole)]
items = []
for r in sorted({i.row() for i in self.table.selectionModel().selectedRows()}):
cell = self.table.item(r, 1)
it = cell.data(Qt.UserRole) if cell is not None else None
if it is not None:
items.append(it)
return items
def _on_selection_changed(self):
items = self._selected_items()
self.download_btn.setEnabled(bool(items))
if items:
self._show_details(items[0])
def _show_details(self, it):
if self._closing:
return
self.details.set_item(it)
self._meta_gen += 1
gen = self._meta_gen
lc = it.get("_lib_cover")
if lc is not None:
rid, system = lc
else:
rid, system = covers.stable_key(it["name"]), it.get("system") or "games"
cached = info.load(str(rid), system)
if cached is not None:
self.details.set_metadata(cached)
return
title = covers.plain_title(store.display_name(it["name"])) or it["name"]
thread = QThread(self)
worker = StoreMetaWorker(title, gen, str(rid), system)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_store_meta)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_store_meta(self, gen, meta):
if gen != self._meta_gen:
return
self.details.set_metadata(meta)
def _download_current_item(self):
item = getattr(self.details, "_item", None)
if item is not None:
self.download_requested.emit([item])
def _link_item(self):
item = getattr(self.details, "_item", None)
if item is None:
return
lc = item.get("_lib_cover")
rid, system = lc if lc is not None else (covers.stable_key(item["name"]), item.get("system") or "games")
dlg = ScrapeDialog(item, self, rid=rid, system=system)
if dlg.exec() != QDialog.Accepted:
return
meta = dlg.selected_meta
if meta:
title = meta.get("title") or None
info.save(str(rid), system, meta, manual=True, title=title)
item["title_override"] = title or None
meta["manual"] = True
self.details.set_item(item)
self.details.set_metadata(meta)
self._refresh_item_cover(item, rid, system)
def _on_grid_menu(self, pos):
item = self.grid.itemAt(pos)
if item is None:
return
it = item.data(Qt.UserRole)
if it:
menu = QMenu(self)
menu.addAction("Download", lambda: self.download_requested.emit([it]))
if it.get("path"):
menu.addAction("Open containing folder", lambda: open_containing_folder(it.get("path")))
menu.addAction("Scrape…", lambda: self._search_cover(it))
menu.exec(self.grid.mapToGlobal(pos))
def _on_table_menu(self, pos):
item = self.table.itemAt(pos)
if item is None:
return
row = item.row()
cell = self.table.item(row, 1)
it = cell.data(Qt.UserRole) if cell is not None else None
if it is not None:
menu = QMenu(self)
menu.addAction("Download", lambda: self.download_requested.emit([it]))
if it.get("path"):
menu.addAction("Open containing folder", lambda: open_containing_folder(it.get("path")))
menu.addAction("Scrape…", lambda: self._search_cover(it))
menu.exec(self.table.viewport().mapToGlobal(pos))
def _search_cover(self, it):
system = it.get("system") or "games"
rid = covers.stable_key(it["name"])
dlg = ScrapeDialog({"name": it["name"], "system": system}, self, rid=rid, system=system)
if dlg.exec() == QDialog.Accepted:
meta = dlg.selected_meta
if meta:
title = meta.get("title") or None
info.save(str(rid), "games", meta, manual=True, title=title)
it["title_override"] = title or None
self._refresh_item_cover(it, rid, "games")
def _refresh_item_cover(self, it, rid, system):
p = covers.cover_path(rid, system)
if not p.exists():
return
pm = _fit_pixmap(QPixmap(str(p)), self.ICON_W, self.ICON_H)
for i in range(self.grid.count()):
item = self.grid.item(i)
if item.data(Qt.UserRole) is it:
item.setIcon(QIcon(pm))
break
shown = getattr(self.details, "_item", None)
if shown is it:
self.details._set_cover(rid, system)
def _emit_download(self):
self.download_requested.emit(self._selected_items())
def _scrape_tasks(self):
tasks = []
seen = set()
for it in self._items:
lc = it.get("_lib_cover")
if lc is not None:
rid, system = lc
else:
rid, system = covers.stable_key(it["name"]), it.get("system") or "games"
key = (str(rid), system)
if key in seen:
continue
seen.add(key)
tasks.append((str(rid), system, it["name"],
covers.plain_title(store.display_name(it["name"])) or it["name"]))
return tasks
def _on_scrape_all(self):
if self._closing or self._scraping:
return
tasks = self._scrape_tasks()
if not tasks:
QMessageBox.information(self, "Nothing to scrape", "The store is empty.")
return
self._scraping = True
dlg = QProgressDialog("Fetching metadata and covers for every game…", "Cancel", 0, len(tasks), self)
dlg.setWindowTitle("Scrape store")
dlg.setWindowModality(Qt.WindowModal)
dlg.setMinimumDuration(0)
dlg.setValue(0)
self._scrape_dialog = dlg
thread = QThread(self)
worker = StoreScrapeAllWorker(tasks)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.progress.connect(self._on_scrape_progress)
worker.finished.connect(thread.quit)
dlg.canceled.connect(self._on_scrape_cancelled)
worker.finished.connect(self._on_scrape_done)
self._scrape_thread = thread
self._scrape_worker = worker
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_scrape_cancelled(self):
worker = self._scrape_worker
if worker is not None:
worker.cancel()
dlg = self._scrape_dialog
if dlg is not None:
dlg.close()
# Drop the dialog reference so queued progress signals can't re-show it
# (QProgressDialog re-appears on setValue while a value is pending).
# Keep _scraping True until the worker actually finishes (_on_scrape_done)
# so a re-click doesn't spawn a second scrape while this one winds down.
self._scrape_dialog = None
def _on_scrape_progress(self, done, total):
dlg = self._scrape_dialog
if dlg is not None:
dlg.setLabelText(f"Fetching metadata + covers… {done}/{total}")
dlg.setValue(done)
def _on_scrape_done(self):
self._scraping = False
dlg = self._scrape_dialog
if dlg is not None:
dlg.close()
self._scrape_dialog = None
thread = self._scrape_thread
worker = self._scrape_worker
if thread is not None and thread in self._threads:
self._threads.remove(thread)
if worker is not None and worker in self._workers:
self._workers.remove(worker)
self._scrape_thread = None
self._scrape_worker = None
if not self._closing:
self._apply_filter()
def shutdown(self):
self._closing = True
if self._fill_timer is not None:
self._fill_timer.stop()
for loader in self._loaders:
loader.cancel()
if self._scrape_worker is not None:
self._scrape_worker.cancel()
dlg = self._scrape_dialog
if dlg is not None:
dlg.close()
for t in self._threads:
try:
if t.isRunning():
t.quit()
t.wait()
except RuntimeError:
pass
self._threads = []
self._workers = []
self._loaders = []
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",
"nps-download": "NoPayStation",
}
def __init__(self, parent=None):
super().__init__(parent)
self.queue = None
self._rows = {}
layout = QVBoxLayout(self)
header = QHBoxLayout()
header.addWidget(QLabel("<b>Transfers</b>"))
header.addStretch(1)
self.clear_finished_btn = QPushButton("Clear finished")
self.clear_finished_btn.clicked.connect(self._clear_finished)
header.addWidget(self.clear_finished_btn)
layout.addLayout(header)
self.table = QTableWidget(0, 7)
self.table.setHorizontalHeaderLabels(["Item", "Type", "Status", "Progress", "Destination", "", ""])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(5, QHeaderView.ResizeToContents)
self.table.horizontalHeader().setSectionResizeMode(6, QHeaderView.ResizeToContents)
self.table.verticalHeader().setVisible(False)
layout.addWidget(self.table)
def set_queue(self, q):
self.queue = q
q.job_added.connect(self._on_added)
q.job_updated.connect(self._on_updated)
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._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, 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, 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, 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, 2)
if status_item is not None:
status_item.setText(self._status_text(job))
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, 5)
if isinstance(cancel, QPushButton):
cancel.setEnabled(job["status"] in ("queued", "running"))
delete = self.table.cellWidget(row, 6)
if isinstance(delete, QPushButton):
delete.setEnabled(job["status"] not in ("running", "cancelling"))
def _on_removed(self, job_id):
row = self._rows.pop(job_id, None)
if row is None:
return
self.table.removeRow(row)
for jid, r in self._rows.items():
if r > row:
self._rows[jid] = r - 1
def _clear_finished(self):
if self.queue is not None:
self.queue.clear_finished()
@staticmethod
def _status_text(job):
status = job["status"]
if status == "running":
return f"{job['stage']}…" if job["stage"] else "Running…"
if status == "done":
return "Done"
if status == "skipped":
return "Already present"
if status == "cancelled":
return "Cancelled"
if status == "cancelling":
return "Cancelling…"
if status == "error":
return f"Error: {job['error'][:80]}"
return "Queued"
class UmdReadWorker(QObject):
"""Read PSP_GAME / UMD_VIDEO PARAM.SFO from a mounted UMD ISO."""
result = Signal(int, dict) # gen, payload
finished = Signal()
def __init__(self, gen, iso_path, mount):
super().__init__()
self.gen = gen
self.iso_path = iso_path
self.mount = mount
def run(self):
try:
identity = iso_reader.read_identity(self.iso_path)
size = Path(self.iso_path).stat().st_size
icon = None
for dir_name in ("PSP_GAME", "UMD_VIDEO"):
try:
icon = iso_reader.read_file(self.iso_path, dir_name, "ICON0.PNG")
break
except iso_reader.IsoError:
continue
self.result.emit(self.gen, {
"mount": self.mount,
"iso": self.iso_path,
"identity": identity,
"size": size,
"icon": icon,
})
except Exception as exc: # noqa: BLE001
self.result.emit(self.gen, {"error": f"{type(exc).__name__}: {exc}"})
self.finished.emit()
class PspInventoryWorker(QObject):
"""Read a PSP's inventory + free space off the GUI thread."""
result = Signal(int, dict) # gen, payload
finished = Signal()
def __init__(self, gen, root):
super().__init__()
self.gen = gen
self.root = root
def run(self):
try:
inventory = psp.list_installed(self.root)
usage = psp.free_space(self.root)
except Exception as exc: # noqa: BLE001
self.result.emit(self.gen, {"error": f"{type(exc).__name__}: {exc}"})
self.finished.emit()
return
self.result.emit(self.gen, {"inventory": inventory, "usage": usage})
self.finished.emit()
class PspSyncPage(QWidget):
"""Show what is installed on a connected PSP and read a mounted UMD."""
device_changed = Signal(str) # psp root path ("" when none)
install_requested = Signal(list) # library items to copy to the PSP
dump_requested = Signal(str, str, str) # umd iso path, system, serial
def __init__(self, parent=None):
super().__init__(parent)
self._root = ""
self._inventory = {"games": [], "videos": [], "homebrew": [], "pkg": []}
self._library_items = []
self._lib_raw = {}
self._lib_norm = {}
self._gen = 0
self._umd_gen = 0
self._umd_iso = None
self._umd_serial = None
self._umd_kind = None
self._threads = []
self._workers = []
layout = QVBoxLayout(self)
device_row = QHBoxLayout()
device_row.addWidget(QLabel("PSP device:"))
self.device_combo = QComboBox()
self.device_combo.setMinimumContentsLength(24)
self.device_combo.currentTextChanged.connect(self._on_device_selected)
device_row.addWidget(self.device_combo, 1)
browse_btn = QPushButton("Browse…")
browse_btn.clicked.connect(self._browse)
device_row.addWidget(browse_btn)
refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(self.refresh)
device_row.addWidget(refresh_btn)
eject_btn = QPushButton("Eject")
eject_btn.setToolTip("Unmount the PSP so it is safe to unplug")
eject_btn.clicked.connect(self._eject)
device_row.addWidget(eject_btn)
layout.addLayout(device_row)
self.status = QLabel("No PSP connected")
layout.addWidget(self.status)
self.tabs = QTabWidget()
self.games_table = self._make_table(["", "Name", "Category", "Added", "Release", "Region", "Size"], stretch_col=1)
self.videos_table = self._make_table(["", "Name", "Added", "Size"], stretch_col=1)
self.homebrew_table = self._make_table(["", "ID", "Name", "Added", "Size"], stretch_col=2)
self.pkg_table = self._make_table(["", "Name", "Added", "Size"], stretch_col=1)
self.tabs.addTab(self.games_table, "Games")
self.tabs.addTab(self.videos_table, "UMD Videos")
self.tabs.addTab(self.homebrew_table, "Homebrew / PSN")
self.tabs.addTab(self.pkg_table, "PKG")
self.tabs.addTab(self._build_umd_tab(), "UMD")
self.tabs.addTab(self._build_sync_tab(), "Sync from Library")
layout.addWidget(self.tabs, 1)
for tbl in (self.games_table, self.videos_table, self.homebrew_table, self.pkg_table):
tbl.itemSelectionChanged.connect(self._update_delete_enabled)
tbl.itemChanged.connect(self._update_delete_enabled)
tbl.setContextMenuPolicy(Qt.CustomContextMenu)
tbl.customContextMenuRequested.connect(self._on_table_menu)
self.tabs.currentChanged.connect(self._update_delete_enabled)
del_row = QHBoxLayout()
del_row.addStretch(1)
self.delete_btn = QPushButton("Delete checked from PSP")
self.delete_btn.setEnabled(False)
self.delete_btn.clicked.connect(self._delete_selected)
del_row.addWidget(self.delete_btn)
del_row.addStretch(1)
layout.addLayout(del_row)
self.refresh()
@staticmethod
def _make_table(headers, stretch_col=1):
table = QTableWidget(0, len(headers))
table.setHorizontalHeaderLabels(headers)
table.horizontalHeader().setSectionResizeMode(stretch_col, QHeaderView.Stretch)
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed)
table.horizontalHeader().resizeSection(0, 32)
table.verticalHeader().setVisible(False)
table.setEditTriggers(QAbstractItemView.NoEditTriggers)
table.setSelectionBehavior(QAbstractItemView.SelectRows)
table.setSelectionMode(QAbstractItemView.ExtendedSelection)
table.setSortingEnabled(True)
return table
def root(self):
return self._root
def inventory(self):
return self._inventory
def set_library_items(self, items):
self._library_items = list(items or [])
self._lib_raw = {}
self._lib_norm = {}
for it in self._library_items:
label = it.get("canonical") or it.get("name") or ""
if not label:
continue
stem = Path(label).stem
self._lib_raw.setdefault(stem.lower(), it)
n = matcher.normalize_name(stem)
if n:
self._lib_norm.setdefault(n, it)
self._filter_sync()
self._populate(self._inventory)
def _build_sync_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
bar = QHBoxLayout()
bar.addWidget(QLabel("Search:"))
self.sync_search = QLineEdit()
self.sync_search.setPlaceholderText("Filter library…")
self.sync_search.textChanged.connect(self._filter_sync)
bar.addWidget(self.sync_search, 1)
self.sync_type = QComboBox()
self.sync_type.addItems(["All", "Games", "Videos"])
self.sync_type.currentTextChanged.connect(self._filter_sync)
bar.addWidget(self.sync_type)
self.sync_installed = QComboBox()
self.sync_installed.addItems(["All", "Not installed", "Installed"])
self.sync_installed.setToolTip("Filter by install status on the PSP")
self.sync_installed.currentTextChanged.connect(self._filter_sync)
bar.addWidget(self.sync_installed)
layout.addLayout(bar)
self.sync_table = QTableWidget(0, 4)
self.sync_table.setHorizontalHeaderLabels(["Sync", "Title", "Type", "Size"])
self.sync_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.sync_table.verticalHeader().setVisible(False)
self.sync_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.sync_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.sync_table.setSortingEnabled(True)
layout.addWidget(self.sync_table, 1)
self.sync_btn = QPushButton("Sync selected to PSP")
self.sync_btn.setEnabled(False)
self.sync_btn.clicked.connect(self._emit_install)
layout.addWidget(self.sync_btn)
return page
def _build_umd_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
bar = QHBoxLayout()
bar.addWidget(QLabel("UMD (USB UMD mode):"))
self.umd_refresh_btn = QPushButton("Refresh")
self.umd_refresh_btn.clicked.connect(self.refresh_umd)
bar.addWidget(self.umd_refresh_btn)
self.umd_dump_btn = QPushButton("Copy to library")
self.umd_dump_btn.setEnabled(False)
self.umd_dump_btn.setToolTip("Copy this UMD into your library, then scan and rename it")
self.umd_dump_btn.clicked.connect(self._emit_dump)
bar.addWidget(self.umd_dump_btn)
bar.addStretch(1)
layout.addLayout(bar)
self.umd_status = QLabel("No UMD mounted")
layout.addWidget(self.umd_status)
summary_row = QHBoxLayout()
self.umd_icon = QLabel()
self.umd_icon.setFixedWidth(168)
self.umd_icon.setAlignment(Qt.AlignCenter)
self.umd_icon.setVisible(False)
summary_row.addWidget(self.umd_icon)
self.umd_summary = QLabel("")
self.umd_summary.setWordWrap(True)
summary_row.addWidget(self.umd_summary, 1)
layout.addLayout(summary_row)
self.umd_table = QTableWidget(0, 2)
self.umd_table.setHorizontalHeaderLabels(["Field", "Value"])
self.umd_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.umd_table.verticalHeader().setVisible(False)
self.umd_table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.umd_table.setSelectionMode(QAbstractItemView.NoSelection)
layout.addWidget(self.umd_table, 1)
return page
def refresh_umd(self):
self._umd_gen += 1
gen = self._umd_gen
mounts = psp.umd_mounts()
log.info("umd: %d mount(s) detected", len(mounts))
if not mounts:
self._clear_umd()
return
mount = mounts[0]
iso = psp.find_umd_iso(mount)
if iso is None:
self._clear_umd()
return
self.umd_status.setText(f"Reading UMD… ({Path(mount).name})")
thread = QThread(self)
worker = UmdReadWorker(gen, str(iso), mount)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_umd_result)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_umd_result(self, gen, res):
if gen != self._umd_gen:
return
self.umd_table.setRowCount(0)
if "error" in res:
self._umd_iso = None
self.umd_status.setText(f"UMD read failed: {res['error']}")
self.umd_summary.setText("")
self.umd_dump_btn.setEnabled(False)
self.umd_icon.setVisible(False)
return
ident = res["identity"]
sfo = ident.get("sfo") or {}
title = str(ident.get("title") or "Unknown")
serial = str(ident.get("serial") or Path(res["mount"]).name)
kind = "Game" if ident.get("kind") == "game" else "Video"
self._umd_iso = Path(res["iso"])
self._umd_serial = serial
self._umd_kind = ident.get("kind")
self.umd_dump_btn.setEnabled(True)
self._set_umd_icon(res.get("icon"))
size_mb = res["size"] / (1024 * 1024)
self.umd_status.setText(f"UMD: {title}")
self.umd_summary.setText(
f"<b>{title}</b> · {serial} ({kind}) · {size_mb:.1f} MB · volume {Path(res['mount']).name}"
)
rows = [
("Title", title),
("Disc ID", serial),
("Type", kind),
("Size", f"{size_mb:.1f} MB"),
("Volume", Path(res["mount"]).name),
("File", Path(res["iso"]).name),
]
for key in sorted(sfo):
if key in ("TITLE", "DISC_ID"):
continue
rows.append((str(key), str(sfo[key])))
self.umd_table.setRowCount(len(rows))
for r, (k, v) in enumerate(rows):
self.umd_table.setItem(r, 0, QTableWidgetItem(k))
self.umd_table.setItem(r, 1, QTableWidgetItem(v))
def _set_umd_icon(self, data):
if data:
img = QImage.fromData(data)
if not img.isNull():
pm = QPixmap.fromImage(img).scaled(160, 120, Qt.KeepAspectRatio, Qt.SmoothTransformation)
self.umd_icon.setPixmap(pm)
self.umd_icon.setVisible(True)
return
self.umd_icon.setVisible(False)
def _clear_umd(self):
self._umd_iso = None
self._umd_serial = None
self._umd_kind = None
self.umd_status.setText("No UMD mounted")
self.umd_summary.setText("")
self.umd_table.setRowCount(0)
self.umd_dump_btn.setEnabled(False)
self.umd_icon.setVisible(False)
def _emit_dump(self):
if self._umd_iso is None or not self._umd_iso.exists():
return
system = "videos" if self._umd_kind == "video" else "games"
self.dump_requested.emit(str(self._umd_iso), system, self._umd_serial or "")
def _filter_sync(self):
if not hasattr(self, "sync_table"):
return
q = self.sync_search.text().strip().lower()
typ = self.sync_type.currentText()
inst = self.sync_installed.currentText() if hasattr(self, "sync_installed") else "All"
self.sync_table.setUpdatesEnabled(False)
self.sync_table.setSortingEnabled(False)
self.sync_table.setRowCount(0)
selectable = 0
for it in self._library_items:
if typ == "Games" and it.get("system") != "games":
continue
if typ == "Videos" and it.get("system") != "videos":
continue
on_psp = bool(it.get("on_psp"))
if inst == "Not installed" and on_psp:
continue
if inst == "Installed" and not on_psp:
continue
label = it.get("title_override") or it.get("canonical") or it.get("name") or ""
if q and q not in label.lower():
continue
row = self.sync_table.rowCount()
self.sync_table.insertRow(row)
cb = QTableWidgetItem()
if on_psp:
cb.setCheckState(Qt.Checked)
cb.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled)
cb.setToolTip("Already on PSP")
else:
cb.setCheckState(Qt.Unchecked)
cb.setFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
selectable += 1
self.sync_table.setItem(row, 0, cb)
title = QTableWidgetItem(label)
title.setData(Qt.UserRole, it)
self.sync_table.setItem(row, 1, title)
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)
self.sync_table.setUpdatesEnabled(True)
self.sync_btn.setEnabled(selectable > 0 and bool(self._root))
def _emit_install(self):
items = []
for row in range(self.sync_table.rowCount()):
cb = self.sync_table.item(row, 0)
if (cb is not None and cb.checkState() == Qt.Checked
and bool(cb.flags() & Qt.ItemIsUserCheckable)):
cell = self.sync_table.item(row, 1)
it = cell.data(Qt.UserRole) if cell is not None else None
if it is not None:
items.append(it)
if items:
self.install_requested.emit(items)
def refresh(self):
current = self.device_combo.currentText()
mounts = psp.detect_mounts()
umd = psp.umd_mounts()
log.info("psp sync: %d mount(s), %d umd volume(s) detected", len(mounts), len(umd))
self.device_combo.blockSignals(True)
self.device_combo.clear()
self.device_combo.addItem("")
for m in mounts:
self.device_combo.addItem(m, ("stick", m))
for u in umd:
self.device_combo.addItem(f"UMD: {Path(u).name}", ("umd", u))
if current and current in mounts:
self.device_combo.setCurrentText(current)
elif mounts:
self.device_combo.setCurrentIndex(1) # auto-select the first PSP found
self.device_combo.blockSignals(False)
self._root = self._selected_root()
self.refresh_inventory()
self.refresh_umd()
self.device_changed.emit(self._root)
def _selected_root(self):
data = self.device_combo.currentData()
if isinstance(data, tuple) and data[0] == "stick":
return data[1]
return ""
def refresh_inventory(self):
self._gen += 1
gen = self._gen
if not self._root:
self._inventory = {"games": [], "videos": [], "homebrew": [], "pkg": []}
self.status.setText("No PSP connected")
self._populate(self._inventory)
return
log.info("reading psp inventory from %s…", self._root)
self.status.setText("Reading PSP…")
thread = QThread(self)
worker = PspInventoryWorker(gen, self._root)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_inventory)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_inventory(self, gen, res):
if gen != self._gen:
return
if "error" in res:
self._inventory = {"games": [], "videos": [], "homebrew": [], "pkg": []}
self.status.setText(f"Error reading PSP: {res['error']}")
log.warning("psp inventory error: %s", res["error"])
else:
inventory = res["inventory"]
self._inventory = inventory
log.info("psp inventory: %d game(s), %d video(s), %d homebrew, %d pkg",
len(inventory["games"]), len(inventory["videos"]),
len(inventory["homebrew"]), len(inventory.get("pkg", [])))
usage = res.get("usage")
parts = [
f"{len(inventory['games'])} game(s)",
f"{len(inventory['videos'])} video(s)",
f"{len(inventory['homebrew'])} homebrew",
]
if usage is not None:
free_mb = usage["free"] / (1024 * 1024)
total_mb = usage["total"] / (1024 * 1024)
parts.append(f"free {free_mb:.0f} / {total_mb:.0f} MB")
self.status.setText(" · ".join(parts))
self._populate(self._inventory)
self._update_delete_enabled()
self.device_changed.emit(self._root)
def _populate(self, inventory):
self._fill_table(self.games_table, inventory.get("games", []), "games")
self._fill_table(self.videos_table, inventory.get("videos", []), "videos")
self._fill_table(self.homebrew_table, inventory.get("homebrew", []), "homebrew")
self._fill_table(self.pkg_table, inventory.get("pkg", []), "pkg")
@staticmethod
def _fmt_date(ts):
if not ts:
return ""
try:
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
except (OSError, OverflowError, ValueError):
return ""
def _match_library(self, psp_item):
stem = Path(psp_item["name"]).stem
lib = self._lib_raw.get(stem.lower())
if lib is None:
n = matcher.normalize_name(stem)
lib = self._lib_norm.get(n) if n else None
return lib or {}
def _fill_table(self, table, items, kind):
table.setUpdatesEnabled(False)
table.setSortingEnabled(False)
table.setRowCount(0)
for it in items:
row = table.rowCount()
table.insertRow(row)
cb = QTableWidgetItem()
cb.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
cb.setCheckState(Qt.Unchecked)
cb.setData(Qt.UserRole, it)
table.setItem(row, 0, cb)
name = QTableWidgetItem(it["name"])
name.setData(Qt.UserRole, it)
size = it.get("size") or 0
added = self._fmt_date(it.get("mtime"))
if kind == "games":
table.setItem(row, 1, name)
lib = self._match_library(it)
table.setItem(row, 2, QTableWidgetItem(it.get("category") or ""))
table.setItem(row, 3, QTableWidgetItem(added))
table.setItem(row, 4, QTableWidgetItem(lib.get("release_date") or ""))
table.setItem(row, 5, QTableWidgetItem(lib.get("region") or ""))
table.setItem(row, 6, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size))
elif kind == "homebrew":
table.setItem(row, 1, QTableWidgetItem(it.get("id") or ""))
table.setItem(row, 2, name)
table.setItem(row, 3, QTableWidgetItem(added))
table.setItem(row, 4, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size))
else:
table.setItem(row, 1, name)
table.setItem(row, 2, QTableWidgetItem(added))
table.setItem(row, 3, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size))
table.setSortingEnabled(True)
table.setUpdatesEnabled(True)
def _current_inventory_table(self):
w = self.tabs.currentWidget()
if w in (self.games_table, self.videos_table, self.homebrew_table, self.pkg_table):
return w
return None
def _update_delete_enabled(self, *_):
table = self._current_inventory_table()
self.delete_btn.setEnabled(table is not None and bool(self._checked_items(table)))
def _checked_items(self, table):
items = []
for row in range(table.rowCount()):
cb = table.item(row, 0)
if cb is not None and cb.checkState() == Qt.Checked:
it = cb.data(Qt.UserRole)
if it is not None:
items.append(it)
return items
def _on_table_menu(self, pos):
table = self.sender()
if not isinstance(table, QTableWidget):
return
item = table.itemAt(pos)
if item is None:
return
it = None
cb = table.item(item.row(), 0)
if cb is not None:
it = cb.data(Qt.UserRole)
if it is None:
cell = table.item(item.row(), 1)
it = cell.data(Qt.UserRole) if cell is not None else None
if it is None:
return
menu = QMenu(self)
menu.addAction("Delete…", lambda: self._delete_items([it]))
menu.exec(table.viewport().mapToGlobal(pos))
def _delete_selected(self):
table = self._current_inventory_table()
if table is None:
return
items = self._checked_items(table)
if not items:
QMessageBox.information(self, "Delete from PSP",
"Check the box next to the item(s) you want to delete.")
return
self._delete_items(items)
def _delete_items(self, items):
if not items:
return
preview = ", ".join(it["name"] for it in items[:5])
if len(items) > 5:
preview += f", … (+{len(items) - 5} more)"
res = QMessageBox.question(
self, "Delete from PSP",
f"Permanently delete {len(items)} item(s) from the PSP?\n\n{preview}",
QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel,
)
if res != QMessageBox.Yes:
return
failed = 0
for it in items:
target = Path(it["path"]).parent if it.get("kind") == "homebrew" else Path(it["path"])
if not transfers.remove_path(target):
failed += 1
if failed:
QMessageBox.warning(self, "Delete incomplete", f"{failed} item(s) could not be deleted.")
self.refresh_inventory()
def _on_device_selected(self, text):
self._root = self._selected_root()
self.refresh_inventory()
self.refresh_umd()
self.device_changed.emit(self._root)
def _browse(self):
path = QFileDialog.getExistingDirectory(self, "Select PSP root")
if not path:
return
if not psp.is_psp_root(path):
QMessageBox.warning(self, "Not a PSP", "That folder does not look like a PSP memory stick (no PSP/ or ISO/ folder).")
return
mounts = [self.device_combo.itemText(i) for i in range(self.device_combo.count())]
if path not in mounts:
self.device_combo.addItem(path, ("stick", path))
self.device_combo.setCurrentText(path)
def _eject(self):
if not self._root:
QMessageBox.information(self, "Eject", "No PSP selected.")
return
ok, msg = psp.eject(self._root)
log.info("eject %s -> %s", self._root, "ok" if ok else f"failed: {msg}")
self.refresh()
if ok:
self.status.setText("Ejected — safe to disconnect")
else:
QMessageBox.warning(self, "Eject failed", msg)
def shutdown(self):
for t in self._threads:
try:
if t.isRunning():
t.quit()
t.wait()
except RuntimeError:
pass
self._threads = []
self._workers = []
def build_library_items(ps1_dir="", output_dir="", nps_dir=""):
"""Build the library item list from the DB with a single joined query."""
items = []
seen = set()
overrides = info.override_titles()
dates = info.cached_release_dates()
conn = init_db(config.DB_PATH)
try:
rows = conn.execute(
"SELECT f.path, f.name, f.size, f.crc32, f.md5, f.sha1, f.serial, f.title, f.release_id, "
"r.id AS rid, r.name AS rname, r.region, r.serial AS rserial, d.system "
"FROM files f "
"LEFT JOIN releases r ON r.id = f.release_id "
"LEFT JOIN dats d ON d.id = r.dat_id "
"ORDER BY f.name"
).fetchall()
for row in rows:
if not Path(row["path"]).exists():
continue
key = (row["name"], row["size"])
if key in seen:
continue
seen.add(key)
if row["rid"] is not None:
release = {
"id": row["rid"], "name": row["rname"],
"region": row["region"], "serial": row["rserial"], "system": row["system"],
}
canon_serial = row["serial"] or (
iso_reader.normalize_serial(row["rserial"])
if iso_reader.looks_like_serial(row["rserial"]) else None
)
canonical = namer.canonical_name(release, canon_serial) if release.get("name") else None
else:
record = {
"path": row["path"], "name": row["name"], "size": row["size"],
"serial": row["serial"], "title": row["title"],
"crc32": row["crc32"], "md5": row["md5"], "sha1": row["sha1"],
}
ident = pipeline.identify(conn, record)
release = ident.get("release")
canonical = ident.get("canonical")
if release and release.get("id"):
conn.execute(
"UPDATE files SET release_id = ? WHERE path = ?",
(release.get("id"), row["path"]),
)
if not release or not release.get("name"):
continue
rid = release.get("id") or covers.stable_key(release["name"])
system = release.get("system")
items.append({
"name": release["name"],
"release_id": rid,
"system": system,
"canonical": canonical,
"serial": release.get("serial"),
"region": release.get("region") or namer.region_from_name(release["name"]),
"title_override": overrides.get((system, str(rid))),
"release_date": dates.get((str(rid), system)) or "",
"path": row["path"],
"size": row["size"],
})
conn.commit()
finally:
conn.close()
items.extend(_ps1_library_items(ps1_dir, output_dir, overrides, dates))
items.extend(_nps_library_items(nps_dir, overrides, dates))
return items
def _ps1_library_items(ps1_dir, output_dir, overrides, dates):
"""Converted PS1 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:
# Only converted EBOOTs are shown in the Library; pending source discs
# live in the PS1 tab, where they can be converted.
if e["kind"] != "eboot":
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
def _nps_library_items(nps_dir, overrides, dates):
"""NoPayStation-downloaded PSP games and PS1 Classics, merged into the library.
Scans ``<nps>/PSP/*/EBOOT.PBP`` (PSP PSN games) and ``<nps>/PSX/*/EBOOT.PBP``
(PS1 Classics). PSP games are ``system="games"`` with ``psp_kind="eboot"``;
PSX games are ``system="ps1"`` with ``ps1_kind="eboot"``.
"""
if not nps_dir:
return []
root = Path(nps_dir)
out = []
for sub, system in (("PSP", "games"), ("PSX", "ps1")):
d = root / sub
if not d.is_dir():
continue
for eboot in sorted(d.rglob("EBOOT.PBP")):
if not eboot.is_file():
continue
folder = eboot.parent
name = psp._pbp_title(eboot) or folder.name
rid = covers.stable_key(name)
try:
size = sum(p.stat().st_size for p in folder.rglob("*") if p.is_file())
except OSError:
size = 0
item = {
"name": name,
"release_id": rid,
"system": system,
"canonical": name,
"serial": "",
"region": None,
"title_override": overrides.get((system, str(rid))),
"release_date": dates.get((str(rid), system)) or "",
"path": str(eboot),
"size": size,
}
if system == "ps1":
item["ps1_kind"] = "eboot"
item["ps1_status"] = "converted"
else:
item["psp_kind"] = "eboot"
out.append(item)
return out
class LibraryWorker(QObject):
result = Signal(list)
finished = Signal()
def __init__(self, ps1_dir="", output_dir="", nps_dir=""):
super().__init__()
self.ps1_dir = ps1_dir
self.output_dir = output_dir
self.nps_dir = nps_dir
def run(self):
try:
items = build_library_items(self.ps1_dir, self.output_dir, self.nps_dir)
except Exception: # noqa: BLE001
items = []
self.result.emit(items)
self.finished.emit()
class SaveSyncPlanWorker(QObject):
"""Compute the save-sync plan off the GUI thread (content hashing is I/O-heavy)."""
plan = Signal(list, bool) # plan, switch_to_queue
finished = Signal()
def __init__(self, locations, switch_tab=False):
super().__init__()
self.locations = locations
self.switch_tab = switch_tab
def run(self):
try:
plan = saves.sync_plan(self.locations)
except Exception: # noqa: BLE001
plan = []
self.plan.emit(plan, self.switch_tab)
self.finished.emit()
class SavesPage(QWidget):
"""PSP save-data sync between PSP, PPSSPP, and a cloud folder."""
sync_requested = Signal(list, bool) # (plan, switch_to_queue)
def __init__(self, parent=None):
super().__init__(parent)
self._psp_dir = ""
self._ppsspp_dir = ""
self._cloud_dir = ""
self._syncing = False
self._closing = False
self._threads = []
self._workers = []
layout = QVBoxLayout(self)
bar = QHBoxLayout()
refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(self.refresh)
bar.addWidget(refresh_btn)
self.sync_btn = QPushButton("Sync now")
self.sync_btn.clicked.connect(lambda: self.sync_now(True))
bar.addWidget(self.sync_btn)
self.delete_btn = QPushButton("Delete selected")
self.delete_btn.setEnabled(False)
self.delete_btn.setStyleSheet("color: #e06c6c;")
self.delete_btn.clicked.connect(self._delete_checked)
bar.addWidget(self.delete_btn)
bar.addStretch(1)
layout.addLayout(bar)
self.table = QTableWidget(0, 6)
self.table.setHorizontalHeaderLabels(["", "Save", "ID", "PSP", "PPSSPP", "Cloud"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed)
self.table.setColumnWidth(0, 28)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSortingEnabled(True)
self.table.setIconSize(QSize(56, 40))
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._on_context_menu)
self.table.itemChanged.connect(self._on_item_changed)
self.table.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder)
layout.addWidget(self.table)
self.status = QLabel("")
layout.addWidget(self.status)
self._data = {}
self._populating = False
def _save_icon(self, folder):
"""Return a small QIcon from a save folder's ICON0.PNG, or None."""
if not folder:
return None
p = Path(folder) / "ICON0.PNG"
if not p.is_file():
return None
pm = QPixmap(str(p))
if pm.isNull():
return None
return QIcon(_fit_pixmap(pm, 56, 40))
def set_locations(self, psp_dir, ppsspp_dir, cloud_dir):
self._psp_dir = psp_dir or ""
self._ppsspp_dir = ppsspp_dir or ""
self._cloud_dir = cloud_dir or ""
self.refresh()
def _locations(self):
return {"psp": self._psp_dir, "ppsspp": self._ppsspp_dir, "cloud": self._cloud_dir}
def _checked_ids(self):
ids = []
for row in range(self.table.rowCount()):
cb = self.table.item(row, 0)
if cb is not None and cb.checkState() == Qt.Checked:
sid = cb.data(Qt.UserRole)
if sid:
ids.append(sid)
return ids
def _on_item_changed(self, item):
if getattr(self, "_populating", False) or item.column() != 0:
return
self._update_delete_enabled()
def _update_delete_enabled(self):
self.delete_btn.setEnabled(bool(self._checked_ids()))
def _on_context_menu(self, pos):
item = self.table.itemAt(pos)
if item is None:
return
row = item.row()
cb = self.table.item(row, 0)
sid = cb.data(Qt.UserRole) if cb is not None else None
if not sid:
id_item = self.table.item(row, 2)
sid = id_item.text() if id_item is not None else None
if not sid:
return
menu = QMenu(self)
menu.addAction("Export…", lambda: self._export_save(sid))
menu.addAction("Delete…", lambda: self._delete_saves([sid]))
menu.exec(self.table.viewport().mapToGlobal(pos))
def _delete_checked(self):
ids = self._checked_ids()
if ids:
self._delete_saves(ids)
def _delete_saves(self, sids):
targets = []
seen = set()
for sid in sids:
for loc in ("psp", "ppsspp", "cloud"):
s = self._data.get(loc, {}).get(sid)
if not s:
continue
p = s.get("path")
if p and p not in seen:
seen.add(p)
targets.append(p)
if not targets:
QMessageBox.information(self, "Delete saves", "No local copies found.")
return
preview = "\n".join(targets[:8])
if len(targets) > 8:
preview += f"\n… (+{len(targets) - 8} more)"
res = QMessageBox.question(
self, "Delete saves",
f"Delete these save folders from disk?\n\n{preview}",
QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel,
)
if res != QMessageBox.Yes:
return
failed = 0
for p in targets:
if not transfers.remove_path(Path(p)):
failed += 1
if failed:
QMessageBox.warning(self, "Delete incomplete", f"{failed} folder(s) could not be deleted.")
self.refresh()
def _export_save(self, sid):
src = None
for loc in ("psp", "ppsspp", "cloud"):
s = self._data.get(loc, {}).get(sid)
if s and Path(s.get("path", "")).is_dir():
src = s["path"]
break
if not src:
QMessageBox.information(self, "Export save", "No local copy found.")
return
dest = QFileDialog.getExistingDirectory(self, "Export save folder")
if not dest:
return
try:
transfers.copy_tree(Path(src), Path(dest) / sid)
except Exception as exc: # noqa: BLE001
QMessageBox.warning(self, "Export failed", str(exc))
return
QMessageBox.information(self, "Export save", f"Exported to:\n{Path(dest) / sid}")
def refresh(self):
locs = self._locations()
data = {name: {s["id"]: s for s in saves.list_saves(root)} for name, root in locs.items()}
self._data = data
all_ids = set()
for d in data.values():
all_ids.update(d)
sort_col = self.table.horizontalHeader().sortIndicatorSection()
sort_order = self.table.horizontalHeader().sortIndicatorOrder()
self.table.setSortingEnabled(False)
self._populating = True
self.table.setRowCount(0)
for sid in sorted(all_ids):
row = self.table.rowCount()
self.table.insertRow(row)
cb = QTableWidgetItem()
cb.setFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
cb.setCheckState(Qt.Unchecked)
cb.setData(Qt.UserRole, sid)
self.table.setItem(row, 0, cb)
title = sid
enc = False
icon = None
for loc in ("psp", "ppsspp", "cloud"):
s = data[loc].get(sid)
if s is None:
continue
if s.get("title"):
title = s["title"]
if s.get("encrypted"):
enc = True
if icon is None:
icon = self._save_icon(s.get("path"))
if enc:
title += " \u26a0"
t = QTableWidgetItem(title)
t.setToolTip(sid)
if icon is not None:
t.setIcon(icon)
self.table.setItem(row, 1, t)
self.table.setItem(row, 2, QTableWidgetItem(sid))
for col, loc in enumerate(("psp", "ppsspp", "cloud"), start=3):
s = data[loc].get(sid)
if s is None:
self.table.setItem(row, col, QTableWidgetItem(""))
else:
stamp = datetime.datetime.fromtimestamp(s["mtime"]).strftime("%Y-%m-%d %H:%M")
item = QTableWidgetItem(stamp)
item.setToolTip(f"{s['size'] / 1024:.1f} KB")
self.table.setItem(row, col, item)
self._populating = False
self.table.setSortingEnabled(True)
self.table.sortByColumn(sort_col, sort_order)
self.status.setText(f"{len(all_ids)} save(s) across {sum(1 for r in locs.values() if r)} location(s)")
self._update_delete_enabled()
def sync_now(self, switch_tab=False):
if self._syncing:
return
self._syncing = True
self.sync_btn.setEnabled(False)
self.status.setText("Comparing saves…")
thread = QThread(self)
worker = SaveSyncPlanWorker(self._locations(), switch_tab)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.plan.connect(self._on_sync_plan)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_sync_plan(self, plan, switch_tab):
if self._closing:
return
self._syncing = False
self.sync_btn.setEnabled(True)
if not plan:
self.status.setText("Already in sync")
return
self.status.setText(f"Queued {len(plan)} sync operation(s)…")
self.sync_requested.emit(plan, switch_tab)
def shutdown(self):
self._closing = True
for t in self._threads:
if _thread_running(t):
t.quit()
t.wait()
self._threads = []
self._workers = []
class Ps1ScanWorker(QObject):
results = Signal(list)
failed = Signal(str)
def __init__(self, ps1_dir, output_dir):
super().__init__()
self.ps1_dir = ps1_dir
self.output_dir = output_dir
def run(self):
try:
self.results.emit(ps1.scan_library(self.ps1_dir, self.output_dir))
except Exception as exc: # noqa: BLE001
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 that still need converting."""
convert_requested = Signal(dict)
def __init__(self, parent=None):
super().__init__(parent)
self._ps1_dir = ""
self._output_dir = ""
self._entries = []
self._thread = None
self._worker = None
self._preview_gen = 0
self._preview_threads = []
self._preview_workers = []
layout = QVBoxLayout(self)
bar = QHBoxLayout()
self.status = QLabel("PS1 discs — set the PS1 folder in Settings → General.")
bar.addWidget(self.status, 1)
self.refresh_btn = QPushButton("Refresh")
self.refresh_btn.clicked.connect(self.refresh)
self.convert_btn = QPushButton("Convert selected")
self.convert_btn.clicked.connect(self._convert_selected)
bar.addWidget(self.refresh_btn)
bar.addWidget(self.convert_btn)
layout.addLayout(bar)
self.table = QTableWidget(0, 4)
self.table.setHorizontalHeaderLabels(["Type", "Name", "Serial", "Status"])
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.verticalHeader().setVisible(False)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
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
self._output_dir = output_dir
def refresh(self):
if not self._ps1_dir:
self.status.setText("PS1 folder not set — configure it in Settings → General.")
self.table.setRowCount(0)
self._entries = []
return
if self._thread is not None and _thread_running(self._thread):
return
self.status.setText("Scanning PS1 folders…")
self._worker = Ps1ScanWorker(self._ps1_dir, self._output_dir)
self._thread = QThread(self)
self._worker.moveToThread(self._thread)
self._thread.started.connect(self._worker.run)
self._worker.results.connect(self._on_results)
self._worker.failed.connect(self._on_failed)
self._worker.results.connect(self._thread.quit)
self._worker.failed.connect(self._thread.quit)
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):
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("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}")
def _convert_selected(self):
row = self.table.currentRow()
if row < 0 or row >= len(self._entries):
return
e = self._entries[row]
if e["kind"] != "disc":
QMessageBox.information(self, "PS1", "Select a raw PS1 disc (Disc row), not an EBOOT.")
return
if e["status"] == "converted":
QMessageBox.information(self, "PS1", "This disc already has a converted EBOOT.")
return
game_id = e.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.convert_requested.emit({"source": e["path"], "title": e["name"], "game_id": game_id})
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("psp preservation tool ~ quoije")
self.resize(1240, 780)
self._rows = []
self._renames = []
self.thread = None
self.worker = None
self._games_root = None
self._videos_root = None
self._library_cache = []
self._meta_threads = []
self._meta_workers = []
self._meta_gen = 0
self._closing = False
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 = []
self._library_workers = []
self._library_loading = False
self.settings = QSettings("PSPPreserve", "PSPPreserve")
self._games_dir = self._first_existing(config.GAMES_ROOTS) or self.settings.value("games_dir", "") or ""
self._videos_dir = self._first_existing(config.VIDEO_ROOTS) or self.settings.value("videos_dir", "") or ""
self._store_dir = str(self.settings.value("store_dir", str(config.STORE_ROOT)) or "")
self._store_server_url = str(self.settings.value("store_server_url", config.STORE_SERVER_URL) or "")
self._store_token = str(self.settings.value("store_token", config.STORE_TOKEN) or "")
self._store_archive_id = str(self.settings.value("store_archive_id", config.ARCHIVE_ITEM) or "")
self._store_archive_sig = str(self.settings.value("store_archive_sig", config.ARCHIVE_LOGGED_IN_SIG) or "")
self._store_archive_user = str(self.settings.value("store_archive_user", config.ARCHIVE_LOGGED_IN_USER) or "")
self._ppsspp_saves_dir = str(self.settings.value("ppsspp_saves_dir", str(saves.default_ppsspp_saves())) or "")
self._cloud_saves_dir = str(self.settings.value("cloud_saves_dir", "") or "")
self._auto_sync_saves = str(self.settings.value("auto_sync_saves", "false")).lower() == "true"
self._ppsspp_exe = str(self.settings.value("ppsspp_exe", "") or "")
self._iso_category = str(self.settings.value("iso_category", "") or "")
self._ps1_category = str(self.settings.value("ps1_category", "") or "")
self._psp_category = str(self.settings.value("psp_category", "") or "")
self._ps1_dir = self._first_existing(config.PS1_ROOTS) or str(self.settings.value("ps1_dir", "") or "")
self._ps1_output_dir = str(self.settings.value("ps1_output_dir", str(config.PS1_OUTPUT_DIR)) or "")
self._ps1_base_pbp = str(self.settings.value("ps1_base_pbp", "") or "")
self._nps_dir = str(self.settings.value("nps_dir", str(config.NPS_OUTPUT_DIR)) or "")
self._pkg2zip = str(self.settings.value("pkg2zip_path", config.PKG2ZIP_PATH) or "")
log.info("games dir: %s", self._games_dir or "(none)")
log.info("videos dir: %s", self._videos_dir or "(none)")
log.info("store: %s", self._store_server_url or self._store_dir or "(none)")
log.info("archive.org item: %s", self._store_archive_id or "(none)")
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
layout.setContentsMargins(8, 8, 0, 8)
self.tabs = QTabWidget()
self.tabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 0px; }")
layout.addWidget(self.tabs)
self.queue = queue.TransferQueue(self)
self.queue.job_finished.connect(self._on_queue_job_done)
self.tabs.addTab(self._build_library_tab(), "Library")
self.store_page = StorePage()
self.store_page.set_store_config(self._store_dir, self._store_server_url, self._store_token,
self._store_archive_id, self._store_archive_sig, self._store_archive_user,
self._nps_dir, self._pkg2zip)
self.store_page.download_requested.connect(self._on_store_download)
self.tabs.addTab(self.store_page, "Store")
self.queue_page = QueuePage()
self.queue_page.set_queue(self.queue)
self.tabs.addTab(self.queue_page, "Queue")
self.psp_page = PspSyncPage()
self.psp_page.device_changed.connect(self._on_psp_device_changed)
self.psp_page.install_requested.connect(self._copy_items_to_psp)
self.psp_page.dump_requested.connect(self._on_umd_dump)
self.tabs.addTab(self.psp_page, "PSP Sync")
self.saves_page = SavesPage()
self.saves_page.sync_requested.connect(self._on_saves_sync)
self.tabs.addTab(self.saves_page, "Saves")
self.ps1_page = Ps1Page()
self.ps1_page.set_dirs(self._ps1_dir, self._ps1_output_dir)
self.ps1_page.convert_requested.connect(self._on_ps1_convert)
self.tabs.addTab(self.ps1_page, "PS1")
self.tabs.addTab(self._build_settings_tab(), "Settings")
self.tabs.currentChanged.connect(self._on_tab_changed)
self._on_psp_device_changed(self.psp_page.root())
self._update_saves_locations()
self.statusBar().showMessage("")
self._update_dir_status()
self._current_item = None
geometry = self.settings.value("window_geometry")
if geometry:
self.restoreGeometry(geometry)
QTimer.singleShot(0, self.load_library)
QTimer.singleShot(500, self.store_page.refresh)
if (str(self.settings.value("auto_scan", False)).lower() == "true"
and (self._games_dir or self._videos_dir)):
QTimer.singleShot(0, self._start_scan)
# -- Library tab ------------------------------------------------------- #
def _build_library_tab(self):
page = QWidget()
layout = QVBoxLayout(page)
m = layout.contentsMargins()
layout.setContentsMargins(m.left(), m.top(), 0, m.bottom())
bar = QHBoxLayout()
bar.addWidget(QLabel("Search:"))
self.library_search = QLineEdit()
self.library_search.setPlaceholderText("Filter by title…")
self.library_search.textChanged.connect(self._apply_library_filter)
bar.addWidget(self.library_search, 1)
self.library_type = QComboBox()
self.library_type.addItems(["All types", "Games", "Videos", "PS1"])
self.library_type.currentTextChanged.connect(self._apply_library_filter)
bar.addWidget(self.library_type)
self.library_psp = QComboBox()
self.library_psp.addItems(["All PSP", "On PSP", "Not on PSP"])
self.library_psp.setToolTip("Filter by whether the disc is installed on the connected PSP")
self.library_psp.currentTextChanged.connect(self._apply_library_filter)
bar.addWidget(self.library_psp)
self.library_view = QComboBox()
self.library_view.addItems(["Cover view", "List view"])
bar.addWidget(self.library_view)
reload_btn = QPushButton("Reload")
reload_btn.clicked.connect(self.load_library)
bar.addWidget(reload_btn)
self.search_missing_btn = QPushButton("Scrape missing…")
self.search_missing_btn.setEnabled(False)
self.search_missing_btn.setToolTip("Scrape metadata and covers for games that are missing one")
bar.addWidget(self.search_missing_btn)
self.scan_shortcut_btn = QPushButton("Scan…")
self.scan_shortcut_btn.setToolTip("Scan your folders for new discs and apply renames")
self.scan_shortcut_btn.clicked.connect(self._open_scan)
bar.addWidget(self.scan_shortcut_btn)
layout.addLayout(bar)
splitter = QSplitter(Qt.Horizontal)
self.library_subtabs = QTabWidget()
self.library_subtabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 0px; }")
self.library_subtabs.addTab(self._build_library_view(), "Library")
self.library_subtabs.addTab(self._build_scan_view(), "Scan")
splitter.addWidget(self.library_subtabs)
self.details_panel = GameDetailsPanel()
splitter.addWidget(self.details_panel)
splitter.setStretchFactor(0, 1)
layout.addWidget(splitter)
self.library_subtabs.currentChanged.connect(self._on_library_subtab_changed)
self.cover_grid.selected.connect(self._on_library_selected)
self.cover_grid.cover_loaded.connect(self._on_cover_loaded)
self.cover_grid.missing_changed.connect(lambda n: self.search_missing_btn.setEnabled(n > 0))
self.search_missing_btn.clicked.connect(self.cover_grid.search_missing)
self.cover_grid.copy_requested.connect(self._copy_to_psp)
self.cover_grid.delete_requested.connect(self._delete_library_item)
self.cover_grid.delete_psp_requested.connect(self._delete_from_psp)
self.cover_grid.play_requested.connect(self._play_item_on_ppsspp)
self.cover_grid.scrape_requested.connect(self._scrape_grid_item)
self.details_panel.edit_requested.connect(self._edit_details)
self.details_panel.link_requested.connect(self._scrape_item)
self.details_panel.convert_requested.connect(self._convert_current_item)
self.details_panel.copy_requested.connect(self._copy_current_to_psp)
self.details_panel.delete_requested.connect(self._delete_current)
self.details_panel.delete_psp_requested.connect(self._delete_current_from_psp)
self.details_panel.play_requested.connect(self._play_current_on_ppsspp)
return page
def _open_scan(self):
self.library_subtabs.setCurrentWidget(self.scan_view)
self._start_scan()
def _on_library_subtab_changed(self, index):
self.details_panel.setVisible(self.library_subtabs.widget(index) is not self.scan_view)
def _build_library_view(self):
self.library_stack = QStackedWidget()
self.cover_grid = CoverShowcase()
self.library_stack.addWidget(self.cover_grid)
self.library_table = QTableWidget(0, 6)
self.library_table.setHorizontalHeaderLabels(["Title", "Type", "Region", "Serial", "Size", "Released"])
self.library_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.library_table.verticalHeader().setVisible(False)
self.library_table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.library_table.setSelectionMode(QAbstractItemView.SingleSelection)
self.library_table.itemSelectionChanged.connect(self._on_library_table_selected)
self.library_table.itemDoubleClicked.connect(self._on_library_double_clicked)
self.library_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.library_table.customContextMenuRequested.connect(self._on_library_table_menu)
self.library_table.setSortingEnabled(True)
self.library_stack.addWidget(self.library_table)
self.library_view.currentIndexChanged.connect(self.library_stack.setCurrentIndex)
return self.library_stack
# -- Scan view (inside the Library tab) -------------------------------- #
def _build_scan_view(self):
self.scan_view = QWidget()
layout = QVBoxLayout(self.scan_view)
controls = QHBoxLayout()
self.scan_btn = QPushButton("Scan")
self.scan_btn.clicked.connect(self._start_scan)
controls.addWidget(self.scan_btn)
self.cancel_btn = QPushButton("Cancel")
self.cancel_btn.setEnabled(False)
self.cancel_btn.clicked.connect(self._cancel_scan)
controls.addWidget(self.cancel_btn)
self.apply_btn = QPushButton("Apply renames")
self.apply_btn.setEnabled(False)
self.apply_btn.clicked.connect(self._apply_renames)
controls.addWidget(self.apply_btn)
self.hash_check = QCheckBox("Verify integrity (full hash)")
controls.addWidget(self.hash_check)
self.progress = QProgressBar()
controls.addWidget(self.progress, 1)
layout.addLayout(controls)
self.table = QTableWidget(0, 7)
self.table.setHorizontalHeaderLabels(
["Type", "Current filename", "Path", "Status", "Serial", "Canonical name", "Rename?"]
)
self.table.horizontalHeader().setStretchLastSection(False)
header = self.table.horizontalHeader()
header.resizeSection(0, 64)
header.resizeSection(1, 280)
header.resizeSection(2, 240)
header.resizeSection(5, 300)
header.setContextMenuPolicy(Qt.CustomContextMenu)
header.customContextMenuRequested.connect(self._show_column_menu)
self.table.itemChanged.connect(self._on_item_changed)
layout.addWidget(self.table)
return self.scan_view
def _show_column_menu(self, pos):
header = self.table.horizontalHeader()
menu = QMenu(self)
for col in range(self.table.columnCount()):
header_item = self.table.horizontalHeaderItem(col)
title = header_item.text() if header_item else f"Column {col}"
action = menu.addAction(title)
action.setCheckable(True)
action.setChecked(not self.table.isColumnHidden(col))
action.toggled.connect(lambda checked, c=col: self.table.setColumnHidden(c, not checked))
menu.exec(header.mapToGlobal(pos))
# -- Settings tab ------------------------------------------------------ #
def _build_settings_tab(self):
self.settings_page = SettingsPage(self._games_dir, self._videos_dir, self._store_dir,
self._ppsspp_saves_dir, self._cloud_saves_dir, self._ppsspp_exe,
self._store_server_url, self._store_token, self._store_archive_id,
self._store_archive_sig, self._store_archive_user, self._iso_category,
self._ps1_category, self._psp_category,
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp,
self._nps_dir, self._pkg2zip)
self.settings_page.saved.connect(self._on_settings_saved)
return self.settings_page
def _on_settings_saved(self):
self._games_dir = self.settings_page.games_dir()
self._videos_dir = self.settings_page.videos_dir()
self._store_dir = self.settings_page.store_dir()
self._store_server_url = self.settings_page.store_server_url()
self._store_token = self.settings_page.store_token()
self._store_archive_id = self.settings_page.archive_item()
self._store_archive_sig = self.settings_page.archive_sig()
self._store_archive_user = self.settings_page.archive_user()
self.settings.setValue("games_dir", self._games_dir)
self.settings.setValue("videos_dir", self._videos_dir)
self.settings.setValue("store_dir", self._store_dir)
self.settings.setValue("store_server_url", self._store_server_url)
self.settings.setValue("store_token", self._store_token)
self.settings.setValue("store_archive_id", self._store_archive_id)
self.settings.setValue("store_archive_sig", self._store_archive_sig)
self.settings.setValue("store_archive_user", self._store_archive_user)
self.store_page.set_store_config(self._store_dir, self._store_server_url, self._store_token,
self._store_archive_id, self._store_archive_sig, self._store_archive_user,
self._nps_dir, self._pkg2zip)
self.store_page.refresh()
self._ppsspp_saves_dir = self.settings_page.ppsspp_saves_dir()
self._cloud_saves_dir = self.settings_page.cloud_saves_dir()
self._auto_sync_saves = self.settings_page.auto_sync_saves()
self._ppsspp_exe = self.settings_page.ppsspp_exe()
self._iso_category = self.settings_page.iso_category()
self._ps1_category = self.settings_page.ps1_category()
self._psp_category = self.settings_page.psp_category()
self._ps1_dir = self.settings_page.ps1_dir()
self._ps1_output_dir = self.settings_page.ps1_output_dir()
self._ps1_base_pbp = self.settings_page.ps1_base_pbp()
self._nps_dir = self.settings_page.nps_dir()
self._pkg2zip = self.settings_page.pkg2zip_path()
self.settings.setValue("ps1_dir", self._ps1_dir)
self.settings.setValue("ps1_output_dir", self._ps1_output_dir)
self.settings.setValue("ps1_base_pbp", self._ps1_base_pbp)
self.settings.setValue("nps_dir", self._nps_dir)
self.settings.setValue("pkg2zip_path", self._pkg2zip)
self.ps1_page.set_dirs(self._ps1_dir, self._ps1_output_dir)
self.settings.setValue("ppsspp_saves_dir", self._ppsspp_saves_dir)
self.settings.setValue("cloud_saves_dir", self._cloud_saves_dir)
self.settings.setValue("auto_sync_saves", self._auto_sync_saves)
self.settings.setValue("ppsspp_exe", self._ppsspp_exe)
self.settings.setValue("iso_category", self._iso_category)
self.settings.setValue("ps1_category", self._ps1_category)
self.settings.setValue("psp_category", self._psp_category)
self._update_saves_locations()
self._update_dir_status()
def _on_tab_changed(self, index):
if hasattr(self, "ps1_page") and index == self.tabs.indexOf(self.ps1_page):
self.ps1_page.refresh()
def _update_saves_locations(self):
if not hasattr(self, "saves_page"):
return
psp_dir = str(Path(self._psp_root) / "PSP" / "SAVEDATA") if self._psp_root else ""
self.saves_page.set_locations(psp_dir, self._ppsspp_saves_dir, self._cloud_saves_dir)
def _maybe_auto_sync(self):
if self._auto_sync_saves and self._psp_root:
self.saves_page.sync_now(False)
def _on_saves_sync(self, plan, switch_tab=False):
for op in plan:
src = transfers.TreeCopySource(op["src"])
self.queue.enqueue(src, op["dst_root"], {"dest_name": op["dst_name"]}, op["dst_name"], kind="save-sync")
if plan and switch_tab:
self.tabs.setCurrentWidget(self.queue_page)
def _refresh_saves(self):
if hasattr(self, "saves_page"):
self.saves_page.refresh()
# -- Library data ------------------------------------------------------ #
def load_library(self):
if self._closing or self._library_loading:
return
self._library_loading = True
self.statusBar().showMessage("Loading library…")
log.info("loading library…")
thread = QThread(self)
worker = LibraryWorker(self._ps1_dir, self._ps1_output_dir, self._nps_dir)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_library_loaded)
worker.finished.connect(thread.quit)
thread.finished.connect(lambda t=thread, w=worker: self._forget_library_thread(t, w))
self._library_threads.append(thread)
self._library_workers.append(worker)
thread.start()
def _on_library_loaded(self, items):
self._library_loading = False
self._library_cache = items or []
log.info("library loaded: %d item(s)", len(self._library_cache))
self._apply_library_filter()
if hasattr(self, "store_page"):
self.store_page.set_library_items(self._library_cache)
self.statusBar().showMessage("")
def _forget_library_thread(self, thread, worker):
if thread in self._library_threads:
self._library_threads.remove(thread)
if worker in self._library_workers:
self._library_workers.remove(worker)
def _apply_library_filter(self):
if not hasattr(self, "_library_cache"):
return
q = self.library_search.text().strip().lower()
typ = self.library_type.currentText()
psp_f = self.library_psp.currentText() if hasattr(self, "library_psp") else "All PSP"
items = []
for it in self._library_cache:
it["on_psp"] = self._on_psp(it)
if typ == "Games" and it["system"] != "games":
continue
if typ == "Videos" and it["system"] != "videos":
continue
if typ == "PS1" and it["system"] != "ps1":
continue
if q and q not in (it["canonical"] or it["name"]).lower():
continue
if psp_f == "On PSP" and not it.get("on_psp"):
continue
if psp_f == "Not on PSP" and it.get("on_psp"):
continue
items.append(it)
self._library_filtered = items
self._populate_library_table(items)
self.cover_grid.set_items(items)
if hasattr(self, "psp_page"):
self.psp_page.set_library_items(self._library_cache)
def _populate_library_table(self, items):
self.library_table.setUpdatesEnabled(False)
self.library_table.setSortingEnabled(False)
try:
self.library_table.setRowCount(len(items))
for row, it in enumerate(items):
title = QTableWidgetItem(it.get("title_override") or it["canonical"] or it["name"])
title.setData(Qt.UserRole, it)
if it.get("on_psp"):
title.setIcon(self._psp_dot_icon())
title.setToolTip("Installed on PSP")
self.library_table.setItem(row, 0, title)
kind_label = {"games": "Game", "videos": "Video", "ps1": "PS1"}.get(it["system"], "Game")
self.library_table.setItem(row, 1, QTableWidgetItem(kind_label))
self.library_table.setItem(row, 2, QTableWidgetItem(it.get("region") or ""))
self.library_table.setItem(row, 3, QTableWidgetItem(it.get("serial") or ""))
size = it.get("size")
self.library_table.setItem(row, 4, NumericItem(f"{size / (1024 * 1024):.1f} MB" if size else "", size or 0))
self.library_table.setItem(row, 5, QTableWidgetItem(it.get("release_date") or ""))
finally:
self.library_table.setSortingEnabled(True)
self.library_table.setUpdatesEnabled(True)
def _on_library_table_selected(self):
row = self.library_table.currentRow()
cell = self.library_table.item(row, 0) if row >= 0 else None
if cell is not None:
it = cell.data(Qt.UserRole)
if it:
self._on_library_selected(it)
def _on_library_double_clicked(self, item):
if item is None:
return
cell = self.library_table.item(item.row(), 0)
it = cell.data(Qt.UserRole) if cell is not None else None
if it and it.get("system") != "videos":
self._play_item_on_ppsspp(it)
def _on_library_table_menu(self, pos):
item = self.library_table.itemAt(pos)
if item is None:
return
row = item.row()
cell = self.library_table.item(row, 0)
it = cell.data(Qt.UserRole) if cell is not None else None
if not it:
return
menu = QMenu(self)
if it.get("system") != "videos":
menu.addAction("Play on PPSSPP", lambda: self._play_item_on_ppsspp(it))
menu.addAction("Copy to PSP", lambda: self._copy_to_psp(it))
if it.get("on_psp"):
menu.addAction("Delete from PSP", lambda: self._delete_from_psp(it))
menu.addAction("Open containing folder", lambda: open_containing_folder(it.get("path")))
menu.addAction("Delete…", lambda: self._delete_library_item(it))
menu.exec(self.library_table.viewport().mapToGlobal(pos))
def _on_library_selected(self, item):
self._current_item = item
self.details_panel.set_item(item)
rid = str(item["release_id"])
system = item.get("system")
cached = info.load(rid, system)
if cached is not None:
self.details_panel.set_metadata(cached)
return
self._meta_gen += 1
gen = self._meta_gen
if item.get("title_override"):
title = item["title_override"]
elif system == "videos":
title = covers.movie_search_title(item["name"])
else:
title = covers.plain_title(item["name"])
thread = QThread(self)
worker = MetadataWorker(title, gen, system, rid)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_metadata)
worker.finished.connect(thread.quit)
self._meta_threads.append(thread)
self._meta_workers.append(worker)
thread.start()
def _on_metadata(self, gen, meta):
if gen != self._meta_gen:
return
self.details_panel.set_metadata(meta)
def _on_cover_loaded(self, item):
current = getattr(self, "_current_item", None)
if current is not None and item.get("release_id") == current.get("release_id"):
self.details_panel._set_cover(item.get("release_id"), item.get("system"))
def _edit_details(self):
item = getattr(self, "_current_item", None)
if item is None:
return
rid = str(item["release_id"])
system = item.get("system")
dlg = TagDialog(item, self.details_panel.current_meta(), self)
if dlg.exec() != QDialog.Accepted:
return
if dlg.reset_pressed:
info.reset(rid, system)
item["title_override"] = None
self.cover_grid.update_item_label(item)
self._on_library_selected(item) # re-fetch and re-display
return
result = dlg.result_meta()
title = result.get("title") or None
info.save(rid, system, result, manual=True, title=title)
item["title_override"] = title or None
self.cover_grid.update_item_label(item)
result["manual"] = True
self.details_panel.set_item(item)
self.details_panel.set_metadata(result)
def _scrape_item(self):
item = getattr(self, "_current_item", None)
if item is None:
return
rid = str(item.get("release_id") or covers.stable_key(item.get("name") or ""))
system = item.get("system") or "games"
dlg = ScrapeDialog(item, self, rid=rid, system=system)
if dlg.exec() != QDialog.Accepted:
return
meta = dlg.selected_meta
if meta:
title = meta.get("title") or None
info.save(rid, system, meta, manual=True, title=title)
item["title_override"] = title or None
meta["manual"] = True
self.details_panel.set_item(item)
self.details_panel.set_metadata(meta)
# Always refresh the details panel's cover box: a cover-only scrape
# (no metadata backend) skips set_item above, and `rid` may fall back
# to a stable key when the release has no database id.
self.details_panel._set_cover(rid, system)
self.cover_grid.update_item_label(item)
self.cover_grid.refresh_item(item)
def _scrape_grid_item(self, it):
self._current_item = it
self.details_panel.set_item(it)
self._scrape_item()
def _convert_current_item(self):
item = getattr(self, "_current_item", None)
if item is None or item.get("system") != "ps1" or item.get("ps1_kind") != "disc":
return
game_id = item.get("serial") or ""
if not game_id:
text, ok = QInputDialog.getText(self, "PS1", "Game ID (e.g. SLUS00594):")
if not ok or not text.strip():
return
game_id = text.strip()
self._on_ps1_convert({"source": item.get("path"), "title": item.get("name"), "game_id": game_id})
def _on_store_download(self, items):
if not items:
return
src = self.store_page.download_source()
log.info("downloading %d item(s) from store", len(items))
for it in items:
if isinstance(src, transfers.NoPayStationSource):
if not self._nps_dir:
QMessageBox.warning(self, "No NPS output dir", "Set the NoPayStation output dir in Settings → Store.")
return
sub = "PSP" if src.content_type == "psp" else "PSX"
dest_dir = Path(self._nps_dir) / sub
kind = "nps-download"
else:
if not self._games_dir:
QMessageBox.warning(self, "No games directory", "Set the games directory in Settings, then scan.")
return
dest_dir = self._games_dir
kind = "download"
self.queue.enqueue(src, dest_dir, it, store.display_name(it["name"]), kind=kind)
self.tabs.setCurrentWidget(self.queue_page)
# -- PSP sync ---------------------------------------------------------- #
def _on_psp_device_changed(self, root):
self._psp_root = root or ""
log.info("psp device changed: %s", self._psp_root or "(none)")
if self._psp_root:
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
self._psp_paths["raw"].setdefault(stem.lower(), []).append(it)
n = matcher.normalize_name(stem)
if n:
self._psp_paths["norm"].setdefault(n, []).append(it)
log.info("psp index: %d raw / %d normalized names",
len(self._psp_index.get("raw", set())),
len(self._psp_index.get("names", set())))
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)
self.details_panel.set_psp_installed(bool(current and current.get("on_psp")))
if hasattr(self, "_library_cache"):
self._apply_library_filter()
if hasattr(self, "store_page"):
self.store_page.set_library_items(self._library_cache)
self._update_saves_locations()
self._maybe_auto_sync()
def _on_psp(self, item):
if not self._psp_index:
return False
stem = Path(item.get("canonical") or item.get("name") or "").stem
if stem.lower() in self._psp_index.get("raw", set()):
return True
norm = matcher.normalize_name(stem)
if norm and norm in self._psp_index.get("names", set()):
return True
if item.get("system") == "ps1" or item.get("psp_kind") == "eboot":
# PS1 EBOOTs and NoPayStation PSP games live as PSP/GAME/{category}/
# {GAME_ID}/EBOOT.PBP; match the source game-folder id (or the
# embedded title) against the homebrew index.
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)
if icon is None:
pm = QPixmap(12, 12)
pm.fill(Qt.transparent)
painter = QPainter(pm)
painter.setRenderHint(QPainter.Antialiasing)
painter.setBrush(QColor(70, 200, 120))
painter.setPen(Qt.NoPen)
painter.drawEllipse(1, 1, 10, 10)
painter.end()
icon = QIcon(pm)
self._psp_icon = icon
return icon
def _copy_current_to_psp(self):
item = getattr(self, "_current_item", None)
if item is not None:
self._copy_to_psp(item)
def _play_current_on_ppsspp(self):
self._play_item_on_ppsspp(getattr(self, "_current_item", None))
def _play_item_on_ppsspp(self, item):
if item is None:
return
if item.get("system") == "videos":
return # UMD Video discs can't be played in PPSSPP
if item.get("system") == "ps1" and item.get("ps1_kind") != "eboot":
return # raw PS1 discs are converted, not played
if item.get("psp_kind") == "eboot":
return # encrypted PSP PSN EBOOTs can't run in PPSSPP
path = item.get("path")
if not path or not Path(path).exists():
QMessageBox.warning(self, "Missing file", f"File not found:\n{path}")
return
log.info("launching ppsspp: %s", path)
ok, msg = launcher.launch_ppsspp(path, self._ppsspp_exe)
if not ok:
QMessageBox.warning(self, "PPSSPP", msg)
def _delete_current(self):
item = getattr(self, "_current_item", None)
if item is not None:
self._delete_library_item(item)
def _delete_library_item(self, item):
if item is None:
return
path = Path(item.get("path") or "")
if not path.exists():
QMessageBox.warning(self, "Missing file", f"File not found:\n{path}")
return
name = item.get("title_override") or item.get("canonical") or item.get("name") or path.name
res = QMessageBox.question(
self, "Delete disc",
f"Permanently delete this disc from disk?\n\n{name}\n{path}",
QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel,
)
if res != QMessageBox.Yes:
return
if not transfers.remove_path(path):
QMessageBox.critical(self, "Delete failed", f"Could not delete:\n{path}")
return
log.info("deleted %s", path)
try:
conn = get_connection(config.DB_PATH)
conn.execute("DELETE FROM files WHERE path = ?", (str(path),))
conn.commit()
conn.close()
except Exception: # noqa: BLE001
pass
self.load_library()
def _copy_to_psp(self, item):
if item is None:
return
if not self._psp_root:
QMessageBox.warning(self, "No PSP connected", "Connect the PSP and select it in the PSP Sync tab first.")
return
if self._enqueue_psp_copy(item):
self.tabs.setCurrentWidget(self.queue_page)
def _copy_items_to_psp(self, items):
if not items:
return
if not self._psp_root:
QMessageBox.warning(self, "No PSP connected", "Connect the PSP and select it in the PSP Sync tab first.")
return
queued = 0
for item in items:
if self._enqueue_psp_copy(item):
queued += 1
if queued:
self.tabs.setCurrentWidget(self.queue_page)
def _enqueue_psp_copy(self, item):
if not item.get("path") or not Path(item["path"]).exists():
QMessageBox.warning(self, "Missing file", f"File not found:\n{item.get('path')}")
return False
system = item.get("system")
psp_root = Path(self._psp_root)
if system == "videos":
dest_dir = psp_root / "ISO" / "VIDEO"
elif system == "ps1" or item.get("psp_kind") == "eboot":
# PS1 EBOOTs and NoPayStation PSP games install as a whole game
# folder under /PSP/GAME (optionally nested in a Category Lite
# folder).
src_folder = Path(item["path"]).parent
if system == "ps1":
ps1_cat = "".join(c for c in (self._ps1_category or "").strip() if c not in "/\\")
dest_dir = (psp_root / "PSP" / "GAME" / ps1_cat) if ps1_cat else (psp_root / "PSP" / "GAME")
else:
psp_cat = "".join(c for c in (self._psp_category or "").strip() if c not in "/\\")
dest_dir = (psp_root / "PSP" / "GAME" / psp_cat) if psp_cat else (psp_root / "PSP" / "GAME")
log.info("copying game folder to psp: %s -> %s", src_folder, dest_dir / src_folder.name)
self.queue.enqueue(transfers.TreeCopySource(str(src_folder)), dest_dir,
{"dest_name": src_folder.name}, item.get("name") or src_folder.name,
kind="psp-install")
return True
else:
cat = "".join(c for c in (self._iso_category or "").strip() if c not in "/\\")
dest_dir = (psp_root / "ISO" / cat) if cat else (psp_root / "ISO")
dest_name = item.get("canonical") or item.get("name") or Path(item["path"]).name
if not dest_name.lower().endswith(".iso"):
dest_name += ".iso"
src = transfers.FileCopySource(item["path"])
log.info("copying to psp: %s -> %s", item["path"], dest_dir / dest_name)
self.queue.enqueue(src, dest_dir, {"dest_name": dest_name}, dest_name, kind="psp-install")
return True
def _on_umd_dump(self, iso_path, system, serial):
src = Path(iso_path)
if not src.exists():
QMessageBox.warning(self, "Missing UMD", f"UMD image not found:\n{iso_path}")
return
dest_root = self._videos_dir if system == "videos" else self._games_dir
if not dest_root:
QMessageBox.warning(self, "No target directory",
"Set the games/videos directories in Settings before dumping a UMD.")
return
serial = (serial or "").strip()
dest_name = f"UMD9660 - {serial}.iso" if serial else "UMD9660.iso"
log.info("umd dump: %s -> %s", src, Path(dest_root) / dest_name)
self._pending_umd_dump = Path(dest_root) / dest_name
self.queue.enqueue(transfers.FileCopySource(str(src)), Path(dest_root),
{"dest_name": dest_name}, f"Dump {serial or src.name}", kind="umd-dump")
self.tabs.setCurrentWidget(self.queue_page)
def _rename_umd_dump(self):
target = self._auto_rename_umd
self._auto_rename_umd = None
if target is None:
return
try:
target_resolved = Path(target).resolve()
except OSError:
return
canonical = None
for rec in self._rows:
try:
same = Path(rec["path"]).resolve() == target_resolved
except OSError:
same = False
if same and rec.get("method") != "unknown" and rec.get("canonical"):
canonical = rec["canonical"]
break
if not canonical:
return
src = Path(target)
if not src.exists() or src.name == canonical:
return
dst = src.with_name(canonical)
if dst.exists():
return
conn = init_db(config.DB_PATH)
try:
scanner.rename_and_recache(conn, src, dst)
conn.commit()
finally:
conn.close()
log.info("renamed umd dump: %s -> %s", src.name, canonical)
def _delete_current_from_psp(self):
self._delete_from_psp(getattr(self, "_current_item", None))
def _delete_from_psp(self, item):
if item is None:
return
if not self._psp_root:
QMessageBox.warning(self, "No PSP connected", "Connect the PSP and select it in the PSP Sync tab first.")
return
if not item.get("on_psp"):
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" or item.get("psp_kind") == "eboot":
# PS1 EBOOTs / NoPayStation PSP games: 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)
matches = list(self._psp_paths.get("norm", {}).get(n, []))
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(m["path"] for m in matches[:5])
if len(matches) > 5:
preview += f"\n… (+{len(matches) - 5} more)"
res = QMessageBox.question(
self, "Delete from PSP",
f"Delete this disc 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"])):
failed += 1
log.info("deleted %d file(s) from psp: %s", len(matches) - failed, name)
if failed:
QMessageBox.warning(self, "Delete incomplete", f"{failed} file(s) could not be deleted.")
self._refresh_psp()
def _refresh_psp(self):
if hasattr(self, "psp_page"):
self.psp_page.refresh_inventory()
def _on_ps1_convert(self, item):
if not self._ps1_base_pbp or not Path(self._ps1_base_pbp).is_file():
QMessageBox.warning(self, "PS1", "Set a valid PS1 base.pbp path in Settings → General first.")
return
if not item.get("game_id"):
QMessageBox.warning(self, "PS1", "No game ID for this disc.")
return
out_dir = Path(self._ps1_output_dir) if self._ps1_output_dir else Path.cwd() / "ps1_converted"
source = transfers.Ps1ConvertSource(self._ps1_base_pbp)
self.queue.enqueue(source, out_dir, item, item.get("title") or item["source"], kind="ps1-convert")
self.tabs.setCurrentWidget(self.queue_page)
def _on_queue_job_done(self, job):
if self._closing:
return
status = job.get("status")
kind = job.get("kind")
if kind == "umd-dump" and status in ("done", "skipped"):
if self._pending_umd_dump is not None:
self._auto_rename_umd = self._pending_umd_dump
self._pending_umd_dump = None
QTimer.singleShot(0, self._start_scan)
if status != "done":
return
if kind == "download":
remaining = any(j.status in ("queued", "running", "cancelling") for j in self.queue.jobs)
if not remaining:
QTimer.singleShot(0, self._start_scan)
if kind == "nps-download":
remaining = any(j.status in ("queued", "running", "cancelling") for j in self.queue.jobs)
if not remaining:
QTimer.singleShot(0, self.load_library)
if kind == "psp-install":
QTimer.singleShot(0, self._refresh_psp)
if kind == "save-sync":
QTimer.singleShot(0, self._refresh_saves)
if kind == "ps1-convert":
QTimer.singleShot(0, self.ps1_page.refresh)
QTimer.singleShot(0, self.load_library)
# -- Common ------------------------------------------------------------ #
@staticmethod
def _first_existing(roots):
for root in roots:
if Path(root).exists():
return str(root)
return ""
def _update_dir_status(self):
parts = []
if self._games_dir:
parts.append(f"Games: {self._games_dir}")
if self._videos_dir:
parts.append(f"Videos: {self._videos_dir}")
self.statusBar().showMessage(" | ".join(parts) if parts else "Set directories in Settings, then scan.")
def _start_scan(self):
if self._closing:
return
games_text = self._games_dir
videos_text = self._videos_dir
roots = [Path(t) for t in (games_text, videos_text) if t]
self._games_root = Path(games_text) if games_text else None
self._videos_root = Path(videos_text) if videos_text else None
if not roots:
QMessageBox.warning(self, "No directories", "Set at least one directory in Settings, then scan.")
return
self._rows = []
self._renames = []
self.table.setRowCount(0)
self.progress.setRange(0, 0)
log.info("scanning roots: %s (workers: %d, hash: %s)",
[str(r) for r in roots],
int(self.settings.value("scan_workers", 1) or 1),
self.hash_check.isChecked())
self.scan_btn.setEnabled(False)
self.cancel_btn.setEnabled(True)
self.apply_btn.setEnabled(False)
self.statusBar().showMessage("Scanning…")
self.thread = QThread()
workers = int(self.settings.value("scan_workers", 1) or 1)
self.worker = ScanWorker(
roots, config.DB_PATH,
hash_files=self.hash_check.isChecked(),
workers=workers,
)
self.worker.moveToThread(self.thread)
self.thread.started.connect(self.worker.run)
self.worker.record.connect(self._on_record)
self.worker.prompt.connect(self._on_prompt)
self.worker.done.connect(self._on_done)
self.worker.cancelled.connect(self._on_cancelled)
self.worker.failed.connect(self._on_failed)
self.thread.start()
def _cancel_scan(self):
if self.worker:
self.worker.cancel()
def _on_record(self, rec):
self._rows.append(rec)
rec["_type"] = self._record_type(rec)
row = self.table.rowCount()
self.table.insertRow(row)
readonly = Qt.ItemIsSelectable | Qt.ItemIsEnabled
item = QTableWidgetItem(self._type_label(rec["_type"]))
item.setFlags(readonly)
self.table.setItem(row, 0, item)
item = QTableWidgetItem(rec["name"])
item.setFlags(readonly)
self.table.setItem(row, 1, item)
item = QTableWidgetItem(rec["path"])
item.setFlags(readonly)
item.setToolTip(rec["path"])
self.table.setItem(row, 2, item)
item = QTableWidgetItem(rec["method"])
item.setFlags(readonly)
self.table.setItem(row, 3, item)
item = QTableWidgetItem(rec.get("serial") or "")
item.setFlags(readonly)
self.table.setItem(row, 4, item)
canonical = rec.get("canonical") or ""
item = QTableWidgetItem(canonical)
item.setFlags(readonly | Qt.ItemIsEditable) # editable target name
self.table.setItem(row, 5, item)
do_rename = rec["method"] != "unknown" and canonical and Path(rec["path"]).name != canonical
cb = QCheckBox()
cb.setChecked(do_rename)
cb.toggled.connect(lambda _checked=False: self._update_apply_enabled())
if do_rename:
self._renames.append((rec["path"], canonical))
self.table.setCellWidget(row, 6, cb)
def _on_item_changed(self, item):
if item.column() != 5:
return
row = item.row()
widget = self.table.cellWidget(row, 6)
if isinstance(widget, QCheckBox):
widget.setChecked(bool(item.text().strip()))
self._update_apply_enabled()
def _update_apply_enabled(self):
enabled = False
for i in range(self.table.rowCount()):
widget = self.table.cellWidget(i, 6)
cell = self.table.item(i, 5)
name_item = self.table.item(i, 1)
canonical = (cell.text().strip() if cell else "") or ""
name = name_item.text() if name_item else ""
if (isinstance(widget, QCheckBox) and widget.isChecked()
and canonical and name != canonical):
enabled = True
break
self.apply_btn.setEnabled(enabled)
def _record_type(self, rec):
release = rec.get("release")
if release and release.get("system"):
return release["system"]
try:
p = Path(rec["path"]).resolve()
except Exception: # noqa: BLE001
return "?"
if self._videos_root is not None and p.is_relative_to(self._videos_root.resolve()):
return "videos"
if self._games_root is not None and p.is_relative_to(self._games_root.resolve()):
return "games"
return "?"
@staticmethod
def _type_label(kind):
return {"games": "Game", "videos": "Video"}.get(kind, "?")
def _on_prompt(self, rec, candidates):
chosen = self._ask_user(rec, candidates)
if self.worker is not None:
self.worker.choose(chosen)
def _ask_user(self, rec, candidates):
dlg = QDialog(self)
dlg.setWindowTitle("Identify disc")
layout = QVBoxLayout(dlg)
layout.addWidget(QLabel(
f"Multiple catalog entries match:\n{rec['name']}\n\nPick the correct one:"
))
combo = QComboBox()
for cand in candidates:
combo.addItem(cand["name"])
layout.addWidget(combo)
buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
buttons.accepted.connect(dlg.accept)
buttons.rejected.connect(dlg.reject)
layout.addWidget(buttons)
if dlg.exec() == QDialog.Accepted:
idx = combo.currentIndex()
return candidates[idx] if 0 <= idx < len(candidates) else None
return None
def _on_done(self, count):
self.progress.setRange(0, 1)
self.progress.setValue(1)
self.scan_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
self._update_apply_enabled()
matched = sum(1 for r in self._rows if r["method"] != "unknown")
unknown = len(self._rows) - matched
parts = [f"Scanned {len(self._rows)} — matched {matched}, unknown {unknown}"]
try:
conn = get_connection(config.DB_PATH)
totals = {
r["system"]: r["c"]
for r in conn.execute(
"SELECT d.system, COUNT(*) AS c FROM releases r JOIN dats d ON d.id = r.dat_id GROUP BY d.system"
)
}
conn.close()
on_disk = {}
for r in self._rows:
if r.get("release"):
on_disk.setdefault(r["release"]["system"], set()).add(r["release"].get("id"))
for system in sorted(totals):
have = len(on_disk.get(system, set()))
parts.append(f"{system} {have}/{totals[system]}")
except Exception: # noqa: BLE001
pass
self.statusBar().showMessage(" | ".join(parts))
log.info("scan complete: %s", " | ".join(parts))
self._thread_cleanup()
self._rename_umd_dump()
self.load_library()
def _on_failed(self, msg):
self.progress.setRange(0, 1)
self.progress.setValue(1)
self.scan_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
self.statusBar().showMessage("Scan failed")
log.error("scan failed: %s", msg)
QMessageBox.critical(self, "Scan failed", msg)
self._thread_cleanup()
def _on_cancelled(self):
self.progress.setRange(0, 1)
self.progress.setValue(1)
self.scan_btn.setEnabled(True)
self.cancel_btn.setEnabled(False)
self._update_apply_enabled()
self.statusBar().showMessage("Scan cancelled")
log.info("scan cancelled")
self._thread_cleanup()
def _thread_cleanup(self):
if self.thread:
self.thread.quit()
self.thread.wait()
self.thread = None
self.worker = None
def _open_sources(self):
pass
def _apply_renames(self):
conn = init_db(config.DB_PATH)
renamed = 0
try:
for i, rec in enumerate(self._rows):
widget = self.table.cellWidget(i, 6)
if not (isinstance(widget, QCheckBox) and widget.isChecked()):
continue
cell = self.table.item(i, 5)
canonical = (cell.text().strip() if cell else "") or rec.get("canonical") or ""
if not canonical:
continue
src = Path(rec["path"])
dst = src.with_name(canonical)
if src.exists() and not dst.exists() and src.name != canonical:
scanner.rename_and_recache(conn, src, dst)
renamed += 1
conn.commit()
finally:
conn.close()
self._renames = []
self.apply_btn.setEnabled(False)
QMessageBox.information(self, "Done", f"Renamed {renamed} file(s).")
self.load_library()
def closeEvent(self, event):
self._closing = True
self.settings.setValue("window_geometry", self.saveGeometry())
if self.worker is not None:
if getattr(self.worker, "_prompt_pending", False):
self.worker.choose(None)
self.worker.cancel()
self._thread_cleanup()
self.cover_grid.shutdown()
for t in self._meta_threads:
if _thread_running(t):
t.quit()
t.wait()
for t in self._library_threads:
if _thread_running(t):
t.quit()
t.wait()
if hasattr(self, "queue"):
self.queue.shutdown()
if hasattr(self, "store_page"):
self.store_page.shutdown()
if hasattr(self, "psp_page"):
self.psp_page.shutdown()
if hasattr(self, "saves_page"):
self.saves_page.shutdown()
event.accept()
def _start_bundle_check(window):
"""Download any missing offline cover bundles in the background."""
server = getattr(window, "_store_server_url", "") or ""
try:
covers.ensure_cover_bundles(server)
except Exception: # noqa: BLE001 - background best-effort
pass
def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s")
init_db() # migrate legacy on-share DB/covers to local storage, then schema
log.info("starting psp-preserve (db: %s)", config.DB_PATH)
app = QApplication(sys.argv)
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path)))
window = MainWindow()
window.show()
log.info("main window ready")
threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())