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