Compare commits

..
1 Commits
Author SHA1 Message Date
quoije 7381f2c39d Fix scrape crash + cover compose responsiveness
build / Linux x86_64 (push) Successful in 6m22s
build / Windows x64 (Wine) (push) Successful in 6m16s
- 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
2 changed files with 122 additions and 53 deletions
+21 -10
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:
+79 -21
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)
@@ -654,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()
@@ -956,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 "")
@@ -971,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()
@@ -984,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()
@@ -1035,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()
@@ -1080,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)
@@ -1089,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()
@@ -1126,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…")
@@ -1133,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:
@@ -1155,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()
@@ -4954,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":
@@ -4965,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