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:
parent
b30a469dde
commit
2fbf2f6472
87
CHANGELOG.md
Normal file
87
CHANGELOG.md
Normal 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`
|
||||
45
README.md
45
README.md
@ -39,7 +39,7 @@ Supports custom styling via `custom.qss` — see [Theming](#theming).
|
||||
- Auto-detect site API type — just paste the URL
|
||||
- Tag search with autocomplete, history dropdown, and saved searches
|
||||
- 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)
|
||||
- Thumbnail grid with keyboard navigation
|
||||
- **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)
|
||||
- GIF animation, Pixiv ugoira auto-conversion (zip to animated 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
|
||||
- **Preview toolbar** — Bookmark, Save, BL Tag, BL Post, and Popout buttons above the preview panel
|
||||
|
||||
### Slideshow Mode
|
||||
- Right-click preview → "Slideshow Mode" for fullscreen viewing
|
||||
### Popout Viewer
|
||||
- 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)
|
||||
- `,` / `.` seek 5 seconds in videos, `Space` toggles play/pause
|
||||
- Toolbar with Bookmark, Save/Unsave, Blacklist Tag, and Blacklist Post buttons
|
||||
- `,` / `.` seek 3 seconds in videos, `Space` toggles play/pause
|
||||
- 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
|
||||
- Bidirectional sync — clicking posts in the main grid updates the slideshow
|
||||
- Video position and player state synced between preview and slideshow
|
||||
- Window auto-sizes to content aspect ratio; state persisted across sessions
|
||||
- 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
|
||||
- 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
|
||||
- Multi-select (Ctrl/Shift+Click, Ctrl+A) with bulk actions
|
||||
- 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
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
|
||||
### Linux
|
||||
@ -101,17 +102,17 @@ Requires Python 3.11+ and pip. Most distros ship Python but you may need to inst
|
||||
|
||||
**Arch / CachyOS:**
|
||||
```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+):**
|
||||
```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:**
|
||||
```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:
|
||||
@ -147,6 +148,8 @@ Categories=Graphics;
|
||||
- PySide6 (Qt6)
|
||||
- httpx
|
||||
- Pillow
|
||||
- python-mpv
|
||||
- mpv (system package on Linux, bundled DLL on Windows)
|
||||
|
||||
## Keybinds
|
||||
|
||||
@ -169,22 +172,22 @@ Categories=Graphics;
|
||||
| Scroll wheel | Zoom |
|
||||
| Middle click / `0` | Reset view |
|
||||
| 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) |
|
||||
| Right click | Context menu (bookmark, save, slideshow) |
|
||||
| Right click | Context menu (bookmark, save, popout) |
|
||||
|
||||
### Slideshow
|
||||
### Popout
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| Arrow keys / `h`/`j`/`k`/`l` | Navigate posts |
|
||||
| `,` / `.` | Seek 5s (video) |
|
||||
| `,` / `.` | Seek 3s (video) |
|
||||
| `Space` | Play / pause (video) |
|
||||
| Scroll wheel | Volume up / down (video) |
|
||||
| `F11` | Toggle fullscreen / windowed |
|
||||
| `Ctrl+H` | Hide / show UI |
|
||||
| `Ctrl+P` | Privacy screen |
|
||||
| `Escape` / `Q` | Close slideshow |
|
||||
| `Escape` / `Q` | Close popout |
|
||||
|
||||
### Global
|
||||
|
||||
@ -227,8 +230,8 @@ A template is also available in Settings > Theme > Create from Template.
|
||||
|
||||
## Settings
|
||||
|
||||
- **General** — page size, thumbnail size, default rating/score, prefetch mode (Off / Nearby / Aggressive), infinite scroll, slideshow monitor, file dialog platform
|
||||
- **Cache** — max cache size, auto-evict, clear cache on exit (session-only mode)
|
||||
- **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, max thumbnail cache, auto-evict, clear cache on exit (session-only mode)
|
||||
- **Blacklist** — tag blacklist with toggle, post URL blacklist
|
||||
- **Paths** — data directory, cache, database, configurable library directory
|
||||
- **Theme** — custom.qss editor, template generator, CSS guide
|
||||
|
||||
@ -20,12 +20,13 @@ hiddenimports = [
|
||||
'PIL.GifImagePlugin',
|
||||
'PIL.WebPImagePlugin',
|
||||
'PIL.BmpImagePlugin',
|
||||
'mpv',
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
['booru_viewer/main_gui.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
binaries=[('mpv-2.dll', '.')] if sys.platform == 'win32' else [],
|
||||
datas=[('icon.png', '.')],
|
||||
hiddenimports=hiddenimports,
|
||||
hookspath=[],
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
@ -90,6 +91,36 @@ class BooruClient(ABC):
|
||||
async def _log_request(request: httpx.Request) -> None:
|
||||
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:
|
||||
pass # shared client stays open
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ class DanbooruClient(BooruClient):
|
||||
url = f"{self.base_url}/posts.json"
|
||||
log.info(f"GET {url}")
|
||||
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}")
|
||||
if resp.status_code != 200:
|
||||
log.warning(f" body: {resp.text[:500]}")
|
||||
@ -66,8 +66,8 @@ class DanbooruClient(BooruClient):
|
||||
params["login"] = self.api_user
|
||||
params["api_key"] = self.api_key
|
||||
|
||||
resp = await self.client.get(
|
||||
f"{self.base_url}/posts/{post_id}.json", params=params
|
||||
resp = await self._request(
|
||||
"GET", f"{self.base_url}/posts/{post_id}.json", params=params
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
@ -91,8 +91,8 @@ class DanbooruClient(BooruClient):
|
||||
|
||||
async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
|
||||
try:
|
||||
resp = await self.client.get(
|
||||
f"{self.base_url}/autocomplete.json",
|
||||
resp = await self._request(
|
||||
"GET", f"{self.base_url}/autocomplete.json",
|
||||
params={"search[query]": query, "search[type]": "tag_query", "limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
@ -44,7 +44,7 @@ class E621Client(BooruClient):
|
||||
url = f"{self.base_url}/posts.json"
|
||||
log.info(f"GET {url}")
|
||||
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}")
|
||||
if resp.status_code != 200:
|
||||
log.warning(f" body: {resp.text[:500]}")
|
||||
@ -86,8 +86,8 @@ class E621Client(BooruClient):
|
||||
params["login"] = self.api_user
|
||||
params["api_key"] = self.api_key
|
||||
|
||||
resp = await self.client.get(
|
||||
f"{self.base_url}/posts/{post_id}.json", params=params
|
||||
resp = await self._request(
|
||||
"GET", f"{self.base_url}/posts/{post_id}.json", params=params
|
||||
)
|
||||
if resp.status_code == 404:
|
||||
return None
|
||||
@ -113,8 +113,8 @@ class E621Client(BooruClient):
|
||||
|
||||
async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
|
||||
try:
|
||||
resp = await self.client.get(
|
||||
f"{self.base_url}/tags.json",
|
||||
resp = await self._request(
|
||||
"GET", f"{self.base_url}/tags.json",
|
||||
params={
|
||||
"search[name_matches]": f"{query}*",
|
||||
"search[order]": "count",
|
||||
|
||||
@ -38,7 +38,7 @@ class GelbooruClient(BooruClient):
|
||||
url = f"{self.base_url}/index.php"
|
||||
log.info(f"GET {url}")
|
||||
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}")
|
||||
if resp.status_code != 200:
|
||||
log.warning(f" body: {resp.text[:500]}")
|
||||
@ -94,7 +94,7 @@ class GelbooruClient(BooruClient):
|
||||
params["api_key"] = self.api_key
|
||||
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:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
@ -111,7 +111,7 @@ class GelbooruClient(BooruClient):
|
||||
id=item["id"],
|
||||
file_url=file_url,
|
||||
preview_url=item.get("preview_url"),
|
||||
tags=item.get("tags", ""),
|
||||
tags=self._decode_tags(item.get("tags", "")),
|
||||
score=item.get("score", 0),
|
||||
rating=item.get("rating"),
|
||||
source=item.get("source"),
|
||||
@ -122,8 +122,8 @@ class GelbooruClient(BooruClient):
|
||||
|
||||
async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
|
||||
try:
|
||||
resp = await self.client.get(
|
||||
f"{self.base_url}/index.php",
|
||||
resp = await self._request(
|
||||
"GET", f"{self.base_url}/index.php",
|
||||
params={
|
||||
"page": "dapi",
|
||||
"s": "tag",
|
||||
|
||||
@ -21,7 +21,7 @@ class MoebooruClient(BooruClient):
|
||||
params["login"] = self.api_user
|
||||
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()
|
||||
try:
|
||||
data = resp.json()
|
||||
@ -59,7 +59,7 @@ class MoebooruClient(BooruClient):
|
||||
params["login"] = self.api_user
|
||||
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:
|
||||
return None
|
||||
resp.raise_for_status()
|
||||
@ -87,8 +87,8 @@ class MoebooruClient(BooruClient):
|
||||
|
||||
async def autocomplete(self, query: str, limit: int = 10) -> list[str]:
|
||||
try:
|
||||
resp = await self.client.get(
|
||||
f"{self.base_url}/tag.json",
|
||||
resp = await self._request(
|
||||
"GET", f"{self.base_url}/tag.json",
|
||||
params={"name": f"*{query}*", "order": "count", "limit": limit},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
@ -310,6 +310,26 @@ def evict_oldest(max_bytes: int, protected_paths: set[str] | None = None) -> int
|
||||
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:
|
||||
"""Delete all cached files. Returns count deleted."""
|
||||
deleted = 0
|
||||
|
||||
@ -91,6 +91,7 @@ CREATE TABLE IF NOT EXISTS saved_searches (
|
||||
|
||||
_DEFAULTS = {
|
||||
"max_cache_mb": "2048",
|
||||
"max_thumb_cache_mb": "500",
|
||||
"auto_evict": "1",
|
||||
"thumbnail_size": "180",
|
||||
"page_size": "40",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -39,6 +39,7 @@ class BookmarksView(QWidget):
|
||||
|
||||
bookmark_selected = 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:
|
||||
super().__init__(parent)
|
||||
@ -230,6 +231,20 @@ class BookmarksView(QWidget):
|
||||
save_lib_menu.addSeparator()
|
||||
save_lib_new = save_lib_menu.addAction("+ New Folder...")
|
||||
|
||||
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_url = menu.addAction("Copy Image URL")
|
||||
@ -282,6 +297,7 @@ class BookmarksView(QWidget):
|
||||
from ..core.cache import delete_from_library
|
||||
if delete_from_library(fav.post_id, fav.folder):
|
||||
self.refresh()
|
||||
self.bookmarks_changed.emit()
|
||||
elif action == copy_file:
|
||||
path = fav.cached_path
|
||||
if path and Path(path).exists():
|
||||
@ -315,6 +331,7 @@ class BookmarksView(QWidget):
|
||||
elif action == remove_bookmark:
|
||||
self._db.remove_bookmark(fav.site_id, fav.post_id)
|
||||
self.refresh()
|
||||
self.bookmarks_changed.emit()
|
||||
|
||||
def _on_multi_context_menu(self, indices: list, pos) -> None:
|
||||
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:
|
||||
delete_from_library(fav.post_id, fav.folder)
|
||||
self.refresh()
|
||||
self.bookmarks_changed.emit()
|
||||
elif action == move_none:
|
||||
for fav in favs:
|
||||
self._db.move_bookmark_to_folder(fav.id, None)
|
||||
@ -367,3 +385,4 @@ class BookmarksView(QWidget):
|
||||
for fav in favs:
|
||||
self._db.remove_bookmark(fav.site_id, fav.post_id)
|
||||
self.refresh()
|
||||
self.bookmarks_changed.emit()
|
||||
|
||||
@ -5,12 +5,13 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
|
||||
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 (
|
||||
QWidget,
|
||||
QScrollArea,
|
||||
QMenu,
|
||||
QApplication,
|
||||
QRubberBand,
|
||||
)
|
||||
|
||||
from ..core.api.base import Post
|
||||
@ -304,6 +305,9 @@ class ThumbnailGrid(QScrollArea):
|
||||
self._last_click_index = -1 # for shift-click range
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
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
|
||||
def selected_index(self) -> int:
|
||||
@ -355,6 +359,13 @@ class ThumbnailGrid(QScrollArea):
|
||||
self._thumbs[idx].set_multi_selected(False)
|
||||
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:
|
||||
if index < 0 or index >= len(self._thumbs):
|
||||
return
|
||||
@ -417,6 +428,42 @@ class ThumbnailGrid(QScrollArea):
|
||||
self.ensureWidgetVisible(self._thumbs[index])
|
||||
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:
|
||||
self._clear_multi()
|
||||
for i in range(len(self._thumbs)):
|
||||
|
||||
@ -40,6 +40,7 @@ class LibraryView(QWidget):
|
||||
|
||||
file_selected = 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:
|
||||
super().__init__(parent)
|
||||
@ -351,11 +352,13 @@ class LibraryView(QWidget):
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
post_id = int(filepath.stem) if filepath.stem.isdigit() else None
|
||||
filepath.unlink(missing_ok=True)
|
||||
# Also remove cached thumbnail
|
||||
lib_thumb = thumbnails_dir() / "library" / f"{filepath.stem}.jpg"
|
||||
lib_thumb.unlink(missing_ok=True)
|
||||
self.refresh()
|
||||
if post_id is not None:
|
||||
self.files_deleted.emit([post_id])
|
||||
|
||||
def _on_multi_context_menu(self, indices: list, pos) -> None:
|
||||
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,
|
||||
)
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
deleted_ids = []
|
||||
for f in files:
|
||||
if f.stem.isdigit():
|
||||
deleted_ids.append(int(f.stem))
|
||||
f.unlink(missing_ok=True)
|
||||
lib_thumb = thumbnails_dir() / "library" / f"{f.stem}.jpg"
|
||||
lib_thumb.unlink(missing_ok=True)
|
||||
self.refresh()
|
||||
if deleted_ids:
|
||||
self.files_deleted.emit(deleted_ids)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -99,6 +99,18 @@ class SettingsDialog(QDialog):
|
||||
self._default_rating.setCurrentIndex(idx)
|
||||
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
|
||||
self._default_score = QSpinBox()
|
||||
self._default_score.setRange(0, 99999)
|
||||
@ -135,7 +147,7 @@ class SettingsDialog(QDialog):
|
||||
idx = self._monitor_combo.findText(current_monitor)
|
||||
if idx >= 0:
|
||||
self._monitor_combo.setCurrentIndex(idx)
|
||||
form.addRow("Slideshow monitor:", self._monitor_combo)
|
||||
form.addRow("Popout monitor:", self._monitor_combo)
|
||||
|
||||
# File dialog platform (Linux only)
|
||||
self._file_dialog_combo = None
|
||||
@ -190,6 +202,12 @@ class SettingsDialog(QDialog):
|
||||
self._max_cache.setValue(self._db.get_setting_int("max_cache_mb"))
|
||||
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.setChecked(self._db.get_setting_bool("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("thumbnail_size", str(self._thumb_size.value()))
|
||||
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("preload_thumbnails", "1" if self._preload.isChecked() else "0")
|
||||
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("library_dir", self._library_dir.text().strip())
|
||||
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("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")
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
[Setup]
|
||||
AppName=booru-viewer
|
||||
AppVersion=0.1.9
|
||||
AppVersion=0.2.0
|
||||
AppPublisher=pax
|
||||
AppPublisherURL=https://git.pax.moe/pax/booru-viewer
|
||||
DefaultDirName={localappdata}\booru-viewer
|
||||
|
||||
@ -4,13 +4,14 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "booru-viewer"
|
||||
version = "0.1.9"
|
||||
version = "0.2.0"
|
||||
description = "Local booru image browser with Qt6 GUI"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"httpx[http2]>=0.27",
|
||||
"Pillow>=10.0",
|
||||
"PySide6>=6.6",
|
||||
"python-mpv>=1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@ -185,6 +185,18 @@ QTabBar::tab:selected {
|
||||
|
||||
### 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
|
||||
QSlider::groove:horizontal {
|
||||
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)
|
||||
|
||||
```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
|
||||
|
||||
```css
|
||||
@ -257,4 +317,3 @@ ThumbnailWidget {
|
||||
- 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`
|
||||
- Use `QLabel { background: transparent; }` to prevent labels from getting opaque backgrounds
|
||||
- Right-click on thumbnails selects visually but does not change the preview
|
||||
|
||||
@ -117,3 +117,10 @@ QTabBar::tab:selected {
|
||||
background: #45475a;
|
||||
color: #cba6f7;
|
||||
}
|
||||
|
||||
/* Popout & preview overlay controls */
|
||||
QWidget#_preview_controls,
|
||||
QWidget#_slideshow_toolbar,
|
||||
QWidget#_slideshow_controls {
|
||||
background: rgba(0, 0, 0, 160);
|
||||
}
|
||||
|
||||
@ -114,3 +114,10 @@ QTabBar::tab:selected {
|
||||
background: #4f585e;
|
||||
color: #a7c080;
|
||||
}
|
||||
|
||||
/* Popout & preview overlay controls */
|
||||
QWidget#_preview_controls,
|
||||
QWidget#_slideshow_toolbar,
|
||||
QWidget#_slideshow_controls {
|
||||
background: rgba(0, 0, 0, 160);
|
||||
}
|
||||
|
||||
@ -114,3 +114,10 @@ QTabBar::tab:selected {
|
||||
background: #504945;
|
||||
color: #fe8019;
|
||||
}
|
||||
|
||||
/* Popout & preview overlay controls */
|
||||
QWidget#_preview_controls,
|
||||
QWidget#_slideshow_toolbar,
|
||||
QWidget#_slideshow_controls {
|
||||
background: rgba(0, 0, 0, 160);
|
||||
}
|
||||
|
||||
@ -117,3 +117,10 @@ QTabBar::tab:selected {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -114,3 +114,10 @@ QTabBar::tab:selected {
|
||||
background: #586e75;
|
||||
color: #fdf6e3;
|
||||
}
|
||||
|
||||
/* Popout & preview overlay controls */
|
||||
QWidget#_preview_controls,
|
||||
QWidget#_slideshow_toolbar,
|
||||
QWidget#_slideshow_controls {
|
||||
background: rgba(0, 0, 0, 160);
|
||||
}
|
||||
|
||||
@ -114,3 +114,10 @@ QTabBar::tab:selected {
|
||||
background: #3b4261;
|
||||
color: #7aa2f7;
|
||||
}
|
||||
|
||||
/* Popout & preview overlay controls */
|
||||
QWidget#_preview_controls,
|
||||
QWidget#_slideshow_toolbar,
|
||||
QWidget#_slideshow_controls {
|
||||
background: rgba(0, 0, 0, 160);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user