Optional prefetch with progress bar, post blacklist, theme template link

- Prefetch adjacent posts is now a toggle in Settings > General (off by default)
- Prefetch progress bar on thumbnails shows download state
- Blacklist Post: right-click to hide a specific post by URL
- "Create from Template" opens themes reference on git.pax.moe
  and spawns the default text editor with custom.qss
This commit is contained in:
pax 2026-04-05 00:45:53 -05:00
parent 5d48581f52
commit f311326e73
4 changed files with 72 additions and 32 deletions

View File

@ -54,6 +54,11 @@ CREATE TABLE IF NOT EXISTS blacklisted_tags (
tag TEXT NOT NULL UNIQUE tag TEXT NOT NULL UNIQUE
); );
CREATE TABLE IF NOT EXISTS blacklisted_posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
@ -384,6 +389,20 @@ class Database:
rows = self.conn.execute("SELECT tag FROM blacklisted_tags ORDER BY tag").fetchall() rows = self.conn.execute("SELECT tag FROM blacklisted_tags ORDER BY tag").fetchall()
return [r["tag"] for r in rows] return [r["tag"] for r in rows]
# -- Blacklisted Posts --
def add_blacklisted_post(self, url: str) -> None:
self.conn.execute("INSERT OR IGNORE INTO blacklisted_posts (url) VALUES (?)", (url,))
self.conn.commit()
def remove_blacklisted_post(self, url: str) -> None:
self.conn.execute("DELETE FROM blacklisted_posts WHERE url = ?", (url,))
self.conn.commit()
def get_blacklisted_posts(self) -> set[str]:
rows = self.conn.execute("SELECT url FROM blacklisted_posts").fetchall()
return {r["url"] for r in rows}
# -- Settings -- # -- Settings --
def get_setting(self, key: str) -> str: def get_setting(self, key: str) -> str:

View File

@ -84,6 +84,7 @@ class AsyncSignals(QObject):
batch_progress = Signal(int, int) # current, total batch_progress = Signal(int, int) # current, total
batch_done = Signal(str) batch_done = Signal(str)
download_progress = Signal(int, int) # bytes_downloaded, total_bytes download_progress = Signal(int, int) # bytes_downloaded, total_bytes
prefetch_progress = Signal(int, float) # index, progress (0-1 or -1 to hide)
# -- Info Panel -- # -- Info Panel --
@ -236,6 +237,11 @@ class BooruApp(QMainWindow):
s.batch_progress.connect(self._on_batch_progress, Q) s.batch_progress.connect(self._on_batch_progress, Q)
s.batch_done.connect(lambda m: self._status.showMessage(m), Q) s.batch_done.connect(lambda m: self._status.showMessage(m), Q)
s.download_progress.connect(self._on_download_progress, Q) s.download_progress.connect(self._on_download_progress, Q)
s.prefetch_progress.connect(self._on_prefetch_progress, Q)
def _on_prefetch_progress(self, index: int, progress: float) -> None:
if 0 <= index < len(self._grid._thumbs):
self._grid._thumbs[index].set_prefetch_progress(progress)
def _clear_loading(self) -> None: def _clear_loading(self) -> None:
self._loading = False self._loading = False
@ -628,6 +634,10 @@ class BooruApp(QMainWindow):
bl_tags = set(self._db.get_blacklisted_tags()) bl_tags = set(self._db.get_blacklisted_tags())
if bl_tags: if bl_tags:
posts = [p for p in posts if not bl_tags.intersection(p.tag_list)] posts = [p for p in posts if not bl_tags.intersection(p.tag_list)]
# Filter blacklisted posts by URL
bl_posts = self._db.get_blacklisted_posts()
if bl_posts:
posts = [p for p in posts if p.file_url not in bl_posts]
self._posts = posts self._posts = posts
self._status.showMessage(f"{len(posts)} results") self._status.showMessage(f"{len(posts)} results")
thumbs = self._grid.set_posts(len(posts)) thumbs = self._grid.set_posts(len(posts))
@ -762,7 +772,8 @@ class BooruApp(QMainWindow):
self._run_async(_load) self._run_async(_load)
# Prefetch adjacent posts # Prefetch adjacent posts
self._prefetch_adjacent(index) if self._db.get_setting_bool("prefetch_adjacent"):
self._prefetch_adjacent(index)
def _prefetch_adjacent(self, index: int) -> None: def _prefetch_adjacent(self, index: int) -> None:
"""Prefetch posts in all 4 directions (left, right, up, down).""" """Prefetch posts in all 4 directions (left, right, up, down)."""
@ -772,10 +783,15 @@ class BooruApp(QMainWindow):
for offset in offsets: for offset in offsets:
adj = index + offset adj = index + offset
if 0 <= adj < len(self._posts) and self._posts[adj].file_url: if 0 <= adj < len(self._posts) and self._posts[adj].file_url:
self._signals.prefetch_progress.emit(adj, 0.0)
try: try:
await download_image(self._posts[adj].file_url) def _progress(dl, total, idx=adj):
if total > 0:
self._signals.prefetch_progress.emit(idx, dl / total)
await download_image(self._posts[adj].file_url, progress_callback=_progress)
except Exception: except Exception:
pass pass
self._signals.prefetch_progress.emit(adj, -1)
self._run_async(_prefetch_batch) self._run_async(_prefetch_batch)
def _on_download_progress(self, downloaded: int, total: int) -> None: def _on_download_progress(self, downloaded: int, total: int) -> None:
@ -1054,6 +1070,7 @@ class BooruApp(QMainWindow):
else: else:
for tag in post.tag_list[:30]: for tag in post.tag_list[:30]:
bl_menu.addAction(tag) bl_menu.addAction(tag)
bl_post_action = menu.addAction("Blacklist Post")
action = menu.exec(pos) action = menu.exec(pos)
if not action: if not action:
@ -1105,6 +1122,10 @@ class BooruApp(QMainWindow):
self._db.set_setting("blacklist_enabled", "1") self._db.set_setting("blacklist_enabled", "1")
self._status.showMessage(f"Blacklisted: {tag}") self._status.showMessage(f"Blacklisted: {tag}")
self._do_search() self._do_search()
elif action == bl_post_action:
self._db.add_blacklisted_post(post.file_url)
self._status.showMessage(f"Post #{post.id} blacklisted")
self._do_search()
@staticmethod @staticmethod
def _is_child_of_menu(action, menu) -> bool: def _is_child_of_menu(action, menu) -> bool:

