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
|
- 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
|
||||||
|
|||||||
@ -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=[],
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
@ -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,6 +231,20 @@ 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 = 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")
|
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")
|
||||||
@ -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()
|
||||||
|
|||||||
@ -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)):
|
||||||
|
|||||||
@ -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
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user