Compare commits

...
4 Commits
Author SHA1 Message Date
quoije 99e9cdc49a Prevent 'Too many open files' crash on store source switch
build / Linux x86_64 (push) Successful in 3m53s
build / Windows x64 (Wine) (push) Successful in 4m9s
- Raise the process soft file-descriptor limit to the hard limit at startup
  (GLib aborts the process with 'Creating pipes for GWakeup: Too many open
  files' when cover/metadata loading exhausts a 1024-soft-limit environment).
- Reduce per-loader network concurrency 8 -> 6 threads to lower socket
  pressure against the store server.
2026-09-14 23:11:51 -04:00
quoije a4d132f134 Harden worker cleanup + reduce cover-loading churn
build / Linux x86_64 (push) Successful in 3m30s
build / Windows x64 (Wine) (push) Successful in 4m22s
- Library/metadata workers: delete the worker on its own thread via
  worker.finished -> worker.deleteLater (bound) instead of letting Python GC
  delete it on the GUI thread after untracking.
- Cover templates: compute the transparent-window scan outside the lock so a
  slow first scan never holds the lock and stalls other cover threads.
- Store: larger cover/date batches (500/400/300) so the store loads 3449
  archives with far fewer QThread + ThreadPoolExecutor creations.
2026-09-14 22:33:49 -04:00
quoije 3a69f3f6a1 Fix scrape crash: don't run GUI slots on worker threads
build / Linux x86_64 (push) Successful in 3m4s
build / Windows x64 (Wine) (push) Successful in 3m44s
Connecting a signal to a Python lambda has no QObject receiver, so PySide6
treats it as a direct connection and runs the lambda on the emitting (worker)
thread. The scrape dialog connected its results signals to lambdas, so
_on_results/_on_scraped executed on the worker thread and called
accept()/done() there -> QThread::wait on itself and GUI widget mutation off
the GUI thread -> 'QThread: Destroyed while thread is still running' abort.

Connect to bound methods (queued to the GUI thread) and carry the generation
on the worker (read via sender().gen). Also replaced the thread.finished
lambdas in load_library/metadata with bound-method cleanup keyed by a
thread._worker attribute.
2026-09-14 21:54:59 -04:00
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
2 changed files with 176 additions and 64 deletions
+23 -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,15 @@ 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 not None:
return _game_template_cache
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 +813,15 @@ 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 entry = (
img,
_transparent_rect(img) if img is not None and not img.isNull() else (0, 0, 0, 0),
)
with _game_template_lock:
if _game_template_cache is None:
_game_template_cache = entry
return _game_template_cache
def compose_game_cover(key_art_bytes): def compose_game_cover(key_art_bytes):
@@ -822,14 +836,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:
+131 -32
View File
@@ -136,7 +136,7 @@ class CoverLoader(QObject):
def run(self): def run(self):
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
pool = ThreadPoolExecutor(max_workers=8) pool = ThreadPoolExecutor(max_workers=6)
futures = { futures = {
pool.submit(load_cover, it): i pool.submit(load_cover, it): i
for i, it in enumerate(self.items) for i, it in enumerate(self.items)
@@ -165,12 +165,15 @@ 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 not None:
return _template_cache
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 +183,49 @@ 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 entry = (img, _transparent_window(img))
with _template_lock:
if _template_cache is None:
_template_cache = entry
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 +666,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()
@@ -952,14 +971,21 @@ class ScrapeSearchWorker(QObject):
results = Signal(list) # candidates with thumb_bytes added results = Signal(list) # candidates with thumb_bytes added
finished = Signal() finished = Signal()
def __init__(self, system, query): def __init__(self, system, query, gen):
super().__init__() super().__init__()
self.system = system self.system = system
self.query = query self.query = query
self.gen = gen
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 +997,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()
@@ -979,15 +1006,23 @@ class ScrapeFetchWorker(QObject):
result = Signal(dict) # {"meta": {...}, "cover": bytes|None} result = Signal(dict) # {"meta": {...}, "cover": bytes|None}
finished = Signal() finished = Signal()
def __init__(self, system, rid, candidate): def __init__(self, system, rid, candidate, gen):
super().__init__() super().__init__()
self.system = system self.system = system
self.rid = rid self.rid = rid
self.candidate = candidate self.candidate = candidate
self.gen = gen
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 +1070,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,13 +1116,17 @@ 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)
self.list.clear() self.list.clear()
self.candidates = [] self.candidates = []
thread = QThread(self) thread = QThread(self)
worker = ScrapeSearchWorker(self.system, query) worker = ScrapeSearchWorker(self.system, query, gen)
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(self._on_results)
@@ -1096,6 +1136,8 @@ class ScrapeDialog(QDialog):
thread.start() thread.start()
def _on_results(self, results): def _on_results(self, results):
if getattr(self.sender(), "gen", None) != 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,11 +1168,15 @@ 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…")
thread = QThread(self) thread = QThread(self)
worker = ScrapeFetchWorker(self.system, self.rid, candidate) worker = ScrapeFetchWorker(self.system, self.rid, candidate, gen)
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(self._on_scraped)
@@ -1140,6 +1186,8 @@ class ScrapeDialog(QDialog):
thread.start() thread.start()
def _on_scraped(self, payload): def _on_scraped(self, payload):
if getattr(self.sender(), "gen", None) != 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:
@@ -1154,14 +1202,32 @@ class ScrapeDialog(QDialog):
self.search_btn.setEnabled(True) self.search_btn.setEnabled(True)
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 _shutdown_threads(self):
"""Cancel workers and wait for their threads to fully finish.
Must run before the dialog is destroyed — QThread children abort the
process with "QThread: Destroyed while thread is still running" if they
are deleted while their worker thread is alive. ``done()`` covers
accept()/reject() (including the accept() in ``_on_scraped``); the
close event covers the window-manager close path.
"""
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 = []
def closeEvent(self, event):
self._shutdown_threads()
event.accept() event.accept()
def done(self, result):
self._shutdown_threads()
super().done(result)
class SettingsPage(QWidget): class SettingsPage(QWidget):
saved = Signal() saved = Signal()
@@ -1568,7 +1634,7 @@ class StoreCoverLoader(QObject):
def run(self): def run(self):
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
pool = ThreadPoolExecutor(max_workers=8) pool = ThreadPoolExecutor(max_workers=6)
futures = { futures = {
pool.submit(load_cover, it, 8, self.quick): row pool.submit(load_cover, it, 8, self.quick): row
for row, it in self.tasks for row, it in self.tasks
@@ -2221,11 +2287,11 @@ class StorePage(QWidget):
return (0, -date_int, -mtime) return (0, -date_int, -mtime)
return (1, 0, -mtime) return (1, 0, -mtime)
CHUNK = 150 # items filled per timer tick CHUNK = 200 # items filled per timer tick
COVER_BATCH = 200 # covers fetched for the initially-visible area COVER_BATCH = 500 # covers fetched for the initially-visible area
COVER_SCROLL_BATCH = 100 COVER_SCROLL_BATCH = 400
RECENT_LIMIT = 300 # default: show only the newest N store items RECENT_LIMIT = 300 # default: show only the newest N store items
DATE_BATCH = 150 # release-date metadata fetched per batch DATE_BATCH = 300 # release-date metadata fetched per batch
def _populate(self, items, show_count): def _populate(self, items, show_count):
self._generation += 1 self._generation += 1
@@ -4809,12 +4875,13 @@ class MainWindow(QMainWindow):
thread = QThread(self) thread = QThread(self)
worker = LibraryWorker(self._ps1_dir, self._ps1_output_dir, self._nps_dir) worker = LibraryWorker(self._ps1_dir, self._ps1_output_dir, self._nps_dir)
worker.moveToThread(thread) worker.moveToThread(thread)
thread._worker = worker
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(worker.deleteLater) worker.finished.connect(worker.deleteLater)
worker.finished.connect(thread.quit)
thread.finished.connect(thread.deleteLater) thread.finished.connect(thread.deleteLater)
thread.finished.connect(lambda t=thread, w=worker: self._forget_library_thread(t, w)) thread.finished.connect(self._forget_library_thread)
self._library_threads.append(thread) self._library_threads.append(thread)
self._library_workers.append(worker) self._library_workers.append(worker)
thread.start() thread.start()
@@ -4828,10 +4895,12 @@ class MainWindow(QMainWindow):
self.store_page.set_library_items(self._library_cache) self.store_page.set_library_items(self._library_cache)
self.statusBar().showMessage("") self.statusBar().showMessage("")
def _forget_library_thread(self, thread, worker): def _forget_library_thread(self):
thread = self.sender()
worker = getattr(thread, "_worker", None)
if thread in self._library_threads: if thread in self._library_threads:
self._library_threads.remove(thread) self._library_threads.remove(thread)
if worker in self._library_workers: if worker is not None and worker in self._library_workers:
self._library_workers.remove(worker) self._library_workers.remove(worker)
def _apply_library_filter(self): def _apply_library_filter(self):
@@ -4954,6 +5023,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":
@@ -4963,13 +5034,25 @@ class MainWindow(QMainWindow):
thread = QThread(self) thread = QThread(self)
worker = MetadataWorker(title, gen, system, rid) worker = MetadataWorker(title, gen, system, rid)
worker.moveToThread(thread) worker.moveToThread(thread)
thread._worker = worker
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(self._forget_meta_thread)
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 = self.sender()
worker = getattr(thread, "_worker", None)
if thread in self._meta_threads:
self._meta_threads.remove(thread)
if worker is not None and 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
@@ -5871,8 +5954,24 @@ class MainWindow(QMainWindow):
event.accept() event.accept()
def _raise_nofile_limit():
"""Raise the soft file-descriptor limit to the hard limit.
Cover/metadata loading opens many short-lived sockets, and each worker
QThread's GLib event dispatcher creates a wakeup pipe. On systems with a
1024 soft limit this aborts the process with GLib's "Too many open files".
"""
try:
import resource
_soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
resource.setrlimit(resource.RLIMIT_NOFILE, (hard, hard))
except Exception: # noqa: BLE001 - best effort, never block startup
pass
def main(): def main():
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)-7s %(name)-14s %(message)s")
_raise_nofile_limit()
init_db() # migrate legacy on-share DB/covers to local storage, then schema init_db() # migrate legacy on-share DB/covers to local storage, then schema
# Auto-ingest any missing DATs so scanning matches against the catalog # Auto-ingest any missing DATs so scanning matches against the catalog
# even on a fresh/reset database (no manual `python -m app.ingest` step). # even on a fresh/reset database (no manual `python -m app.ingest` step).