diff --git a/booru_viewer/core/db.py b/booru_viewer/core/db.py index e19d766..7cf4722 100644 --- a/booru_viewer/core/db.py +++ b/booru_viewer/core/db.py @@ -54,6 +54,11 @@ CREATE TABLE IF NOT EXISTS blacklisted_tags ( 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 ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -384,6 +389,20 @@ class Database: rows = self.conn.execute("SELECT tag FROM blacklisted_tags ORDER BY tag").fetchall() 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 -- def get_setting(self, key: str) -> str: diff --git a/booru_viewer/gui/app.py b/booru_viewer/gui/app.py index 25e31cb..a80aea8 100644 --- a/booru_viewer/gui/app.py +++ b/booru_viewer/gui/app.py @@ -84,6 +84,7 @@ class AsyncSignals(QObject): batch_progress = Signal(int, int) # current, total batch_done = Signal(str) download_progress = Signal(int, int) # bytes_downloaded, total_bytes + prefetch_progress = Signal(int, float) # index, progress (0-1 or -1 to hide) # -- Info Panel -- @@ -236,6 +237,11 @@ class BooruApp(QMainWindow): s.batch_progress.connect(self._on_batch_progress, Q) s.batch_done.connect(lambda m: self._status.showMessage(m), 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: self._loading = False @@ -628,6 +634,10 @@ class BooruApp(QMainWindow): bl_tags = set(self._db.get_blacklisted_tags()) if bl_tags: 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._status.showMessage(f"{len(posts)} results") thumbs = self._grid.set_posts(len(posts)) @@ -762,7 +772,8 @@ class BooruApp(QMainWindow): self._run_async(_load) # 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: """Prefetch posts in all 4 directions (left, right, up, down).""" @@ -772,10 +783,15 @@ class BooruApp(QMainWindow): for offset in offsets: adj = index + offset if 0 <= adj < len(self._posts) and self._posts[adj].file_url: + self._signals.prefetch_progress.emit(adj, 0.0) 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: pass + self._signals.prefetch_progress.emit(adj, -1) self._run_async(_prefetch_batch) def _on_download_progress(self, downloaded: int, total: int) -> None: @@ -1054,6 +1070,7 @@ class BooruApp(QMainWindow): else: for tag in post.tag_list[:30]: bl_menu.addAction(tag) + bl_post_action = menu.addAction("Blacklist Post") action = menu.exec(pos) if not action: @@ -1105,6 +1122,10 @@ class BooruApp(QMainWindow): self._db.set_setting("blacklist_enabled", "1") self._status.showMessage(f"Blacklisted: {tag}") 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 def _is_child_of_menu(action, menu) -> bool: diff --git a/booru_viewer/gui/grid.py b/booru_viewer/gui/grid.py index 781594e..2038736 100644 --- a/booru_viewer/gui/grid.py +++ b/booru_viewer/gui/grid.py @@ -38,6 +38,7 @@ class ThumbnailWidget(QWidget): self._hover = False self._drag_start: QPoint | 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.setCursor(Qt.CursorShape.PointingHandCursor) self.setMouseTracking(True) @@ -66,6 +67,11 @@ class ThumbnailWidget(QWidget): self._saved_locally = saved 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: # Ensure QSS is applied so palette picks up custom colors self.ensurePolished() @@ -120,6 +126,17 @@ class ThumbnailWidget(QWidget): p.drawLine(7, 10, 9, 13) 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() def enterEvent(self, event) -> None: diff --git a/booru_viewer/gui/settings.py b/booru_viewer/gui/settings.py index 8ce4883..d93c172 100644 --- a/booru_viewer/gui/settings.py +++ b/booru_viewer/gui/settings.py @@ -110,6 +110,11 @@ class SettingsDialog(QDialog): self._preload.setChecked(self._db.get_setting_bool("preload_thumbnails")) 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) self._file_dialog_combo = None if not IS_WINDOWS: @@ -372,37 +377,14 @@ class SettingsDialog(QDialog): QDesktopServices.openUrl(QUrl.fromLocalFile(str(css_path))) 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" - if css_path.exists(): - reply = QMessageBox.question( - self, "Confirm", "Overwrite existing custom.qss with template?", - 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}") + if not css_path.exists(): + css_path.write_text("/* booru-viewer custom stylesheet */\n/* See themes reference for examples */\n\n") + QDesktopServices.openUrl(QUrl.fromLocalFile(str(css_path))) def _view_css_guide(self) -> None: 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_score", str(self._default_score.value())) 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("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")