View File

@ -38,6 +38,7 @@ class ThumbnailWidget(QWidget):
self._hover = False self._hover = False
self._drag_start: QPoint | None = None self._drag_start: QPoint | None = None
self._cached_path: str | None = None self._cached_path: str | None = None
self._prefetch_progress: float = -1 # -1 = not prefetching, 0-1 = progress
self.setFixedSize(THUMB_SIZE, THUMB_SIZE) self.setFixedSize(THUMB_SIZE, THUMB_SIZE)
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setMouseTracking(True) self.setMouseTracking(True)
@ -66,6 +67,11 @@ class ThumbnailWidget(QWidget):
self._saved_locally = saved self._saved_locally = saved
self.update() self.update()
def set_prefetch_progress(self, progress: float) -> None:
"""Set prefetch progress: -1 = hide, 0.0-1.0 = progress."""
self._prefetch_progress = progress
self.update()
def paintEvent(self, event) -> None: def paintEvent(self, event) -> None:
# Ensure QSS is applied so palette picks up custom colors # Ensure QSS is applied so palette picks up custom colors
self.ensurePolished() self.ensurePolished()
@ -120,6 +126,17 @@ class ThumbnailWidget(QWidget):
p.drawLine(7, 10, 9, 13) p.drawLine(7, 10, 9, 13)
p.drawLine(9, 13, 14, 7) p.drawLine(9, 13, 14, 7)
# Prefetch progress bar
if self._prefetch_progress >= 0:
bar_h = 3
bar_y = self.height() - bar_h - 2
bar_w = int((self.width() - 8) * self._prefetch_progress)
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor(100, 100, 100, 120))
p.drawRect(4, bar_y, self.width() - 8, bar_h)
p.setBrush(highlight)
p.drawRect(4, bar_y, bar_w, bar_h)
p.end() p.end()
def enterEvent(self, event) -> None: def enterEvent(self, event) -> None:

View File

