cache_size_bytes() does a full stat() of every file in the cache directory. It was called on every image load and every infinite scroll drain. Now skipped if less than 30 seconds since the last check. Also replace QPixmap with QImageReader in image_dimensions() to read width/height from the file header without decoding the full image into memory. behavior change: cache eviction checks run at most once per 30s instead of on every image load. Library image dimensions are read via QImageReader (header-only) instead of QPixmap (full decode).
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""Image/video loading, prefetch, download progress, and cache eviction."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from ..core.cache import download_image, cache_size_bytes, evict_oldest, evict_oldest_thumbnails
|
|
|
|
if TYPE_CHECKING:
|
|
from .main_window import BooruApp
|
|
|
|
log = logging.getLogger("booru")
|
|
|
|
|
|
# -- Pure functions (tested in tests/gui/test_media_controller.py) --
|
|
|
|
|
|
def compute_prefetch_order(
|
|
index: int, total: int, columns: int, mode: str,
|
|
) -> list[int]:
|
|
"""Return an ordered list of indices to prefetch around *index*.
|
|
|
|
*mode* is ``"Nearby"`` (4 cardinals) or ``"Aggressive"`` (ring expansion
|
|
capped at ~3 rows radius).
|
|
"""
|
|
if total == 0:
|
|
return []
|
|
|
|
if mode == "Nearby":
|
|
order = []
|
|
for offset in [1, -1, columns, -columns]:
|
|
adj = index + offset
|
|
if 0 <= adj < total:
|
|
order.append(adj)
|
|
return order
|
|
|
|
# Aggressive: ring expansion
|
|
max_radius = 3
|
|
max_posts = columns * max_radius * 2 + columns
|
|
seen = {index}
|
|
order = []
|
|
for dist in range(1, max_radius + 1):
|
|
ring = set()
|
|
for dy in (-dist, 0, dist):
|
|
for dx in (-dist, 0, dist):
|
|
if dy == 0 and dx == 0:
|
|
continue
|
|
adj = index + dy * columns + dx
|
|
if 0 <= adj < total and adj not in seen:
|
|
ring.add(adj)
|
|
for adj in (index + dist, index - dist):
|
|
if 0 <= adj < total and adj not in seen:
|
|
ring.add(adj)
|
|
for adj in sorted(ring):
|
|
seen.add(adj)
|
|
order.append(adj)
|
|
if len(order) >= max_posts:
|
|
break
|
|
return order
|
|
|
|
|
|
# -- Controller --
|
|
|
|
|
|
class MediaController:
|
|
"""Owns image/video loading, prefetch, download progress, and cache eviction."""
|
|
|
|
def __init__(self, app: BooruApp) -> None:
|
|
self._app = app
|
|
self._prefetch_pause = asyncio.Event()
|
|
self._prefetch_pause.set() # not paused
|
|
self._last_evict_check = 0.0 # monotonic timestamp
|
|
|
|
# -- Post activation (media load) --
|
|
|
|
def on_post_activated(self, index: int) -> None:
|
|
if 0 <= index < len(self._app._posts):
|
|
post = self._app._posts[index]
|
|
log.info(f"Preview: #{post.id} -> {post.file_url}")
|
|
try:
|
|
if self._app._popout_ctrl.window:
|
|
self._app._popout_ctrl.window.force_mpv_pause()
|
|
pmpv = self._app._preview._video_player._mpv
|
|
if pmpv is not None:
|
|
pmpv.pause = True
|
|
except Exception:
|
|
pass
|
|
self._app._preview._current_post = post
|
|
self._app._preview._current_site_id = self._app._site_combo.currentData()
|
|
self._app._preview.set_post_tags(post.tag_categories, post.tag_list)
|
|
self._app._ensure_post_categories_async(post)
|
|
site_id = self._app._preview._current_site_id
|
|
self._app._preview.update_bookmark_state(
|
|
bool(site_id and self._app._db.is_bookmarked(site_id, post.id))
|
|
)
|
|
self._app._preview.update_save_state(self._app._post_actions.is_post_saved(post.id))
|
|
self._app._status.showMessage(f"Loading #{post.id}...")
|
|
preview_hidden = not (
|
|
self._app._preview.isVisible() and self._app._preview.width() > 0
|
|
)
|
|
if preview_hidden:
|
|
self._app._signals.prefetch_progress.emit(index, 0.0)
|
|
else:
|
|
self._app._dl_progress.show()
|
|
self._app._dl_progress.setRange(0, 0)
|
|
|
|
def _progress(downloaded, total):
|
|
self._app._signals.download_progress.emit(downloaded, total)
|
|
if preview_hidden and total > 0:
|
|
self._app._signals.prefetch_progress.emit(
|
|
index, downloaded / total
|
|
)
|
|
|
|
info = (f"#{post.id} {post.width}x{post.height} score:{post.score} [{post.rating}] {Path(post.file_url.split('?')[0]).suffix.lstrip('.').upper() if post.file_url else ''}"
|
|
+ (f" {post.created_at}" if post.created_at else ""))
|
|
|
|
from ..core.cache import is_cached
|
|
from .media.constants import VIDEO_EXTENSIONS
|
|
is_video = bool(
|
|
post.file_url
|
|
and Path(post.file_url.split('?')[0]).suffix.lower() in VIDEO_EXTENSIONS
|
|
)
|
|
streaming = is_video and post.file_url and not is_cached(post.file_url)
|
|
if streaming:
|
|
self._app._signals.video_stream.emit(
|
|
post.file_url, info, post.width, post.height
|
|
)
|
|
|
|
async def _load():
|
|
self._prefetch_pause.clear()
|
|
try:
|
|
if streaming:
|
|
return
|
|
path = await download_image(post.file_url, progress_callback=_progress)
|
|
self._app._signals.image_done.emit(str(path), info)
|
|
except Exception as e:
|
|
log.error(f"Image download failed: {e}")
|
|
self._app._signals.image_error.emit(str(e))
|
|
finally:
|
|
self._prefetch_pause.set()
|
|
if preview_hidden:
|
|
self._app._signals.prefetch_progress.emit(index, -1)
|
|
|
|
self._app._run_async(_load)
|
|
|
|
if self._app._db.get_setting("prefetch_mode") in ("Nearby", "Aggressive"):
|
|
self.prefetch_adjacent(index)
|
|
|
|
# -- Image/video result handlers --
|
|
|
|
def on_image_done(self, path: str, info: str) -> None:
|
|
self._app._dl_progress.hide()
|
|
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
|
|
self._app._preview._info_label.setText(info)
|
|
self._app._preview._current_path = path
|
|
else:
|
|
self.set_preview_media(path, info)
|
|
self._app._status.showMessage(f"{len(self._app._posts)} results — Loaded")
|
|
idx = self._app._grid.selected_index
|
|
if 0 <= idx < len(self._app._grid._thumbs):
|
|
self._app._grid._thumbs[idx]._cached_path = path
|
|
self._app._popout_ctrl.update_media(path, info)
|
|
self.auto_evict_cache()
|
|
|
|
def on_video_stream(self, url: str, info: str, width: int, height: int) -> None:
|
|
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
|
|
self._app._preview._info_label.setText(info)
|
|
self._app._preview._current_path = url
|
|
self._app._popout_ctrl.window.set_media(url, info, width=width, height=height)
|
|
self._app._popout_ctrl.update_state()
|
|
else:
|
|
self._app._preview._video_player.stop()
|
|
self._app._preview.set_media(url, info)
|
|
self._app._status.showMessage(f"Streaming #{Path(url.split('?')[0]).name}...")
|
|
|
|
def on_download_progress(self, downloaded: int, total: int) -> None:
|
|
popout_open = bool(self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible())
|
|
if total > 0:
|
|
if not popout_open:
|
|
self._app._dl_progress.setRange(0, total)
|
|
self._app._dl_progress.setValue(downloaded)
|
|
self._app._dl_progress.show()
|
|
mb = downloaded / (1024 * 1024)
|
|
total_mb = total / (1024 * 1024)
|
|
self._app._status.showMessage(f"Downloading... {mb:.1f}/{total_mb:.1f} MB")
|
|
if downloaded >= total and not popout_open:
|
|
self._app._dl_progress.hide()
|
|
elif not popout_open:
|
|
self._app._dl_progress.setRange(0, 0)
|
|
self._app._dl_progress.show()
|
|
|
|
def set_preview_media(self, path: str, info: str) -> None:
|
|
"""Set media on preview or just info if popout is open."""
|
|
if self._app._popout_ctrl.window and self._app._popout_ctrl.window.isVisible():
|
|
self._app._preview._info_label.setText(info)
|
|
self._app._preview._current_path = path
|
|
else:
|
|
self._app._preview.set_media(path, info)
|
|
|
|
# -- Prefetch --
|
|
|
|
def on_prefetch_progress(self, index: int, progress: float) -> None:
|
|
if 0 <= index < len(self._app._grid._thumbs):
|
|
self._app._grid._thumbs[index].set_prefetch_progress(progress)
|
|
|
|
def prefetch_adjacent(self, index: int) -> None:
|
|
"""Prefetch posts around the given index."""
|
|
total = len(self._app._posts)
|
|
if total == 0:
|
|
return
|
|
cols = self._app._grid._flow.columns
|
|
mode = self._app._db.get_setting("prefetch_mode")
|
|
order = compute_prefetch_order(index, total, cols, mode)
|
|
|
|
async def _prefetch_spiral():
|
|
for adj in order:
|
|
await self._prefetch_pause.wait()
|
|
if 0 <= adj < len(self._app._posts) and self._app._posts[adj].file_url:
|
|
self._app._signals.prefetch_progress.emit(adj, 0.0)
|
|
try:
|
|
def _progress(dl, total_bytes, idx=adj):
|
|
if total_bytes > 0:
|
|
self._app._signals.prefetch_progress.emit(idx, dl / total_bytes)
|
|
await download_image(self._app._posts[adj].file_url, progress_callback=_progress)
|
|
except Exception as e:
|
|
log.warning(f"Operation failed: {e}")
|
|
self._app._signals.prefetch_progress.emit(adj, -1)
|
|
await asyncio.sleep(0.2)
|
|
self._app._run_async(_prefetch_spiral)
|
|
|
|
# -- Cache eviction --
|
|
|
|
def auto_evict_cache(self) -> None:
|
|
import time
|
|
now = time.monotonic()
|
|
if now - self._last_evict_check < 30:
|
|
return
|
|
self._last_evict_check = now
|
|
if not self._app._db.get_setting_bool("auto_evict"):
|
|
return
|
|
max_mb = self._app._db.get_setting_int("max_cache_mb")
|
|
if max_mb <= 0:
|
|
return
|
|
max_bytes = max_mb * 1024 * 1024
|
|
current = cache_size_bytes(include_thumbnails=False)
|
|
if current > max_bytes:
|
|
protected = set()
|
|
for fav in self._app._db.get_bookmarks(limit=999999):
|
|
if fav.cached_path:
|
|
protected.add(fav.cached_path)
|
|
evicted = evict_oldest(max_bytes, protected)
|
|
if evicted:
|
|
log.info(f"Auto-evicted {evicted} cached files")
|
|
max_thumb_mb = self._app._db.get_setting_int("max_thumb_cache_mb") or 500
|
|
max_thumb_bytes = max_thumb_mb * 1024 * 1024
|
|
evicted_thumbs = evict_oldest_thumbnails(max_thumb_bytes)
|
|
if evicted_thumbs:
|
|
log.info(f"Auto-evicted {evicted_thumbs} thumbnails")
|
|
|
|
# -- Utility --
|
|
|
|
@staticmethod
|
|
def image_dimensions(path: str) -> tuple[int, int]:
|
|
"""Read image width/height from a local file without decoding pixels."""
|
|
from .media.constants import _is_video
|
|
if _is_video(path):
|
|
return 0, 0
|
|
try:
|
|
from PySide6.QtGui import QImageReader
|
|
reader = QImageReader(path)
|
|
size = reader.size()
|
|
if size.isValid():
|
|
return size.width(), size.height()
|
|
except Exception:
|
|
pass
|
|
return 0, 0
|