Compare commits

...
2 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
2 changed files with 146 additions and 53 deletions
+33 -22
View File
@@ -773,11 +773,16 @@ def save_cover(release_id, system, data):
# --------------------------------------------------------------------------- #
# 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):
"""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
for y in range(img.height()):
for x in range(img.width()):
@@ -790,24 +795,31 @@ def _transparent_rect(img):
def _game_template():
"""Load the PSP game cover template (top header + transparent window)."""
global _game_template_image
if _game_template_image is None:
try:
from PySide6.QtGui import QImage
except Exception: # noqa: BLE001 - headless API may lack PySide6
return None
img = QImage()
for path in (
config.COVERS_DIR / "UMD_GAME_COVER_TEMPLATE.png",
config.DATA_DIR / "covers" / "UMD_GAME_COVER_TEMPLATE.png",
):
if path.exists():
img.load(str(path))
if not img.isNull():
break
_game_template_image = img.convertToFormat(QImage.Format_ARGB32) if not img.isNull() else None
return _game_template_image
"""Load the PSP game cover template; return ``(template, window)``."""
global _game_template_cache
if _game_template_cache is None:
with _game_template_lock:
if _game_template_cache is None:
try:
from PySide6.QtGui import QImage
except Exception: # noqa: BLE001 - headless API may lack PySide6
_game_template_cache = (None, (0, 0, 0, 0))
return _game_template_cache
img = QImage()
for path in (
config.COVERS_DIR / "UMD_GAME_COVER_TEMPLATE.png",
config.DATA_DIR / "covers" / "UMD_GAME_COVER_TEMPLATE.png",
):
if path.exists():
img.load(str(path))
if not img.isNull():
break
img = img.convertToFormat(QImage.Format_ARGB32) if not img.isNull() else None
_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):
@@ -822,14 +834,13 @@ def compose_game_cover(key_art_bytes):
except Exception: # noqa: BLE001
return key_art_bytes
template = _game_template()
template, (x, y, w, h) = _game_template()
art = QImage.fromData(key_art_bytes)
if template is None or template.isNull() or art.isNull():
return key_art_bytes
art = art.convertToFormat(QImage.Format_ARGB32)
out = QImage(template.size(), QImage.Format_ARGB32)
out.fill(Qt.transparent)
x, y, w, h = _transparent_rect(template)
painter = QPainter(out)
if w > 0 and h > 0:
+113 -31
View File
@@ -165,55 +165,65 @@ class CoverLoader(QObject):
self.finished.emit()
_template_image = None
_template_cache = None # (template QImage, transparent window (x, y, w, h))
_template_lock = threading.Lock()
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
"""Return ``(template QImage, transparent window rect)`` — loaded once."""
global _template_cache
if _template_cache is None:
with _template_lock:
if _template_cache 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
img = img.convertToFormat(QImage.Format_ARGB32)
_template_cache = (img, _transparent_window(img))
return _template_cache
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()):
"""Bounding box of fully-transparent pixels as ``(x, y, w, h)``.
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:
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)
return (0, 0, 0, 0)
return (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()
template, (wx, wy, ww, wh) = _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
if ww > 0 and wh > 0:
scaled = poster.scaled(QSize(ww, wh), Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
ox = wx + (ww - scaled.width()) // 2
oy = wy + (wh - scaled.height()) // 2
painter.save()
painter.setClipRect(window)
painter.setClipRect(wx, wy, ww, wh)
painter.drawImage(ox, oy, scaled)
painter.restore()
painter.drawImage(0, 0, template)
@@ -463,7 +473,9 @@ class CoverShowcase(QWidget):
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._on_loader_finished)
self.loader.finished.connect(self.thread.quit)
self.thread.finished.connect(self._on_thread_finished)
self._threads.append(self.thread)
self._loaders.append(self.loader)
self.thread.start()
@@ -521,6 +533,26 @@ class CoverShowcase(QWidget):
self._loading = False
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):
if self._closing:
return
@@ -632,9 +664,16 @@ class MetadataWorker(QObject):
self.gen = gen
self.system = system
self.release_id = release_id
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
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.finished.emit()
@@ -934,10 +973,16 @@ class ScrapeSearchWorker(QObject):
super().__init__()
self.system = system
self.query = query
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
def run(self):
found = covers.search_metadata_candidates(self.system, self.query, limit=20, timeout=10)
for r in found:
if self._cancel.is_set():
break
src = r.get("source")
if src == "openvgdb":
r["thumb_bytes"] = covers.fetch_openvgdb_local(r.get("title") or "")
@@ -949,7 +994,8 @@ class ScrapeSearchWorker(QObject):
else:
url = r.get("thumb_url")
r["thumb_bytes"] = covers.download_image(url, timeout=8) if url else None
self.results.emit(found)
if not self._cancel.is_set():
self.results.emit(found)
self.finished.emit()
@@ -962,10 +1008,17 @@ class ScrapeFetchWorker(QObject):
self.system = system
self.rid = rid
self.candidate = candidate
self._cancel = threading.Event()
def cancel(self):
self._cancel.set()
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)
if self._cancel.is_set():
self.finished.emit()
return
self.result.emit({"meta": meta, "cover": cover})
self.finished.emit()
@@ -1013,6 +1066,7 @@ class ScrapeDialog(QDialog):
self.resize(600, 580)
self.candidates = []
self.selected_meta = None
self._search_gen = 0
layout = QVBoxLayout(self)
top = QHBoxLayout()
@@ -1058,6 +1112,10 @@ class ScrapeDialog(QDialog):
query = self.query_edit.text().strip()
if not query:
return
self._search_gen += 1
gen = self._search_gen
for w in self._workers:
w.cancel()
self.status.setText("Searching…")
self.search_btn.setEnabled(False)
self.ok_btn.setEnabled(False)
@@ -1067,13 +1125,15 @@ class ScrapeDialog(QDialog):
worker = ScrapeSearchWorker(self.system, query)
worker.moveToThread(thread)
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)
self._threads.append(thread)
self._workers.append(worker)
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.candidates = results
self.list.clear()
@@ -1104,6 +1164,10 @@ class ScrapeDialog(QDialog):
if item is None:
return
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.search_btn.setEnabled(False)
self.status.setText("Scraping…")
@@ -1111,13 +1175,15 @@ class ScrapeDialog(QDialog):
worker = ScrapeFetchWorker(self.system, self.rid, candidate)
worker.moveToThread(thread)
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)
self._threads.append(thread)
self._workers.append(worker)
thread.start()
def _on_scraped(self, payload):
def _on_scraped(self, payload, gen):
if gen != self._search_gen:
return
meta = payload.get("meta") or {}
cover = payload.get("cover")
if cover:
@@ -1133,11 +1199,14 @@ class ScrapeDialog(QDialog):
self.status.setText("Could not fetch anything for that selection.")
def closeEvent(self, event):
for w in self._workers:
w.cancel()
for t in self._threads:
if _thread_running(t):
t.quit()
t.wait()
self._workers = []
self._threads = []
event.accept()
@@ -4790,6 +4859,8 @@ class MainWindow(QMainWindow):
thread.started.connect(worker.run)
worker.result.connect(self._on_library_loaded)
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))
self._library_threads.append(thread)
self._library_workers.append(worker)
@@ -4930,6 +5001,8 @@ class MainWindow(QMainWindow):
return
self._meta_gen += 1
gen = self._meta_gen
for w in self._meta_workers:
w.cancel()
if item.get("title_override"):
title = item["title_override"]
elif system == "videos":
@@ -4941,11 +5014,20 @@ class MainWindow(QMainWindow):
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.result.connect(self._on_metadata)
worker.finished.connect(worker.deleteLater)
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_workers.append(worker)
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):
if gen != self._meta_gen:
return