@ -110,6 +110,11 @@ class SettingsDialog(QDialog):
self._preload.setChecked(self._db.get_setting_bool("preload_thumbnails")) self._preload.setChecked(self._db.get_setting_bool("preload_thumbnails"))
form.addRow("", self._preload) form.addRow("", self._preload)
# Prefetch adjacent posts
self._prefetch = QCheckBox("Prefetch adjacent posts for faster navigation")
self._prefetch.setChecked(self._db.get_setting_bool("prefetch_adjacent"))
form.addRow("", self._prefetch)
# File dialog platform (Linux only) # File dialog platform (Linux only)
self._file_dialog_combo = None self._file_dialog_combo = None
if not IS_WINDOWS: if not IS_WINDOWS:
@ -372,37 +377,14 @@ class SettingsDialog(QDialog):
QDesktopServices.openUrl(QUrl.fromLocalFile(str(css_path))) QDesktopServices.openUrl(QUrl.fromLocalFile(str(css_path)))
def _create_css_template(self) -> None: def _create_css_template(self) -> None:
from PySide6.QtGui import QDesktopServices
from PySide6.QtCore import QUrl
# Open themes reference online and create a blank custom.qss for editing
QDesktopServices.openUrl(QUrl("https://git.pax.moe/pax/booru-viewer/src/branch/main/themes"))
css_path = data_dir() / "custom.qss" css_path = data_dir() / "custom.qss"
if css_path.exists(): if not css_path.exists():
reply = QMessageBox.question( css_path.write_text("/* booru-viewer custom stylesheet */\n/* See themes reference for examples */\n\n")
self, "Confirm", "Overwrite existing custom.qss with template?", QDesktopServices.openUrl(QUrl.fromLocalFile(str(css_path)))
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
template = (
"/* booru-viewer custom stylesheet */\n"
"/* Edit and restart the app to apply changes */\n\n"
"/* -- Accent color -- */\n"
"/* QWidget { color: #00ff00; } */\n\n"
"/* -- Background -- */\n"
"/* QWidget { background-color: #000000; } */\n\n"
"/* -- Font -- */\n"
"/* QWidget { font-family: monospace; font-size: 13px; } */\n\n"
"/* -- Buttons -- */\n"
"/* QPushButton { padding: 6px 16px; border-radius: 4px; } */\n"
"/* QPushButton:hover { border-color: #00ff00; } */\n\n"
"/* -- Inputs -- */\n"
"/* QLineEdit { padding: 6px 10px; border-radius: 4px; } */\n"
"/* QLineEdit:focus { border-color: #00ff00; } */\n\n"
"/* -- Scrollbar -- */\n"
"/* QScrollBar:vertical { width: 10px; } */\n\n"
"/* -- Video seek bar -- */\n"
"/* QSlider::groove:horizontal { background: #333; height: 6px; } */\n"
"/* QSlider::handle:horizontal { background: #00ff00; width: 14px; } */\n"
)
css_path.write_text(template)
QMessageBox.information(self, "Done", f"Template created at:\n{css_path}")
def _view_css_guide(self) -> None: def _view_css_guide(self) -> None:
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
@ -611,6 +593,7 @@ class SettingsDialog(QDialog):
self._db.set_setting("default_rating", self._default_rating.currentText()) self._db.set_setting("default_rating", self._default_rating.currentText())
self._db.set_setting("default_score", str(self._default_score.value())) self._db.set_setting("default_score", str(self._default_score.value()))
self._db.set_setting("preload_thumbnails", "1" if self._preload.isChecked() else "0") self._db.set_setting("preload_thumbnails", "1" if self._preload.isChecked() else "0")
self._db.set_setting("prefetch_adjacent", "1" if self._prefetch.isChecked() else "0")
self._db.set_setting("max_cache_mb", str(self._max_cache.value())) self._db.set_setting("max_cache_mb", str(self._max_cache.value()))
self._db.set_setting("auto_evict", "1" if self._auto_evict.isChecked() else "0") self._db.set_setting("auto_evict", "1" if self._auto_evict.isChecked() else "0")
self._db.set_setting("clear_cache_on_exit", "1" if self._clear_on_exit.isChecked() else "0") self._db.set_setting("clear_cache_on_exit", "1" if self._clear_on_exit.isChecked() else "0")