Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78f6d44c73 | ||
|
|
a7f90cd5a7 | ||
|
|
c9575817fe | ||
|
|
b576ff5747 | ||
|
|
6510229b07 | ||
|
|
e61d91418e | ||
|
|
a1c461f6aa | ||
|
|
985c8df2cc |
@@ -11,6 +11,19 @@ _BUNDLE_DIR = getattr(sys, "_MEIPASS", None)
|
|||||||
|
|
||||||
PROJECT_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(_BUNDLE_DIR).resolve() if _BUNDLE_DIR else Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Frozen bundles don't ship the system CA store, so Python's urllib would fail
|
||||||
|
# TLS verification against the store server / NoPayStation / TMDB / archive.org.
|
||||||
|
# Point OpenSSL at the certifi bundle (a curl_cffi dependency) when frozen only;
|
||||||
|
# source runs keep using the OS CA store.
|
||||||
|
if IS_FROZEN:
|
||||||
|
try:
|
||||||
|
import certifi
|
||||||
|
_CA_BUNDLE = certifi.where()
|
||||||
|
os.environ.setdefault("SSL_CERT_FILE", _CA_BUNDLE)
|
||||||
|
os.environ.setdefault("REQUESTS_CA_BUNDLE", _CA_BUNDLE)
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def resource_path(*parts):
|
def resource_path(*parts):
|
||||||
"""Absolute path to a read-only bundled resource (works when frozen)."""
|
"""Absolute path to a read-only bundled resource (works when frozen)."""
|
||||||
|
|||||||
+84
-1
@@ -1280,6 +1280,22 @@ class SettingsPage(QWidget):
|
|||||||
form.addRow(self.clear_covers_btn)
|
form.addRow(self.clear_covers_btn)
|
||||||
col.addWidget(box)
|
col.addWidget(box)
|
||||||
|
|
||||||
|
# -- Storage ---------------------------------------------------------- #
|
||||||
|
box, form = self._group("Storage")
|
||||||
|
self._path_row(form, "State directory", str(config.STATE_DIR))
|
||||||
|
self._path_row(form, "Library database", str(config.DB_PATH))
|
||||||
|
self._path_row(form, "Cover cache", str(config.COVERS_DIR))
|
||||||
|
self._path_row(form, "User data (credentials, NPS cache…)", str(config.USER_DATA_DIR))
|
||||||
|
note = QLabel(
|
||||||
|
"Your library, covers, and credentials live here — outside the app "
|
||||||
|
"bundle, so they survive upgrades. On Linux this is the "
|
||||||
|
"~/.local/state/psp-preserve folder."
|
||||||
|
)
|
||||||
|
note.setWordWrap(True)
|
||||||
|
note.setStyleSheet("color: #9a9ba5;")
|
||||||
|
form.addRow(note)
|
||||||
|
col.addWidget(box)
|
||||||
|
|
||||||
col.addStretch(1)
|
col.addStretch(1)
|
||||||
|
|
||||||
def _group(self, title):
|
def _group(self, title):
|
||||||
@@ -1342,6 +1358,29 @@ class SettingsPage(QWidget):
|
|||||||
form.addRow(label, edit)
|
form.addRow(label, edit)
|
||||||
return edit
|
return edit
|
||||||
|
|
||||||
|
def _path_row(self, form, label, path):
|
||||||
|
edit = QLineEdit(path)
|
||||||
|
edit.setReadOnly(True)
|
||||||
|
open_btn = QPushButton("Open")
|
||||||
|
open_btn.clicked.connect(lambda _=False, p=Path(path): self._open_path(p))
|
||||||
|
row = QWidget()
|
||||||
|
h = QHBoxLayout(row)
|
||||||
|
h.setContentsMargins(0, 0, 0, 0)
|
||||||
|
h.addWidget(edit, 1)
|
||||||
|
h.addWidget(open_btn)
|
||||||
|
form.addRow(label, row)
|
||||||
|
return edit
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _open_path(path):
|
||||||
|
path = Path(path)
|
||||||
|
target = path if path.is_dir() else path.parent
|
||||||
|
if target.is_dir():
|
||||||
|
QDesktopServices.openUrl(QUrl.fromLocalFile(str(target)))
|
||||||
|
else:
|
||||||
|
QMessageBox.information(None, "Not found",
|
||||||
|
f"Folder not created yet:\n{target}\n\nIt is created on first use.")
|
||||||
|
|
||||||
def games_dir(self):
|
def games_dir(self):
|
||||||
return self.games_edit.text().strip()
|
return self.games_edit.text().strip()
|
||||||
|
|
||||||
@@ -1789,6 +1828,11 @@ class StorePage(QWidget):
|
|||||||
self.psp_filter.setToolTip("Filter by whether the archive matches a disc installed on the connected 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)
|
self.psp_filter.currentIndexChanged.connect(self._apply_filter)
|
||||||
bar.addWidget(self.psp_filter)
|
bar.addWidget(self.psp_filter)
|
||||||
|
self.store_sort = QComboBox()
|
||||||
|
self.store_sort.addItems(["Newest first", "Title (A–Z)", "Region (A–Z)", "Size (largest)", "In library first"])
|
||||||
|
self.store_sort.setToolTip("Sort the cover grid")
|
||||||
|
self.store_sort.currentTextChanged.connect(self._apply_filter)
|
||||||
|
bar.addWidget(self.store_sort)
|
||||||
refresh_btn = QPushButton("Refresh")
|
refresh_btn = QPushButton("Refresh")
|
||||||
refresh_btn.clicked.connect(lambda: self.refresh(force=True))
|
refresh_btn.clicked.connect(lambda: self.refresh(force=True))
|
||||||
bar.addWidget(refresh_btn)
|
bar.addWidget(refresh_btn)
|
||||||
@@ -2046,7 +2090,7 @@ class StorePage(QWidget):
|
|||||||
if not it.get("release_date"):
|
if not it.get("release_date"):
|
||||||
it["release_date"] = dates.get((str(rid), system)) or ""
|
it["release_date"] = dates.get((str(rid), system)) or ""
|
||||||
filtered.append(it)
|
filtered.append(it)
|
||||||
filtered.sort(key=self._store_sort_key)
|
self._sort_store_items(filtered)
|
||||||
self._filtered = filtered
|
self._filtered = filtered
|
||||||
if self.all_check.isChecked():
|
if self.all_check.isChecked():
|
||||||
show = len(filtered)
|
show = len(filtered)
|
||||||
@@ -2054,6 +2098,19 @@ class StorePage(QWidget):
|
|||||||
show = min(self.RECENT_LIMIT, len(filtered))
|
show = min(self.RECENT_LIMIT, len(filtered))
|
||||||
self._populate(filtered, show)
|
self._populate(filtered, show)
|
||||||
|
|
||||||
|
def _sort_store_items(self, items):
|
||||||
|
mode = self.store_sort.currentText()
|
||||||
|
if mode == "Title (A–Z)":
|
||||||
|
items.sort(key=lambda it: store.display_name(it["name"]).lower())
|
||||||
|
elif mode == "Region (A–Z)":
|
||||||
|
items.sort(key=lambda it: (store.region(it["name"]) or "").lower())
|
||||||
|
elif mode == "Size (largest)":
|
||||||
|
items.sort(key=lambda it: it.get("size") or 0, reverse=True)
|
||||||
|
elif mode == "In library first":
|
||||||
|
items.sort(key=lambda it: (not it.get("in_library"), store.display_name(it["name"]).lower()))
|
||||||
|
else: # Newest first
|
||||||
|
items.sort(key=self._store_sort_key)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _store_sort_key(it):
|
def _store_sort_key(it):
|
||||||
d = it.get("release_date") or ""
|
d = it.get("release_date") or ""
|
||||||
@@ -4366,6 +4423,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.library_view = QComboBox()
|
self.library_view = QComboBox()
|
||||||
self.library_view.addItems(["Cover view", "List view"])
|
self.library_view.addItems(["Cover view", "List view"])
|
||||||
bar.addWidget(self.library_view)
|
bar.addWidget(self.library_view)
|
||||||
|
self.library_sort = QComboBox()
|
||||||
|
self.library_sort.addItems(["Title", "Release date", "Size", "Region", "Type", "Serial"])
|
||||||
|
self.library_sort.setToolTip("Sort the cover grid")
|
||||||
|
self.library_sort.currentTextChanged.connect(self._apply_library_filter)
|
||||||
|
bar.addWidget(self.library_sort)
|
||||||
reload_btn = QPushButton("Reload")
|
reload_btn = QPushButton("Reload")
|
||||||
reload_btn.clicked.connect(self.load_library)
|
reload_btn.clicked.connect(self.load_library)
|
||||||
bar.addWidget(reload_btn)
|
bar.addWidget(reload_btn)
|
||||||
@@ -4633,12 +4695,30 @@ class MainWindow(QMainWindow):
|
|||||||
if psp_f == "Not on PSP" and it.get("on_psp"):
|
if psp_f == "Not on PSP" and it.get("on_psp"):
|
||||||
continue
|
continue
|
||||||
items.append(it)
|
items.append(it)
|
||||||
|
self._sort_library_items(items)
|
||||||
self._library_filtered = items
|
self._library_filtered = items
|
||||||
self._populate_library_table(items)
|
self._populate_library_table(items)
|
||||||
self.cover_grid.set_items(items)
|
self.cover_grid.set_items(items)
|
||||||
if hasattr(self, "psp_page"):
|
if hasattr(self, "psp_page"):
|
||||||
self.psp_page.set_library_items(self._library_cache)
|
self.psp_page.set_library_items(self._library_cache)
|
||||||
|
|
||||||
|
def _sort_library_items(self, items):
|
||||||
|
mode = self.library_sort.currentText()
|
||||||
|
title = lambda it: (it.get("title_override") or it.get("canonical") or it["name"] or "").lower()
|
||||||
|
if mode == "Release date":
|
||||||
|
items.sort(key=lambda it: it.get("release_date") or "", reverse=True)
|
||||||
|
elif mode == "Size":
|
||||||
|
items.sort(key=lambda it: it.get("size") or 0, reverse=True)
|
||||||
|
elif mode == "Region":
|
||||||
|
items.sort(key=lambda it: (it.get("region") or "").lower())
|
||||||
|
elif mode == "Type":
|
||||||
|
order = {"games": 0, "videos": 1, "ps1": 2, "theme": 3, "dlc": 4}
|
||||||
|
items.sort(key=lambda it: (order.get(it.get("system"), 9), title(it)))
|
||||||
|
elif mode == "Serial":
|
||||||
|
items.sort(key=lambda it: (it.get("serial") or "").lower())
|
||||||
|
else: # Title
|
||||||
|
items.sort(key=title)
|
||||||
|
|
||||||
def _populate_library_table(self, items):
|
def _populate_library_table(self, items):
|
||||||
self.library_table.setUpdatesEnabled(False)
|
self.library_table.setUpdatesEnabled(False)
|
||||||
self.library_table.setSortingEnabled(False)
|
self.library_table.setSortingEnabled(False)
|
||||||
@@ -5599,10 +5679,13 @@ def main():
|
|||||||
log.info("starting psp-preserve (db: %s)", config.DB_PATH)
|
log.info("starting psp-preserve (db: %s)", config.DB_PATH)
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
style.apply_theme(app)
|
style.apply_theme(app)
|
||||||
|
app.setApplicationName("PSP Preservation Tool")
|
||||||
|
app.setDesktopFileName("psp-preserve")
|
||||||
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
|
icon_path = config.PROJECT_ROOT / "img" / "app-icon.png"
|
||||||
if icon_path.is_file():
|
if icon_path.is_file():
|
||||||
app.setWindowIcon(QIcon(str(icon_path)))
|
app.setWindowIcon(QIcon(str(icon_path)))
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
|
window.setWindowIcon(app.windowIcon())
|
||||||
window.show()
|
window.show()
|
||||||
log.info("main window ready")
|
log.info("main window ready")
|
||||||
threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start()
|
threading.Thread(target=_start_bundle_check, args=(window,), daemon=True).start()
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=PSP Preservation Tool
|
||||||
|
Comment=Preserve and manage your PSP library
|
||||||
|
Exec=psp-preserve
|
||||||
|
Icon=psp-preserve
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;AudioVideo;
|
||||||
|
StartupWMClass=psp-preserve
|
||||||
@@ -24,6 +24,7 @@ datas = [
|
|||||||
("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"),
|
("data/covers/UMD_GAME_COVER_TEMPLATE.png", "data/covers"),
|
||||||
("img/app-icon.png", "img"),
|
("img/app-icon.png", "img"),
|
||||||
("img/chevron.png", "img"),
|
("img/chevron.png", "img"),
|
||||||
|
("packaging/psp-preserve.desktop", "."),
|
||||||
]
|
]
|
||||||
|
|
||||||
a = Analysis(
|
a = Analysis(
|
||||||
@@ -53,6 +54,7 @@ exe = EXE(
|
|||||||
upx=False,
|
upx=False,
|
||||||
console=False,
|
console=False,
|
||||||
disable_windowed_traceback=False,
|
disable_windowed_traceback=False,
|
||||||
|
contents_directory="psp-preserve",
|
||||||
)
|
)
|
||||||
|
|
||||||
coll = COLLECT(
|
coll = COLLECT(
|
||||||
|
|||||||
+31
-15
@@ -12,34 +12,50 @@ ENV DEBIAN_FRONTEND=noninteractive \
|
|||||||
WINEDLLOVERRIDES="mscoree,mshtml=" \
|
WINEDLLOVERRIDES="mscoree,mshtml=" \
|
||||||
PYTHON_VERSION=3.12.8
|
PYTHON_VERSION=3.12.8
|
||||||
|
|
||||||
# Wine (64-bit), node + git for JS actions, and build/package tooling.
|
# WineHQ wine-staging (newer than Debian's 8.0, needed for Python 3.12),
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
# node + git for JS actions, and build/package tooling.
|
||||||
ca-certificates curl wget unzip zip \
|
RUN dpkg --add-architecture i386 \
|
||||||
wine wine64 xvfb winetricks cabextract \
|
&& apt-get update \
|
||||||
nodejs git \
|
&& apt-get install -y --no-install-recommends ca-certificates curl wget gnupg2 \
|
||||||
|
&& mkdir -p /etc/apt/keyrings \
|
||||||
|
&& wget -qO- https://dl.winehq.org/wine-builds/winehq.key | gpg --dearmor -o /etc/apt/keyrings/winehq.gpg \
|
||||||
|
&& echo "deb [signed-by=/etc/apt/keyrings/winehq.gpg] https://dl.winehq.org/wine-builds/debian/ bookworm main" > /etc/apt/sources.list.d/winehq.list \
|
||||||
|
&& apt-get update \
|
||||||
|
&& apt-get install -y --install-recommends winehq-staging \
|
||||||
|
&& apt-get install -y --no-install-recommends unzip zip xvfb xauth cabextract nodejs git \
|
||||||
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
&& apt-get clean && rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# winetricks (standalone script; not packaged in Debian main).
|
||||||
|
RUN wget -qO /usr/local/bin/winetricks \
|
||||||
|
"https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks" \
|
||||||
|
&& chmod +x /usr/local/bin/winetricks
|
||||||
|
|
||||||
# Gitea Actions runner (linux-amd64 binary).
|
# Gitea Actions runner (linux-amd64 binary).
|
||||||
RUN wget -qO /usr/local/bin/act_runner \
|
RUN wget -qO /usr/local/bin/act_runner \
|
||||||
"https://gitea.com/gitea/act_runner/releases/download/v0.6.1/act_runner-0.6.1-linux-amd64" \
|
"https://gitea.com/gitea/act_runner/releases/download/v0.6.1/act_runner-0.6.1-linux-amd64" \
|
||||||
&& chmod +x /usr/local/bin/act_runner
|
&& chmod +x /usr/local/bin/act_runner
|
||||||
|
|
||||||
# Windows CPython 3.12, silent install into C:\Python312.
|
# MSVC runtime required by PySide6's Qt DLLs (also initializes the Wine prefix).
|
||||||
RUN wget -qO /tmp/python-installer.exe \
|
|
||||||
"https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-amd64.exe" \
|
|
||||||
&& xvfb-run -a wine /tmp/python-installer.exe \
|
|
||||||
/quiet InstallAllUsers=1 PrependPath=1 Include_test=0 Include_launcher=0 Shortcuts=0 \
|
|
||||||
&& rm -f /tmp/python-installer.exe \
|
|
||||||
&& wine "C:\\Python312\\python.exe" --version
|
|
||||||
|
|
||||||
# MSVC runtime required by PySide6's Qt DLLs.
|
|
||||||
RUN xvfb-run -a winetricks --unattended vcrun2022
|
RUN xvfb-run -a winetricks --unattended vcrun2022
|
||||||
|
|
||||||
|
# Windows CPython 3.12 (embeddable zip) + pip.
|
||||||
|
RUN wget -qO /tmp/pyembed.zip \
|
||||||
|
"https://www.python.org/ftp/python/${PYTHON_VERSION}/python-${PYTHON_VERSION}-embed-amd64.zip" \
|
||||||
|
&& mkdir -p /root/.wine/drive_c/Python312 \
|
||||||
|
&& cd /root/.wine/drive_c/Python312 \
|
||||||
|
&& unzip -q /tmp/pyembed.zip \
|
||||||
|
&& rm -f /tmp/pyembed.zip \
|
||||||
|
&& printf 'python312.zip\n.\nimport site\nLib\\site-packages\n' > python312._pth \
|
||||||
|
&& wine "C:\\Python312\\python.exe" --version
|
||||||
|
|
||||||
# Convenience wrapper: winpython -> wine C:\Python312\python.exe
|
# Convenience wrapper: winpython -> wine C:\Python312\python.exe
|
||||||
RUN printf '#!/bin/sh\nexec wine "C:\\Python312\\python.exe" "$@"\n' > /usr/local/bin/winpython \
|
RUN printf '#!/bin/sh\nexec wine "C:\\Python312\\python.exe" "$@"\n' > /usr/local/bin/winpython \
|
||||||
&& chmod +x /usr/local/bin/winpython
|
&& chmod +x /usr/local/bin/winpython
|
||||||
|
|
||||||
# Pre-install the Windows build deps so CI jobs start fast.
|
# Bootstrap pip, then pre-install the Windows build deps so CI jobs start fast.
|
||||||
|
RUN wget -qO /tmp/get-pip.py https://bootstrap.pypa.io/get-pip.py \
|
||||||
|
&& wine "C:\\Python312\\python.exe" /tmp/get-pip.py \
|
||||||
|
&& rm -f /tmp/get-pip.py
|
||||||
COPY requirements-build.txt /tmp/requirements-build.txt
|
COPY requirements-build.txt /tmp/requirements-build.txt
|
||||||
RUN winpython -m pip install --upgrade pip \
|
RUN winpython -m pip install --upgrade pip \
|
||||||
&& winpython -m pip install -r /tmp/requirements-build.txt \
|
&& winpython -m pip install -r /tmp/requirements-build.txt \
|
||||||
|
|||||||
Reference in New Issue
Block a user