Phase 2: PS1 library scan, settings, conversion queue, and PS1 tab
This commit is contained in:
@@ -50,10 +50,17 @@ ARCHIVE_LOGGED_IN_USER = os.environ.get("PSP_ARCHIVE_LOGGED_IN_USER", "")
|
|||||||
GAMES_ROOTS = [Path(p) for p in os.environ.get("PSP_GAMES_ROOTS", "/data/umd_games").split(",") if p.strip()]
|
GAMES_ROOTS = [Path(p) for p in os.environ.get("PSP_GAMES_ROOTS", "/data/umd_games").split(",") if p.strip()]
|
||||||
VIDEO_ROOTS = [Path(p) for p in os.environ.get("PSP_VIDEO_ROOTS", "/data/umd_videos").split(",") if p.strip()]
|
VIDEO_ROOTS = [Path(p) for p in os.environ.get("PSP_VIDEO_ROOTS", "/data/umd_videos").split(",") if p.strip()]
|
||||||
|
|
||||||
|
# PS1: source folder (raw ISO/BIN/CUE) and converted EBOOT.PBP output folder.
|
||||||
|
PS1_ROOTS = [Path(p) for p in os.environ.get("PSP_PS1_ROOTS", "/data/ps1").split(",") if p.strip()]
|
||||||
|
PS1_OUTPUT_DIR = Path(os.environ.get("PSP_PS1_OUTPUT_DIR", "/data/ps1_converted"))
|
||||||
|
|
||||||
# Only .iso discs are tracked; everything below is explicitly ignored.
|
# Only .iso discs are tracked; everything below is explicitly ignored.
|
||||||
DISC_EXTENSIONS = {".iso"}
|
DISC_EXTENSIONS = {".iso"}
|
||||||
IGNORED_EXTENSIONS = {".pkg", ".pbp", ".cso"}
|
IGNORED_EXTENSIONS = {".pkg", ".pbp", ".cso"}
|
||||||
|
|
||||||
|
# PS1 source images (scanned separately from PSP .iso discs).
|
||||||
|
PS1_EXTENSIONS = {".cue", ".iso", ".img", ".bin"}
|
||||||
|
|
||||||
# Parallel hashing workers for the scanner. Keep at 1 on spinning disks
|
# Parallel hashing workers for the scanner. Keep at 1 on spinning disks
|
||||||
# (concurrent reads cause seek thrash); raise it for SSD / fast arrays.
|
# (concurrent reads cause seek thrash); raise it for SSD / fast arrays.
|
||||||
SCAN_WORKERS = int(os.environ.get("PSP_SCAN_WORKERS", "1"))
|
SCAN_WORKERS = int(os.environ.get("PSP_SCAN_WORKERS", "1"))
|
||||||
|
|||||||
@@ -101,6 +101,20 @@ CREATE TABLE IF NOT EXISTS save_sigs (
|
|||||||
sig TEXT NOT NULL,
|
sig TEXT NOT NULL,
|
||||||
PRIMARY KEY (save_id, size, mtime_ns)
|
PRIMARY KEY (save_id, size, mtime_ns)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ps1_entries (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
source_path TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT,
|
||||||
|
serial TEXT,
|
||||||
|
region TEXT,
|
||||||
|
disc INTEGER NOT NULL DEFAULT 1,
|
||||||
|
disc_total INTEGER NOT NULL DEFAULT 1,
|
||||||
|
output_eboot TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
error TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+154
-6
@@ -14,13 +14,13 @@ from PySide6.QtCore import QBuffer, QByteArray, QObject, QRect, QSettings, QSize
|
|||||||
from PySide6.QtGui import QColor, QDesktopServices, QFont, QIcon, QImage, QPainter, QPixmap
|
from PySide6.QtGui import QColor, QDesktopServices, QFont, QIcon, QImage, QPainter, QPixmap
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView, QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
QAbstractItemView, QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox,
|
||||||
QFileDialog, QFormLayout, QGridLayout, QHBoxLayout, QHeaderView, QLabel, QLineEdit,
|
QFileDialog, QFormLayout, QGridLayout, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit,
|
||||||
QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit,
|
QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit,
|
||||||
QProgressBar, QProgressDialog, QPushButton, QScrollArea, QSpinBox, QSplitter, QStackedWidget,
|
QProgressBar, QProgressDialog, QPushButton, QScrollArea, QSpinBox, QSplitter, QStackedWidget,
|
||||||
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
from . import config, covers, info, iso_reader, launcher, matcher, namer, pipeline, psp, queue, saves, scanner, sources, store, transfers
|
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
|
from .db import get_connection, init_db
|
||||||
|
|
||||||
log = logging.getLogger("gui")
|
log = logging.getLogger("gui")
|
||||||
@@ -1011,7 +1011,7 @@ class SettingsPage(QWidget):
|
|||||||
|
|
||||||
def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe,
|
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,
|
store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user,
|
||||||
iso_category, parent=None):
|
iso_category, ps1_dir, ps1_output_dir, ps1_base_pbp, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
outer = QVBoxLayout(self)
|
outer = QVBoxLayout(self)
|
||||||
outer.setContentsMargins(0, 0, 0, 0)
|
outer.setContentsMargins(0, 0, 0, 0)
|
||||||
@@ -1025,6 +1025,9 @@ class SettingsPage(QWidget):
|
|||||||
page, form = self._new_form()
|
page, form = self._new_form()
|
||||||
self.games_edit = self._dir_row(form, "Games directory:", games_dir)
|
self.games_edit = self._dir_row(form, "Games directory:", games_dir)
|
||||||
self.videos_edit = self._dir_row(form, "Videos directory:", videos_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 = QCheckBox("Scan directories automatically on startup")
|
||||||
self.auto_scan.setChecked(str(s.value("auto_scan", False)).lower() == "true")
|
self.auto_scan.setChecked(str(s.value("auto_scan", False)).lower() == "true")
|
||||||
form.addRow(self.auto_scan)
|
form.addRow(self.auto_scan)
|
||||||
@@ -1156,7 +1159,7 @@ class SettingsPage(QWidget):
|
|||||||
return edit
|
return edit
|
||||||
|
|
||||||
def _browse_file(self, edit):
|
def _browse_file(self, edit):
|
||||||
path, _ = QFileDialog.getOpenFileName(self, "Select PPSSPP executable")
|
path, _ = QFileDialog.getOpenFileName(self, "Select file")
|
||||||
if path:
|
if path:
|
||||||
edit.setText(path)
|
edit.setText(path)
|
||||||
|
|
||||||
@@ -1210,6 +1213,15 @@ class SettingsPage(QWidget):
|
|||||||
def iso_category(self):
|
def iso_category(self):
|
||||||
return self.iso_category_edit.text().strip()
|
return self.iso_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):
|
def _save(self):
|
||||||
sources.save({key: edit.text() for key, edit in self.fields.items()})
|
sources.save({key: edit.text() for key, edit in self.fields.items()})
|
||||||
covers.clear_miss_cache()
|
covers.clear_miss_cache()
|
||||||
@@ -3220,6 +3232,113 @@ class SavesPage(QWidget):
|
|||||||
self._workers = []
|
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 Ps1Page(QWidget):
|
||||||
|
"""PS1 tab: list raw source discs, converted EBOOTs and PSN games."""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
||||||
|
layout.addWidget(self.table, 1)
|
||||||
|
|
||||||
|
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):
|
||||||
|
self._entries = entries
|
||||||
|
self.table.setRowCount(len(entries))
|
||||||
|
for row, e in enumerate(entries):
|
||||||
|
kind = "Disc" if e["kind"] == "disc" else "EBOOT"
|
||||||
|
self.table.setItem(row, 0, QTableWidgetItem(kind))
|
||||||
|
self.table.setItem(row, 1, QTableWidgetItem(e["name"]))
|
||||||
|
self.table.setItem(row, 2, QTableWidgetItem(e.get("serial") or ""))
|
||||||
|
self.table.setItem(row, 3, QTableWidgetItem(e.get("status") or "pending"))
|
||||||
|
pending = sum(1 for e in entries if e["kind"] == "disc" and e["status"] != "converted")
|
||||||
|
self.status.setText(f"{len(entries)} item(s) — {pending} ready to convert")
|
||||||
|
|
||||||
|
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):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -3259,6 +3378,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._auto_sync_saves = str(self.settings.value("auto_sync_saves", "false")).lower() == "true"
|
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._ppsspp_exe = str(self.settings.value("ppsspp_exe", "") or "")
|
||||||
self._iso_category = str(self.settings.value("iso_category", "") or "")
|
self._iso_category = str(self.settings.value("iso_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 "")
|
||||||
log.info("games dir: %s", self._games_dir or "(none)")
|
log.info("games dir: %s", self._games_dir or "(none)")
|
||||||
log.info("videos dir: %s", self._videos_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("store: %s", self._store_server_url or self._store_dir or "(none)")
|
||||||
@@ -3291,6 +3413,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.saves_page = SavesPage()
|
self.saves_page = SavesPage()
|
||||||
self.saves_page.sync_requested.connect(self._on_saves_sync)
|
self.saves_page.sync_requested.connect(self._on_saves_sync)
|
||||||
self.tabs.addTab(self.saves_page, "Saves")
|
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.addTab(self._build_settings_tab(), "Settings")
|
||||||
self.tabs.currentChanged.connect(self._on_tab_changed)
|
self.tabs.currentChanged.connect(self._on_tab_changed)
|
||||||
self._on_psp_device_changed(self.psp_page.root())
|
self._on_psp_device_changed(self.psp_page.root())
|
||||||
@@ -3461,7 +3587,8 @@ class MainWindow(QMainWindow):
|
|||||||
self.settings_page = SettingsPage(self._games_dir, self._videos_dir, self._store_dir,
|
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._ppsspp_saves_dir, self._cloud_saves_dir, self._ppsspp_exe,
|
||||||
self._store_server_url, self._store_token, self._store_archive_id,
|
self._store_server_url, self._store_token, self._store_archive_id,
|
||||||
self._store_archive_sig, self._store_archive_user, self._iso_category)
|
self._store_archive_sig, self._store_archive_user, self._iso_category,
|
||||||
|
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp)
|
||||||
self.settings_page.saved.connect(self._on_settings_saved)
|
self.settings_page.saved.connect(self._on_settings_saved)
|
||||||
return self.settings_page
|
return self.settings_page
|
||||||
|
|
||||||
@@ -3490,6 +3617,13 @@ class MainWindow(QMainWindow):
|
|||||||
self._auto_sync_saves = self.settings_page.auto_sync_saves()
|
self._auto_sync_saves = self.settings_page.auto_sync_saves()
|
||||||
self._ppsspp_exe = self.settings_page.ppsspp_exe()
|
self._ppsspp_exe = self.settings_page.ppsspp_exe()
|
||||||
self._iso_category = self.settings_page.iso_category()
|
self._iso_category = self.settings_page.iso_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.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.ps1_page.set_dirs(self._ps1_dir, self._ps1_output_dir)
|
||||||
self.settings.setValue("ppsspp_saves_dir", self._ppsspp_saves_dir)
|
self.settings.setValue("ppsspp_saves_dir", self._ppsspp_saves_dir)
|
||||||
self.settings.setValue("cloud_saves_dir", self._cloud_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("auto_sync_saves", self._auto_sync_saves)
|
||||||
@@ -3499,7 +3633,8 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_dir_status()
|
self._update_dir_status()
|
||||||
|
|
||||||
def _on_tab_changed(self, index):
|
def _on_tab_changed(self, index):
|
||||||
pass # store builds eagerly and caches its view; no per-tab work needed
|
if hasattr(self, "ps1_page") and index == self.tabs.indexOf(self.ps1_page):
|
||||||
|
self.ps1_page.refresh()
|
||||||
|
|
||||||
def _update_saves_locations(self):
|
def _update_saves_locations(self):
|
||||||
if not hasattr(self, "saves_page"):
|
if not hasattr(self, "saves_page"):
|
||||||
@@ -3984,6 +4119,17 @@ class MainWindow(QMainWindow):
|
|||||||
if hasattr(self, "psp_page"):
|
if hasattr(self, "psp_page"):
|
||||||
self.psp_page.refresh_inventory()
|
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")
|
||||||
|
|
||||||
def _on_queue_job_done(self, job):
|
def _on_queue_job_done(self, job):
|
||||||
if self._closing:
|
if self._closing:
|
||||||
return
|
return
|
||||||
@@ -4004,6 +4150,8 @@ class MainWindow(QMainWindow):
|
|||||||
QTimer.singleShot(0, self._refresh_psp)
|
QTimer.singleShot(0, self._refresh_psp)
|
||||||
if kind == "save-sync":
|
if kind == "save-sync":
|
||||||
QTimer.singleShot(0, self._refresh_saves)
|
QTimer.singleShot(0, self._refresh_saves)
|
||||||
|
if kind == "ps1-convert":
|
||||||
|
QTimer.singleShot(0, self.ps1_page.refresh)
|
||||||
|
|
||||||
# -- Common ------------------------------------------------------------ #
|
# -- Common ------------------------------------------------------------ #
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ def detect_system(dat_name):
|
|||||||
return "videos"
|
return "videos"
|
||||||
if "PlayStation Portable" in dat_name:
|
if "PlayStation Portable" in dat_name:
|
||||||
return "games"
|
return "games"
|
||||||
|
# Redump PS1: "Sony - PlayStation" (assumes no PS2/PS3 DATs are present).
|
||||||
|
if "PlayStation" in dat_name:
|
||||||
|
return "ps1"
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ _ARTICLE_RE = re.compile(r"^(.+),\s*(the|a|an)(?:\s+-\s+(.+))?$")
|
|||||||
|
|
||||||
def _normalize(name):
|
def _normalize(name):
|
||||||
name = name.lower()
|
name = name.lower()
|
||||||
name = re.sub(r"\.(iso|cso|pbp|img|bin)$", "", name) # strip disc extension
|
name = re.sub(r"\.(iso|cso|pbp|img|bin|cue)$", "", name) # strip disc extension
|
||||||
name = re.sub(r"^\s*\d+\s*-\s*", "", name) # legacy No-Intro "#### - Name" catalog number
|
name = re.sub(r"^\s*\d+\s*-\s*", "", name) # legacy No-Intro "#### - Name" catalog number
|
||||||
name = re.sub(r"\([^)]*\)", " ", name) # parenthetical tags
|
name = re.sub(r"\([^)]*\)", " ", name) # parenthetical tags
|
||||||
name = " ".join(name.split())
|
name = " ".join(name.split())
|
||||||
|
|||||||
+2
-2
@@ -62,7 +62,7 @@ def _reorder_article(title):
|
|||||||
return title
|
return title
|
||||||
|
|
||||||
|
|
||||||
def canonical_name(release, serial):
|
def canonical_name(release, serial, ext=".iso"):
|
||||||
name = release["name"]
|
name = release["name"]
|
||||||
region = release.get("region")
|
region = release.get("region")
|
||||||
if region:
|
if region:
|
||||||
@@ -78,4 +78,4 @@ def canonical_name(release, serial):
|
|||||||
parts.append(f"({serial})")
|
parts.append(f"({serial})")
|
||||||
name = _ILLEGAL.sub(" ", " ".join(parts)).strip()
|
name = _ILLEGAL.sub(" ", " ".join(parts)).strip()
|
||||||
name = re.sub(r"\s+", " ", name)
|
name = re.sub(r"\s+", " ", name)
|
||||||
return f"{name}.iso"
|
return f"{name}{ext}"
|
||||||
|
|||||||
+158
@@ -0,0 +1,158 @@
|
|||||||
|
"""PS1 library helpers: discover source discs and converted EBOOT.PBPs.
|
||||||
|
|
||||||
|
This is the read/scan side of PS1 support (the conversion side lives in
|
||||||
|
``app.ps1_convert``). It finds raw PS1 disc images (``.cue`` sets and
|
||||||
|
standalone ``.iso``/``.img``), reads each disc's boot serial, and lists
|
||||||
|
converted ``EBOOT.PBP`` files in the output folder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
RAW_SECTOR = 2352
|
||||||
|
COOKED_SECTOR = 2048
|
||||||
|
EBOOT_NAME = "EBOOT.PBP"
|
||||||
|
|
||||||
|
# SYSTEM.CNF boot line: cdrom:\SLPS_017.62;1 (backslash optional in some rips)
|
||||||
|
_SERIAL_RE = re.compile(rb"cdrom:\\?([A-Za-z]{4})[_-]?(\d{3})\.(\d{2})")
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_size(path: Path) -> int:
|
||||||
|
size = path.stat().st_size
|
||||||
|
if size and size % RAW_SECTOR == 0:
|
||||||
|
return RAW_SECTOR
|
||||||
|
return COOKED_SECTOR
|
||||||
|
|
||||||
|
|
||||||
|
def read_serial(image_path: Path) -> Optional[str]:
|
||||||
|
"""Read the boot serial (e.g. ``SLPS01762``) from a PS1 disc image.
|
||||||
|
|
||||||
|
Works on both raw 2352-byte/sector images (BIN/IMG) and cooked 2048-byte
|
||||||
|
images (ISO). Returns ``None`` if the boot line can't be found.
|
||||||
|
"""
|
||||||
|
image_path = Path(image_path)
|
||||||
|
if not image_path.is_file():
|
||||||
|
return None
|
||||||
|
sector_size = _sector_size(image_path)
|
||||||
|
data_off = 24 if sector_size == RAW_SECTOR else 0
|
||||||
|
|
||||||
|
buf = bytearray()
|
||||||
|
with open(image_path, "rb") as fh:
|
||||||
|
for _ in range(128): # SYSTEM.CNF is always near the start of the disc
|
||||||
|
sector = fh.read(sector_size)
|
||||||
|
if len(sector) < sector_size:
|
||||||
|
break
|
||||||
|
buf += sector[data_off:data_off + COOKED_SECTOR]
|
||||||
|
|
||||||
|
m = _SERIAL_RE.search(bytes(buf))
|
||||||
|
if m:
|
||||||
|
return (m.group(1) + m.group(2) + m.group(3)).decode().upper()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cue_data_track(cue_path: Path) -> Optional[Path]:
|
||||||
|
"""Return the first data-track image referenced by a cue sheet."""
|
||||||
|
from . import ps1_convert
|
||||||
|
|
||||||
|
try:
|
||||||
|
disc = ps1_convert.parse_cue(cue_path.read_text(encoding="utf-8", errors="replace"))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
track = disc.data_track
|
||||||
|
if track is None:
|
||||||
|
return None
|
||||||
|
return (cue_path.parent / track.file) if track.file else None
|
||||||
|
|
||||||
|
|
||||||
|
def iter_source_discs(root: Path):
|
||||||
|
"""Yield PS1 source discs under ``root`` (cue sheets + standalone images)."""
|
||||||
|
root = Path(root)
|
||||||
|
if not root.is_dir():
|
||||||
|
return
|
||||||
|
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
suffix = path.suffix.lower()
|
||||||
|
if suffix == ".cue":
|
||||||
|
yield path
|
||||||
|
elif suffix in (".iso", ".img"):
|
||||||
|
# A standalone image: no cue sheet next to it (same stem).
|
||||||
|
if not (path.parent / f"{path.stem}.cue").exists():
|
||||||
|
yield path
|
||||||
|
|
||||||
|
|
||||||
|
def find_eboots(root: Path) -> List[Path]:
|
||||||
|
"""List converted/PSN ``EBOOT.PBP`` files under ``root``."""
|
||||||
|
root = Path(root)
|
||||||
|
if not root.is_dir():
|
||||||
|
return []
|
||||||
|
return [p for p in sorted(root.rglob("*")) if p.is_file() and p.name.upper() == EBOOT_NAME]
|
||||||
|
|
||||||
|
|
||||||
|
def eboot_title(pbp_path: Path) -> str:
|
||||||
|
"""Read the game title embedded in an EBOOT.PBP (falls back to folder name)."""
|
||||||
|
from . import iso_reader, ps1_convert
|
||||||
|
|
||||||
|
try:
|
||||||
|
header = ps1_convert.read_pbp_header(pbp_path)
|
||||||
|
idx = ps1_convert.SECTION_OFFSET["sfo"] // 4
|
||||||
|
sfo = ps1_convert.read_pbp_section(pbp_path, header, idx)
|
||||||
|
parsed = iso_reader.parse_sfo(sfo)
|
||||||
|
title = parsed.get("TITLE") or parsed.get("TITLE_0") or parsed.get("TITLE_00")
|
||||||
|
if title:
|
||||||
|
return str(title)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return pbp_path.parent.name
|
||||||
|
|
||||||
|
|
||||||
|
def scan_library(ps1_dir, output_dir) -> List[dict]:
|
||||||
|
"""Return a combined view of PS1 source discs and converted EBOOTs.
|
||||||
|
|
||||||
|
Each entry is a dict with ``kind`` ("disc" or "eboot"), ``path``, ``name``,
|
||||||
|
``serial`` and ``status`` ("pending" / "converted").
|
||||||
|
"""
|
||||||
|
ps1_dir = Path(ps1_dir)
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
|
||||||
|
eboots = find_eboots(output_dir)
|
||||||
|
eboot_names = {e.name.upper() for e in eboots}
|
||||||
|
eboot_titles = {eboot_title(e).lower() for e in eboots}
|
||||||
|
|
||||||
|
entries: List[dict] = []
|
||||||
|
for src in iter_source_discs(ps1_dir):
|
||||||
|
if src.suffix.lower() == ".cue":
|
||||||
|
image = cue_data_track(src) or src
|
||||||
|
else:
|
||||||
|
image = src
|
||||||
|
serial = read_serial(image)
|
||||||
|
title = src.stem
|
||||||
|
# A disc is "converted" when an EBOOT's embedded title or filename
|
||||||
|
# matches the source disc's title/serial.
|
||||||
|
status = "pending"
|
||||||
|
if title.lower() in eboot_titles or (serial and any(serial.lower() in t for t in eboot_titles)):
|
||||||
|
status = "converted"
|
||||||
|
entries.append({
|
||||||
|
"kind": "disc",
|
||||||
|
"path": str(src),
|
||||||
|
"name": title,
|
||||||
|
"serial": serial or "",
|
||||||
|
"status": status,
|
||||||
|
})
|
||||||
|
|
||||||
|
for e in eboots:
|
||||||
|
entries.append({
|
||||||
|
"kind": "eboot",
|
||||||
|
"path": str(e),
|
||||||
|
"name": eboot_title(e),
|
||||||
|
"serial": "",
|
||||||
|
"status": "converted",
|
||||||
|
})
|
||||||
|
|
||||||
|
return entries
|
||||||
@@ -6,6 +6,7 @@ archives, a remote HTTPS store server, etc.
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -200,6 +201,53 @@ class FileCopySource(Source):
|
|||||||
return dest
|
return dest
|
||||||
|
|
||||||
|
|
||||||
|
class Ps1ConvertSource(Source):
|
||||||
|
"""Convert a PS1 disc image (.cue/.iso) into an EBOOT.PBP.
|
||||||
|
|
||||||
|
``item`` must carry ``source`` (path to the .cue or single image),
|
||||||
|
``title`` and ``game_id``. ``base_pbp`` is a PSN PS1 classic EBOOT used to
|
||||||
|
supply the encrypted DATA.PSP loader and STARTDAT block.
|
||||||
|
"""
|
||||||
|
|
||||||
|
label = "PS1 convert"
|
||||||
|
|
||||||
|
def __init__(self, base_pbp):
|
||||||
|
self.base_pbp = Path(base_pbp)
|
||||||
|
|
||||||
|
def scan(self, root=None):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def download(self, item, dest_dir, progress_cb=None, cancel_event=None):
|
||||||
|
dest_dir = Path(dest_dir)
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
source = Path(item["source"])
|
||||||
|
title = str(item.get("title") or source.stem)
|
||||||
|
game_id = item.get("game_id")
|
||||||
|
if not game_id:
|
||||||
|
raise RuntimeError("missing game_id for PS1 conversion")
|
||||||
|
|
||||||
|
safe = re.sub(r'[<>:"/\\|?*]', " ", title).strip() or "PS1 game"
|
||||||
|
out_path = dest_dir / f"{safe} ({game_id}).pbp"
|
||||||
|
if out_path.exists():
|
||||||
|
raise FileExistsError(str(out_path))
|
||||||
|
|
||||||
|
def cb(done, total):
|
||||||
|
if progress_cb is not None and total:
|
||||||
|
progress_cb("Converting", min(done / total, 1.0), "")
|
||||||
|
|
||||||
|
from . import ps1_convert
|
||||||
|
|
||||||
|
try:
|
||||||
|
ps1_convert.convert(
|
||||||
|
source, title, game_id, self.base_pbp, out_path,
|
||||||
|
progress_cb=cb, cancel_event=cancel_event,
|
||||||
|
)
|
||||||
|
except ps1_convert.Cancelled:
|
||||||
|
raise TransferCancelled() from None
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
class TreeCopySource(Source):
|
class TreeCopySource(Source):
|
||||||
"""Copy a whole directory (save-data folder) into a destination directory."""
|
"""Copy a whole directory (save-data folder) into a destination directory."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user