From 74f948a3e891eb6e366a3c37fdacd35c155a9a59 Mon Sep 17 00:00:00 2001 From: pax Date: Tue, 7 Apr 2026 11:36:23 -0500 Subject: [PATCH] =?UTF-8?q?Speed=20up=20page=20loads=20=E2=80=94=20pre-fet?= =?UTF-8?q?ch=20bookmarks/cache=20as=20sets,=20off-load=20PIL=20conversion?= =?UTF-8?q?=20to=20a=20worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- booru_viewer/core/cache.py | 16 ++++++++++------ booru_viewer/gui/app.py | 34 +++++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/booru_viewer/core/cache.py b/booru_viewer/core/cache.py index 36c4ff3..acb6669 100644 --- a/booru_viewer/core/cache.py +++ b/booru_viewer/core/cache.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import hashlib import zipfile from collections import OrderedDict @@ -165,9 +166,11 @@ async def download_image( gif_path = local.with_suffix(".gif") if gif_path.exists(): return gif_path - # If the zip is cached but not yet converted, convert it now + # If the zip is cached but not yet converted, convert it now. + # PIL frame iteration is CPU-bound and would block the asyncio + # loop for hundreds of ms — run it in a worker thread instead. if local.exists() and zipfile.is_zipfile(local): - return _convert_ugoira_to_gif(local) + return await asyncio.to_thread(_convert_ugoira_to_gif, local) # Check if animated PNG/WebP was already converted to gif if local.suffix.lower() in (".png", ".webp"): @@ -180,7 +183,7 @@ async def download_image( if _is_valid_media(local): # Convert animated PNG/WebP on access if not yet converted if local.suffix.lower() in (".png", ".webp"): - converted = _convert_animated_to_gif(local) + converted = await asyncio.to_thread(_convert_animated_to_gif, local) if converted != local: return converted return local @@ -233,12 +236,13 @@ async def download_image( local.unlink() raise ValueError("Downloaded file is not valid media") - # Convert ugoira zip to animated GIF + # Convert ugoira zip to animated GIF (PIL is sync + CPU-bound; + # off-load to a worker so we don't block the asyncio loop). if local.suffix.lower() == ".zip" and zipfile.is_zipfile(local): - local = _convert_ugoira_to_gif(local) + local = await asyncio.to_thread(_convert_ugoira_to_gif, local) # Convert animated PNG/WebP to GIF for Qt playback elif local.suffix.lower() in (".png", ".webp"): - local = _convert_animated_to_gif(local) + local = await asyncio.to_thread(_convert_animated_to_gif, local) finally: pass # shared client stays open for connection reuse return local diff --git a/booru_viewer/gui/app.py b/booru_viewer/gui/app.py index c27568c..b43d51b 100644 --- a/booru_viewer/gui/app.py +++ b/booru_viewer/gui/app.py @@ -905,6 +905,7 @@ class BooruApp(QMainWindow): QTimer.singleShot(100, self._clear_loading) from ..core.config import saved_dir, saved_folder_dir + from ..core.cache import cached_path_for, cache_dir site_id = self._site_combo.currentData() # Pre-scan saved directories once instead of per-post exists() calls @@ -918,12 +919,22 @@ class BooruApp(QMainWindow): if d.exists(): _folder_saved[folder] = {int(f.stem) for f in d.iterdir() if f.is_file() and f.stem.isdigit()} - # Pre-fetch bookmarks for the site once (used for folder checks) + # Pre-fetch bookmarks for the site once and project to a post-id set + # so the per-post check below is an O(1) membership test instead of + # a synchronous SQLite query (was N queries on the GUI thread). _favs = self._db.get_bookmarks(site_id=site_id) if site_id else [] + _bookmarked_ids: set[int] = {f.post_id for f in _favs} + + # Pre-scan the cache dir into a name set so the per-post drag-path + # lookup is one stat-equivalent (one iterdir) instead of N stat calls. + _cd = cache_dir() + _cached_names: set[str] = set() + if _cd.exists(): + _cached_names = {f.name for f in _cd.iterdir() if f.is_file()} for i, (post, thumb) in enumerate(zip(posts, thumbs)): # Bookmark status (DB) - if site_id and self._db.is_bookmarked(site_id, post.id): + if post.id in _bookmarked_ids: thumb.set_bookmarked(True) # Saved status (filesystem) — independent of bookmark saved = post.id in _saved_ids @@ -934,9 +945,8 @@ class BooruApp(QMainWindow): break thumb.set_saved_locally(saved) # Set drag path from cache - from ..core.cache import cached_path_for cached = cached_path_for(post.file_url) - if cached.exists(): + if cached.name in _cached_names: thumb._cached_path = str(cached) if post.preview_url: @@ -1012,13 +1022,23 @@ class BooruApp(QMainWindow): return from ..core.config import saved_dir - from ..core.cache import cached_path_for + from ..core.cache import cached_path_for, cache_dir site_id = self._site_combo.currentData() _sd = saved_dir() _saved_ids: set[int] = set() if _sd.exists(): _saved_ids = {int(f.stem) for f in _sd.iterdir() if f.is_file() and f.stem.isdigit()} + # Pre-fetch bookmarks → set, and pre-scan cache dir → set, so the + # per-post checks below avoid N synchronous SQLite/stat calls on the + # GUI thread (matches the optimisation in _on_search_done). + _favs = self._db.get_bookmarks(site_id=site_id) if site_id else [] + _bookmarked_ids: set[int] = {f.post_id for f in _favs} + _cd = cache_dir() + _cached_names: set[str] = set() + if _cd.exists(): + _cached_names = {f.name for f in _cd.iterdir() if f.is_file()} + posts = ss.append_queue[:] ss.append_queue.clear() start_idx = len(self._posts) @@ -1027,11 +1047,11 @@ class BooruApp(QMainWindow): for i, (post, thumb) in enumerate(zip(posts, thumbs)): idx = start_idx + i - if site_id and self._db.is_bookmarked(site_id, post.id): + if post.id in _bookmarked_ids: thumb.set_bookmarked(True) thumb.set_saved_locally(post.id in _saved_ids) cached = cached_path_for(post.file_url) - if cached.exists(): + if cached.name in _cached_names: thumb._cached_path = str(cached) if post.preview_url: self._fetch_thumbnail(idx, post.preview_url)