From 7381f2c39d57d3dc08fa77008c0c70528ba1051e Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 14 Sep 2026 21:26:57 -0400 Subject: [PATCH] Fix scrape crash + cover compose responsiveness - 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. --- app/covers.py | 55 ++++++++++++++--------- app/gui.py | 120 +++++++++++++++++++++++++++++++++++++------------- 2 files changed, 122 insertions(+), 53 deletions(-) diff --git a/app/covers.py b/app/covers.py index 6810c89..f04cada 100644 --- a/app/covers.py +++ b/app/covers.py @@ -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: diff --git a/app/gui.py b/app/gui.py index da812a1..3d3de23 100644 --- a/app/gui.py +++ b/app/gui.py @@ -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) @@ -654,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() @@ -956,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 "") @@ -971,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() @@ -984,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() @@ -1035,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() @@ -1080,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) @@ -1089,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() @@ -1126,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…") @@ -1133,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: @@ -1155,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() @@ -4954,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": @@ -4965,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