0.2.0: mpv backend, popout viewer, preview toolbar, API retry, SearchState refactor

Video:
- Replace Qt Multimedia with mpv via python-mpv + OpenGL render API
- Hardware-accelerated decoding, frame-accurate seeking, proper EOF detection
- Translucent overlay controls in both preview and popout
- LC_NUMERIC=C for mpv locale compatibility

Popout viewer (renamed from slideshow):
- Floating toolbar + controls overlay with auto-hide (2s)
- Window auto-resizes to content aspect ratio on navigation
- Hyprland: hyprctl resizewindowpixel + keep_aspect_ratio prop
- Window geometry persisted to DB across sessions
- Smart F11 exit sizing (60% monitor, centered)

Preview toolbar:
- Bookmark, Save, BL Tag, BL Post, Popout buttons above preview
- Save opens folder picker menu, shows Save/Unsave state
- Blacklist actions have confirmation dialogs
- Per-tab button visibility (Library: Save + Popout only)
- Cross-tab state management with grid selection clearing

Search & pagination:
- SearchState dataclass replaces 8 scattered attrs + defensive getattr
- Media type filter dropdown (All/Animated/Video/GIF/Audio)
- API retry with backoff on 429/503/timeout
- Infinite scroll dedup fix (local seen set per backfill round)
- Prev/Next buttons hide at boundaries, "(end)" status indicator

Grid:
- Rubber band drag selection
- Saved/bookmarked dots update instantly across all tabs
- Library/bookmarks emit signals on file deletion for cross-tab sync

Settings & misc:
- Default site option
- Max thumbnail cache setting (500MB default)
- Source URLs clickable in info panel
- Long URLs truncated to prevent splitter blowout
- Bulk save no longer auto-bookmarks
This commit is contained in:
pax 2026-04-06 13:43:46 -05:00
parent b30a469dde
commit 2fbf2f6472
25 changed files with 1585 additions and 431 deletions

87
CHANGELOG.md Normal file
View File

@ -0,0 +1,87 @@
# Changelog
## 0.2.0
### New: mpv video backend
- Replaced Qt Multimedia (QMediaPlayer/QVideoWidget) with embedded mpv via `python-mpv`
- OpenGL render API (`MpvRenderContext`) for Wayland-native compositing — no XWayland needed
- Proper hardware-accelerated decoding (`hwdec=auto`)
- Reliable aspect ratio handling — portrait videos scale correctly
- Proper end-of-file detection via `eof-reached` property observer instead of fragile position-jump heuristic
- Frame-accurate seeking with `absolute+exact` and `relative+exact`
- `keep-open=yes` holds last frame on video end instead of flashing black
- Windows: bundle `mpv-2.dll` in PyInstaller build
### New: popout viewer (renamed from slideshow)
- Renamed "Slideshow" to "Popout" throughout UI
- Toolbar and video controls float over media with translucent background (`rgba(0,0,0,160)`)
- Auto-hide after 2 seconds of inactivity, reappear on mouse move
- Ctrl+H manual toggle
- Media fills entire window — no layout shift when UI appears/disappears
- Video controls only show for video posts, hidden for images/GIFs
- Smart F11 exit: window sizes to 60% of monitor, maintaining content aspect ratio
- Window auto-resizes to content aspect ratio on navigation (height adjusts, position stays)
- Window geometry and fullscreen state persisted to DB across sessions
- Hyprland-specific: uses `hyprctl resizewindowpixel` + `setprop keep_aspect_ratio` to lock window to content aspect ratio (works both floating and tiled)
- Default site setting in Settings > General
### New: preview toolbar
- Action bar above the preview panel: Bookmark, Save, BL Tag, BL Post, Popout
- Appears when a post is active, hidden when preview is cleared
- Save button opens folder picker menu (Unsorted / existing folders / + New Folder)
- Save/Unsave state shown on button text
- Bookmark/Unbookmark state shown on button text
- Per-tab button visibility: Library tab only shows Save + Popout
- All actions work from any tab (Browse, Bookmarks, Library)
- Blacklist tag and blacklist post show confirmation dialogs
- "Unsave from Library" only appears in context menu when post is saved
### New: media type filter
- Replaced "Animated" checkbox with dropdown: All / Animated / Video / GIF / Audio
- Each option appends the corresponding booru tag to the search query
### New: thumbnail cache limits
- Added "Max thumbnail cache" setting (default 500 MB)
- Auto-evicts oldest thumbnails when limit is reached
### Improved: state synchronization
- Saving/unsaving updates grid thumbnail dots instantly (browse, bookmarks, library)
- Unbookmarking refreshes the bookmarks tab immediately
- Saving from browse/bookmarks refreshes the library tab when async save completes
- Library items set `_current_post` on click so toolbar actions work correctly
- Preview toolbar tracks bookmark and save state across all tabs
- Tab switching clears grid selections to prevent cross-tab action conflicts
- Bookmark state updates after async bookmark completes (not before)
### Improved: infinite scroll
- Fixed missing posts when media type filters reduce results per page
- Local dedup set (`seen`) prevents cross-page duplicates within backfill without polluting `shown_post_ids`
- Page counter only advances when results are returned, not when filtering empties them
- Backfill loop increased to 10 max pages with 300ms delay between API calls (first call instant)
### Improved: pagination
- Status bar shows "(end)" when search returns fewer results than page size
- Prev/Next buttons hide when at page boundaries instead of just disabling
- Source URLs clickable in info panel, truncated at 60 chars for display
### Improved: video controls
- Seek step changed from 5s to ~3s for `,` and `.` keys
- `,` and `.` seek keys now work in the main preview panel, not just popout
- Translucent overlay style on video controls in both preview and popout
- Volume slider fixed at 60px to not compete with seek slider at small sizes
### New: API retry logic
- Single retry with backoff on HTTP 429 (rate limit) and 503 (service unavailable)
- Retries on request timeout
- Respects `Retry-After` header (capped at 5s)
- Applied to all API requests (search, get_post, autocomplete) across all four clients
- Downloads are not retried (large payloads, separate client)
### Refactor: SearchState dataclass
- Consolidated 8 scattered search state attributes into a single `SearchState` dataclass
- Eliminated all defensive `getattr`/`hasattr` patterns (8 instances)
- State resets cleanly on new search — no stale infinite scroll data
### Dependencies
- Added `python-mpv>=1.0`
- Removed dependency on `PySide6.QtMultimedia` and `PySide6.QtMultimediaWidgets`

