Compare commits

..
4 Commits
Author SHA1 Message Date
quoije 7381f2c39d Fix scrape crash + cover compose responsiveness
build / Windows x64 (Wine) (push) Successful in 6m16s
build / Linux x86_64 (push) Successful in 6m22s
- Cache the UMD/game cover templates AND their transparent-window rects
  (computed once, under a lock). The old code re-ran an O(w*h) Python pixel
  scan on every composited cover, stalling video cover loading and scraping
  for ~1s per cover with GIL contention.
- ScrapeDialog: ignore stale search/scrape results via a generation counter
  and cancel in-flight workers when a new search/scrape starts; clear threads
  on close.
- MetadataWorker (library details): cancel stale metadata fetches and delete
  finished workers/threads instead of leaking one per item selection.
2026-09-14 21:26:57 -04:00
quoije 8b24d391ac Harden Library tab thread cleanup (covers + items)
build / Windows x64 (Wine) (push) Successful in 9m0s
build / Linux x86_64 (push) Successful in 10m44s
Library covers used the same QThread+worker architecture as the store but
never deleted finished threads/loaders, so every reload leaked one QThread
and one CoverLoader. Library item loading leaked its QThread too.

CoverShowcase now removes + deleteLater's loaders on their finished signal
and deletes their threads on thread.finished; MainWindow.load_library deletes
the finished worker and thread instead of only untracking them.
2026-09-14 20:53:30 -04:00
quoije d94ae52e14 Fix store crash when filtering after load (loader thread cleanup)
build / Windows x64 (Wine) (push) Successful in 10m49s
build / Linux x86_64 (push) Successful in 13m4s
StorePage cover/date loaders were never removed from tracking once their
thread finished, and _populate/_begin_scan dropped the whole list while
threads were still running. That garbage-collected QObjects whose QThread
had already been deleted, leaving a dangling thread affinity that crashed
Qt (SIGSEGV) when searching the store after it fully loaded.

