Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e9cdc49a | ||
|
|
a4d132f134 | ||
|
|
3a69f3f6a1 | ||
|
|
7381f2c39d |
+35
-22
@@ -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,33 @@ 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 not None:
|
||||
return _game_template_cache
|
||||
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
|
||||
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):
|
||||
@@ -822,14 +836,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:
|
||||
|
||||
+141
-42
@@ -136,7 +136,7 @@ class CoverLoader(QObject):
|
||||
def run(self):
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
pool = ThreadPoolExecutor(max_workers=8)
|
||||
pool = ThreadPoolExecutor(max_workers=6)
|
||||
futures = {
|
||||
pool.submit(load_cover, it): i
|
||||
for i, it in enumerate(self.items)
|
||||
@@ -165,55 +165,67 @@ 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 not None:
|
||||
return _template_cache
|
||||
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)
|
||||
entry = (img, _transparent_window(img))
|
||||
with _template_lock:
|
||||
if _template_cache is None:
|
||||
_template_cache = entry
|
||||
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 +666,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()
|
||||
|
||||
@@ -952,14 +971,21 @@ class ScrapeSearchWorker(QObject):
|
||||
results = Signal(list) # candidates with thumb_bytes added
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, system, query):
|
||||
def __init__(self, system, query, gen):
|
||||
super().__init__()
|
||||
self.system = system
|
||||
self.query = query
|
||||
self.gen = gen
|
||||
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 +997,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()
|
||||
|
||||
|
||||
@@ -979,15 +1006,23 @@ class ScrapeFetchWorker(QObject):
|
||||
result = Signal(dict) # {"meta": {...}, "cover": bytes|None}
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, system, rid, candidate):
|
||||
def __init__(self, system, rid, candidate, gen):
|
||||
super().__init__()
|
||||
self.system = system
|
||||
self.rid = rid
|
||||
self.candidate = candidate
|
||||
self.gen = gen
|
||||
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 +1070,7 @@ class ScrapeDialog(QDialog):
|
||||
self.resize(600, 580)
|
||||
self.candidates = []
|
||||
self.selected_meta = None
|
||||
self._search_gen = 0
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
top = QHBoxLayout()
|
||||
@@ -1080,13 +1116,17 @@ 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)
|
||||
self.list.clear()
|
||||
self.candidates = []
|
||||
thread = QThread(self)
|
||||
worker = ScrapeSearchWorker(self.system, query)
|
||||
worker = ScrapeSearchWorker(self.system, query, gen)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.results.connect(self._on_results)
|
||||
@@ -1096,6 +1136,8 @@ class ScrapeDialog(QDialog):
|
||||
thread.start()
|
||||
|
||||
def _on_results(self, results):
|
||||
if getattr(self.sender(), "gen", None) != self._search_gen:
|
||||
return
|
||||
self.search_btn.setEnabled(True)
|
||||
self.candidates = results
|
||||
self.list.clear()
|
||||
@@ -1126,11 +1168,15 @@ 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…")
|
||||
thread = QThread(self)
|
||||
worker = ScrapeFetchWorker(self.system, self.rid, candidate)
|
||||
worker = ScrapeFetchWorker(self.system, self.rid, candidate, gen)
|
||||
worker.moveToThread(thread)
|
||||
thread.started.connect(worker.run)
|
||||
worker.result.connect(self._on_scraped)
|
||||
@@ -1140,6 +1186,8 @@ class ScrapeDialog(QDialog):
|
||||
thread.start()
|
||||
|
||||
def _on_scraped(self, payload):
|
||||
if getattr(self.sender(), "gen", None) != self._search_gen:
|
||||
return
|
||||
meta = payload.get("meta") or {}
|
||||
cover = payload.get("cover")
|
||||
if cover:
|
||||
@@ -1154,14 +1202,32 @@ class ScrapeDialog(QDialog):
|
||||
self.search_btn.setEnabled(True)
|
||||
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:
|
||||
if _thread_running(t):
|
||||
t.quit()
|
||||
t.wait()
|
||||
self._workers = []
|
||||
self._threads = []
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._shutdown_threads()
|
||||
event.accept()
|
||||
|
||||
def done(self, result):
|
||||
self._shutdown_threads()
|
||||
super().done(result)
|
||||
|
||||
|
||||
class SettingsPage(QWidget):
|
||||
saved = Signal()
|
||||
@@ -1568,7 +1634,7 @@ class StoreCoverLoader(QObject):
|
||||
def run(self):
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
pool = ThreadPoolExecutor(max_workers=8)
|
||||
pool = ThreadPoolExecutor(max_workers=6)
|
||||
futures = {
|
||||
pool.submit(load_cover, it, 8, self.quick): row
|
||||
for row, it in self.tasks
|
||||
@@ -2221,11 +2287,11 @@ class StorePage(QWidget):
|
||||
return (0, -date_int, -mtime)
|
||||
return (1, 0, -mtime)
|
||||
|
||||
CHUNK = 150 # items filled per timer tick
|
||||
COVER_BATCH = 200 # covers fetched for the initially-visible area
|
||||
COVER_SCROLL_BATCH = 100
|
||||
CHUNK = 200 # items filled per timer tick
|
||||
COVER_BATCH = 500 # covers fetched for the initially-visible area
|
||||
COVER_SCROLL_BATCH = 400
|
||||
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):
|
||||
self._generation += 1
|
||||
@@ -4809,12 +4875,13 @@ class MainWindow(QMainWindow):
|
||||
thread = QThread(self)
|
||||
worker = LibraryWorker(self._ps1_dir, self._ps1_output_dir, self._nps_dir)
|
||||
worker.moveToThread(thread)
|
||||
thread._worker = worker
|
||||
thread.started.connect(worker.run)
|
||||
worker.result.connect(self._on_library_loaded)
|
||||
worker.finished.connect(thread.quit)
|
||||
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_library_thread(t, w))
|
||||
thread.finished.connect(self._forget_library_thread)
|
||||
self._library_threads.append(thread)
|
||||
self._library_workers.append(worker)
|
||||
thread.start()
|
||||
@@ -4828,10 +4895,12 @@ class MainWindow(QMainWindow):
|
||||
self.store_page.set_library_items(self._library_cache)
|
||||
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:
|
||||
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)
|
||||
|
||||
def _apply_library_filter(self):
|
||||
@@ -4954,6 +5023,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":
|
||||
@@ -4963,13 +5034,25 @@ class MainWindow(QMainWindow):
|
||||
thread = QThread(self)
|
||||
worker = MetadataWorker(title, gen, system, rid)
|
||||
worker.moveToThread(thread)
|
||||
thread._worker = worker
|
||||
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(self._forget_meta_thread)
|
||||
self._meta_threads.append(thread)
|
||||
self._meta_workers.append(worker)
|
||||
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):
|
||||
if gen != self._meta_gen:
|
||||
return
|
||||
@@ -5871,8 +5954,24 @@ class MainWindow(QMainWindow):
|
||||
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():
|
||||
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
|
||||
# Auto-ingest any missing DATs so scanning matches against the catalog
|
||||
# even on a fresh/reset database (no manual `python -m app.ingest` step).
|
||||
|
||||
Reference in New Issue
Block a user