View File

@ -39,7 +39,7 @@ Supports custom styling via `custom.qss` — see [Theming](#theming).
- Auto-detect site API type — just paste the URL - Auto-detect site API type — just paste the URL
- Tag search with autocomplete, history dropdown, and saved searches - Tag search with autocomplete, history dropdown, and saved searches
- Rating and score filtering (server-side `score:>=N`) - Rating and score filtering (server-side `score:>=N`)
- **Animated filter** — checkbox to only show video/gif/animated posts - **Media type filter** — dropdown: All / Animated / Video / GIF / Audio
- Blacklisted tags and posts (client-side filtering with backfill) - Blacklisted tags and posts (client-side filtering with backfill)
- Thumbnail grid with keyboard navigation - Thumbnail grid with keyboard navigation
- **Infinite scroll** — optional, auto-loads more posts at bottom - **Infinite scroll** — optional, auto-loads more posts at bottom
@ -51,17 +51,20 @@ Supports custom styling via `custom.qss` — see [Theming](#theming).
- Image viewer with zoom (scroll wheel), pan (drag), and reset (middle click) - Image viewer with zoom (scroll wheel), pan (drag), and reset (middle click)
- GIF animation, Pixiv ugoira auto-conversion (zip to animated GIF) - GIF animation, Pixiv ugoira auto-conversion (zip to animated GIF)
- Animated PNG/WebP auto-conversion to GIF - Animated PNG/WebP auto-conversion to GIF
- Video playback (MP4, WebM) with play/pause, seek, volume, mute, and seamless looping - Video playback via mpv (MP4, WebM, MKV) with play/pause, seek, volume, mute, and seamless looping
- Info panel with post details, date, clickable tags, and filetype - Info panel with post details, date, clickable tags, and filetype
- **Preview toolbar** — Bookmark, Save, BL Tag, BL Post, and Popout buttons above the preview panel
### Slideshow Mode ### Popout Viewer
- Right-click preview → "Slideshow Mode" for fullscreen viewing - Right-click preview → "Popout" or click the Popout button in the preview toolbar
- Arrow keys / `h`/`j`/`k`/`l` navigate posts (including during video playback) - Arrow keys / `h`/`j`/`k`/`l` navigate posts (including during video playback)
- `,` / `.` seek 5 seconds in videos, `Space` toggles play/pause - `,` / `.` seek 3 seconds in videos, `Space` toggles play/pause
- Toolbar with Bookmark, Save/Unsave, Blacklist Tag, and Blacklist Post buttons - Floating overlay UI — toolbar and video controls auto-hide after 2 seconds, reappear on mouse move
- `F11` toggles fullscreen/windowed, `Ctrl+H` hides all UI, `Ctrl+P` privacy screen - `F11` toggles fullscreen/windowed, `Ctrl+H` hides all UI, `Ctrl+P` privacy screen
- Bidirectional sync — clicking posts in the main grid updates the slideshow - Window auto-sizes to content aspect ratio; state persisted across sessions
- Video position and player state synced between preview and slideshow - Hyprland: `keep_aspect_ratio` prop locks window to content proportions
- Bidirectional sync — clicking posts in the main grid updates the popout
- Video position and player state synced between preview and popout
### Bookmarks & Library ### Bookmarks & Library
- Bookmark posts, organize into folders - Bookmark posts, organize into folders
@ -69,7 +72,7 @@ Supports custom styling via `custom.qss` — see [Theming](#theming).
- Save to library (unsorted or per-folder), drag-and-drop thumbnails as files - Save to library (unsorted or per-folder), drag-and-drop thumbnails as files
- Multi-select (Ctrl/Shift+Click, Ctrl+A) with bulk actions - Multi-select (Ctrl/Shift+Click, Ctrl+A) with bulk actions
- Bulk context menus in both Browse and Bookmarks tabs - Bulk context menus in both Browse and Bookmarks tabs
- Unsave from Library available in grid, preview, and slideshow - Unsave from Library available in grid, preview, and popout (only shown when post is saved)
- Import/export bookmarks as JSON - Import/export bookmarks as JSON
### Library ### Library
@ -91,8 +94,6 @@ Supports custom styling via `custom.qss` — see [Theming](#theming).
Download `booru-viewer-setup.exe` from [Releases](https://git.pax.moe/pax/booru-viewer/releases) and run the installer. It installs to AppData with Start Menu and optional desktop shortcuts. To update, just run the new installer over the old one — your data in `%APPDATA%\booru-viewer\` is preserved. Download `booru-viewer-setup.exe` from [Releases](https://git.pax.moe/pax/booru-viewer/releases) and run the installer. It installs to AppData with Start Menu and optional desktop shortcuts. To update, just run the new installer over the old one — your data in `%APPDATA%\booru-viewer\` is preserved.
For WebM video playback, install [VP9 Video Extensions](https://apps.microsoft.com/detail/9n4d0msmp0pt) from the Microsoft Store.
Windows 10 dark mode is automatically detected and applied. Windows 10 dark mode is automatically detected and applied.
### Linux ### Linux
@ -101,17 +102,17 @@ Requires Python 3.11+ and pip. Most distros ship Python but you may need to inst
**Arch / CachyOS:** **Arch / CachyOS:**
```sh ```sh
sudo pacman -S python python-pip qt6-base qt6-multimedia qt6-multimedia-ffmpeg ffmpeg sudo pacman -S python python-pip qt6-base mpv ffmpeg
``` ```
**Ubuntu / Debian (24.04+):** **Ubuntu / Debian (24.04+):**
```sh ```sh
sudo apt install python3 python3-pip python3-venv libqt6multimedia6 gstreamer1.0-plugins-good gstreamer1.0-plugins-bad gstreamer1.0-libav ffmpeg sudo apt install python3 python3-pip python3-venv mpv libmpv-dev ffmpeg
``` ```
**Fedora:** **Fedora:**
```sh ```sh
sudo dnf install python3 python3-pip qt6-qtbase qt6-qtmultimedia gstreamer1-plugins-good gstreamer1-plugins-bad-free gstreamer1-libav ffmpeg sudo dnf install python3 python3-pip qt6-qtbase mpv mpv-libs-devel ffmpeg
``` ```
Then clone and install: Then clone and install:
@ -147,6 +148,8 @@ Categories=Graphics;
- PySide6 (Qt6) - PySide6 (Qt6)
- httpx - httpx
- Pillow - Pillow
- python-mpv
- mpv (system package on Linux, bundled DLL on Windows)
## Keybinds ## Keybinds
@ -169,22 +172,22 @@ Categories=Graphics;
| Scroll wheel | Zoom | | Scroll wheel | Zoom |
| Middle click / `0` | Reset view | | Middle click / `0` | Reset view |
| Arrow keys / `h`/`j`/`k`/`l` | Navigate posts | | Arrow keys / `h`/`j`/`k`/`l` | Navigate posts |
| `,` / `.` | Seek 5s back / forward (video) | | `,` / `.` | Seek 3s back / forward (video) |
| `Space` | Play / pause (video, hover to activate) | | `Space` | Play / pause (video, hover to activate) |
| Right click | Context menu (bookmark, save, slideshow) | | Right click | Context menu (bookmark, save, popout) |
### Slideshow ### Popout
| Key | Action | | Key | Action |
|-----|--------| |-----|--------|
| Arrow keys / `h`/`j`/`k`/`l` | Navigate posts | | Arrow keys / `h`/`j`/`k`/`l` | Navigate posts |
| `,` / `.` | Seek 5s (video) | | `,` / `.` | Seek 3s (video) |
| `Space` | Play / pause (video) | | `Space` | Play / pause (video) |
| Scroll wheel | Volume up / down (video) | | Scroll wheel | Volume up / down (video) |
| `F11` | Toggle fullscreen / windowed | | `F11` | Toggle fullscreen / windowed |
| `Ctrl+H` | Hide / show UI | | `Ctrl+H` | Hide / show UI |
| `Ctrl+P` | Privacy screen | | `Ctrl+P` | Privacy screen |
| `Escape` / `Q` | Close slideshow | | `Escape` / `Q` | Close popout |
### Global ### Global
@ -227,8 +230,8 @@ A template is also available in Settings > Theme > Create from Template.
## Settings ## Settings
- **General** — page size, thumbnail size, default rating/score, prefetch mode (Off / Nearby / Aggressive), infinite scroll, slideshow monitor, file dialog platform - **General** — page size, thumbnail size, default site, default rating/score, prefetch mode (Off / Nearby / Aggressive), infinite scroll, popout monitor, file dialog platform
- **Cache** — max cache size, auto-evict, clear cache on exit (session-only mode) - **Cache** — max cache size, max thumbnail cache, auto-evict, clear cache on exit (session-only mode)
- **Blacklist** — tag blacklist with toggle, post URL blacklist - **Blacklist** — tag blacklist with toggle, post URL blacklist
- **Paths** — data directory, cache, database, configurable library directory - **Paths** — data directory, cache, database, configurable library directory
- **Theme** — custom.qss editor, template generator, CSS guide - **Theme** — custom.qss editor, template generator, CSS guide

View File

@ -20,12 +20,13 @@ hiddenimports = [
'PIL.GifImagePlugin', 'PIL.GifImagePlugin',
'PIL.WebPImagePlugin', 'PIL.WebPImagePlugin',
'PIL.BmpImagePlugin', 'PIL.BmpImagePlugin',
'mpv',
] ]
a = Analysis( a = Analysis(
['booru_viewer/main_gui.py'], ['booru_viewer/main_gui.py'],
pathex=[], pathex=[],
binaries=[], binaries=[('mpv-2.dll', '.')] if sys.platform == 'win32' else [],
datas=[('icon.png', '.')], datas=[('icon.png', '.')],
hiddenimports=hiddenimports, hiddenimports=hiddenimports,
hookspath=[], hookspath=[],

View File

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import logging import logging
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -90,6 +91,36 @@ class BooruClient(ABC):
async def _log_request(request: httpx.Request) -> None: async def _log_request(request: httpx.Request) -> None:
log_connection(str(request.url)) log_connection(str(request.url))
_RETRYABLE_STATUS = frozenset({429, 503})
async def _request(
self, method: str, url: str, *, params: dict | None = None
) -> httpx.Response:
"""Issue an HTTP request with a single retry on 429/503/timeout."""
for attempt in range(2):
try:
resp = await self.client.request(method, url, params=params)
if resp.status_code not in self._RETRYABLE_STATUS or attempt == 1:
return resp
wait = 1.0
if resp.status_code == 429:
retry_after = resp.headers.get("retry-after")
if retry_after:
try:
wait = min(float(retry_after), 5.0)
except (ValueError, TypeError):
wait = 2.0
else:
wait = 2.0
log.info(f"Retrying {url} after {resp.status_code} (wait {wait}s)")
await asyncio.sleep(wait)
except httpx.TimeoutException:
if attempt == 1:
raise
log.info(f"Retrying {url} after timeout")
await asyncio.sleep(1.0)
return resp # unreachable in practice, satisfies type checker
async def close(self) -> None: async def close(self) -> None:
pass # shared client stays open pass # shared client stays open

View File

@ -24,7 +24,7 @@ class DanbooruClient(BooruClient):
url = f"{self.base_url}/posts.json" url = f"{self.base_url}/posts.json"
log.info(f"GET {url}") log.info(f"GET {url}")
log.debug(f" params: {params}") log.debug(f" params: {params}")
resp = await self.client.get(url, params=params) resp = await self._request("GET", url, params=params)
log.info(f" -> {resp.status_code}") log.info(f" -> {resp.status_code}")
if resp.status_code != 200: if resp.status_code != 200:
log.warning(f" body: {resp.text[:500]}") log.warning(f" body: {resp.text[:500]}")
@ -66,8 +66,8 @@ class DanbooruClient(BooruClient):
params["login"] = self.api_user params["login"] = self.api_user
params["api_key"] = self.api_key params["api_key"] = self.api_key
resp = await self.client.get( resp = await self._request(
f"{self.base_url}/posts/{post_id}.json", params=params "GET", f"{self.base_url}/posts/{post_id}.json", params=params
) )
if resp.status_code == 404: if resp.status_code == 404:
return None return None
@ -91,8 +91,8 @@ class DanbooruClient(BooruClient):
async def autocomplete(self, query: str, limit: int = 10) -> list[str]: async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
try: try:
resp = await self.client.get( resp = await self._request(
f"{self.base_url}/autocomplete.json", "GET", f"{self.base_url}/autocomplete.json",
params={"search[query]": query, "search[type]": "tag_query", "limit": limit}, params={"search[query]": query, "search[type]": "tag_query", "limit": limit},
) )
resp.raise_for_status() resp.raise_for_status()

View File

@ -44,7 +44,7 @@ class E621Client(BooruClient):
url = f"{self.base_url}/posts.json" url = f"{self.base_url}/posts.json"
log.info(f"GET {url}") log.info(f"GET {url}")
log.debug(f" params: {params}") log.debug(f" params: {params}")
resp = await self.client.get(url, params=params) resp = await self._request("GET", url, params=params)
log.info(f" -> {resp.status_code}") log.info(f" -> {resp.status_code}")
if resp.status_code != 200: if resp.status_code != 200:
log.warning(f" body: {resp.text[:500]}") log.warning(f" body: {resp.text[:500]}")
@ -86,8 +86,8 @@ class E621Client(BooruClient):
params["login"] = self.api_user params["login"] = self.api_user
params["api_key"] = self.api_key params["api_key"] = self.api_key
resp = await self.client.get( resp = await self._request(
f"{self.base_url}/posts/{post_id}.json", params=params "GET", f"{self.base_url}/posts/{post_id}.json", params=params
) )
if resp.status_code == 404: if resp.status_code == 404:
return None return None
@ -113,8 +113,8 @@ class E621Client(BooruClient):
async def autocomplete(self, query: str, limit: int = 10) -> list[str]: async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
try: try:
resp = await self.client.get( resp = await self._request(
f"{self.base_url}/tags.json", "GET", f"{self.base_url}/tags.json",
params={ params={
"search[name_matches]": f"{query}*", "search[name_matches]": f"{query}*",
"search[order]": "count", "search[order]": "count",

View File

@ -38,7 +38,7 @@ class GelbooruClient(BooruClient):
url = f"{self.base_url}/index.php" url = f"{self.base_url}/index.php"
log.info(f"GET {url}") log.info(f"GET {url}")
log.debug(f" params: {params}") log.debug(f" params: {params}")
resp = await self.client.get(url, params=params) resp = await self._request("GET", url, params=params)
log.info(f" -> {resp.status_code}") log.info(f" -> {resp.status_code}")
if resp.status_code != 200: if resp.status_code != 200:
log.warning(f" body: {resp.text[:500]}") log.warning(f" body: {resp.text[:500]}")
@ -94,7 +94,7 @@ class GelbooruClient(BooruClient):
params["api_key"] = self.api_key params["api_key"] = self.api_key
params["user_id"] = self.api_user params["user_id"] = self.api_user
resp = await self.client.get(f"{self.base_url}/index.php", params=params) resp = await self._request("GET", f"{self.base_url}/index.php", params=params)
if resp.status_code == 404: if resp.status_code == 404:
return None return None
resp.raise_for_status() resp.raise_for_status()
@ -111,7 +111,7 @@ class GelbooruClient(BooruClient):
id=item["id"], id=item["id"],
file_url=file_url, file_url=file_url,
preview_url=item.get("preview_url"), preview_url=item.get("preview_url"),
tags=item.get("tags", ""), tags=self._decode_tags(item.get("tags", "")),
score=item.get("score", 0), score=item.get("score", 0),
rating=item.get("rating"), rating=item.get("rating"),
source=item.get("source"), source=item.get("source"),
@ -122,8 +122,8 @@ class GelbooruClient(BooruClient):
async def autocomplete(self, query: str, limit: int = 10) -> list[str]: async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
try: try:
resp = await self.client.get( resp = await self._request(
f"{self.base_url}/index.php", "GET", f"{self.base_url}/index.php",
params={ params={
"page": "dapi", "page": "dapi",
"s": "tag", "s": "tag",

View File

@ -21,7 +21,7 @@ class MoebooruClient(BooruClient):
params["login"] = self.api_user params["login"] = self.api_user
params["password_hash"] = self.api_key params["password_hash"] = self.api_key
resp = await self.client.get(f"{self.base_url}/post.json", params=params) resp = await self._request("GET", f"{self.base_url}/post.json", params=params)
resp.raise_for_status() resp.raise_for_status()
try: try:
data = resp.json() data = resp.json()
@ -59,7 +59,7 @@ class MoebooruClient(BooruClient):
params["login"] = self.api_user params["login"] = self.api_user
params["password_hash"] = self.api_key params["password_hash"] = self.api_key
resp = await self.client.get(f"{self.base_url}/post.json", params=params) resp = await self._request("GET", f"{self.base_url}/post.json", params=params)
if resp.status_code == 404: if resp.status_code == 404:
return None return None
resp.raise_for_status() resp.raise_for_status()
@ -87,8 +87,8 @@ class MoebooruClient(BooruClient):
async def autocomplete(self, query: str, limit: int = 10) -> list[str]: async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
try: try:
resp = await self.client.get( resp = await self._request(
f"{self.base_url}/tag.json", "GET", f"{self.base_url}/tag.json",
params={"name": f"*{query}*", "order": "count", "limit": limit}, params={"name": f"*{query}*", "order": "count", "limit": limit},
) )
resp.raise_for_status() resp.raise_for_status()

View File

@ -310,6 +310,26 @@ def evict_oldest(max_bytes: int, protected_paths: set[str] | None = None) -> int
return deleted return deleted
def evict_oldest_thumbnails(max_bytes: int) -> int:
"""Delete oldest thumbnails until under max_bytes. Returns count deleted."""
td = thumbnails_dir()
if not td.exists():
return 0
files = sorted(td.iterdir(), key=lambda f: f.stat().st_mtime)
deleted = 0
current = sum(f.stat().st_size for f in td.iterdir() if f.is_file())
for f in files:
if current <= max_bytes:
break
if not f.is_file():
continue
size = f.stat().st_size
f.unlink()
current -= size
deleted += 1
return deleted
def clear_cache(clear_images: bool = True, clear_thumbnails: bool = True) -> int: def clear_cache(clear_images: bool = True, clear_thumbnails: bool = True) -> int:
"""Delete all cached files. Returns count deleted.""" """Delete all cached files. Returns count deleted."""
deleted = 0 deleted = 0

View File

@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS saved_searches (
_DEFAULTS = { _DEFAULTS = {
"max_cache_mb": "2048", "max_cache_mb": "2048",
"max_thumb_cache_mb": "500",
"auto_evict": "1", "auto_evict": "1",
"thumbnail_size": "180", "thumbnail_size": "180",
"page_size": "40", "page_size": "40",

File diff suppressed because it is too large Load Diff

View File

@ -39,6 +39,7 @@ class BookmarksView(QWidget):
bookmark_selected = Signal(object) bookmark_selected = Signal(object)
bookmark_activated = Signal(object) bookmark_activated = Signal(object)
bookmarks_changed = Signal() # emitted after bookmark add/remove/unsave
def __init__(self, db: Database, parent: QWidget | None = None) -> None: def __init__(self, db: Database, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -230,7 +231,21 @@ class BookmarksView(QWidget):
save_lib_menu.addSeparator() save_lib_menu.addSeparator()
save_lib_new = save_lib_menu.addAction("+ New Folder...") save_lib_new = save_lib_menu.addAction("+ New Folder...")
unsave_lib = menu.addAction("Unsave from Library") unsave_lib = None
# Only show unsave if the post is saved locally
from ..core.config import saved_dir, saved_folder_dir, MEDIA_EXTENSIONS
_saved = False
_sd = saved_dir()
if _sd.exists():
_saved = any((_sd / f"{fav.post_id}{ext}").exists() for ext in MEDIA_EXTENSIONS)
if not _saved:
for folder in self._db.get_folders():
d = saved_folder_dir(folder)
if d.exists() and any((d / f"{fav.post_id}{ext}").exists() for ext in MEDIA_EXTENSIONS):
_saved = True
break
if _saved:
unsave_lib = menu.addAction("Unsave from Library")
copy_file = menu.addAction("Copy File to Clipboard") copy_file = menu.addAction("Copy File to Clipboard")
copy_url = menu.addAction("Copy Image URL") copy_url = menu.addAction("Copy Image URL")
copy_tags = menu.addAction("Copy Tags") copy_tags = menu.addAction("Copy Tags")
@ -282,6 +297,7 @@ class BookmarksView(QWidget):
from ..core.cache import delete_from_library from ..core.cache import delete_from_library
if delete_from_library(fav.post_id, fav.folder): if delete_from_library(fav.post_id, fav.folder):
self.refresh() self.refresh()
self.bookmarks_changed.emit()
elif action == copy_file: elif action == copy_file:
path = fav.cached_path path = fav.cached_path
if path and Path(path).exists(): if path and Path(path).exists():
@ -315,6 +331,7 @@ class BookmarksView(QWidget):
elif action == remove_bookmark: elif action == remove_bookmark:
self._db.remove_bookmark(fav.site_id, fav.post_id) self._db.remove_bookmark(fav.site_id, fav.post_id)
self.refresh() self.refresh()
self.bookmarks_changed.emit()
def _on_multi_context_menu(self, indices: list, pos) -> None: def _on_multi_context_menu(self, indices: list, pos) -> None:
favs = [self._bookmarks[i] for i in indices if 0 <= i < len(self._bookmarks)] favs = [self._bookmarks[i] for i in indices if 0 <= i < len(self._bookmarks)]
@ -353,6 +370,7 @@ class BookmarksView(QWidget):
for fav in favs: for fav in favs:
delete_from_library(fav.post_id, fav.folder) delete_from_library(fav.post_id, fav.folder)
self.refresh() self.refresh()
self.bookmarks_changed.emit()
elif action == move_none: elif action == move_none:
for fav in favs: for fav in favs:
self._db.move_bookmark_to_folder(fav.id, None) self._db.move_bookmark_to_folder(fav.id, None)
@ -367,3 +385,4 @@ class BookmarksView(QWidget):
for fav in favs: for fav in favs:
self._db.remove_bookmark(fav.site_id, fav.post_id) self._db.remove_bookmark(fav.site_id, fav.post_id)
self.refresh() self.refresh()
self.bookmarks_changed.emit()

View File

@ -5,12 +5,13 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from PySide6.QtCore import Qt, Signal, QSize, QRect, QMimeData, QUrl, QPoint, Property from PySide6.QtCore import Qt, Signal, QSize, QRect, QMimeData, QUrl, QPoint, Property
from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QKeyEvent, QWheelEvent, QDrag from PySide6.QtGui import QPixmap, QPainter, QColor, QPen, QKeyEvent, QWheelEvent, QDrag, QMouseEvent
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QWidget, QWidget,
QScrollArea, QScrollArea,
QMenu, QMenu,
QApplication, QApplication,
QRubberBand,
) )
from ..core.api.base import Post from ..core.api.base import Post
@ -304,6 +305,9 @@ class ThumbnailGrid(QScrollArea):
self._last_click_index = -1 # for shift-click range self._last_click_index = -1 # for shift-click range
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom) self.verticalScrollBar().valueChanged.connect(self._check_scroll_bottom)
# Rubber band drag selection
self._rubber_band: QRubberBand | None = None
self._rb_origin: QPoint | None = None
@property @property
def selected_index(self) -> int: def selected_index(self) -> int:
@ -355,6 +359,13 @@ class ThumbnailGrid(QScrollArea):
self._thumbs[idx].set_multi_selected(False) self._thumbs[idx].set_multi_selected(False)
self._multi_selected.clear() self._multi_selected.clear()
def clear_selection(self) -> None:
"""Deselect everything."""
self._clear_multi()
if 0 <= self._selected_index < len(self._thumbs):
self._thumbs[self._selected_index].set_selected(False)
self._selected_index = -1
def _select(self, index: int) -> None: def _select(self, index: int) -> None:
if index < 0 or index >= len(self._thumbs): if index < 0 or index >= len(self._thumbs):
return return
@ -417,6 +428,42 @@ class ThumbnailGrid(QScrollArea):
self.ensureWidgetVisible(self._thumbs[index]) self.ensureWidgetVisible(self._thumbs[index])
self.context_requested.emit(index, pos) self.context_requested.emit(index, pos)
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
# Only start rubber band if click is on empty grid space (not a thumbnail)
child = self.childAt(event.position().toPoint())
if child is self.widget() or child is self.viewport():
self._rb_origin = event.position().toPoint()
if not self._rubber_band:
self._rubber_band = QRubberBand(QRubberBand.Shape.Rectangle, self.viewport())
self._rubber_band.setGeometry(QRect(self._rb_origin, QSize()))
self._rubber_band.show()
self._clear_multi()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QMouseEvent) -> None:
if self._rb_origin and self._rubber_band:
rb_rect = QRect(self._rb_origin, event.position().toPoint()).normalized()
self._rubber_band.setGeometry(rb_rect)
# Select thumbnails that intersect the rubber band
vp_offset = self.widget().mapFrom(self.viewport(), QPoint(0, 0))
self._clear_multi()
for i, thumb in enumerate(self._thumbs):
thumb_rect = thumb.geometry().translated(vp_offset)
if rb_rect.intersects(thumb_rect):
self._multi_selected.add(i)
thumb.set_multi_selected(True)
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
if self._rb_origin and self._rubber_band:
self._rubber_band.hide()
self._rb_origin = None
return
super().mouseReleaseEvent(event)
def select_all(self) -> None: def select_all(self) -> None:
self._clear_multi() self._clear_multi()
for i in range(len(self._thumbs)): for i in range(len(self._thumbs)):

View File

@ -40,6 +40,7 @@ class LibraryView(QWidget):
file_selected = Signal(str) file_selected = Signal(str)
file_activated = Signal(str) file_activated = Signal(str)
files_deleted = Signal(list) # list of post IDs that were deleted
def __init__(self, db=None, parent: QWidget | None = None) -> None: def __init__(self, db=None, parent: QWidget | None = None) -> None:
super().__init__(parent) super().__init__(parent)
@ -351,11 +352,13 @@ class LibraryView(QWidget):
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
post_id = int(filepath.stem) if filepath.stem.isdigit() else None
filepath.unlink(missing_ok=True) filepath.unlink(missing_ok=True)
# Also remove cached thumbnail
lib_thumb = thumbnails_dir() / "library" / f"{filepath.stem}.jpg" lib_thumb = thumbnails_dir() / "library" / f"{filepath.stem}.jpg"
lib_thumb.unlink(missing_ok=True) lib_thumb.unlink(missing_ok=True)
self.refresh() self.refresh()
if post_id is not None:
self.files_deleted.emit([post_id])
def _on_multi_context_menu(self, indices: list, pos) -> None: def _on_multi_context_menu(self, indices: list, pos) -> None:
files = [self._files[i] for i in indices if 0 <= i < len(self._files)] files = [self._files[i] for i in indices if 0 <= i < len(self._files)]
@ -375,8 +378,13 @@ class LibraryView(QWidget):
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if reply == QMessageBox.StandardButton.Yes: if reply == QMessageBox.StandardButton.Yes:
deleted_ids = []
for f in files: for f in files:
if f.stem.isdigit():
deleted_ids.append(int(f.stem))
f.unlink(missing_ok=True) f.unlink(missing_ok=True)
lib_thumb = thumbnails_dir() / "library" / f"{f.stem}.jpg" lib_thumb = thumbnails_dir() / "library" / f"{f.stem}.jpg"
lib_thumb.unlink(missing_ok=True) lib_thumb.unlink(missing_ok=True)
self.refresh() self.refresh()
if deleted_ids:
self.files_deleted.emit(deleted_ids)

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,18 @@ class SettingsDialog(QDialog):
self._default_rating.setCurrentIndex(idx) self._default_rating.setCurrentIndex(idx)
form.addRow("Default rating filter:", self._default_rating) form.addRow("Default rating filter:", self._default_rating)
# Default site
self._default_site = QComboBox()
self._default_site.addItem("(none)", 0)
for site in self._db.get_sites():
self._default_site.addItem(site.name, site.id)
default_site_id = self._db.get_setting_int("default_site_id")
if default_site_id:
idx = self._default_site.findData(default_site_id)
if idx >= 0:
self._default_site.setCurrentIndex(idx)
form.addRow("Default site:", self._default_site)
# Default min score # Default min score
self._default_score = QSpinBox() self._default_score = QSpinBox()
self._default_score.setRange(0, 99999) self._default_score.setRange(0, 99999)
@ -135,7 +147,7 @@ class SettingsDialog(QDialog):
idx = self._monitor_combo.findText(current_monitor) idx = self._monitor_combo.findText(current_monitor)
if idx >= 0: if idx >= 0:
self._monitor_combo.setCurrentIndex(idx) self._monitor_combo.setCurrentIndex(idx)
form.addRow("Slideshow monitor:", self._monitor_combo) form.addRow("Popout monitor:", self._monitor_combo)
# File dialog platform (Linux only) # File dialog platform (Linux only)
self._file_dialog_combo = None self._file_dialog_combo = None
@ -190,6 +202,12 @@ class SettingsDialog(QDialog):
self._max_cache.setValue(self._db.get_setting_int("max_cache_mb")) self._max_cache.setValue(self._db.get_setting_int("max_cache_mb"))
limits_layout.addRow("Max cache size:", self._max_cache) limits_layout.addRow("Max cache size:", self._max_cache)
self._max_thumb_cache = QSpinBox()
self._max_thumb_cache.setRange(50, 10000)
self._max_thumb_cache.setSuffix(" MB")
self._max_thumb_cache.setValue(self._db.get_setting_int("max_thumb_cache_mb") or 500)
limits_layout.addRow("Max thumbnail cache:", self._max_thumb_cache)
self._auto_evict = QCheckBox("Auto-evict oldest when limit reached") self._auto_evict = QCheckBox("Auto-evict oldest when limit reached")
self._auto_evict.setChecked(self._db.get_setting_bool("auto_evict")) self._auto_evict.setChecked(self._db.get_setting_bool("auto_evict"))
limits_layout.addRow("", self._auto_evict) limits_layout.addRow("", self._auto_evict)
@ -683,6 +701,7 @@ class SettingsDialog(QDialog):
self._db.set_setting("page_size", str(self._page_size.value())) self._db.set_setting("page_size", str(self._page_size.value()))
self._db.set_setting("thumbnail_size", str(self._thumb_size.value())) self._db.set_setting("thumbnail_size", str(self._thumb_size.value()))
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_site_id", str(self._default_site.currentData() or 0))
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_mode", self._prefetch_combo.currentText()) self._db.set_setting("prefetch_mode", self._prefetch_combo.currentText())
@ -690,6 +709,7 @@ class SettingsDialog(QDialog):
self._db.set_setting("slideshow_monitor", self._monitor_combo.currentText()) self._db.set_setting("slideshow_monitor", self._monitor_combo.currentText())
self._db.set_setting("library_dir", self._library_dir.text().strip()) self._db.set_setting("library_dir", self._library_dir.text().strip())
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("max_thumb_cache_mb", str(self._max_thumb_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")
self._db.set_setting("blacklist_enabled", "1" if self._bl_enabled.isChecked() else "0") self._db.set_setting("blacklist_enabled", "1" if self._bl_enabled.isChecked() else "0")

View File

@ -2,7 +2,7 @@
[Setup] [Setup]
AppName=booru-viewer AppName=booru-viewer
AppVersion=0.1.9 AppVersion=0.2.0
AppPublisher=pax AppPublisher=pax
AppPublisherURL=https://git.pax.moe/pax/booru-viewer AppPublisherURL=https://git.pax.moe/pax/booru-viewer
DefaultDirName={localappdata}\booru-viewer DefaultDirName={localappdata}\booru-viewer

View File

@ -4,13 +4,14 @@ build-backend = "hatchling.build"
[project] [project]
name = "booru-viewer" name = "booru-viewer"
version = "0.1.9" version = "0.2.0"
description = "Local booru image browser with Qt6 GUI" description = "Local booru image browser with Qt6 GUI"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
"httpx[http2]>=0.27", "httpx[http2]>=0.27",
"Pillow>=10.0", "Pillow>=10.0",
"PySide6>=6.6", "PySide6>=6.6",
"python-mpv>=1.0",
] ]
[project.scripts] [project.scripts]

View File

@ -185,6 +185,18 @@ QTabBar::tab:selected {
### Video Player Controls ### Video Player Controls
The preview panel's video controls bar uses a translucent overlay style by default (`rgba(0,0,0,160)` background, white text). This is styled internally and **overrides QSS** for the controls bar. The seek/volume sliders and buttons inside the controls bar use a built-in dark overlay theme.
To override the preview controls bar background in QSS:
```css
QWidget#_preview_controls {
background: rgba(40, 40, 40, 200); /* your custom translucent bg */
}
```
Standard slider styling still applies outside the controls bar:
```css ```css
QSlider::groove:horizontal { QSlider::groove:horizontal {
background: #333; background: #333;
@ -199,6 +211,43 @@ QSlider::handle:horizontal {
} }
``` ```
### Popout Overlay
The popout (fullscreen preview) toolbar and video controls float over the media with a translucent background and auto-hide after 2 seconds of no mouse activity. Mouse movement or Ctrl+H toggles them.
These overlays use internal styling that overrides QSS. To customize:
```css
/* Popout top toolbar */
QWidget#_slideshow_toolbar {
background: rgba(40, 40, 40, 200);
}
/* Popout bottom video controls */
QWidget#_slideshow_controls {
background: rgba(40, 40, 40, 200);
}
```
Buttons and labels inside both overlays inherit a white-on-transparent style. To override:
```css
QWidget#_slideshow_toolbar QPushButton {
border: 1px solid rgba(255, 255, 255, 120);
color: #ccc;
}
QWidget#_slideshow_controls QPushButton {
border: 1px solid rgba(255, 255, 255, 120);
color: #ccc;
}
```
### Preview Toolbar
The preview panel has an action toolbar (Bookmark, Save, BL Tag, BL Post, Popout) that appears above the media when a post is active. This toolbar uses the app's default button styling.
The toolbar does not have a named object ID — it inherits the app's `QPushButton` styles directly.
### Progress Bar (Download) ### Progress Bar (Download)
```css ```css
@ -230,6 +279,17 @@ QLabel {
} }
``` ```
### Rubber Band Selection
Click and drag on empty grid space to select multiple thumbnails. The rubber band uses the system's default `QRubberBand` style, which can be customized:
```css
QRubberBand {
background: rgba(0, 120, 215, 40);
border: 1px solid #0078d7;
}
```
### Thumbnail Indicators ### Thumbnail Indicators
```css ```css
@ -257,4 +317,3 @@ ThumbnailWidget {
- Tag category colors (Artist, Character, etc.) in the info panel are set in code, not via QSS - Tag category colors (Artist, Character, etc.) in the info panel are set in code, not via QSS
- Saved dot (green) and bookmark star (yellow) are QSS-controllable via `qproperty-savedColor` and `qproperty-bookmarkedColor` on `ThumbnailWidget` - Saved dot (green) and bookmark star (yellow) are QSS-controllable via `qproperty-savedColor` and `qproperty-bookmarkedColor` on `ThumbnailWidget`
- Use `QLabel { background: transparent; }` to prevent labels from getting opaque backgrounds - Use `QLabel { background: transparent; }` to prevent labels from getting opaque backgrounds
- Right-click on thumbnails selects visually but does not change the preview

View File

@ -117,3 +117,10 @@ QTabBar::tab:selected {
background: #45475a; background: #45475a;
color: #cba6f7; color: #cba6f7;
} }
/* Popout & preview overlay controls */
QWidget#_preview_controls,
QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls {
background: rgba(0, 0, 0, 160);
}

View File

@ -114,3 +114,10 @@ QTabBar::tab:selected {
background: #4f585e; background: #4f585e;
color: #a7c080; color: #a7c080;
} }
/* Popout & preview overlay controls */
QWidget#_preview_controls,
QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls {
background: rgba(0, 0, 0, 160);
}

View File

@ -114,3 +114,10 @@ QTabBar::tab:selected {
background: #504945; background: #504945;
color: #fe8019; color: #fe8019;
} }
/* Popout & preview overlay controls */
QWidget#_preview_controls,
QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls {
background: rgba(0, 0, 0, 160);
}

View File

@ -117,3 +117,10 @@ QTabBar::tab:selected {
color: #88c0d0; color: #88c0d0;
border-bottom-color: #88c0d0; border-bottom-color: #88c0d0;
} }
/* Popout & preview overlay controls */
QWidget#_preview_controls,
QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls {
background: rgba(0, 0, 0, 160);
}

View File

@ -114,3 +114,10 @@ QTabBar::tab:selected {
background: #586e75; background: #586e75;
color: #fdf6e3; color: #fdf6e3;
} }
/* Popout & preview overlay controls */
QWidget#_preview_controls,
QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls {
background: rgba(0, 0, 0, 160);
}

View File

@ -114,3 +114,10 @@ QTabBar::tab:selected {
background: #3b4261; background: #3b4261;
color: #7aa2f7; color: #7aa2f7;
} }
/* Popout & preview overlay controls */
QWidget#_preview_controls,
QWidget#_slideshow_toolbar,
QWidget#_slideshow_controls {
background: rgba(0, 0, 0, 160);
}