Loaders now remove themselves and deleteLater on their finished signal, and
the tracking list is kept until each thread winds down.
2026-09-14 20:36:59 -04:00
quoije d8a552c61e Client: keep PS1/theme covers+metadata local when a server is configured
build / Linux x86_64 (push) Successful in 2m54s
build / Windows x64 (Wine) (push) Successful in 3m40s
2026-09-14 20:15:26 -04:00
3 changed files with 177 additions and 62 deletions
+26 -13
View File
@@ -773,11 +773,16 @@ def save_cover(release_id, system, data):
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Game cover compositing (IGDB key art -> full PSP cover) # Game cover compositing (IGDB key art -> full PSP cover)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
_game_template_image = None _game_template_cache = None # (template QImage or None, window (x, y, w, h))
_game_template_lock = threading.Lock()
def _transparent_rect(img): def _transparent_rect(img):
"""Bounding box of the fully-transparent pixels in `img`.""" """Bounding box of the fully-transparent pixels in `img`.
Run once per template (the result is cached) — an O(w*h) scan is far too
slow to repeat for every composed cover.
"""
minx, miny, maxx, maxy = img.width(), img.height(), -1, -1 minx, miny, maxx, maxy = img.width(), img.height(), -1, -1
for y in range(img.height()): for y in range(img.height()):
for x in range(img.width()): for x in range(img.width()):
@@ -790,13 +795,16 @@ def _transparent_rect(img):
def _game_template(): def _game_template():
"""Load the PSP game cover template (top header + transparent window).""" """Load the PSP game cover template; return ``(template, window)``."""
global _game_template_image global _game_template_cache
if _game_template_image is None: if _game_template_cache is None:
with _game_template_lock:
if _game_template_cache is None:
try: try:
from PySide6.QtGui import QImage from PySide6.QtGui import QImage
except Exception: # noqa: BLE001 - headless API may lack PySide6 except Exception: # noqa: BLE001 - headless API may lack PySide6
return None _game_template_cache = (None, (0, 0, 0, 0))
return _game_template_cache
img = QImage() img = QImage()
for path in ( for path in (
config.COVERS_DIR / "UMD_GAME_COVER_TEMPLATE.png", config.COVERS_DIR / "UMD_GAME_COVER_TEMPLATE.png",
@@ -806,8 +814,12 @@ def _game_template():
img.load(str(path)) img.load(str(path))
if not img.isNull(): if not img.isNull():
break break
_game_template_image = img.convertToFormat(QImage.Format_ARGB32) if not img.isNull() else None img = img.convertToFormat(QImage.Format_ARGB32) if not img.isNull() else None
return _game_template_image _game_template_cache = (
img,
_transparent_rect(img) if img is not None and not img.isNull() else (0, 0, 0, 0),
)
return _game_template_cache
def compose_game_cover(key_art_bytes): def compose_game_cover(key_art_bytes):
@@ -822,14 +834,13 @@ def compose_game_cover(key_art_bytes):
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
return key_art_bytes return key_art_bytes
template = _game_template() template, (x, y, w, h) = _game_template()
art = QImage.fromData(key_art_bytes) art = QImage.fromData(key_art_bytes)
if template is None or template.isNull() or art.isNull(): if template is None or template.isNull() or art.isNull():
return key_art_bytes return key_art_bytes
art = art.convertToFormat(QImage.Format_ARGB32) art = art.convertToFormat(QImage.Format_ARGB32)
out = QImage(template.size(), QImage.Format_ARGB32) out = QImage(template.size(), QImage.Format_ARGB32)
out.fill(Qt.transparent) out.fill(Qt.transparent)
x, y, w, h = _transparent_rect(template)
painter = QPainter(out) painter = QPainter(out)
if w > 0 and h > 0: if w > 0 and h > 0:
@@ -1220,9 +1231,11 @@ def fetch_cover(name, release_id, system, timeout=10, quick=False):
except OSError: except OSError:
pass pass
# Server is authoritative for covers when configured — no libretro/IGDB # Server is authoritative for game/video covers when configured — no
# fallback; if the server is unreachable the cover just stays empty. # libretro/IGDB fallback; if the server is unreachable the cover just
if server_client.configured(): # stays empty. PS1/theme art stays client-side (the server catalog only
# covers games and videos).
if server_client.configured() and system in ("games", "videos"):
try: try:
data = server_client.fetch_cover(system, name) data = server_client.fetch_cover(system, name)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
+124 -24
View File
@@ -165,12 +165,16 @@ class CoverLoader(QObject):
self.finished.emit() self.finished.emit()
_template_image = None _template_cache = None # (template QImage, transparent window (x, y, w, h))
_template_lock = threading.Lock()
def _umd_template(): def _umd_template():
global _template_image """Return ``(template QImage, transparent window rect)`` — loaded once."""
if _template_image is None: global _template_cache
if _template_cache is None:
with _template_lock:
if _template_cache is None:
img = QImage() img = QImage()
for path in ( for path in (
config.COVERS_DIR / "UMD_COVER_TEMPLATE.png", config.COVERS_DIR / "UMD_COVER_TEMPLATE.png",
@@ -180,40 +184,46 @@ def _umd_template():
img.load(str(path)) img.load(str(path))
if not img.isNull(): if not img.isNull():
break break
_template_image = img.convertToFormat(QImage.Format_ARGB32) img = img.convertToFormat(QImage.Format_ARGB32)
return _template_image _template_cache = (img, _transparent_window(img))
return _template_cache
def _transparent_window(img): def _transparent_window(img):
minx, miny, maxx, maxy = img.width(), img.height(), -1, -1 """Bounding box of fully-transparent pixels as ``(x, y, w, h)``.
for y in range(img.height()):
for x in range(img.width()): Run once per template (the result is cached), since this is an O(w*h)
scan that is far too slow to repeat per cover.
"""
w, h = img.width(), img.height()
minx, miny, maxx, maxy = w, h, -1, -1
for y in range(h):
for x in range(w):
if ((img.pixel(x, y) >> 24) & 0xFF) == 0: if ((img.pixel(x, y) >> 24) & 0xFF) == 0:
minx, miny = min(minx, x), min(miny, y) minx, miny = min(minx, x), min(miny, y)
maxx, maxy = max(maxx, x), max(maxy, y) maxx, maxy = max(maxx, x), max(maxy, y)
if maxx < minx or maxy < miny: if maxx < minx or maxy < miny:
return QRect() return (0, 0, 0, 0)
return QRect(minx, miny, maxx - minx + 1, maxy - miny + 1) return (minx, miny, maxx - minx + 1, maxy - miny + 1)
def compose_umd_cover(poster_bytes): def compose_umd_cover(poster_bytes):
"""Paste a movie poster into the UMD template's transparent window (PNG bytes).""" """Paste a movie poster into the UMD template's transparent window (PNG bytes)."""
template = _umd_template() template, (wx, wy, ww, wh) = _umd_template()
poster = QImage.fromData(poster_bytes) poster = QImage.fromData(poster_bytes)
if template.isNull() or poster.isNull(): if template.isNull() or poster.isNull():
return None return None
poster = poster.convertToFormat(QImage.Format_ARGB32) poster = poster.convertToFormat(QImage.Format_ARGB32)
out = QImage(template.size(), QImage.Format_ARGB32) out = QImage(template.size(), QImage.Format_ARGB32)
out.fill(Qt.transparent) out.fill(Qt.transparent)
window = _transparent_window(template)
painter = QPainter(out) painter = QPainter(out)
if window.width() > 0 and window.height() > 0: if ww > 0 and wh > 0:
scaled = poster.scaled(window.size(), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation) scaled = poster.scaled(QSize(ww, wh), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
ox = window.x() + (window.width() - scaled.width()) // 2 ox = wx + (ww - scaled.width()) // 2
oy = window.y() + (window.height() - scaled.height()) // 2 oy = wy + (wh - scaled.height()) // 2
painter.save() painter.save()
painter.setClipRect(window) painter.setClipRect(wx, wy, ww, wh)
painter.drawImage(ox, oy, scaled) painter.drawImage(ox, oy, scaled)
painter.restore() painter.restore()
painter.drawImage(0, 0, template) painter.drawImage(0, 0, template)
@@ -463,7 +473,9 @@ class CoverShowcase(QWidget):
self.loader.loaded.connect(self._on_loaded) self.loader.loaded.connect(self._on_loaded)
self.loader.failed.connect(self._on_failed) self.loader.failed.connect(self._on_failed)
self.loader.finished.connect(self._on_loading_done) self.loader.finished.connect(self._on_loading_done)
self.loader.finished.connect(self._on_loader_finished)
self.loader.finished.connect(self.thread.quit) self.loader.finished.connect(self.thread.quit)
self.thread.finished.connect(self._on_thread_finished)
self._threads.append(self.thread) self._threads.append(self.thread)
self._loaders.append(self.loader) self._loaders.append(self.loader)
self.thread.start() self.thread.start()
@@ -521,6 +533,26 @@ class CoverShowcase(QWidget):
self._loading = False self._loading = False
self._update_status() self._update_status()
def _on_loader_finished(self):
"""Drop a finished cover loader from tracking and schedule its deletion."""
loader = self.sender()
if loader is None:
return
if loader in self._loaders:
self._loaders.remove(loader)
if self.loader is loader:
self.loader = None
loader.deleteLater()
def _on_thread_finished(self):
"""Delete a finished cover-loader thread."""
thread = self.sender()
if thread in self._threads:
self._threads.remove(thread)
if self.thread is thread:
self.thread = None
thread.deleteLater()
def _update_status(self): def _update_status(self):
if self._closing: if self._closing:
return return
@@ -632,9 +664,16 @@ class MetadataWorker(QObject):
self.gen = gen self.gen = gen
self.system = system self.system = system
self.release_id = release_id self.release_id = release_id
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self): def run(self):
meta = info.get_or_fetch(self.release_id, self.system, self.title) meta = info.get_or_fetch(self.release_id, self.system, self.title)
if self._cancel.is_set():
self.finished.emit()
return
self.result.emit(self.gen, meta or {}) self.result.emit(self.gen, meta or {})
self.finished.emit() self.finished.emit()
@@ -934,10 +973,16 @@ class ScrapeSearchWorker(QObject):
super().__init__() super().__init__()
self.system = system self.system = system
self.query = query self.query = query
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self): def run(self):
found = covers.search_metadata_candidates(self.system, self.query, limit=20, timeout=10) found = covers.search_metadata_candidates(self.system, self.query, limit=20, timeout=10)
for r in found: for r in found:
if self._cancel.is_set():
break
src = r.get("source") src = r.get("source")
if src == "openvgdb": if src == "openvgdb":
r["thumb_bytes"] = covers.fetch_openvgdb_local(r.get("title") or "") r["thumb_bytes"] = covers.fetch_openvgdb_local(r.get("title") or "")
@@ -949,6 +994,7 @@ class ScrapeSearchWorker(QObject):
else: else:
url = r.get("thumb_url") url = r.get("thumb_url")
r["thumb_bytes"] = covers.download_image(url, timeout=8) if url else None r["thumb_bytes"] = covers.download_image(url, timeout=8) if url else None
if not self._cancel.is_set():
self.results.emit(found) self.results.emit(found)
self.finished.emit() self.finished.emit()
@@ -962,10 +1008,17 @@ class ScrapeFetchWorker(QObject):
self.system = system self.system = system
self.rid = rid self.rid = rid
self.candidate = candidate self.candidate = candidate
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self): def run(self):
meta = covers.link_metadata(self.system, self.candidate, timeout=10) or {} meta = covers.link_metadata(self.system, self.candidate, timeout=10) or {}
cover = _scrape_cover(self.system, self.candidate, self.rid, timeout=20) cover = _scrape_cover(self.system, self.candidate, self.rid, timeout=20)
if self._cancel.is_set():
self.finished.emit()
return
self.result.emit({"meta": meta, "cover": cover}) self.result.emit({"meta": meta, "cover": cover})
self.finished.emit() self.finished.emit()
@@ -1013,6 +1066,7 @@ class ScrapeDialog(QDialog):
self.resize(600, 580) self.resize(600, 580)
self.candidates = [] self.candidates = []
self.selected_meta = None self.selected_meta = None
self._search_gen = 0
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
top = QHBoxLayout() top = QHBoxLayout()
@@ -1058,6 +1112,10 @@ class ScrapeDialog(QDialog):
query = self.query_edit.text().strip() query = self.query_edit.text().strip()
if not query: if not query:
return return
self._search_gen += 1
gen = self._search_gen
for w in self._workers:
w.cancel()
self.status.setText("Searching…") self.status.setText("Searching…")
self.search_btn.setEnabled(False) self.search_btn.setEnabled(False)
self.ok_btn.setEnabled(False) self.ok_btn.setEnabled(False)
@@ -1067,13 +1125,15 @@ class ScrapeDialog(QDialog):
worker = ScrapeSearchWorker(self.system, query) worker = ScrapeSearchWorker(self.system, query)
worker.moveToThread(thread) worker.moveToThread(thread)
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.results.connect(self._on_results) worker.results.connect(lambda r, g=gen: self._on_results(r, g))
worker.finished.connect(thread.quit) worker.finished.connect(thread.quit)
self._threads.append(thread) self._threads.append(thread)
self._workers.append(worker) self._workers.append(worker)
thread.start() thread.start()
def _on_results(self, results): def _on_results(self, results, gen):
if gen != self._search_gen:
return
self.search_btn.setEnabled(True) self.search_btn.setEnabled(True)
self.candidates = results self.candidates = results
self.list.clear() self.list.clear()
@@ -1104,6 +1164,10 @@ class ScrapeDialog(QDialog):
if item is None: if item is None:
return return
candidate = item.data(Qt.UserRole) candidate = item.data(Qt.UserRole)
self._search_gen += 1
gen = self._search_gen
for w in self._workers:
w.cancel()
self.ok_btn.setEnabled(False) self.ok_btn.setEnabled(False)
self.search_btn.setEnabled(False) self.search_btn.setEnabled(False)
self.status.setText("Scraping…") self.status.setText("Scraping…")
@@ -1111,13 +1175,15 @@ class ScrapeDialog(QDialog):
worker = ScrapeFetchWorker(self.system, self.rid, candidate) worker = ScrapeFetchWorker(self.system, self.rid, candidate)
worker.moveToThread(thread) worker.moveToThread(thread)
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.result.connect(self._on_scraped) worker.result.connect(lambda p, g=gen: self._on_scraped(p, g))
worker.finished.connect(thread.quit) worker.finished.connect(thread.quit)
self._threads.append(thread) self._threads.append(thread)
self._workers.append(worker) self._workers.append(worker)
thread.start() thread.start()
def _on_scraped(self, payload): def _on_scraped(self, payload, gen):
if gen != self._search_gen:
return
meta = payload.get("meta") or {} meta = payload.get("meta") or {}
cover = payload.get("cover") cover = payload.get("cover")
if cover: if cover:
@@ -1133,11 +1199,14 @@ class ScrapeDialog(QDialog):
self.status.setText("Could not fetch anything for that selection.") self.status.setText("Could not fetch anything for that selection.")
def closeEvent(self, event): def closeEvent(self, event):
for w in self._workers:
w.cancel()
for t in self._threads: for t in self._threads:
if _thread_running(t): if _thread_running(t):
t.quit() t.quit()
t.wait() t.wait()
self._workers = [] self._workers = []
self._threads = []
event.accept() event.accept()
@@ -2044,7 +2113,6 @@ class StorePage(QWidget):
self._cover_attempted.clear() self._cover_attempted.clear()
for loader in self._loaders: for loader in self._loaders:
loader.cancel() loader.cancel()
self._loaders = []
self.grid.clear() self.grid.clear()
self.table.setRowCount(0) self.table.setRowCount(0)
self.status.setText("Loading…") self.status.setText("Loading…")
@@ -2220,10 +2288,12 @@ class StorePage(QWidget):
self._cover_attempted.clear() self._cover_attempted.clear()
self._placeholder = QIcon(_placeholder_pixmap(self.ICON_W, self.ICON_H)) self._placeholder = QIcon(_placeholder_pixmap(self.ICON_W, self.ICON_H))
# Cancel any in-flight cover loaders (stale generation). # Cancel any in-flight cover loaders (stale generation). Each loader
# removes itself from ``_loaders`` via its own ``finished`` handler once
# its thread winds down — dropping the list here would garbage-collect
# live QObjects and crash Qt.
for loader in self._loaders: for loader in self._loaders:
loader.cancel() loader.cancel()
self._loaders = []
self.grid.clear() self.grid.clear()
self.grid.setUpdatesEnabled(False) self.grid.setUpdatesEnabled(False)
@@ -2401,6 +2471,7 @@ class StorePage(QWidget):
thread.started.connect(loader.run) thread.started.connect(loader.run)
loader.loaded.connect(self._on_date_loaded) loader.loaded.connect(self._on_date_loaded)
loader.finished.connect(self._on_date_batch_done) loader.finished.connect(self._on_date_batch_done)
loader.finished.connect(self._on_loader_finished)
loader.finished.connect(thread.quit) loader.finished.connect(thread.quit)
thread.finished.connect(self._on_thread_finished) thread.finished.connect(self._on_thread_finished)
self._threads.append(thread) self._threads.append(thread)
@@ -2425,6 +2496,21 @@ class StorePage(QWidget):
if cell is not None: if cell is not None:
cell.setText(date) cell.setText(date)
def _on_loader_finished(self):
"""Drop a finished cover/date loader from tracking.
Loaders stay in ``_loaders`` until their thread finishes so their
QObject wrapper is never garbage-collected while its thread is still
winding down (that left a dangling thread affinity and crashed Qt on
filter/refresh).
"""
loader = self.sender()
if loader is None:
return
if loader in self._loaders:
self._loaders.remove(loader)
loader.deleteLater()
def _start_cover_loader(self, gen, tasks): def _start_cover_loader(self, gen, tasks):
if self._closing or not tasks: if self._closing or not tasks:
return return
@@ -2434,6 +2520,7 @@ class StorePage(QWidget):
thread.started.connect(loader.run) thread.started.connect(loader.run)
loader.loaded.connect(self._on_cover_loaded) loader.loaded.connect(self._on_cover_loaded)
loader.finished.connect(self._on_cover_batch_done) loader.finished.connect(self._on_cover_batch_done)
loader.finished.connect(self._on_loader_finished)
loader.finished.connect(thread.quit) loader.finished.connect(thread.quit)
thread.finished.connect(self._on_thread_finished) thread.finished.connect(self._on_thread_finished)
self._threads.append(thread) self._threads.append(thread)
@@ -4772,6 +4859,8 @@ class MainWindow(QMainWindow):
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.result.connect(self._on_library_loaded) worker.result.connect(self._on_library_loaded)
worker.finished.connect(thread.quit) worker.finished.connect(thread.quit)
worker.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda t=thread, w=worker: self._forget_library_thread(t, w)) thread.finished.connect(lambda t=thread, w=worker: self._forget_library_thread(t, w))
self._library_threads.append(thread) self._library_threads.append(thread)
self._library_workers.append(worker) self._library_workers.append(worker)
@@ -4912,6 +5001,8 @@ class MainWindow(QMainWindow):
return return
self._meta_gen += 1 self._meta_gen += 1
gen = self._meta_gen gen = self._meta_gen
for w in self._meta_workers:
w.cancel()
if item.get("title_override"): if item.get("title_override"):
title = item["title_override"] title = item["title_override"]
elif system == "videos": elif system == "videos":
@@ -4923,11 +5014,20 @@ class MainWindow(QMainWindow):
worker.moveToThread(thread) worker.moveToThread(thread)
thread.started.connect(worker.run) thread.started.connect(worker.run)
worker.result.connect(self._on_metadata) worker.result.connect(self._on_metadata)
worker.finished.connect(worker.deleteLater)
worker.finished.connect(thread.quit) worker.finished.connect(thread.quit)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda t=thread, w=worker: self._forget_meta_thread(t, w))
self._meta_threads.append(thread) self._meta_threads.append(thread)
self._meta_workers.append(worker) self._meta_workers.append(worker)
thread.start() thread.start()
def _forget_meta_thread(self, thread, worker):
if thread in self._meta_threads:
self._meta_threads.remove(thread)
if worker in self._meta_workers:
self._meta_workers.remove(worker)
def _on_metadata(self, gen, meta): def _on_metadata(self, gen, meta):
if gen != self._meta_gen: if gen != self._meta_gen:
return return
+5 -3
View File
@@ -199,9 +199,11 @@ def get_or_fetch(release_id, system, fetch_title):
info = load(release_id, system) info = load(release_id, system)
if info is not None: if info is not None:
return info return info
# Server is authoritative for metadata when configured — no IGDB/TMDB # Server is authoritative for game/video metadata when configured — no
# fallback; an unreachable server just leaves metadata empty. # IGDB/TMDB fallback; an unreachable server just leaves metadata empty.
if server_client.configured(): # PS1 metadata stays client-side (the server catalog only covers games
# and videos).
if server_client.configured() and system in ("games", "videos"):
try: try:
meta = server_client.fetch_metadata(system, fetch_title) meta = server_client.fetch_metadata(system, fetch_title)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001