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

5860 lines
231 KiB
Python
Raw Permalink 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 psppt (PySide6 / Qt).
Run with::
python3 -m app.main
"""
import datetime
import json
import logging
import signal
import sys
import threading
from pathlib import Path
from PySide6.QtCore import QBuffer, QByteArray, QEvent, 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, QPushButton, QScrollArea, QSpinBox, QSplitter, QStackedWidget,
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QGroupBox,
)
from . import config, covers, info, ingest, iso_reader, launcher, matcher, namer, pipeline, ps1, psp, queue, saves, scanner, sources, store, style, theme, 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, prefer_region=None):
super().__init__()
self.roots = roots
self.db_path = db_path
self.hash_files = hash_files
self.workers = workers
self.prefer_region = prefer_region
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, prefer_region=self.prefer_region)
if ident["method"] == "unknown" and rec.get("serial") and not self._cancel.is_set():
candidates = matcher.serial_candidates(conn, rec)
if len(candidates) > 1:
if self.prefer_region:
candidates = matcher.apply_region_preference(candidates, self.prefer_region)
if len(candidates) > 1:
chosen = self._ask(rec, candidates)
method = "choice"
elif len(candidates) == 1:
chosen = candidates[0]
method = "region"
else:
chosen = None
method = None
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, method)
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, QImage) # gen, item index, fitted cover image
failed = Signal(int, int, str) # gen, item index, name (no cover found)
finished = Signal()
def __init__(self, items, gen, icon_w=150, icon_h=220):
super().__init__()
self.items = items
self.gen = gen
self.icon_w = icon_w
self.icon_h = icon_h
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:
img = QImage(str(path))
if img.isNull():
self.failed.emit(self.gen, i, self.items[i]["name"])
else:
self.loaded.emit(self.gen, i, _fit_image(img, self.icon_w, self.icon_h))
else:
self.failed.emit(self.gen, i, self.items[i]["name"])
finally:
# Return promptly on cancel/exit instead of blocking the GUI thread
# on in-flight network timeouts during shutdown.
pool.shutdown(wait=not self._cancel.is_set(), 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 _fit_image(img, w, h):
"""Scale a QImage into a letterboxed ``w x h`` ARGB image (thread-safe).
Decoding + scaling happen off the GUI thread in the cover loaders; the
main thread only converts the result to a QPixmap (cheap).
"""
canvas = QImage(w, h, QImage.Format_ARGB32)
canvas.fill(Qt.transparent)
if img.isNull():
return canvas
scaled = img.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
x = (w - scaled.width()) // 2
y = (h - scaled.height()) // 2
painter = QPainter(canvas)
painter.drawImage(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)
# The vertical scrollbar appearing/disappearing changes the viewport
# width, so re-fit the grid whenever the viewport resizes.
self.list.viewport().installEventFilter(self)
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._fit_grid()
self._start_loader(gen)
def _fit_grid(self):
"""Stretch cover cells to fill the viewport width (no dead right gap)."""
lst = self.list
vp = lst.viewport().width()
if vp <= 0:
return
spacing = lst.spacing() or 6
cols = max(1, round((vp + spacing) / (self.GRID_W + spacing)))
cell_w = (vp - (cols - 1) * spacing) // cols
cell_w = max(cell_w, self.ICON_W + 16)
cell_w = min(cell_w, self.GRID_W + 48)
cur = lst.gridSize()
if cur.width() != cell_w or cur.height() != self.GRID_H:
lst.setGridSize(QSize(cell_w, self.GRID_H))
def eventFilter(self, obj, event):
if obj is self.list.viewport() and event.type() == QEvent.Resize:
self._fit_grid()
return super().eventFilter(obj, event)
def showEvent(self, event):
super().showEvent(event)
self._fit_grid()
def resizeEvent(self, event):
super().resizeEvent(event)
self._fit_grid()
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.ICON_W, self.ICON_H)
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, image):
if self._closing or gen != self._generation:
return
item = self.list.item(index)
if item is not None:
item.setIcon(QIcon(QPixmap.fromImage(image)))
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") not in ("videos", "theme", "dlc"):
self.play_requested.emit(data)
def _on_failed(self, gen, index, name):
if self._closing or 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):
if self._closing:
return
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._closing:
return
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") not in ("videos", "theme", "dlc"):
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))
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, 16, 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)
self.link_btn = QPushButton("Scrape…")
self.link_btn.clicked.connect(self.link_requested.emit)
self.layout.addWidget(self.link_btn)
self.edit_btn = QPushButton("Edit details…")
self.edit_btn.clicked.connect(self.edit_requested.emit)
self.layout.addWidget(self.edit_btn)
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")
self.layout.addWidget(self.copy_btn)
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")
self.layout.addWidget(self.delete_btn)
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")
self.layout.addWidget(self.del_psp_btn)
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()
download_covers = 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="", parent=None):
super().__init__(parent)
s = QSettings("psppt", "psppt")
outer = QVBoxLayout(self)
outer.setContentsMargins(16, 16, 16, 16)
outer.setSpacing(12)
# Header ---------------------------------------------------------- #
header = QHBoxLayout()
title_col = QVBoxLayout()
title_col.setSpacing(2)
title = QLabel("Settings")
title.setStyleSheet("font-size: 17px; font-weight: 600;")
subtitle = QLabel("Library folders, store access, devices, and cover sources.")
subtitle.setStyleSheet("color: #9a9ba5;")
title_col.addWidget(title)
title_col.addWidget(subtitle)
header.addLayout(title_col)
header.addStretch(1)
self.save_btn = QPushButton("Save settings")
self.save_btn.setDefault(True)
self.save_btn.clicked.connect(self._save)
header.addWidget(self.save_btn)
outer.addLayout(header)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QScrollArea.NoFrame)
body = QWidget()
col = QVBoxLayout(body)
col.setContentsMargins(0, 4, 8, 4)
col.setSpacing(12)
scroll.setWidget(body)
outer.addWidget(scroll, 1)
# -- Library folders ------------------------------------------------ #
box, form = self._group("Library folders")
self.games_edit = self._dir_row(form, "Games directory", games_dir)
self.videos_edit = self._dir_row(form, "Videos directory", videos_dir)
col.addWidget(box)
# -- PS1 converter --------------------------------------------------- #
box, form = self._group("PS1 converter")
self.ps1_edit = self._dir_row(form, "PS1 source directory", ps1_dir)
self.ps1_out_edit = self._dir_row(form, "Converted output directory", ps1_output_dir)
self.ps1_base_edit = self._file_row(form, "base.pbp (PSN classic)", ps1_base_pbp)
col.addWidget(box)
# -- Store ----------------------------------------------------------- #
box, form = self._group("Store")
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 directory", store_dir)
self.nps_edit = self._dir_row(form, "NoPayStation output directory", nps_dir)
col.addWidget(box)
# -- Saves ----------------------------------------------------------- #
box, form = self._group("Saves")
self.ppsspp_edit = self._dir_row(form, "PPSSPP saves directory", ppsspp_dir)
self.cloud_edit = self._dir_row(form, "Cloud saves directory", 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)
col.addWidget(box)
# -- PSP device ------------------------------------------------------ #
box, form = self._group("PSP device (Category Lite)")
self.iso_category_edit = QLineEdit(iso_category)
self.iso_category_edit.setPlaceholderText("e.g. Homebrew (blank = ISO root)")
form.addRow("ISO category", 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", 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", self.psp_category_edit)
col.addWidget(box)
# -- Scanning -------------------------------------------------------- #
box, form = self._group("Scanning")
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.hash_default = QCheckBox("Verify integrity (full hash) by default")
self.hash_default.setChecked(str(s.value("hash_default", False)).lower() == "true")
form.addRow(self.hash_default)
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)
col.addWidget(box)
# -- Matching -------------------------------------------------------- #
box, form = self._group("Matching")
self.region_combo = QComboBox()
self.region_combo.addItem("No preference — ask me", "")
for label in ("USA", "Europe", "Japan", "Asia", "Korea", "World"):
self.region_combo.addItem(label, label)
idx = self.region_combo.findData(str(s.value("prefer_region", "") or ""))
self.region_combo.setCurrentIndex(idx if idx >= 0 else 0)
form.addRow("Preferred region", self.region_combo)
self.region_hint = QLabel(
"When several catalog entries share a serial, auto-pick this region "
"instead of asking. Europe also covers individual European countries."
)
self.region_hint.setWordWrap(True)
self.region_hint.setStyleSheet("color: #9a9ba5;")
form.addRow(self.region_hint)
col.addWidget(box)
# -- Cover sources --------------------------------------------------- #
box, form = self._group("Cover sources")
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)
note.setStyleSheet("color: #9a9ba5;")
form.addRow(note)
self._subtitle(form, "Games — covers &amp; metadata")
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")
self._subtitle(form, "Videos — metadata &amp; posters")
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.USER_DATA_DIR / 'manual_covers'}</tt> "
"(subfolders <tt>games/</tt> and <tt>videos/</tt>)."
)
hint.setWordWrap(True)
hint.setStyleSheet("color: #9a9ba5;")
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.download_bundle_btn = QPushButton("Download offline cover bundle…")
self.download_bundle_btn.clicked.connect(self.download_covers.emit)
form.addRow(self.download_bundle_btn)
col.addWidget(box)
# -- Storage ---------------------------------------------------------- #
box, form = self._group("Storage")
self._path_row(form, "State directory", str(config.STATE_DIR))
self._path_row(form, "Library database", str(config.DB_PATH))
self._path_row(form, "Cover cache", str(config.COVERS_DIR))
self._path_row(form, "User data (credentials, NPS cache…)", str(config.USER_DATA_DIR))
note = QLabel(
"Your library, covers, and credentials live here — outside the app "
"bundle, so they survive upgrades. On Linux this is the "
"~/.local/state/psppt folder."
)
note.setWordWrap(True)
note.setStyleSheet("color: #9a9ba5;")
form.addRow(note)
col.addWidget(box)
col.addStretch(1)
def _group(self, title):
box = QGroupBox(title)
form = QFormLayout(box)
form.setContentsMargins(16, 18, 16, 16)
form.setHorizontalSpacing(16)
form.setVerticalSpacing(10)
form.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow)
return box, form
def _subtitle(self, form, text):
label = QLabel(f"<b>{text}</b>")
form.addRow(label)
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 _path_row(self, form, label, path):
edit = QLineEdit(path)
edit.setReadOnly(True)
open_btn = QPushButton("Open")
open_btn.clicked.connect(lambda _=False, p=Path(path): self._open_path(p))
row = QWidget()
h = QHBoxLayout(row)
h.setContentsMargins(0, 0, 0, 0)
h.addWidget(edit, 1)
h.addWidget(open_btn)
form.addRow(label, row)
return edit
@staticmethod
def _open_path(path):
path = Path(path)
target = path if path.is_dir() else path.parent
if target.is_dir():
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
else:
QMessageBox.information(None, "Not found",
f"Folder not created yet:\n{target}\n\nIt is created on first use.")
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 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 prefer_region(self):
return self.region_combo.currentData() or ""
def _save(self):
sources.save({key: edit.text() for key, edit in self.fields.items()})
covers.clear_miss_cache()
s = QSettings("psppt", "psppt")
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())
s.setValue("hash_default", self.hash_default.isChecked())
s.setValue("prefer_region", self.region_combo.currentData() or "")
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, QImage) # gen, grid row, fitted cover image
finished = Signal()
def __init__(self, tasks, gen, quick=True, icon_w=150, icon_h=220):
super().__init__()
self.tasks = tasks # list of (grid_row, item_dict)
self.gen = gen
self.quick = quick
self.icon_w = icon_w
self.icon_h = icon_h
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:
img = QImage(str(path))
if not img.isNull():
self.loaded.emit(self.gen, row, _fit_image(img, self.icon_w, self.icon_h))
finally:
# Return promptly on cancel/exit instead of blocking the GUI thread
# on in-flight network timeouts during shutdown.
pool.shutdown(wait=not self._cancel.is_set(), 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, 16, 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"]), item.get("system") or "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
scrape_status = Signal(str) # progress text for the window status bar
scrape_complete = Signal() # fired when a background scrape finishes
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._items = []
self._filtered = []
self._lib_norm = set()
self._lib_cover = {} # norm -> (release_id, system)
self._psp_norm = set() # norm of library items installed on the PSP
self._cover_attempted = set()
self._cover_busy = False
self._date_busy = False
self._generation = 0
self._scan_gen = 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_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", "PSP Themes", "PSP DLC"])
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)
self.store_sort = QComboBox()
self.store_sort.addItems(["Newest first", "Title (A–Z)", "Region (A–Z)", "Size (largest)", "In library first"])
self.store_sort.setToolTip("Sort the cover grid")
self.store_sort.currentTextChanged.connect(self._apply_filter)
bar.addWidget(self.store_sort)
refresh_btn = QPushButton("Refresh")
refresh_btn.clicked.connect(lambda: self.refresh(force=True))
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.grid.viewport().installEventFilter(self)
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=""):
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 ""
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", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
return transfers.NoPayStationSource(ct)
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(), set(), {}
psp_norm, psp_seen = set(), set()
for it in items or []:
# The store lists PSP games/themes/DLC; ignore UMD videos (a game
# archive must never match a same-named movie) and PS1 (its titles
# are matched separately).
system = it.get("system")
if system not in ("games", "theme", "dlc"):
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.add(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.add(n)
self._lib_norm = norm
self._lib_cover = cover
self._psp_norm = psp_norm
if self._items:
self._apply_filter()
def _begin_scan(self):
"""Clear the stale view and invalidate in-flight cover/date batches."""
self._generation += 1
self._cover_busy = False
self._date_busy = False
self._cover_queue = []
self._cover_next = 0
self._date_queue = []
self._date_next = 0
self._cover_attempted.clear()
for loader in self._loaders:
loader.cancel()
self._loaders = []
self.grid.clear()
self.table.setRowCount(0)
self.status.setText("Loading…")
def refresh(self, force=False):
if self._closing:
return
self._begin_scan()
self._scan_gen += 1
gen = self._scan_gen
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", "psx", "theme", "dlc")[self.nps_type.currentIndex()]
log.info("store: fetching NoPayStation %s list", ct)
self.status.setText(f"Fetching NoPayStation ({ct.upper()})…")
worker = RemoteStoreScanWorker(transfers.NoPayStationSource(ct, force=force))
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.gen = gen # used by _on_scan_results/_on_scan_failed via sender()
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)
worker.finished.connect(self._on_worker_finished)
thread.finished.connect(self._on_thread_finished)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_thread_finished(self):
thread = self.sender()
if thread in self._threads:
self._threads.remove(thread)
thread.deleteLater()
def _on_worker_finished(self):
worker = self.sender()
if worker in self._workers:
self._workers.remove(worker)
worker.deleteLater()
def _on_scan_failed(self, message):
gen = getattr(self.sender(), "gen", self._scan_gen)
if self._closing or gen != self._scan_gen:
return
self._items = []
self._filtered = []
self._dirty = False
self.grid.clear()
self.table.setRowCount(0)
log.warning("store unavailable: %s", message)
self.status.setText(f"Store unavailable: {message}")
def _on_scan_results(self, items):
gen = getattr(self.sender(), "gen", self._scan_gen)
if self._closing or gen != self._scan_gen:
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").
if n in self._lib_norm:
it["in_library"] = True
it["_lib_cover"] = self._lib_cover.get(n)
if n in self._psp_norm:
it["on_psp"] = True
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)
self._sort_store_items(filtered)
self._filtered = filtered
if self.all_check.isChecked():
show = len(filtered)
else:
show = min(self.RECENT_LIMIT, len(filtered))
self._populate(filtered, show)
def _sort_store_items(self, items):
mode = self.store_sort.currentText()
if mode == "Title (A–Z)":
items.sort(key=lambda it: store.display_name(it["name"]).lower())
elif mode == "Region (A–Z)":
items.sort(key=lambda it: (store.region(it["name"]) or "").lower())
elif mode == "Size (largest)":
items.sort(key=lambda it: it.get("size") or 0, reverse=True)
elif mode == "In library first":
items.sort(key=lambda it: (not it.get("in_library"), store.display_name(it["name"]).lower()))
else: # Newest first
items.sort(key=self._store_sort_key)
@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 = 300 # 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_busy = False
self._date_busy = False
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.grid.setUpdatesEnabled(False)
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"
# Covers are decoded + scaled off the GUI thread (StoreCoverLoader).
# Never load a QPixmap here — doing so for cached covers made the store
# unresponsive while filling/scrolling.
if system not in ("theme", "dlc") and rid not in self._cover_attempted:
# Themes and DLC have no meaningful box art in libretro/IGDB, and
# bulk-scraping hundreds of them on every switch to the DLC list
# saturates the network/CPU and makes the store unresponsive.
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 _fit_grid(self):
"""Stretch store cover cells to fill the viewport (no dead right gap)."""
vp = self.grid.viewport().width()
if vp <= 0:
return
spacing = self.grid.spacing() or 6
cols = max(1, round((vp + spacing) / (self.GRID_W + spacing)))
cell_w = (vp - (cols - 1) * spacing) // cols
cell_w = max(cell_w, self.ICON_W + 16)
cell_w = min(cell_w, self.GRID_W + 48)
cur = self.grid.gridSize()
if cur.width() != cell_w or cur.height() != self.GRID_H:
self.grid.setGridSize(QSize(cell_w, self.GRID_H))
def eventFilter(self, obj, event):
if obj is self.grid.viewport() and event.type() == QEvent.Resize:
self._fit_grid()
return super().eventFilter(obj, event)
def showEvent(self, event):
super().showEvent(event)
self._fit_grid()
def resizeEvent(self, event):
super().resizeEvent(event)
self._fit_grid()
def _finish_fill(self):
if self._fill_timer is not None:
self._fill_timer.stop()
self.grid.setUpdatesEnabled(True)
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 or self._cover_busy:
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._cover_busy = True
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 or self._date_busy:
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._date_busy = True
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(self._on_date_batch_done)
loader.finished.connect(thread.quit)
thread.finished.connect(self._on_thread_finished)
self._threads.append(thread)
self._loaders.append(loader)
thread.start()
def _on_date_batch_done(self):
if self._closing:
return
loader = self.sender()
if getattr(loader, "gen", None) != self._generation:
return
self._date_busy = False
self._load_date_batch(self.DATE_BATCH)
def _on_date_loaded(self, gen, row, date):
if self._closing or 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, icon_w=self.ICON_W, icon_h=self.ICON_H)
loader.moveToThread(thread)
thread.started.connect(loader.run)
loader.loaded.connect(self._on_cover_loaded)
loader.finished.connect(self._on_cover_batch_done)
loader.finished.connect(thread.quit)
thread.finished.connect(self._on_thread_finished)
self._threads.append(thread)
self._loaders.append(loader)
thread.start()
def _on_cover_batch_done(self):
if self._closing:
return
loader = self.sender()
if getattr(loader, "gen", None) != self._generation:
return
self._cover_busy = False
self._load_cover_batch(self.COVER_SCROLL_BATCH)
def _on_cover_loaded(self, gen, row, image):
if self._closing or gen != self._generation:
return
item = self.grid.item(row)
if item is not None:
item.setIcon(QIcon(QPixmap.fromImage(image)))
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)
worker.finished.connect(self._on_worker_finished)
thread.finished.connect(self._on_thread_finished)
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), system, meta, manual=True, title=title)
it["title_override"] = title or None
self._refresh_item_cover(it, rid, system)
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
self.scrape_btn.setEnabled(False)
self.scrape_status.emit("Scraping store…")
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)
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_progress(self, done, total):
self.scrape_status.emit(f"Scraping store… {done}/{total}")
def _on_scrape_done(self):
self._scraping = False
self.scrape_btn.setEnabled(True)
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.scrape_status.emit("Store scrape complete")
self._apply_filter()
self.scrape_complete.emit()
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()
for t in list(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",
"covers": "Covers bundle",
}
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.Fixed)
self.table.horizontalHeader().setSectionResizeMode(6, QHeaderView.Fixed)
self.table.setColumnWidth(5, 80)
self.table.setColumnWidth(6, 34)
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.setFixedHeight(22)
cancel.setStyleSheet("padding: 0px 8px;")
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.setFixedSize(22, 22)
delete.setStyleSheet("padding: 0px;")
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 PspEjectWorker(QObject):
"""Unmount a PSP off the GUI thread (udisksctl can block for seconds)."""
result = Signal(bool, str) # ok, message
finished = Signal()
def __init__(self, root):
super().__init__()
self.root = root
def run(self):
try:
ok, msg = psp.eject(self.root)
except Exception as exc: # noqa: BLE001
ok, msg = False, f"{type(exc).__name__}: {exc}"
self.result.emit(ok, msg)
self.finished.emit()
class PspMountsWorker(QObject):
"""Detect mounted PSP sticks + UMD volumes off the GUI thread."""
result = Signal(int, list, list) # gen, mounts, umd_mounts
finished = Signal()
def __init__(self, gen):
super().__init__()
self.gen = gen
def run(self):
try:
mounts = psp.detect_mounts()
umd = psp.umd_mounts()
except Exception: # noqa: BLE001
mounts, umd = [], []
self.result.emit(self.gen, mounts, umd)
self.finished.emit()
class PspUmdScanWorker(QObject):
"""Detect mounted UMD volumes off the GUI thread."""
result = Signal(int, list) # gen, umd_mounts
finished = Signal()
def __init__(self, gen):
super().__init__()
self.gen = gen
def run(self):
try:
umd = psp.umd_mounts()
except Exception: # noqa: BLE001
umd = []
self.result.emit(self.gen, umd)
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": [], "themes": [], "dlc": []}
self._library_items = []
self._lib_raw = {}
self._lib_norm = {}
self._lib_path_name = {}
self._gen = 0
self._umd_gen = 0
self._mounts_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)
self.eject_btn = QPushButton("Eject")
self.eject_btn.setToolTip("Unmount the PSP so it is safe to unplug")
self.eject_btn.clicked.connect(self._eject)
device_row.addWidget(self.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.themes_table = self._make_table(["", "Name", "Added", "Size"], stretch_col=1)
self.dlc_table = self._make_table(["", "Game", "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.themes_table, "Themes")
self.tabs.addTab(self.dlc_table, "DLC")
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.themes_table, self.dlc_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 = {}
self._lib_path_name = {}
for it in self._library_items:
label = it.get("canonical") or it.get("name") or ""
if label:
stem = Path(label).stem
self._lib_raw.setdefault(stem.lower(), it)
n = matcher.normalize_name(stem)
if n:
self._lib_norm.setdefault(n, it)
# Map the on-disk filename to the friendly name for themes/DLC
# (the PSP only stores the raw file, so resolve names by filename).
p = it.get("path") or ""
if p:
self._lib_path_name.setdefault(Path(p).name.lower(), label)
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", "PS1", "Themes", "DLC"])
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(lambda: 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, umd_mounts=None):
self._umd_gen += 1
gen = self._umd_gen
if umd_mounts is None:
thread = QThread(self)
worker = PspUmdScanWorker(gen)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_umd_mounts)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
return
self._on_umd_mounts(gen, umd_mounts)
def _on_umd_mounts(self, gen, mounts):
if gen != self._umd_gen:
return
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
if typ == "PS1" and it.get("system") != "ps1":
continue
if typ == "Themes" and it.get("system") != "theme":
continue
if typ == "DLC" and it.get("system") != "dlc":
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",
"theme": "Theme", "dlc": "DLC"}.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):
self._mounts_gen += 1
gen = self._mounts_gen
log.info("psp sync: detecting mounts…")
thread = QThread(self)
worker = PspMountsWorker(gen)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_mounts)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_mounts(self, gen, mounts, umd):
if gen != self._mounts_gen:
return
log.info("psp sync: %d mount(s), %d umd volume(s) detected", len(mounts), len(umd))
current = self.device_combo.currentText()
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(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": [], "themes": [], "dlc": []}
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": [], "themes": [], "dlc": []}
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 theme(s), %d dlc, %d pkg",
len(inventory["games"]), len(inventory["videos"]),
len(inventory["homebrew"]), len(inventory.get("themes", [])),
len(inventory.get("dlc", [])), 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 inventory.get("themes"):
parts.append(f"{len(inventory['themes'])} theme(s)")
if inventory.get("dlc"):
parts.append(f"{len(inventory['dlc'])} dlc")
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.themes_table, inventory.get("themes", []), "theme")
self._fill_table(self.dlc_table, inventory.get("dlc", []), "dlc")
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))
elif kind == "dlc":
friendly = self._lib_path_name.get((it.get("name") or "").lower())
table.setItem(row, 1, QTableWidgetItem(it.get("id") or ""))
table.setItem(row, 2, QTableWidgetItem(friendly or it.get("name") or ""))
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.themes_table, self.dlc_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
log.info("ejecting %s…", self._root)
self.status.setText("Ejecting…")
self.eject_btn.setEnabled(False)
thread = QThread(self)
worker = PspEjectWorker(self._root)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_eject_result)
worker.finished.connect(thread.quit)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_eject_result(self, ok, msg):
self.eject_btn.setEnabled(True)
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 content, merged into the library.
Scans ``<nps>/PSP/*/EBOOT.PBP`` (PSP PSN games), ``<nps>/PSX/*/EBOOT.PBP``
(PS1 Classics), ``<nps>/THEME/*/*.ptf`` (themes) and ``<nps>/DLC/*/*.edat``
(DLC). PSP games are ``system="games"`` with ``psp_kind="eboot"``; PSX games
are ``system="ps1"`` with ``ps1_kind="eboot"``; themes/DLC use
``psp_kind="theme"/"dlc"``.
"""
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)
# Themes: <nps>/THEME/<TITLE_ID>/<name>.ptf (extension case varies)
theme_dir = root / "THEME"
if theme_dir.is_dir():
for ptf in sorted(theme_dir.rglob("*")):
if not ptf.is_file() or ptf.suffix.lower() != ".ptf":
continue
folder = ptf.parent
name = _sidecar_name(folder) or theme.ptf_name(ptf) or ptf.stem
item = _nps_extra_item(ptf, "theme", name)
item["release_id"] = covers.stable_key(name)
_write_theme_cover(ptf, item["release_id"])
out.append(item)
# DLC: <nps>/DLC/<GAME_ID>/<name>.edat
dlc_dir = root / "DLC"
if dlc_dir.is_dir():
for edat in sorted(dlc_dir.rglob("*")):
if not edat.is_file() or edat.suffix.lower() != ".edat":
continue
if edat.name.upper() == "ISO.BIN.EDAT":
continue # package wrapper, not DLC content
name = _sidecar_name(edat.parent) or edat.stem
out.append(_nps_extra_item(edat, "dlc", name))
return out
def _sidecar_name(folder):
"""Read a display name saved next to a downloaded NPS item, if present."""
try:
meta = json.loads((Path(folder) / "meta.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return ""
return (meta.get("name") or "").strip()
def _write_theme_cover(ptf_path, rid):
"""Extract a theme's embedded preview into the cover cache (idempotent)."""
path = covers.cover_path(rid, "theme")
if path.exists() and covers.file_is_image(path):
return
try:
res = theme.theme_preview_rgba(ptf_path)
except Exception: # noqa: BLE001
res = None
if not res:
return
w, h, rgba = res
img = QImage(rgba, w, h, w * 4, QImage.Format_RGBA8888)
if img.isNull():
return
try:
img.save(str(path), "PNG")
except OSError:
pass
def _nps_extra_item(path, psp_kind, name):
try:
size = path.stat().st_size
except OSError:
size = 0
rid = covers.stable_key(name)
return {
"name": name,
"release_id": rid,
"system": psp_kind,
"canonical": name,
"serial": "",
"region": None,
"release_date": "",
"path": str(path),
"size": size,
"psp_kind": psp_kind,
}
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 SaveScanWorker(QObject):
"""Scan the three SAVEDATA locations off the GUI thread."""
result = Signal(int, dict) # gen, {location: {save_id: meta}}
finished = Signal()
def __init__(self, gen, locations):
super().__init__()
self.gen = gen
self.locations = locations
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
data = {}
for name, root in self.locations.items():
if self._cancel.is_set():
break
try:
data[name] = {s["id"]: s for s in saves.list_saves(root)}
except Exception: # noqa: BLE001
data[name] = {}
self.result.emit(self.gen, data)
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._gen = 0
self._scan_worker = None
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):
if self._closing:
return
self._gen += 1
gen = self._gen
if self._scan_worker is not None:
self._scan_worker.cancel()
self.status.setText("Reading saves…")
thread = QThread(self)
worker = SaveScanWorker(gen, self._locations())
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_saves_scanned)
worker.finished.connect(thread.quit)
self._scan_worker = worker
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_saves_scanned(self, gen, data):
if gen != self._gen or self._closing:
return
self._scan_worker = None
self._data = data
self._populate(data)
def _populate(self, 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 self._locations().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 = []
self._closing = False
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 self._closing or 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):
if self._closing:
return
# 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):
if not self._closing:
self.status.setText(f"PS1 scan failed: {message}")
def shutdown(self):
self._closing = True
if self._thread is not None and _thread_running(self._thread):
self._thread.quit()
self._thread.wait()
for t in self._preview_threads:
if _thread_running(t):
t.quit()
t.wait()
self._preview_threads = []
self._preview_workers = []
self._thread = None
self._worker = None
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("psppt ~ 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("psppt", "psppt")
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._prefer_region = str(self.settings.value("prefer_region", "") 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: 10px 0 0 0; }")
layout.addWidget(self.tabs)
# App logo in the top-right corner of the tab bar.
logo = QLabel()
logo.setStyleSheet("padding-right: 10px;")
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
if icon_path.is_file():
pm = QPixmap(str(icon_path))
pm = pm.scaled(28, 28, Qt.KeepAspectRatio, Qt.SmoothTransformation)
logo.setPixmap(pm)
logo.setToolTip("psppt")
self.tabs.setCornerWidget(logo, Qt.TopRightCorner)
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.store_page.download_requested.connect(self._on_store_download)
self.store_page.scrape_status.connect(self._on_store_scrape_status)
self.store_page.scrape_complete.connect(self.load_library)
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", "Themes", "DLC"])
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)
self.library_sort = QComboBox()
self.library_sort.addItems(["Title", "Release date", "Size", "Region", "Type", "Serial"])
self.library_sort.setToolTip("Sort the cover grid")
self.library_sort.currentTextChanged.connect(self._apply_library_filter)
bar.addWidget(self.library_sort)
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)
layout.addLayout(bar)
splitter = QSplitter(Qt.Horizontal)
self.library_subtabs = QTabWidget()
self.library_subtabs.setStyleSheet("QTabWidget::pane { border: 0px; padding: 10px 0 0 0; }")
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 _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)")
self.hash_check.setChecked(
str(self.settings.value("hash_default", False)).lower() == "true")
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)
self.table.setContextMenuPolicy(Qt.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._on_scan_table_menu)
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.settings_page.saved.connect(self._on_settings_saved)
self.settings_page.download_covers.connect(self._download_covers_bundle)
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.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.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._prefer_region = self.settings_page.prefer_region()
self.settings.setValue("prefer_region", self._prefer_region)
self.hash_check.setChecked(str(self.settings.value("hash_default", False)).lower() == "true")
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 typ == "Themes" and it["system"] != "theme":
continue
if typ == "DLC" and it["system"] != "dlc":
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._sort_library_items(items)
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 _sort_library_items(self, items):
mode = self.library_sort.currentText()
title = lambda it: (it.get("title_override") or it.get("canonical") or it["name"] or "").lower()
if mode == "Release date":
items.sort(key=lambda it: it.get("release_date") or "", reverse=True)
elif mode == "Size":
items.sort(key=lambda it: it.get("size") or 0, reverse=True)
elif mode == "Region":
items.sort(key=lambda it: (it.get("region") or "").lower())
elif mode == "Type":
order = {"games": 0, "videos": 1, "ps1": 2, "theme": 3, "dlc": 4}
items.sort(key=lambda it: (order.get(it.get("system"), 9), title(it)))
elif mode == "Serial":
items.sort(key=lambda it: (it.get("serial") or "").lower())
else: # Title
items.sort(key=title)
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",
"theme": "Theme", "dlc": "DLC"}.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") not in ("videos", "theme", "dlc"):
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") not in ("videos", "theme", "dlc"):
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": "PSP", "psx": "PSX", "theme": "THEME", "dlc": "DLC"}.get(src.content_type, "PSP")
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)
def _on_store_scrape_status(self, msg):
self.statusBar().showMessage(msg)
# -- 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):
psp_kind = item.get("psp_kind")
if psp_kind in ("theme", "dlc") and self._psp_root:
name = Path(item.get("path") or "").name
if psp_kind == "theme":
return (Path(self._psp_root) / "PSP" / "THEME" / name).exists()
game_id = Path(item.get("path") or "").parent.name
return (Path(self._psp_root) / "PSP" / "GAME" / game_id / name).exists()
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") in ("eboot", "theme", "dlc"):
return # encrypted PSN EBOOTs/themes/DLC 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 item.get("psp_kind") == "theme":
# Themes install as a single .ptf under /PSP/THEME.
dest_name = Path(item["path"]).name
dest_dir = psp_root / "PSP" / "THEME"
src = transfers.FileCopySource(item["path"])
log.info("copying theme 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
elif item.get("psp_kind") == "dlc":
# DLC installs as a single .edat into /PSP/GAME/<GAME_ID>.
game_id = Path(item["path"]).parent.name
dest_name = Path(item["path"]).name
dest_dir = psp_root / "PSP" / "GAME" / game_id
src = transfers.FileCopySource(item["path"])
log.info("copying dlc 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
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
psp_kind = item.get("psp_kind")
if psp_kind in ("theme", "dlc"):
name = Path(item.get("path") or "").name
target = (Path(self._psp_root) / "PSP" / "THEME" / name) if psp_kind == "theme" \
else (Path(self._psp_root) / "PSP" / "GAME" / Path(item.get("path") or "").parent.name / name)
if not target.exists():
QMessageBox.warning(self, "Not found", f"Could not find the installed copy on the PSP:\n{name}")
return
res = QMessageBox.question(
self, "Delete from PSP",
f"Delete this from the PSP?\n\n{name}\n\n{target}",
QMessageBox.Yes | QMessageBox.Cancel, QMessageBox.Cancel,
)
if res != QMessageBox.Yes:
return
if not transfers.remove_path(target):
QMessageBox.warning(self, "Delete incomplete", f"Could not delete:\n{target}")
self._refresh_psp()
return
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 == "covers":
covers.clear_miss_cache()
QTimer.singleShot(0, self.load_library)
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,
prefer_region=self._prefer_region,
)
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 _on_scan_table_menu(self, pos):
row = self.table.rowAt(pos.y())
if row < 0 or row >= len(self._rows):
return
rec = self._rows[row]
method = rec.get("method") or ""
if not rec.get("serial") or method not in ("choice", "unknown"):
return
menu = QMenu(self)
menu.addAction("Redo match choice…", lambda: self._redo_choice(row))
menu.exec(self.table.viewport().mapToGlobal(pos))
def _redo_choice(self, row):
rec = self._rows[row]
serial = (rec.get("serial") or "").strip().upper()
if not serial:
return
conn = get_connection(config.DB_PATH)
try:
# Drop the remembered choice so the disc can be re-identified.
conn.execute("DELETE FROM choices WHERE serial = ? AND size = ?",
(serial, rec.get("size")))
candidates = matcher.serial_candidates(conn, rec)
chosen = None
if len(candidates) == 1:
chosen = candidates[0]
elif len(candidates) > 1:
chosen = self._ask_user(rec, candidates)
if chosen is not None:
conn.execute(
"INSERT OR REPLACE INTO choices (serial, size, release_id) VALUES (?,?,?)",
(serial, rec.get("size"), chosen["id"]),
)
ident = pipeline.identify_with_release(rec, chosen, "choice")
else:
ident = {"method": "unknown", "release": None,
"serial": rec.get("serial"), "canonical": None}
rel = ident.get("release") or {}
conn.execute("UPDATE files SET release_id = ? WHERE path = ?",
(rel.get("id"), rec["path"]))
conn.commit()
finally:
conn.close()
self._apply_row_ident(row, rec, ident)
self.load_library()
def _apply_row_ident(self, row, rec, ident):
rec["method"] = ident["method"]
rec["release"] = ident.get("release")
rec["serial"] = ident.get("serial")
rec["canonical"] = ident.get("canonical")
rec["_type"] = self._record_type(rec)
item = self.table.item(row, 0)
if item is not None:
item.setText(self._type_label(rec["_type"]))
item = self.table.item(row, 3)
if item is not None:
item.setText(ident["method"])
item = self.table.item(row, 4)
if item is not None:
item.setText(ident.get("serial") or "")
canonical = ident.get("canonical") or ""
item = self.table.item(row, 5)
if item is not None:
item.setText(canonical)
cb = self.table.cellWidget(row, 6)
if isinstance(cb, QCheckBox):
do_rename = ident["method"] != "unknown" and canonical and Path(rec["path"]).name != canonical
cb.blockSignals(True)
cb.setChecked(do_rename)
cb.blockSignals(False)
self._update_apply_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 maybe_offer_covers_bundle(self):
"""Offer to download the offline cover bundle once, if it's missing."""
if self._closing or covers.covers_bundle_present():
return
s = QSettings("psppt", "psppt")
if str(s.value("covers_bundle_skip", "false")).lower() == "true":
return
box = QMessageBox(self)
box.setWindowTitle("Offline covers")
box.setIcon(QMessageBox.Question)
box.setText(
"The offline cover bundle (~843 MB) isn't installed.\n\n"
"Download it now from the project repo? Covers keep working online "
"without it — the bundle just makes them instant and offline."
)
download = box.addButton("Download", QMessageBox.AcceptRole)
box.addButton("Not now", QMessageBox.RejectRole)
never = box.addButton("Don't ask again", QMessageBox.DestructiveRole)
box.setDefaultButton(download)
box.exec()
clicked = box.clickedButton()
if clicked is never:
s.setValue("covers_bundle_skip", "true")
elif clicked is download:
self._download_covers_bundle()
def _download_covers_bundle(self):
"""Queue the offline cover bundle download in the transfer queue."""
if self._closing:
return
for j in self.queue.jobs:
if j.kind == "covers" and j.status in ("queued", "running", "cancelling"):
return # already queued or running
self.queue.enqueue(
transfers.CoversBundleSource(),
str(config.COVERS_DIR),
None,
"Offline cover bundle",
kind="covers",
)
self.tabs.setCurrentWidget(self.queue_page)
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()
if hasattr(self, "ps1_page"):
self.ps1_page.shutdown()
event.accept()
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
# Auto-ingest any missing DATs so scanning matches against the catalog
# even on a fresh/reset database (no manual `python -m app.ingest` step).
n = ingest.ensure_catalog()
if n:
log.info("ingested %d DAT(s) into the catalog", n)
log.info("starting psppt (db: %s)", config.DB_PATH)
app = QApplication(sys.argv)
style.apply_theme(app)
app.setApplicationName("psppt")
app.setDesktopFileName("psppt")
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
if icon_path.is_file():
app.setWindowIcon(QIcon(str(icon_path)))
window = MainWindow()
window.setWindowIcon(app.windowIcon())
window.show()
log.info("main window ready")
QTimer.singleShot(1200, window.maybe_offer_covers_bundle)
# Ctrl+C should close the window gracefully (running closeEvent's thread
# cleanup) rather than tearing Qt down mid-thread, which segfaults.
interrupt = {"pending": False}
def _sigint_handler(signum, frame):
interrupt["pending"] = True
signal.signal(signal.SIGINT, _sigint_handler)
sigint_timer = QTimer()
sigint_timer.setInterval(100)
sigint_timer.timeout.connect(
lambda: window.close() if interrupt["pending"] else None)
sigint_timer.start()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())