Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a69f3f6a1 | ||
|
|
7381f2c39d | ||
|
|
8b24d391ac | ||
|
|
d94ae52e14 | ||
|
|
d8a552c61e |
+26
-13
@@ -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
|
||||||
|
|||||||
+149
-28
@@ -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()
|
||||||
|
|
||||||
@@ -930,14 +969,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 "")
|
||||||
@@ -949,6 +995,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()
|
||||||
|
|
||||||
@@ -957,15 +1004,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()
|
||||||
|
|
||||||
@@ -1013,6 +1068,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,13 +1114,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)
|
||||||
@@ -1074,6 +1134,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()
|
||||||
@@ -1104,11 +1166,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)
|
||||||
@@ -1118,6 +1184,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:
|
||||||
@@ -1132,14 +1200,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()
|
||||||
@@ -2044,7 +2130,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 +2305,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 +2488,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 +2513,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 +2537,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)
|
||||||
@@ -4769,10 +4873,11 @@ 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(thread.quit)
|
||||||
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()
|
||||||
@@ -4786,11 +4891,14 @@ 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)
|
||||||
|
thread.deleteLater()
|
||||||
|
|
||||||
def _apply_library_filter(self):
|
def _apply_library_filter(self):
|
||||||
if not hasattr(self, "_library_cache"):
|
if not hasattr(self, "_library_cache"):
|
||||||
@@ -4912,6 +5020,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":
|
||||||
@@ -4921,13 +5031,24 @@ 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(thread.quit)
|
worker.finished.connect(thread.quit)
|
||||||
|
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)
|
||||||
|
thread.deleteLater()
|
||||||
|
|
||||||
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user